���ѧۧݧ�ӧ�� �ާ֧ߧ֧էا֧� - ���֧էѧܧ�ڧ��ӧѧ�� - /home3/cpr76684/public_html/src.tar
���ѧ٧ѧ�
form/meta/form.json 0000644 00000000237 15151771056 0010305 0 ustar 00 { "moodle-availability_group-form": { "requires": [ "base", "node", "event", "moodle-core_availability-form" ] } } form/build.json 0000644 00000000235 15151771056 0007511 0 ustar 00 { "name": "moodle-availability_group-form", "builds": { "moodle-availability_group-form": { "jsfiles": [ "form.js" ] } } } form/js/form.js 0000644 00000005477 15151771056 0007451 0 ustar 00 /** * JavaScript for form editing group conditions. * * @module moodle-availability_group-form */ M.availability_group = M.availability_group || {}; /** * @class M.availability_group.form * @extends M.core_availability.plugin */ M.availability_group.form = Y.Object(M.core_availability.plugin); /** * Groups available for selection (alphabetical order). * * @property groups * @type Array */ M.availability_group.form.groups = null; /** * Initialises this plugin. * * @method initInner * @param {Array} groups Array of objects containing groupid => name */ M.availability_group.form.initInner = function(groups) { this.groups = groups; }; M.availability_group.form.getNode = function(json) { // Create HTML structure. var html = '<label><span class="pr-3">' + M.util.get_string('title', 'availability_group') + '</span> ' + '<span class="availability-group">' + '<select name="id" class="custom-select">' + '<option value="choose">' + M.util.get_string('choosedots', 'moodle') + '</option>' + '<option value="any">' + M.util.get_string('anygroup', 'availability_group') + '</option>'; for (var i = 0; i < this.groups.length; i++) { var group = this.groups[i]; // String has already been escaped using format_string. html += '<option value="' + group.id + '">' + group.name + '</option>'; } html += '</select></span></label>'; var node = Y.Node.create('<span class="form-inline">' + html + '</span>'); // Set initial values (leave default 'choose' if creating afresh). if (json.creating === undefined) { if (json.id !== undefined && node.one('select[name=id] > option[value=' + json.id + ']')) { node.one('select[name=id]').set('value', '' + json.id); } else if (json.id === undefined) { node.one('select[name=id]').set('value', 'any'); } } // Add event handlers (first time only). if (!M.availability_group.form.addedEvents) { M.availability_group.form.addedEvents = true; var root = Y.one('.availability-field'); root.delegate('change', function() { // Just update the form fields. M.core_availability.form.update(); }, '.availability_group select'); } return node; }; M.availability_group.form.fillValue = function(value, node) { var selected = node.one('select[name=id]').get('value'); if (selected === 'choose') { value.id = 'choose'; } else if (selected !== 'any') { value.id = parseInt(selected, 10); } }; M.availability_group.form.fillErrors = function(errors, node) { var value = {}; this.fillValue(value, node); // Check group item id. if (value.id && value.id === 'choose') { errors.push('availability_group:error_selectgroup'); } }; OAuth/OAuthServer.php 0000644 00000015321 15152003527 0010506 0 ustar 00 <?php namespace IMSGlobal\LTI\OAuth; /** * Class to represent an %OAuth Server * * @copyright Andy Smith * @version 2008-08-04 * @license https://opensource.org/licenses/MIT The MIT License */ class OAuthServer { protected $timestamp_threshold = 300; // in seconds, five minutes protected $version = '1.0'; // hi blaine protected $signature_methods = array(); protected $data_store; function __construct($data_store) { $this->data_store = $data_store; } public function add_signature_method($signature_method) { $this->signature_methods[$signature_method->get_name()] = $signature_method; } // high level functions /** * process a request_token request * returns the request token on success */ public function fetch_request_token(&$request) { $this->get_version($request); $consumer = $this->get_consumer($request); // no token required for the initial token request $token = NULL; $this->check_signature($request, $consumer, $token); // Rev A change $callback = $request->get_parameter('oauth_callback'); $new_token = $this->data_store->new_request_token($consumer, $callback); return $new_token; } /** * process an access_token request * returns the access token on success */ public function fetch_access_token(&$request) { $this->get_version($request); $consumer = $this->get_consumer($request); // requires authorized request token $token = $this->get_token($request, $consumer, "request"); $this->check_signature($request, $consumer, $token); // Rev A change $verifier = $request->get_parameter('oauth_verifier'); $new_token = $this->data_store->new_access_token($token, $consumer, $verifier); return $new_token; } /** * verify an api call, checks all the parameters */ public function verify_request(&$request) { $this->get_version($request); $consumer = $this->get_consumer($request); $token = $this->get_token($request, $consumer, "access"); $this->check_signature($request, $consumer, $token); return array($consumer, $token); } // Internals from here /** * version 1 */ private function get_version(&$request) { $version = $request->get_parameter("oauth_version"); if (!$version) { // Service Providers MUST assume the protocol version to be 1.0 if this parameter is not present. // Chapter 7.0 ("Accessing Protected Ressources") $version = '1.0'; } if ($version !== $this->version) { throw new OAuthException("OAuth version '$version' not supported"); } return $version; } /** * figure out the signature with some defaults */ private function get_signature_method($request) { $signature_method = $request instanceof OAuthRequest ? $request->get_parameter('oauth_signature_method') : NULL; if (!$signature_method) { // According to chapter 7 ("Accessing Protected Ressources") the signature-method // parameter is required, and we can't just fallback to PLAINTEXT throw new OAuthException('No signature method parameter. This parameter is required'); } if (!in_array($signature_method, array_keys($this->signature_methods))) { throw new OAuthException( "Signature method '$signature_method' not supported " . 'try one of the following: ' . implode(', ', array_keys($this->signature_methods)) ); } return $this->signature_methods[$signature_method]; } /** * try to find the consumer for the provided request's consumer key */ private function get_consumer($request) { $consumer_key = $request instanceof OAuthRequest ? $request->get_parameter('oauth_consumer_key') : NULL; if (!$consumer_key) { throw new OAuthException('Invalid consumer key'); } $consumer = $this->data_store->lookup_consumer($consumer_key); if (!$consumer) { throw new OAuthException('Invalid consumer'); } return $consumer; } /** * try to find the token for the provided request's token key */ private function get_token($request, $consumer, $token_type="access") { $token_field = $request instanceof OAuthRequest ? $request->get_parameter('oauth_token') : NULL; $token = $this->data_store->lookup_token($consumer, $token_type, $token_field); if (!$token) { throw new OAuthException("Invalid $token_type token: $token_field"); } return $token; } /** * all-in-one function to check the signature on a request * should guess the signature method appropriately */ private function check_signature($request, $consumer, $token) { // this should probably be in a different method $timestamp = $request instanceof OAuthRequest ? $request->get_parameter('oauth_timestamp') : NULL; $nonce = $request instanceof OAuthRequest ? $request->get_parameter('oauth_nonce') : NULL; $this->check_timestamp($timestamp); $this->check_nonce($consumer, $token, $nonce, $timestamp); $signature_method = $this->get_signature_method($request); $signature = $request->get_parameter('oauth_signature'); $valid_sig = $signature_method->check_signature($request, $consumer, $token, $signature); if (!$valid_sig) { throw new OAuthException('Invalid signature'); } } /** * check that the timestamp is new enough */ private function check_timestamp($timestamp) { if(!$timestamp) throw new OAuthException('Missing timestamp parameter. The parameter is required'); // verify that timestamp is recentish $now = time(); if (abs($now - $timestamp) > $this->timestamp_threshold) { throw new OAuthException("Expired timestamp, yours $timestamp, ours $now"); } } /** * check that the nonce is not repeated */ private function check_nonce($consumer, $token, $nonce, $timestamp) { if(!$nonce) throw new OAuthException('Missing nonce parameter. The parameter is required'); // verify that the nonce is uniqueish $found = $this->data_store->lookup_nonce($consumer, $token, $nonce, $timestamp); if ($found) { throw new OAuthException("Nonce already used: $nonce"); } } } OAuth/OAuthSignatureMethod_HMAC_SHA1.php 0000644 00000002265 15152003527 0013711 0 ustar 00 <?php namespace IMSGlobal\LTI\OAuth; /** * Class to represent an %OAuth HMAC_SHA1 signature method * * @copyright Andy Smith * @version 2008-08-04 * @license https://opensource.org/licenses/MIT The MIT License */ /** * The HMAC-SHA1 signature method uses the HMAC-SHA1 signature algorithm as defined in [RFC2104] * where the Signature Base String is the text and the key is the concatenated values (each first * encoded per Parameter Encoding) of the Consumer Secret and Token Secret, separated by an '&' * character (ASCII code 38) even if empty. * - Chapter 9.2 ("HMAC-SHA1") */ class OAuthSignatureMethod_HMAC_SHA1 extends OAuthSignatureMethod { function get_name() { return "HMAC-SHA1"; } public function build_signature($request, $consumer, $token) { $base_string = $request->get_signature_base_string(); $request->base_string = $base_string; $key_parts = array( $consumer->secret, ($token) ? $token->secret : "" ); $key_parts = OAuthUtil::urlencode_rfc3986($key_parts); $key = implode('&', $key_parts); return base64_encode(hash_hmac('sha1', $base_string, $key, true)); } } OAuth/OAuthRequest.php 0000644 00000021000 15152003527 0010657 0 ustar 00 <?php namespace IMSGlobal\LTI\OAuth; /** * Class to represent an %OAuth Request * * @copyright Andy Smith * @version 2008-08-04 * @license https://opensource.org/licenses/MIT The MIT License */ class OAuthRequest { protected $parameters; protected $http_method; protected $http_url; // for debug purposes public $base_string; public static $version = '1.0'; function __construct($http_method, $http_url, $parameters = null) { $parameters = ($parameters) ? $parameters : array(); $parameters = array_merge( OAuthUtil::parse_parameters(parse_url($http_url, PHP_URL_QUERY)), $parameters); $this->parameters = $parameters; $this->http_method = $http_method; $this->http_url = $http_url; } /** * attempt to build up a request from what was passed to the server */ public static function from_request($http_method = null, $http_url = null, $parameters = null) { $scheme = (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] != "on") ? 'http' : 'https'; $http_url = ($http_url) ? $http_url : $scheme . '://' . $_SERVER['SERVER_NAME'] . ':' . $_SERVER['SERVER_PORT'] . $_SERVER['REQUEST_URI']; $http_method = ($http_method) ? $http_method : $_SERVER['REQUEST_METHOD']; // We weren't handed any parameters, so let's find the ones relevant to // this request. // If you run XML-RPC or similar you should use this to provide your own // parsed parameter-list if (!$parameters) { // Find request headers $request_headers = OAuthUtil::get_headers(); // Parse the query-string to find GET parameters if (isset($_SERVER['QUERY_STRING'])) { $parameters = OAuthUtil::parse_parameters($_SERVER['QUERY_STRING']); } else { $parameters = array(); } // We have a Authorization-header with OAuth data. Parse the header and add those. if (isset($request_headers['Authorization']) && substr($request_headers['Authorization'], 0, 6) == 'OAuth ') { $header_parameters = OAuthUtil::split_header($request_headers['Authorization']); $parameters = array_merge($parameters, $header_parameters); } // If there are parameters in $_POST, these are likely what will be used. Therefore, they should be considered // the final value in the case of any duplicates from sources parsed above. $parameters = array_merge($parameters, $_POST); } return new OAuthRequest($http_method, $http_url, $parameters); } /** * pretty much a helper function to set up the request */ public static function from_consumer_and_token($consumer, $token, $http_method, $http_url, $parameters = null) { $parameters = ($parameters) ? $parameters : array(); $defaults = array('oauth_version' => OAuthRequest::$version, 'oauth_nonce' => OAuthRequest::generate_nonce(), 'oauth_timestamp' => OAuthRequest::generate_timestamp(), 'oauth_consumer_key' => $consumer->key); if ($token) $defaults['oauth_token'] = $token->key; $parameters = array_merge($defaults, $parameters); return new OAuthRequest($http_method, $http_url, $parameters); } public function set_parameter($name, $value, $allow_duplicates = true) { if ($allow_duplicates && isset($this->parameters[$name])) { // We have already added parameter(s) with this name, so add to the list if (is_scalar($this->parameters[$name])) { // This is the first duplicate, so transform scalar (string) // into an array so we can add the duplicates $this->parameters[$name] = array($this->parameters[$name]); } $this->parameters[$name][] = $value; } else { $this->parameters[$name] = $value; } } public function get_parameter($name) { return isset($this->parameters[$name]) ? $this->parameters[$name] : null; } public function get_parameters() { return $this->parameters; } public function unset_parameter($name) { unset($this->parameters[$name]); } /** * The request parameters, sorted and concatenated into a normalized string. * @return string */ public function get_signable_parameters() { // Grab all parameters $params = $this->parameters; // Remove oauth_signature if present // Ref: Spec: 9.1.1 ("The oauth_signature parameter MUST be excluded.") if (isset($params['oauth_signature'])) { unset($params['oauth_signature']); } return OAuthUtil::build_http_query($params); } /** * Returns the base string of this request * * The base string defined as the method, the url * and the parameters (normalized), each urlencoded * and the concated with &. */ public function get_signature_base_string() { $parts = array( $this->get_normalized_http_method(), $this->get_normalized_http_url(), $this->get_signable_parameters() ); $parts = OAuthUtil::urlencode_rfc3986($parts); return implode('&', $parts); } /** * just uppercases the http method */ public function get_normalized_http_method() { return strtoupper($this->http_method); } /** * parses the url and rebuilds it to be * scheme://host/path */ public function get_normalized_http_url() { $parts = parse_url($this->http_url); $scheme = (isset($parts['scheme'])) ? $parts['scheme'] : 'http'; $port = (isset($parts['port'])) ? $parts['port'] : (($scheme == 'https') ? '443' : '80'); $host = (isset($parts['host'])) ? strtolower($parts['host']) : ''; $path = (isset($parts['path'])) ? $parts['path'] : ''; if (($scheme == 'https' && $port != '443') || ($scheme == 'http' && $port != '80')) { $host = "$host:$port"; } return "$scheme://$host$path"; } /** * builds a url usable for a GET request */ public function to_url() { $post_data = $this->to_postdata(); $out = $this->get_normalized_http_url(); if ($post_data) { $out .= '?'.$post_data; } return $out; } /** * builds the data one would send in a POST request */ public function to_postdata() { return OAuthUtil::build_http_query($this->parameters); } /** * builds the Authorization: header */ public function to_header($realm = null) { $first = true; if($realm) { $out = 'Authorization: OAuth realm="' . OAuthUtil::urlencode_rfc3986($realm) . '"'; $first = false; } else $out = 'Authorization: OAuth'; $total = array(); foreach ($this->parameters as $k => $v) { if (substr($k, 0, 5) != "oauth") continue; if (is_array($v)) { throw new OAuthException('Arrays not supported in headers'); } $out .= ($first) ? ' ' : ','; $out .= OAuthUtil::urlencode_rfc3986($k) . '="' . OAuthUtil::urlencode_rfc3986($v) . '"'; $first = false; } return $out; } public function __toString() { return $this->to_url(); } public function sign_request($signature_method, $consumer, $token) { $this->set_parameter( "oauth_signature_method", $signature_method->get_name(), false ); $signature = $this->build_signature($signature_method, $consumer, $token); $this->set_parameter("oauth_signature", $signature, false); } public function build_signature($signature_method, $consumer, $token) { $signature = $signature_method->build_signature($this, $consumer, $token); return $signature; } /** * util function: current timestamp */ private static function generate_timestamp() { return time(); } /** * util function: current nonce */ private static function generate_nonce() { $mt = microtime(); $rand = mt_rand(); return md5($mt . $rand); // md5s look nicer than numbers } } OAuth/OAuthConsumer.php 0000644 00000001041 15152003527 0011025 0 ustar 00 <?php namespace IMSGlobal\LTI\OAuth; /** * Class to represent an %OAuth Consumer * * @copyright Andy Smith * @version 2008-08-04 * @license https://opensource.org/licenses/MIT The MIT License */ class OAuthConsumer { public $key; public $secret; function __construct($key, $secret, $callback_url=NULL) { $this->key = $key; $this->secret = $secret; $this->callback_url = $callback_url; } function __toString() { return "OAuthConsumer[key=$this->key,secret=$this->secret]"; } } OAuth/OAuthToken.php 0000644 00000001643 15152003527 0010322 0 ustar 00 <?php namespace IMSGlobal\LTI\OAuth; /** * Class to represent an %OAuth Token * * @copyright Andy Smith * @version 2008-08-04 * @license https://opensource.org/licenses/MIT The MIT License */ class OAuthToken { // access tokens and request tokens public $key; public $secret; /** * key = the token * secret = the token secret */ function __construct($key, $secret) { $this->key = $key; $this->secret = $secret; } /** * generates the basic string serialization of a token that a server * would respond to request_token and access_token calls with */ function to_string() { return 'oauth_token=' . OAuthUtil::urlencode_rfc3986($this->key) . '&oauth_token_secret=' . OAuthUtil::urlencode_rfc3986($this->secret); } function __toString() { return $this->to_string(); } } OAuth/OAuthSignatureMethod.php 0000644 00000003402 15152003527 0012337 0 ustar 00 <?php namespace IMSGlobal\LTI\OAuth; /** * Class to represent an %OAuth Signature Method * * @copyright Andy Smith * @version 2008-08-04 * @license https://opensource.org/licenses/MIT The MIT License */ /** * A class for implementing a Signature Method * See section 9 ("Signing Requests") in the spec */ abstract class OAuthSignatureMethod { /** * Needs to return the name of the Signature Method (ie HMAC-SHA1) * @return string */ abstract public function get_name(); /** * Build up the signature * NOTE: The output of this function MUST NOT be urlencoded. * the encoding is handled in OAuthRequest when the final * request is serialized * @param OAuthRequest $request * @param OAuthConsumer $consumer * @param OAuthToken $token * @return string */ abstract public function build_signature($request, $consumer, $token); /** * Verifies that a given signature is correct * @param OAuthRequest $request * @param OAuthConsumer $consumer * @param OAuthToken $token * @param string $signature * @return bool */ public function check_signature($request, $consumer, $token, $signature) { $built = $this->build_signature($request, $consumer, $token); // Check for zero length, although unlikely here if (strlen($built) == 0 || strlen($signature) == 0) { return false; } if (strlen($built) != strlen($signature)) { return false; } // Avoid a timing leak with a (hopefully) time insensitive compare $result = 0; for ($i = 0; $i < strlen($signature); $i++) { $result |= ord($built[$i]) ^ ord($signature[$i]); } return $result == 0; } } OAuth/OAuthException.php 0000644 00000000403 15152003527 0011171 0 ustar 00 <?php namespace IMSGlobal\LTI\OAuth; /** * Class to represent an %OAuth Exception * * @copyright Andy Smith * @version 2008-08-04 * @license https://opensource.org/licenses/MIT The MIT License */ class OAuthException extends \Exception { // pass } OAuth/OAuthDataStore.php 0000644 00000001616 15152003527 0011130 0 ustar 00 <?php namespace IMSGlobal\LTI\OAuth; /** * Class to represent an %OAuth Data Store * * @copyright Andy Smith * @version 2008-08-04 * @license https://opensource.org/licenses/MIT The MIT License */ class OAuthDataStore { function lookup_consumer($consumer_key) { // implement me } function lookup_token($consumer, $token_type, $token) { // implement me } function lookup_nonce($consumer, $token, $nonce, $timestamp) { // implement me } function new_request_token($consumer, $callback = null) { // return a new token attached to this consumer } function new_access_token($token, $consumer, $verifier = null) { // return a new access token attached to this consumer // for the user associated with this token if the request token // is authorized // should also invalidate the request token } } OAuth/OAuthUtil.php 0000644 00000014124 15152003527 0010155 0 ustar 00 <?php namespace IMSGlobal\LTI\OAuth; /** * Class to provide %OAuth utility methods * * @copyright Andy Smith * @version 2008-08-04 * @license https://opensource.org/licenses/MIT The MIT License */ class OAuthUtil { public static function urlencode_rfc3986($input) { if (is_array($input)) { return array_map(array('IMSGlobal\LTI\OAuth\OAuthUtil', 'urlencode_rfc3986'), $input); } else if (is_scalar($input)) { return str_replace('+', ' ', str_replace('%7E', '~', rawurlencode($input))); } else { return ''; } } // This decode function isn't taking into consideration the above // modifications to the encoding process. However, this method doesn't // seem to be used anywhere so leaving it as is. public static function urldecode_rfc3986($string) { return urldecode($string); } // Utility function for turning the Authorization: header into // parameters, has to do some unescaping // Can filter out any non-oauth parameters if needed (default behaviour) // May 28th, 2010 - method updated to tjerk.meesters for a speed improvement. // see http://code.google.com/p/oauth/issues/detail?id=163 public static function split_header($header, $only_allow_oauth_parameters = true) { $params = array(); if (preg_match_all('/('.($only_allow_oauth_parameters ? 'oauth_' : '').'[a-z_-]*)=(:?"([^"]*)"|([^,]*))/', $header, $matches)) { foreach ($matches[1] as $i => $h) { $params[$h] = OAuthUtil::urldecode_rfc3986(empty($matches[3][$i]) ? $matches[4][$i] : $matches[3][$i]); } if (isset($params['realm'])) { unset($params['realm']); } } return $params; } // helper to try to sort out headers for people who aren't running apache public static function get_headers() { if (function_exists('apache_request_headers')) { // we need this to get the actual Authorization: header // because apache tends to tell us it doesn't exist $headers = apache_request_headers(); // sanitize the output of apache_request_headers because // we always want the keys to be Cased-Like-This and arh() // returns the headers in the same case as they are in the // request $out = array(); foreach ($headers AS $key => $value) { $key = str_replace(" ", "-", ucwords(strtolower(str_replace("-", " ", $key)))); $out[$key] = $value; } } else { // otherwise we don't have apache and are just going to have to hope // that $_SERVER actually contains what we need $out = array(); if( isset($_SERVER['CONTENT_TYPE']) ) $out['Content-Type'] = $_SERVER['CONTENT_TYPE']; if( isset($_ENV['CONTENT_TYPE']) ) $out['Content-Type'] = $_ENV['CONTENT_TYPE']; foreach ($_SERVER as $key => $value) { if (substr($key, 0, 5) == 'HTTP_') { // this is chaos, basically it is just there to capitalize the first // letter of every word that is not an initial HTTP and strip HTTP // code from przemek $key = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5))))); $out[$key] = $value; } } } return $out; } // This function takes a input like a=b&a=c&d=e and returns the parsed // parameters like this // array('a' => array('b','c'), 'd' => 'e') public static function parse_parameters( $input ) { if (!isset($input) || !$input) return array(); $pairs = explode('&', $input); $parsed_parameters = array(); foreach ($pairs as $pair) { $split = explode('=', $pair, 2); $parameter = self::urldecode_rfc3986($split[0]); $value = isset($split[1]) ? self::urldecode_rfc3986($split[1]) : ''; if (isset($parsed_parameters[$parameter])) { // We have already recieved parameter(s) with this name, so add to the list // of parameters with this name if (is_scalar($parsed_parameters[$parameter])) { // This is the first duplicate, so transform scalar (string) into an array // so we can add the duplicates $parsed_parameters[$parameter] = array($parsed_parameters[$parameter]); } $parsed_parameters[$parameter][] = $value; } else { $parsed_parameters[$parameter] = $value; } } return $parsed_parameters; } public static function build_http_query($params) { if (!$params) return ''; // Urlencode both keys and values $keys = OAuthUtil::urlencode_rfc3986(array_keys($params)); $values = OAuthUtil::urlencode_rfc3986(array_values($params)); $params = array_combine($keys, $values); // Parameters are sorted by name, using lexicographical byte value ordering. // Ref: Spec: 9.1.1 (1) uksort($params, 'strcmp'); $pairs = array(); foreach ($params as $parameter => $value) { if (is_array($value)) { // If two or more parameters share the same name, they are sorted by their value // Ref: Spec: 9.1.1 (1) // June 12th, 2010 - changed to sort because of issue 164 by hidetaka sort($value, SORT_STRING); foreach ($value as $duplicate_value) { $pairs[] = $parameter . '=' . $duplicate_value; } } else { $pairs[] = $parameter . '=' . $value; } } // For each parameter, the name is separated from the corresponding value by an '=' character (ASCII code 61) // Each name-value pair is separated by an '&' character (ASCII code 38) return implode('&', $pairs); } } OAuth/OAuthSignatureMethod_HMAC_SHA256.php 0000644 00000002376 15152003527 0014070 0 ustar 00 <?php namespace IMSGlobal\LTI\OAuth; /** * Class to represent an %OAuth HMAC_SHA256 signature method * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 2015-11-30 * @license https://opensource.org/licenses/MIT The MIT License */ /** * The HMAC-SHA256 signature method uses the HMAC-SHA256 signature algorithm as defined in [RFC6234] * where the Signature Base String is the text and the key is the concatenated values (each first * encoded per Parameter Encoding) of the Consumer Secret and Token Secret, separated by an '&' * character (ASCII code 38) even if empty. */ class OAuthSignatureMethod_HMAC_SHA256 extends OAuthSignatureMethod { function get_name() { return "HMAC-SHA256"; } public function build_signature($request, $consumer, $token) { $base_string = $request->get_signature_base_string(); $request->base_string = $base_string; $key_parts = array( $consumer->secret, ($token) ? $token->secret : "" ); $key_parts = OAuthUtil::urlencode_rfc3986($key_parts); $key = implode('&', $key_parts); return base64_encode(hash_hmac('sha256', $base_string, $key, true)); } } HTTPMessage.php 0000644 00000011315 15152003527 0007342 0 ustar 00 <?php namespace IMSGlobal\LTI; global $CFG; require_once($CFG->libdir . '/filelib.php'); /** * Class to represent an HTTP message * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class HTTPMessage { /** * True if message was sent successfully. * * @var boolean $ok */ public $ok = false; /** * Request body. * * @var request $request */ public $request = null; /** * Request headers. * * @var request_headers $requestHeaders */ public $requestHeaders = ''; /** * Response body. * * @var response $response */ public $response = null; /** * Response headers. * * @var response_headers $responseHeaders */ public $responseHeaders = ''; /** * Status of response (0 if undetermined). * * @var status $status */ public $status = 0; /** * Error message * * @var error $error */ public $error = ''; /** * Request URL. * * @var url $url */ private $url = null; /** * Request method. * * @var method $method */ private $method = null; /** * Class constructor. * * @param string $url URL to send request to * @param string $method Request method to use (optional, default is GET) * @param mixed $params Associative array of parameter values to be passed or message body (optional, default is none) * @param string $header Values to include in the request header (optional, default is none) */ function __construct($url, $method = 'GET', $params = null, $header = null) { $this->url = $url; $this->method = strtoupper($method); if (is_array($params)) { $this->request = http_build_query($params); } else { $this->request = $params; } if (!empty($header)) { $this->requestHeaders = explode("\n", $header); } } /** * Send the request to the target URL. * * @return boolean True if the request was successful */ public function send() { $this->ok = false; // Try using curl if available if (function_exists('curl_init')) { $resp = ''; $chResp = ''; $curl = new \curl(); $options = [ 'CURLOPT_HEADER' => true, 'CURLINFO_HEADER_OUT' => true, ]; if (!empty($this->requestHeaders)) { $options['CURLOPT_HTTPHEADER'] = $this->requestHeaders; } else { $options['CURLOPT_HEADER'] = 0; } if ($this->method === 'POST') { $chResp = $curl->post($this->url, $this->request, $options); } else if ($this->method !== 'GET') { if (!is_null($this->request)) { $chResp = $curl->post($this->url, $this->request, $options); } } else { $chResp = $curl->get($this->url, null, $options); } $info = $curl->get_info(); if (!$curl->get_errno() && !$curl->error) { $chResp = str_replace("\r\n", "\n", $chResp); $chRespSplit = explode("\n\n", $chResp, 2); if ((count($chRespSplit) > 1) && (substr($chRespSplit[1], 0, 5) === 'HTTP/')) { $chRespSplit = explode("\n\n", $chRespSplit[1], 2); } $this->responseHeaders = $chRespSplit[0]; $resp = $chRespSplit[1]; $this->status = $info['http_code']; $this->ok = $this->status < 400; if (!$this->ok) { $this->error = $curl->error; } } else { $this->error = $curl->error; $resp = $chResp; } $this->response = $resp; $this->requestHeaders = !empty($info['request_header']) ? str_replace("\r\n", "\n", $info['request_header']) : ''; } else { // Try using fopen if curl was not available $opts = array('method' => $this->method, 'content' => $this->request ); if (!empty($this->requestHeaders)) { $opts['header'] = $this->requestHeaders; } try { $ctx = stream_context_create(array('http' => $opts)); $fp = @fopen($this->url, 'rb', false, $ctx); if ($fp) { $resp = @stream_get_contents($fp); $this->ok = $resp !== false; } } catch (\Exception $e) { $this->ok = false; } } return $this->ok; } } ToolProvider/ContentItem.php 0000644 00000006060 15152003527 0012140 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; /** * Class to represent a content-item object * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class ContentItem { /** * Media type for LTI launch links. */ const LTI_LINK_MEDIA_TYPE = 'application/vnd.ims.lti.v1.ltilink'; /** * Class constructor. * * @param string $type Class type of content-item * @param ContentItemPlacement $placementAdvice Placement object for item (optional) * @param string $id URL of content-item (optional) */ function __construct($type, $placementAdvice = null, $id = null) { $this->{'@type'} = $type; if (is_object($placementAdvice) && (count(get_object_vars($placementAdvice)) > 0)) { $this->placementAdvice = $placementAdvice; } if (!empty($id)) { $this->{'@id'} = $id; } } /** * Set a URL value for the content-item. * * @param string $url URL value */ public function setUrl($url) { if (!empty($url)) { $this->url = $url; } else { unset($this->url); } } /** * Set a media type value for the content-item. * * @param string $mediaType Media type value */ public function setMediaType($mediaType) { if (!empty($mediaType)) { $this->mediaType = $mediaType; } else { unset($this->mediaType); } } /** * Set a title value for the content-item. * * @param string $title Title value */ public function setTitle($title) { if (!empty($title)) { $this->title = $title; } else if (isset($this->title)) { unset($this->title); } } /** * Set a link text value for the content-item. * * @param string $text Link text value */ public function setText($text) { if (!empty($text)) { $this->text = $text; } else if (isset($this->text)) { unset($this->text); } } /** * Wrap the content items to form a complete application/vnd.ims.lti.v1.contentitems+json media type instance. * * @param mixed $items An array of content items or a single item * @return string */ public static function toJson($items) { /* $data = array(); if (!is_array($items)) { $data[] = json_encode($items); } else { foreach ($items as $item) { $data[] = json_encode($item); } } $json = '{ "@context" : "http://purl.imsglobal.org/ctx/lti/v1/ContentItem", "@graph" : [' . implode(", ", $data) . '] }'; */ $obj = new \stdClass(); $obj->{'@context'} = 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem'; if (!is_array($items)) { $obj->{'@graph'} = array(); $obj->{'@graph'}[] = $items; } else { $obj->{'@graph'} = $items; } return json_encode($obj); } } ToolProvider/ConsumerNonce.php 0000644 00000003564 15152003527 0012473 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; /** * Class to represent a tool consumer nonce * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class ConsumerNonce { /** * Maximum age nonce values will be retained for (in minutes). */ const MAX_NONCE_AGE = 30; // in minutes /** * Date/time when the nonce value expires. * * @var int $expires */ public $expires = null; /** * Tool Consumer to which this nonce applies. * * @var ToolConsumer $consumer */ private $consumer = null; /** * Nonce value. * * @var string $value */ private $value = null; /** * Class constructor. * * @param ToolConsumer $consumer Consumer object * @param string $value Nonce value (optional, default is null) */ public function __construct($consumer, $value = null) { $this->consumer = $consumer; $this->value = $value; $this->expires = time() + (self::MAX_NONCE_AGE * 60); } /** * Load a nonce value from the database. * * @return boolean True if the nonce value was successfully loaded */ public function load() { return $this->consumer->getDataConnector()->loadConsumerNonce($this); } /** * Save a nonce value in the database. * * @return boolean True if the nonce value was successfully saved */ public function save() { return $this->consumer->getDataConnector()->saveConsumerNonce($this); } /** * Get tool consumer. * * @return ToolConsumer Consumer for this nonce */ public function getConsumer() { return $this->consumer; } /** * Get outcome value. * * @return string Outcome value */ public function getValue() { return $this->value; } } ToolProvider/ToolProvider.php 0000644 00000141646 15152003527 0012351 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; use IMSGlobal\LTI\Profile\Item; use IMSGlobal\LTI\ToolProvider\DataConnector\DataConnector; use IMSGlobal\LTI\ToolProvider\MediaType; use IMSGlobal\LTI\Profile; use IMSGlobal\LTI\HTTPMessage; use IMSGlobal\LTI\OAuth; /** * Class to represent an LTI Tool Provider * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license GNU Lesser General Public License, version 3 (<http://www.gnu.org/licenses/lgpl.html>) */ class ToolProvider { /** * Default connection error message. */ const CONNECTION_ERROR_MESSAGE = 'Sorry, there was an error connecting you to the application.'; /** * LTI version 1 for messages. */ const LTI_VERSION1 = 'LTI-1p0'; /** * LTI version 2 for messages. */ const LTI_VERSION2 = 'LTI-2p0'; /** * Use ID value only. */ const ID_SCOPE_ID_ONLY = 0; /** * Prefix an ID with the consumer key. */ const ID_SCOPE_GLOBAL = 1; /** * Prefix the ID with the consumer key and context ID. */ const ID_SCOPE_CONTEXT = 2; /** * Prefix the ID with the consumer key and resource ID. */ const ID_SCOPE_RESOURCE = 3; /** * Character used to separate each element of an ID. */ const ID_SCOPE_SEPARATOR = ':'; /** * Permitted LTI versions for messages. */ private static $LTI_VERSIONS = array(self::LTI_VERSION1, self::LTI_VERSION2); /** * List of supported message types and associated class methods. */ private static $MESSAGE_TYPES = array('basic-lti-launch-request' => 'onLaunch', 'ContentItemSelectionRequest' => 'onContentItem', 'ToolProxyRegistrationRequest' => 'register'); /** * List of supported message types and associated class methods * * @var array $METHOD_NAMES */ private static $METHOD_NAMES = array('basic-lti-launch-request' => 'onLaunch', 'ContentItemSelectionRequest' => 'onContentItem', 'ToolProxyRegistrationRequest' => 'onRegister'); /** * Names of LTI parameters to be retained in the consumer settings property. * * @var array $LTI_CONSUMER_SETTING_NAMES */ private static $LTI_CONSUMER_SETTING_NAMES = array('custom_tc_profile_url', 'custom_system_setting_url'); /** * Names of LTI parameters to be retained in the context settings property. * * @var array $LTI_CONTEXT_SETTING_NAMES */ private static $LTI_CONTEXT_SETTING_NAMES = array('custom_context_setting_url', 'custom_lineitems_url', 'custom_results_url', 'custom_context_memberships_url'); /** * Names of LTI parameters to be retained in the resource link settings property. * * @var array $LTI_RESOURCE_LINK_SETTING_NAMES */ private static $LTI_RESOURCE_LINK_SETTING_NAMES = array('lis_course_section_sourcedid', 'lis_result_sourcedid', 'lis_outcome_service_url', 'ext_ims_lis_basic_outcome_url', 'ext_ims_lis_resultvalue_sourcedids', 'ext_ims_lis_memberships_id', 'ext_ims_lis_memberships_url', 'ext_ims_lti_tool_setting', 'ext_ims_lti_tool_setting_id', 'ext_ims_lti_tool_setting_url', 'custom_link_setting_url', 'custom_lineitem_url', 'custom_result_url'); /** * Names of LTI custom parameter substitution variables (or capabilities) and their associated default message parameter names. * * @var array $CUSTOM_SUBSTITUTION_VARIABLES */ private static $CUSTOM_SUBSTITUTION_VARIABLES = array('User.id' => 'user_id', 'User.image' => 'user_image', 'User.username' => 'username', 'User.scope.mentor' => 'role_scope_mentor', 'Membership.role' => 'roles', 'Person.sourcedId' => 'lis_person_sourcedid', 'Person.name.full' => 'lis_person_name_full', 'Person.name.family' => 'lis_person_name_family', 'Person.name.given' => 'lis_person_name_given', 'Person.email.primary' => 'lis_person_contact_email_primary', 'Context.id' => 'context_id', 'Context.type' => 'context_type', 'Context.title' => 'context_title', 'Context.label' => 'context_label', 'CourseOffering.sourcedId' => 'lis_course_offering_sourcedid', 'CourseSection.sourcedId' => 'lis_course_section_sourcedid', 'CourseSection.label' => 'context_label', 'CourseSection.title' => 'context_title', 'ResourceLink.id' => 'resource_link_id', 'ResourceLink.title' => 'resource_link_title', 'ResourceLink.description' => 'resource_link_description', 'Result.sourcedId' => 'lis_result_sourcedid', 'BasicOutcome.url' => 'lis_outcome_service_url', 'ToolConsumerProfile.url' => 'custom_tc_profile_url', 'ToolProxy.url' => 'tool_proxy_url', 'ToolProxy.custom.url' => 'custom_system_setting_url', 'ToolProxyBinding.custom.url' => 'custom_context_setting_url', 'LtiLink.custom.url' => 'custom_link_setting_url', 'LineItems.url' => 'custom_lineitems_url', 'LineItem.url' => 'custom_lineitem_url', 'Results.url' => 'custom_results_url', 'Result.url' => 'custom_result_url', 'ToolProxyBinding.memberships.url' => 'custom_context_memberships_url'); /** * True if the last request was successful. * * @var boolean $ok */ public $ok = true; /** * Tool Consumer object. * * @var ToolConsumer $consumer */ public $consumer = null; /** * Return URL provided by tool consumer. * * @var string $returnUrl */ public $returnUrl = null; /** * User object. * * @var User $user */ public $user = null; /** * Resource link object. * * @var ResourceLink $resourceLink */ public $resourceLink = null; /** * Context object. * * @var Context $context */ public $context = null; /** * Data connector object. * * @var DataConnector $dataConnector */ public $dataConnector = null; /** * Default email domain. * * @var string $defaultEmail */ public $defaultEmail = ''; /** * Scope to use for user IDs. * * @var int $idScope */ public $idScope = self::ID_SCOPE_ID_ONLY; /** * Whether shared resource link arrangements are permitted. * * @var boolean $allowSharing */ public $allowSharing = false; /** * Message for last request processed * * @var string $message */ public $message = self::CONNECTION_ERROR_MESSAGE; /** * Error message for last request processed. * * @var string $reason */ public $reason = null; /** * Details for error message relating to last request processed. * * @var array $details */ public $details = array(); /** * Base URL for tool provider service * * @var string $baseUrl */ public $baseUrl = null; /** * Vendor details * * @var Item $vendor */ public $vendor = null; /** * Product details * * @var Item $product */ public $product = null; /** * Services required by Tool Provider * * @var array $requiredServices */ public $requiredServices = null; /** * Optional services used by Tool Provider * * @var array $optionalServices */ public $optionalServices = null; /** * Resource handlers for Tool Provider * * @var array $resourceHandlers */ public $resourceHandlers = null; /** * URL to redirect user to on successful completion of the request. * * @var string $redirectUrl */ protected $redirectUrl = null; /** * URL to redirect user to on successful completion of the request. * * @var string $mediaTypes */ protected $mediaTypes = null; /** * URL to redirect user to on successful completion of the request. * * @var string $documentTargets */ protected $documentTargets = null; /** * HTML to be displayed on a successful completion of the request. * * @var string $output */ protected $output = null; /** * HTML to be displayed on an unsuccessful completion of the request and no return URL is available. * * @var string $errorOutput */ protected $errorOutput = null; /** * Whether debug messages explaining the cause of errors are to be returned to the tool consumer. * * @var boolean $debugMode */ protected $debugMode = false; /** * Callback functions for handling requests. * * @var array $callbackHandler */ private $callbackHandler = null; /** * LTI parameter constraints for auto validation checks. * * @var array $constraints */ private $constraints = null; /** * Class constructor * * @param DataConnector $dataConnector Object containing a database connection object */ function __construct($dataConnector) { $this->constraints = array(); $this->dataConnector = $dataConnector; $this->ok = !is_null($this->dataConnector); // Set debug mode $this->debugMode = isset($_POST['custom_debug']) && (strtolower($_POST['custom_debug']) === 'true'); // Set return URL if available if (isset($_POST['launch_presentation_return_url'])) { $this->returnUrl = $_POST['launch_presentation_return_url']; } else if (isset($_POST['content_item_return_url'])) { $this->returnUrl = $_POST['content_item_return_url']; } $this->vendor = new Profile\Item(); $this->product = new Profile\Item(); $this->requiredServices = array(); $this->optionalServices = array(); $this->resourceHandlers = array(); } /** * Process an incoming request */ public function handleRequest() { if ($this->ok) { if ($this->authenticate()) { $this->doCallback(); } } $this->result(); } /** * Add a parameter constraint to be checked on launch * * @param string $name Name of parameter to be checked * @param boolean $required True if parameter is required (optional, default is true) * @param int $maxLength Maximum permitted length of parameter value (optional, default is null) * @param array $messageTypes Array of message types to which the constraint applies (optional, default is all) */ public function setParameterConstraint($name, $required = true, $maxLength = null, $messageTypes = null) { $name = trim($name); if (strlen($name) > 0) { $this->constraints[$name] = array('required' => $required, 'max_length' => $maxLength, 'messages' => $messageTypes); } } /** * Get an array of defined tool consumers * * @return array Array of ToolConsumer objects */ public function getConsumers() { return $this->dataConnector->getToolConsumers(); } /** * Find an offered service based on a media type and HTTP action(s) * * @param string $format Media type required * @param array $methods Array of HTTP actions required * * @return object The service object */ public function findService($format, $methods) { $found = false; $services = $this->consumer->profile->service_offered; if (is_array($services)) { $n = -1; foreach ($services as $service) { $n++; if (!is_array($service->format) || !in_array($format, $service->format)) { continue; } $missing = array(); foreach ($methods as $method) { if (!is_array($service->action) || !in_array($method, $service->action)) { $missing[] = $method; } } $methods = $missing; if (count($methods) <= 0) { $found = $service; break; } } } return $found; } /** * Send the tool proxy to the Tool Consumer * * @return boolean True if the tool proxy was accepted */ public function doToolProxyService() { // Create tool proxy $toolProxyService = $this->findService('application/vnd.ims.lti.v2.toolproxy+json', array('POST')); $secret = DataConnector::getRandomString(12); $toolProxy = new MediaType\ToolProxy($this, $toolProxyService, $secret); $http = $this->consumer->doServiceRequest($toolProxyService, 'POST', 'application/vnd.ims.lti.v2.toolproxy+json', json_encode($toolProxy)); $ok = $http->ok && ($http->status == 201) && isset($http->responseJson->tool_proxy_guid) && (strlen($http->responseJson->tool_proxy_guid) > 0); if ($ok) { $this->consumer->setKey($http->responseJson->tool_proxy_guid); $this->consumer->secret = $toolProxy->security_contract->shared_secret; $this->consumer->toolProxy = json_encode($toolProxy); $this->consumer->save(); } return $ok; } /** * Get an array of fully qualified user roles * * @param mixed $roles Comma-separated list of roles or array of roles * * @return array Array of roles */ public static function parseRoles($roles) { if (!is_array($roles)) { $roles = explode(',', $roles); } $parsedRoles = array(); foreach ($roles as $role) { $role = trim($role); if (!empty($role)) { if (substr($role, 0, 4) !== 'urn:') { $role = 'urn:lti:role:ims/lis/' . $role; } $parsedRoles[] = $role; } } return $parsedRoles; } /** * Generate a web page containing an auto-submitted form of parameters. * * @param string $url URL to which the form should be submitted * @param array $params Array of form parameters * @param string $target Name of target (optional) * @return string */ public static function sendForm($url, $params, $target = '') { $page = <<< EOD <html> <head> <title>IMS LTI message</title> <script type="text/javascript"> //<![CDATA[ function doOnLoad() { document.forms[0].submit(); } window.onload=doOnLoad; //]]> </script> </head> <body> <form action="{$url}" method="post" target="" encType="application/x-www-form-urlencoded"> EOD; foreach($params as $key => $value ) { $key = htmlentities($key, ENT_COMPAT | ENT_HTML401, 'UTF-8'); $value = htmlentities($value, ENT_COMPAT | ENT_HTML401, 'UTF-8'); $page .= <<< EOD <input type="hidden" name="{$key}" value="{$value}" /> EOD; } $page .= <<< EOD </form> </body> </html> EOD; return $page; } ### ### PROTECTED METHODS ### /** * Process a valid launch request * * @return boolean True if no error */ protected function onLaunch() { $this->onError(); } /** * Process a valid content-item request * * @return boolean True if no error */ protected function onContentItem() { $this->onError(); } /** * Process a valid tool proxy registration request * * @return boolean True if no error */ protected function onRegister() { $this->onError(); } /** * Process a response to an invalid request * * @return boolean True if no further error processing required */ protected function onError() { $this->doCallback('onError'); } ### ### PRIVATE METHODS ### /** * Call any callback function for the requested action. * * This function may set the redirect_url and output properties. * * @return boolean True if no error reported */ private function doCallback($method = null) { $callback = $method; if (is_null($callback)) { $callback = self::$METHOD_NAMES[$_POST['lti_message_type']]; } if (method_exists($this, $callback)) { $result = $this->$callback(); } else if (is_null($method) && $this->ok) { $this->ok = false; $this->reason = "Message type not supported: {$_POST['lti_message_type']}"; } if ($this->ok && ($_POST['lti_message_type'] == 'ToolProxyRegistrationRequest')) { $this->consumer->save(); } } /** * Perform the result of an action. * * This function may redirect the user to another URL rather than returning a value. * * @return string Output to be displayed (redirection, or display HTML or message) */ private function result() { $ok = false; if (!$this->ok) { $ok = $this->onError(); } if (!$ok) { if (!$this->ok) { // If not valid, return an error message to the tool consumer if a return URL is provided if (!empty($this->returnUrl)) { $errorUrl = $this->returnUrl; if (strpos($errorUrl, '?') === false) { $errorUrl .= '?'; } else { $errorUrl .= '&'; } if ($this->debugMode && !is_null($this->reason)) { $errorUrl .= 'lti_errormsg=' . urlencode("Debug error: $this->reason"); } else { $errorUrl .= 'lti_errormsg=' . urlencode($this->message); if (!is_null($this->reason)) { $errorUrl .= '<i_errorlog=' . urlencode("Debug error: $this->reason"); } } if (!is_null($this->consumer) && isset($_POST['lti_message_type']) && ($_POST['lti_message_type'] === 'ContentItemSelectionRequest')) { $formParams = array(); if (isset($_POST['data'])) { $formParams['data'] = $_POST['data']; } $version = (isset($_POST['lti_version'])) ? $_POST['lti_version'] : self::LTI_VERSION1; $formParams = $this->consumer->signParameters($errorUrl, 'ContentItemSelection', $version, $formParams); $page = self::sendForm($errorUrl, $formParams); echo $page; } else { header("Location: {$errorUrl}"); } exit; } else { if (!is_null($this->errorOutput)) { echo $this->errorOutput; } else if ($this->debugMode && !empty($this->reason)) { echo "Debug error: {$this->reason}"; } else { echo "Error: {$this->message}"; } } } else if (!is_null($this->redirectUrl)) { header("Location: {$this->redirectUrl}"); exit; } else if (!is_null($this->output)) { echo $this->output; } } } /** * Check the authenticity of the LTI launch request. * * The consumer, resource link and user objects will be initialised if the request is valid. * * @return boolean True if the request has been successfully validated. */ private function authenticate() { // Get the consumer $doSaveConsumer = false; // Check all required launch parameters $this->ok = isset($_POST['lti_message_type']) && array_key_exists($_POST['lti_message_type'], self::$MESSAGE_TYPES); if (!$this->ok) { $this->reason = 'Invalid or missing lti_message_type parameter.'; } if ($this->ok) { $this->ok = isset($_POST['lti_version']) && in_array($_POST['lti_version'], self::$LTI_VERSIONS); if (!$this->ok) { $this->reason = 'Invalid or missing lti_version parameter.'; } } if ($this->ok) { if ($_POST['lti_message_type'] === 'basic-lti-launch-request') { $this->ok = isset($_POST['resource_link_id']) && (strlen(trim($_POST['resource_link_id'])) > 0); if (!$this->ok) { $this->reason = 'Missing resource link ID.'; } } else if ($_POST['lti_message_type'] === 'ContentItemSelectionRequest') { if (isset($_POST['accept_media_types']) && (strlen(trim($_POST['accept_media_types'])) > 0)) { $mediaTypes = array_filter(explode(',', str_replace(' ', '', $_POST['accept_media_types'])), 'strlen'); $mediaTypes = array_unique($mediaTypes); $this->ok = count($mediaTypes) > 0; if (!$this->ok) { $this->reason = 'No accept_media_types found.'; } else { $this->mediaTypes = $mediaTypes; } } else { $this->ok = false; } if ($this->ok && isset($_POST['accept_presentation_document_targets']) && (strlen(trim($_POST['accept_presentation_document_targets'])) > 0)) { $documentTargets = array_filter(explode(',', str_replace(' ', '', $_POST['accept_presentation_document_targets'])), 'strlen'); $documentTargets = array_unique($documentTargets); $this->ok = count($documentTargets) > 0; if (!$this->ok) { $this->reason = 'Missing or empty accept_presentation_document_targets parameter.'; } else { foreach ($documentTargets as $documentTarget) { $this->ok = $this->checkValue($documentTarget, array('embed', 'frame', 'iframe', 'window', 'popup', 'overlay', 'none'), 'Invalid value in accept_presentation_document_targets parameter: %s.'); if (!$this->ok) { break; } } if ($this->ok) { $this->documentTargets = $documentTargets; } } } else { $this->ok = false; } if ($this->ok) { $this->ok = isset($_POST['content_item_return_url']) && (strlen(trim($_POST['content_item_return_url'])) > 0); if (!$this->ok) { $this->reason = 'Missing content_item_return_url parameter.'; } } } else if ($_POST['lti_message_type'] == 'ToolProxyRegistrationRequest') { $this->ok = ((isset($_POST['reg_key']) && (strlen(trim($_POST['reg_key'])) > 0)) && (isset($_POST['reg_password']) && (strlen(trim($_POST['reg_password'])) > 0)) && (isset($_POST['tc_profile_url']) && (strlen(trim($_POST['tc_profile_url'])) > 0)) && (isset($_POST['launch_presentation_return_url']) && (strlen(trim($_POST['launch_presentation_return_url'])) > 0))); if ($this->debugMode && !$this->ok) { $this->reason = 'Missing message parameters.'; } } } $now = time(); // Check consumer key if ($this->ok && ($_POST['lti_message_type'] != 'ToolProxyRegistrationRequest')) { $this->ok = isset($_POST['oauth_consumer_key']); if (!$this->ok) { $this->reason = 'Missing consumer key.'; } if ($this->ok) { $this->consumer = new ToolConsumer($_POST['oauth_consumer_key'], $this->dataConnector); $this->ok = !is_null($this->consumer->created); if (!$this->ok) { $this->reason = 'Invalid consumer key.'; } } if ($this->ok) { $today = date('Y-m-d', $now); if (is_null($this->consumer->lastAccess)) { $doSaveConsumer = true; } else { $last = date('Y-m-d', $this->consumer->lastAccess); $doSaveConsumer = $doSaveConsumer || ($last !== $today); } $this->consumer->last_access = $now; try { $store = new OAuthDataStore($this); $server = new OAuth\OAuthServer($store); $method = new OAuth\OAuthSignatureMethod_HMAC_SHA1(); $server->add_signature_method($method); $request = OAuth\OAuthRequest::from_request(); $res = $server->verify_request($request); } catch (\Exception $e) { $this->ok = false; if (empty($this->reason)) { if ($this->debugMode) { $consumer = new OAuth\OAuthConsumer($this->consumer->getKey(), $this->consumer->secret); $signature = $request->build_signature($method, $consumer, false); $this->reason = $e->getMessage(); if (empty($this->reason)) { $this->reason = 'OAuth exception'; } $this->details[] = 'Timestamp: ' . time(); $this->details[] = "Signature: {$signature}"; $this->details[] = "Base string: {$request->base_string}]"; } else { $this->reason = 'OAuth signature check failed - perhaps an incorrect secret or timestamp.'; } } } } if ($this->ok) { $today = date('Y-m-d', $now); if (is_null($this->consumer->lastAccess)) { $doSaveConsumer = true; } else { $last = date('Y-m-d', $this->consumer->lastAccess); $doSaveConsumer = $doSaveConsumer || ($last !== $today); } $this->consumer->last_access = $now; if ($this->consumer->protected) { if (!is_null($this->consumer->consumerGuid)) { $this->ok = empty($_POST['tool_consumer_instance_guid']) || ($this->consumer->consumerGuid === $_POST['tool_consumer_instance_guid']); if (!$this->ok) { $this->reason = 'Request is from an invalid tool consumer.'; } } } if ($this->ok) { $this->ok = $this->consumer->enabled; if (!$this->ok) { $this->reason = 'Tool consumer has not been enabled by the tool provider.'; } } if ($this->ok) { $this->ok = is_null($this->consumer->enableFrom) || ($this->consumer->enableFrom <= $now); if ($this->ok) { $this->ok = is_null($this->consumer->enableUntil) || ($this->consumer->enableUntil > $now); if (!$this->ok) { $this->reason = 'Tool consumer access has expired.'; } } else { $this->reason = 'Tool consumer access is not yet available.'; } } } // Validate other message parameter values if ($this->ok) { if ($_POST['lti_message_type'] === 'ContentItemSelectionRequest') { if (isset($_POST['accept_unsigned'])) { $this->ok = $this->checkValue($_POST['accept_unsigned'], array('true', 'false'), 'Invalid value for accept_unsigned parameter: %s.'); } if ($this->ok && isset($_POST['accept_multiple'])) { $this->ok = $this->checkValue($_POST['accept_multiple'], array('true', 'false'), 'Invalid value for accept_multiple parameter: %s.'); } if ($this->ok && isset($_POST['accept_copy_advice'])) { $this->ok = $this->checkValue($_POST['accept_copy_advice'], array('true', 'false'), 'Invalid value for accept_copy_advice parameter: %s.'); } if ($this->ok && isset($_POST['auto_create'])) { $this->ok = $this->checkValue($_POST['auto_create'], array('true', 'false'), 'Invalid value for auto_create parameter: %s.'); } if ($this->ok && isset($_POST['can_confirm'])) { $this->ok = $this->checkValue($_POST['can_confirm'], array('true', 'false'), 'Invalid value for can_confirm parameter: %s.'); } } else if (isset($_POST['launch_presentation_document_target'])) { $this->ok = $this->checkValue($_POST['launch_presentation_document_target'], array('embed', 'frame', 'iframe', 'window', 'popup', 'overlay'), 'Invalid value for launch_presentation_document_target parameter: %s.'); } } } if ($this->ok && ($_POST['lti_message_type'] === 'ToolProxyRegistrationRequest')) { $this->ok = $_POST['lti_version'] == self::LTI_VERSION2; if (!$this->ok) { $this->reason = 'Invalid lti_version parameter'; } if ($this->ok) { $http = new HTTPMessage($_POST['tc_profile_url'], 'GET', null, 'Accept: application/vnd.ims.lti.v2.toolconsumerprofile+json'); $this->ok = $http->send(); if (!$this->ok) { $this->reason = 'Tool consumer profile not accessible.'; } else { $tcProfile = json_decode($http->response); $this->ok = !is_null($tcProfile); if (!$this->ok) { $this->reason = 'Invalid JSON in tool consumer profile.'; } } } // Check for required capabilities if ($this->ok) { $this->consumer = new ToolConsumer($_POST['reg_key'], $this->dataConnector); $this->consumer->profile = $tcProfile; $capabilities = $this->consumer->profile->capability_offered; $missing = array(); foreach ($this->resourceHandlers as $resourceHandler) { foreach ($resourceHandler->requiredMessages as $message) { if (!in_array($message->type, $capabilities)) { $missing[$message->type] = true; } } } foreach ($this->constraints as $name => $constraint) { if ($constraint['required']) { if (!in_array($name, $capabilities) && !in_array($name, array_flip($capabilities))) { $missing[$name] = true; } } } if (!empty($missing)) { ksort($missing); $this->reason = 'Required capability not offered - \'' . implode('\', \'', array_keys($missing)) . '\''; $this->ok = false; } } // Check for required services if ($this->ok) { foreach ($this->requiredServices as $service) { foreach ($service->formats as $format) { if (!$this->findService($format, $service->actions)) { if ($this->ok) { $this->reason = 'Required service(s) not offered - '; $this->ok = false; } else { $this->reason .= ', '; } $this->reason .= "'{$format}' [" . implode(', ', $service->actions) . ']'; } } } } if ($this->ok) { if ($_POST['lti_message_type'] === 'ToolProxyRegistrationRequest') { $this->consumer->profile = $tcProfile; $this->consumer->secret = $_POST['reg_password']; $this->consumer->ltiVersion = $_POST['lti_version']; $this->consumer->name = $tcProfile->product_instance->service_owner->service_owner_name->default_value; $this->consumer->consumerName = $this->consumer->name; $this->consumer->consumerVersion = "{$tcProfile->product_instance->product_info->product_family->code}-{$tcProfile->product_instance->product_info->product_version}"; $this->consumer->consumerGuid = $tcProfile->product_instance->guid; $this->consumer->enabled = true; $this->consumer->protected = true; $doSaveConsumer = true; } } } else if ($this->ok && !empty($_POST['custom_tc_profile_url']) && empty($this->consumer->profile)) { $http = new HTTPMessage($_POST['custom_tc_profile_url'], 'GET', null, 'Accept: application/vnd.ims.lti.v2.toolconsumerprofile+json'); if ($http->send()) { $tcProfile = json_decode($http->response); if (!is_null($tcProfile)) { $this->consumer->profile = $tcProfile; $doSaveConsumer = true; } } } // Validate message parameter constraints if ($this->ok) { $invalidParameters = array(); foreach ($this->constraints as $name => $constraint) { if (empty($constraint['messages']) || in_array($_POST['lti_message_type'], $constraint['messages'])) { $ok = true; if ($constraint['required']) { if (!isset($_POST[$name]) || (strlen(trim($_POST[$name])) <= 0)) { $invalidParameters[] = "{$name} (missing)"; $ok = false; } } if ($ok && !is_null($constraint['max_length']) && isset($_POST[$name])) { if (strlen(trim($_POST[$name])) > $constraint['max_length']) { $invalidParameters[] = "{$name} (too long)"; } } } } if (count($invalidParameters) > 0) { $this->ok = false; if (empty($this->reason)) { $this->reason = 'Invalid parameter(s): ' . implode(', ', $invalidParameters) . '.'; } } } if ($this->ok) { // Set the request context if (isset($_POST['context_id'])) { $this->context = Context::fromConsumer($this->consumer, trim($_POST['context_id'])); $title = ''; if (isset($_POST['context_title'])) { $title = trim($_POST['context_title']); } if (empty($title)) { $title = "Course {$this->context->getId()}"; } if (isset($_POST['context_type'])) { $this->context->type = trim($_POST['context_type']); } $this->context->title = $title; } // Set the request resource link if (isset($_POST['resource_link_id'])) { $contentItemId = ''; if (isset($_POST['custom_content_item_id'])) { $contentItemId = $_POST['custom_content_item_id']; } $this->resourceLink = ResourceLink::fromConsumer($this->consumer, trim($_POST['resource_link_id']), $contentItemId); if (!empty($this->context)) { $this->resourceLink->setContextId($this->context->getRecordId()); } $title = ''; if (isset($_POST['resource_link_title'])) { $title = trim($_POST['resource_link_title']); } if (empty($title)) { $title = "Resource {$this->resourceLink->getId()}"; } $this->resourceLink->title = $title; // Delete any existing custom parameters foreach ($this->consumer->getSettings() as $name => $value) { if (strpos($name, 'custom_') === 0) { $this->consumer->setSetting($name); $doSaveConsumer = true; } } if (!empty($this->context)) { foreach ($this->context->getSettings() as $name => $value) { if (strpos($name, 'custom_') === 0) { $this->context->setSetting($name); } } } foreach ($this->resourceLink->getSettings() as $name => $value) { if (strpos($name, 'custom_') === 0) { $this->resourceLink->setSetting($name); } } // Save LTI parameters foreach (self::$LTI_CONSUMER_SETTING_NAMES as $name) { if (isset($_POST[$name])) { $this->consumer->setSetting($name, $_POST[$name]); } else { $this->consumer->setSetting($name); } } if (!empty($this->context)) { foreach (self::$LTI_CONTEXT_SETTING_NAMES as $name) { if (isset($_POST[$name])) { $this->context->setSetting($name, $_POST[$name]); } else { $this->context->setSetting($name); } } } foreach (self::$LTI_RESOURCE_LINK_SETTING_NAMES as $name) { if (isset($_POST[$name])) { $this->resourceLink->setSetting($name, $_POST[$name]); } else { $this->resourceLink->setSetting($name); } } // Save other custom parameters foreach ($_POST as $name => $value) { if ((strpos($name, 'custom_') === 0) && !in_array($name, array_merge(self::$LTI_CONSUMER_SETTING_NAMES, self::$LTI_CONTEXT_SETTING_NAMES, self::$LTI_RESOURCE_LINK_SETTING_NAMES))) { $this->resourceLink->setSetting($name, $value); } } } // Set the user instance $userId = ''; if (isset($_POST['user_id'])) { $userId = trim($_POST['user_id']); } $this->user = User::fromResourceLink($this->resourceLink, $userId); // Set the user name $firstname = (isset($_POST['lis_person_name_given'])) ? $_POST['lis_person_name_given'] : ''; $lastname = (isset($_POST['lis_person_name_family'])) ? $_POST['lis_person_name_family'] : ''; $fullname = (isset($_POST['lis_person_name_full'])) ? $_POST['lis_person_name_full'] : ''; $this->user->setNames($firstname, $lastname, $fullname); // Set the user email $email = (isset($_POST['lis_person_contact_email_primary'])) ? $_POST['lis_person_contact_email_primary'] : ''; $this->user->setEmail($email, $this->defaultEmail); // Set the user image URI if (isset($_POST['user_image'])) { $this->user->image = $_POST['user_image']; } // Set the user roles if (isset($_POST['roles'])) { $this->user->roles = self::parseRoles($_POST['roles']); } // Initialise the consumer and check for changes $this->consumer->defaultEmail = $this->defaultEmail; if ($this->consumer->ltiVersion !== $_POST['lti_version']) { $this->consumer->ltiVersion = $_POST['lti_version']; $doSaveConsumer = true; } if (isset($_POST['tool_consumer_instance_name'])) { if ($this->consumer->consumerName !== $_POST['tool_consumer_instance_name']) { $this->consumer->consumerName = $_POST['tool_consumer_instance_name']; $doSaveConsumer = true; } } if (isset($_POST['tool_consumer_info_product_family_code'])) { $version = $_POST['tool_consumer_info_product_family_code']; if (isset($_POST['tool_consumer_info_version'])) { $version .= "-{$_POST['tool_consumer_info_version']}"; } // do not delete any existing consumer version if none is passed if ($this->consumer->consumerVersion !== $version) { $this->consumer->consumerVersion = $version; $doSaveConsumer = true; } } else if (isset($_POST['ext_lms']) && ($this->consumer->consumerName !== $_POST['ext_lms'])) { $this->consumer->consumerVersion = $_POST['ext_lms']; $doSaveConsumer = true; } if (isset($_POST['tool_consumer_instance_guid'])) { if (is_null($this->consumer->consumerGuid)) { $this->consumer->consumerGuid = $_POST['tool_consumer_instance_guid']; $doSaveConsumer = true; } else if (!$this->consumer->protected) { $doSaveConsumer = ($this->consumer->consumerGuid !== $_POST['tool_consumer_instance_guid']); if ($doSaveConsumer) { $this->consumer->consumerGuid = $_POST['tool_consumer_instance_guid']; } } } if (isset($_POST['launch_presentation_css_url'])) { if ($this->consumer->cssPath !== $_POST['launch_presentation_css_url']) { $this->consumer->cssPath = $_POST['launch_presentation_css_url']; $doSaveConsumer = true; } } else if (isset($_POST['ext_launch_presentation_css_url']) && ($this->consumer->cssPath !== $_POST['ext_launch_presentation_css_url'])) { $this->consumer->cssPath = $_POST['ext_launch_presentation_css_url']; $doSaveConsumer = true; } else if (!empty($this->consumer->cssPath)) { $this->consumer->cssPath = null; $doSaveConsumer = true; } } // Persist changes to consumer if ($doSaveConsumer) { $this->consumer->save(); } if ($this->ok && isset($this->context)) { $this->context->save(); } if ($this->ok && isset($this->resourceLink)) { // Check if a share arrangement is in place for this resource link $this->ok = $this->checkForShare(); // Persist changes to resource link $this->resourceLink->save(); // Save the user instance if (isset($_POST['lis_result_sourcedid'])) { if ($this->user->ltiResultSourcedId !== $_POST['lis_result_sourcedid']) { $this->user->ltiResultSourcedId = $_POST['lis_result_sourcedid']; $this->user->save(); } } else if (!empty($this->user->ltiResultSourcedId)) { $this->user->ltiResultSourcedId = ''; $this->user->save(); } } return $this->ok; } /** * Check if a share arrangement is in place. * * @return boolean True if no error is reported */ private function checkForShare() { $ok = true; $doSaveResourceLink = true; $id = $this->resourceLink->primaryResourceLinkId; $shareRequest = isset($_POST['custom_share_key']) && !empty($_POST['custom_share_key']); if ($shareRequest) { if (!$this->allowSharing) { $ok = false; $this->reason = 'Your sharing request has been refused because sharing is not being permitted.'; } else { // Check if this is a new share key $shareKey = new ResourceLinkShareKey($this->resourceLink, $_POST['custom_share_key']); if (!is_null($shareKey->primaryConsumerKey) && !is_null($shareKey->primaryResourceLinkId)) { // Update resource link with sharing primary resource link details $key = $shareKey->primaryConsumerKey; $id = $shareKey->primaryResourceLinkId; $ok = ($key !== $this->consumer->getKey()) || ($id != $this->resourceLink->getId()); if ($ok) { $this->resourceLink->primaryConsumerKey = $key; $this->resourceLink->primaryResourceLinkId = $id; $this->resourceLink->shareApproved = $shareKey->autoApprove; $ok = $this->resourceLink->save(); if ($ok) { $doSaveResourceLink = false; $this->user->getResourceLink()->primaryConsumerKey = $key; $this->user->getResourceLink()->primaryResourceLinkId = $id; $this->user->getResourceLink()->shareApproved = $shareKey->autoApprove; $this->user->getResourceLink()->updated = time(); // Remove share key $shareKey->delete(); } else { $this->reason = 'An error occurred initialising your share arrangement.'; } } else { $this->reason = 'It is not possible to share your resource link with yourself.'; } } if ($ok) { $ok = !is_null($key); if (!$ok) { $this->reason = 'You have requested to share a resource link but none is available.'; } else { $ok = (!is_null($this->user->getResourceLink()->shareApproved) && $this->user->getResourceLink()->shareApproved); if (!$ok) { $this->reason = 'Your share request is waiting to be approved.'; } } } } } else { // Check no share is in place $ok = is_null($id); if (!$ok) { $this->reason = 'You have not requested to share a resource link but an arrangement is currently in place.'; } } // Look up primary resource link if ($ok && !is_null($id)) { $consumer = new ToolConsumer($key, $this->dataConnector); $ok = !is_null($consumer->created); if ($ok) { $resourceLink = ResourceLink::fromConsumer($consumer, $id); $ok = !is_null($resourceLink->created); } if ($ok) { if ($doSaveResourceLink) { $this->resourceLink->save(); } $this->resourceLink = $resourceLink; } else { $this->reason = 'Unable to load resource link being shared.'; } } return $ok; } /** * Validate a parameter value from an array of permitted values. * * @return boolean True if value is valid */ private function checkValue($value, $values, $reason) { $ok = in_array($value, $values); if (!$ok && !empty($reason)) { $this->reason = sprintf($reason, $value); } return $ok; } } ToolProvider/ToolConsumer.php 0000644 00000037207 15152003527 0012347 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; use IMSGlobal\LTI\ToolProvider\DataConnector\DataConnector; use IMSGlobal\LTI\ToolProvider\Service; use IMSGlobal\LTI\HTTPMessage; use IMSGlobal\LTI\OAuth; use stdClass; /** * Class to represent a tool consumer * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class ToolConsumer { /** * Local name of tool consumer. * * @var string $name */ public $name = null; /** * Shared secret. * * @var string $secret */ public $secret = null; /** * LTI version (as reported by last tool consumer connection). * * @var string $ltiVersion */ public $ltiVersion = null; /** * Name of tool consumer (as reported by last tool consumer connection). * * @var string $consumerName */ public $consumerName = null; /** * Tool consumer version (as reported by last tool consumer connection). * * @var string $consumerVersion */ public $consumerVersion = null; /** * Tool consumer GUID (as reported by first tool consumer connection). * * @var string $consumerGuid */ public $consumerGuid = null; /** * Optional CSS path (as reported by last tool consumer connection). * * @var string $cssPath */ public $cssPath = null; /** * Whether the tool consumer instance is protected by matching the consumer_guid value in incoming requests. * * @var boolean $protected */ public $protected = false; /** * Whether the tool consumer instance is enabled to accept incoming connection requests. * * @var boolean $enabled */ public $enabled = false; /** * Date/time from which the the tool consumer instance is enabled to accept incoming connection requests. * * @var int $enableFrom */ public $enableFrom = null; /** * Date/time until which the tool consumer instance is enabled to accept incoming connection requests. * * @var int $enableUntil */ public $enableUntil = null; /** * Date of last connection from this tool consumer. * * @var int $lastAccess */ public $lastAccess = null; /** * Default scope to use when generating an Id value for a user. * * @var int $idScope */ public $idScope = ToolProvider::ID_SCOPE_ID_ONLY; /** * Default email address (or email domain) to use when no email address is provided for a user. * * @var string $defaultEmail */ public $defaultEmail = ''; /** * Setting values (LTI parameters, custom parameters and local parameters). * * @var array $settings */ public $settings = null; /** * Date/time when the object was created. * * @var int $created */ public $created = null; /** * Date/time when the object was last updated. * * @var int $updated */ public $updated = null; /** * The consumer profile data. * * @var stdClass */ public $profile = null; /** * Consumer ID value. * * @var int $id */ private $id = null; /** * Consumer key value. * * @var string $key */ private $key = null; /** * Whether the settings value have changed since last saved. * * @var boolean $settingsChanged */ private $settingsChanged = false; /** * Data connector object or string. * * @var mixed $dataConnector */ private $dataConnector = null; /** * Class constructor. * * @param string $key Consumer key * @param DataConnector $dataConnector A data connector object * @param boolean $autoEnable true if the tool consumers is to be enabled automatically (optional, default is false) */ public function __construct($key = null, $dataConnector = null, $autoEnable = false) { $this->initialize(); if (empty($dataConnector)) { $dataConnector = DataConnector::getDataConnector(); } $this->dataConnector = $dataConnector; if (!empty($key)) { $this->load($key, $autoEnable); } else { $this->secret = DataConnector::getRandomString(32); } } /** * Initialise the tool consumer. */ public function initialize() { $this->id = null; $this->key = null; $this->name = null; $this->secret = null; $this->ltiVersion = null; $this->consumerName = null; $this->consumerVersion = null; $this->consumerGuid = null; $this->profile = null; $this->toolProxy = null; $this->settings = array(); $this->protected = false; $this->enabled = false; $this->enableFrom = null; $this->enableUntil = null; $this->lastAccess = null; $this->idScope = ToolProvider::ID_SCOPE_ID_ONLY; $this->defaultEmail = ''; $this->created = null; $this->updated = null; } /** * Initialise the tool consumer. * * Pseudonym for initialize(). */ public function initialise() { $this->initialize(); } /** * Save the tool consumer to the database. * * @return boolean True if the object was successfully saved */ public function save() { $ok = $this->dataConnector->saveToolConsumer($this); if ($ok) { $this->settingsChanged = false; } return $ok; } /** * Delete the tool consumer from the database. * * @return boolean True if the object was successfully deleted */ public function delete() { return $this->dataConnector->deleteToolConsumer($this); } /** * Get the tool consumer record ID. * * @return int Consumer record ID value */ public function getRecordId() { return $this->id; } /** * Sets the tool consumer record ID. * * @param int $id Consumer record ID value */ public function setRecordId($id) { $this->id = $id; } /** * Get the tool consumer key. * * @return string Consumer key value */ public function getKey() { return $this->key; } /** * Set the tool consumer key. * * @param string $key Consumer key value */ public function setKey($key) { $this->key = $key; } /** * Get the data connector. * * @return mixed Data connector object or string */ public function getDataConnector() { return $this->dataConnector; } /** * Is the consumer key available to accept launch requests? * * @return boolean True if the consumer key is enabled and within any date constraints */ public function getIsAvailable() { $ok = $this->enabled; $now = time(); if ($ok && !is_null($this->enableFrom)) { $ok = $this->enableFrom <= $now; } if ($ok && !is_null($this->enableUntil)) { $ok = $this->enableUntil > $now; } return $ok; } /** * Get a setting value. * * @param string $name Name of setting * @param string $default Value to return if the setting does not exist (optional, default is an empty string) * * @return string Setting value */ public function getSetting($name, $default = '') { if (array_key_exists($name, $this->settings)) { $value = $this->settings[$name]; } else { $value = $default; } return $value; } /** * Set a setting value. * * @param string $name Name of setting * @param string $value Value to set, use an empty value to delete a setting (optional, default is null) */ public function setSetting($name, $value = null) { $old_value = $this->getSetting($name); if ($value !== $old_value) { if (!empty($value)) { $this->settings[$name] = $value; } else { unset($this->settings[$name]); } $this->settingsChanged = true; } } /** * Get an array of all setting values. * * @return array Associative array of setting values */ public function getSettings() { return $this->settings; } /** * Set an array of all setting values. * * @param array $settings Associative array of setting values */ public function setSettings($settings) { $this->settings = $settings; } /** * Save setting values. * * @return boolean True if the settings were successfully saved */ public function saveSettings() { if ($this->settingsChanged) { $ok = $this->save(); } else { $ok = true; } return $ok; } /** * Check if the Tool Settings service is supported. * * @return boolean True if this tool consumer supports the Tool Settings service */ public function hasToolSettingsService() { $url = $this->getSetting('custom_system_setting_url'); return !empty($url); } /** * Get Tool Settings. * * @param boolean $simple True if all the simple media type is to be used (optional, default is true) * * @return mixed The array of settings if successful, otherwise false */ public function getToolSettings($simple = true) { $url = $this->getSetting('custom_system_setting_url'); $service = new Service\ToolSettings($this, $url, $simple); $response = $service->get(); return $response; } /** * Perform a Tool Settings service request. * * @param array $settings An associative array of settings (optional, default is none) * * @return boolean True if action was successful, otherwise false */ public function setToolSettings($settings = array()) { $url = $this->getSetting('custom_system_setting_url'); $service = new Service\ToolSettings($this, $url); $response = $service->set($settings); return $response; } /** * Add the OAuth signature to an LTI message. * * @param string $url URL for message request * @param string $type LTI message type * @param string $version LTI version * @param array $params Message parameters * * @return array Array of signed message parameters */ public function signParameters($url, $type, $version, $params) { if (!empty($url)) { // Check for query parameters which need to be included in the signature $queryParams = array(); $queryString = parse_url($url, PHP_URL_QUERY); if (!is_null($queryString)) { $queryItems = explode('&', $queryString); foreach ($queryItems as $item) { if (strpos($item, '=') !== false) { list($name, $value) = explode('=', $item); $queryParams[urldecode($name)] = urldecode($value); } else { $queryParams[urldecode($item)] = ''; } } } $params = $params + $queryParams; // Add standard parameters $params['lti_version'] = $version; $params['lti_message_type'] = $type; $params['oauth_callback'] = 'about:blank'; // Add OAuth signature $hmacMethod = new OAuth\OAuthSignatureMethod_HMAC_SHA1(); $consumer = new OAuth\OAuthConsumer($this->getKey(), $this->secret, null); $req = OAuth\OAuthRequest::from_consumer_and_token($consumer, null, 'POST', $url, $params); $req->sign_request($hmacMethod, $consumer, null); $params = $req->get_parameters(); // Remove parameters being passed on the query string foreach (array_keys($queryParams) as $name) { unset($params[$name]); } } return $params; } /** * Add the OAuth signature to an array of message parameters or to a header string. * * @return mixed Array of signed message parameters or header string */ public static function addSignature($endpoint, $consumerKey, $consumerSecret, $data, $method = 'POST', $type = null) { $params = array(); if (is_array($data)) { $params = $data; } // Check for query parameters which need to be included in the signature $queryParams = array(); $queryString = parse_url($endpoint, PHP_URL_QUERY); if (!is_null($queryString)) { $queryItems = explode('&', $queryString); foreach ($queryItems as $item) { if (strpos($item, '=') !== false) { list($name, $value) = explode('=', $item); $queryParams[urldecode($name)] = urldecode($value); } else { $queryParams[urldecode($item)] = ''; } } $params = $params + $queryParams; } if (!is_array($data)) { // Calculate body hash $hash = base64_encode(sha1($data, true)); $params['oauth_body_hash'] = $hash; } // Add OAuth signature $hmacMethod = new OAuth\OAuthSignatureMethod_HMAC_SHA1(); $oauthConsumer = new OAuth\OAuthConsumer($consumerKey, $consumerSecret, null); $oauthReq = OAuth\OAuthRequest::from_consumer_and_token($oauthConsumer, null, $method, $endpoint, $params); $oauthReq->sign_request($hmacMethod, $oauthConsumer, null); $params = $oauthReq->get_parameters(); // Remove parameters being passed on the query string foreach (array_keys($queryParams) as $name) { unset($params[$name]); } if (!is_array($data)) { $header = $oauthReq->to_header(); if (empty($data)) { if (!empty($type)) { $header .= "\nAccept: {$type}"; } } else if (isset($type)) { $header .= "\nContent-Type: {$type}"; $header .= "\nContent-Length: " . strlen($data); } return $header; } else { return $params; } } /** * Perform a service request * * @param object $service Service object to be executed * @param string $method HTTP action * @param string $format Media type * @param mixed $data Array of parameters or body string * * @return HTTPMessage HTTP object containing request and response details */ public function doServiceRequest($service, $method, $format, $data) { $header = ToolConsumer::addSignature($service->endpoint, $this->getKey(), $this->secret, $data, $method, $format); // Connect to tool consumer $http = new HTTPMessage($service->endpoint, $method, $data, $header); // Parse JSON response if ($http->send() && !empty($http->response)) { $http->responseJson = json_decode($http->response); $http->ok = !is_null($http->responseJson); } return $http; } /** * Load the tool consumer from the database by its record ID. * * @param string $id The consumer key record ID * @param DataConnector $dataConnector Database connection object * * @return object ToolConsumer The tool consumer object */ public static function fromRecordId($id, $dataConnector) { $toolConsumer = new ToolConsumer(null, $dataConnector); $toolConsumer->initialize(); $toolConsumer->setRecordId($id); if (!$dataConnector->loadToolConsumer($toolConsumer)) { $toolConsumer->initialize(); } return $toolConsumer; } ### ### PRIVATE METHOD ### /** * Load the tool consumer from the database. * * @param string $key The consumer key value * @param boolean $autoEnable True if the consumer should be enabled (optional, default if false) * * @return boolean True if the consumer was successfully loaded */ private function load($key, $autoEnable = false) { $this->key = $key; $ok = $this->dataConnector->loadToolConsumer($this); if (!$ok) { $this->enabled = $autoEnable; } return $ok; } } ToolProvider/Service/ToolSettings.php 0000644 00000010317 15152003527 0013745 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\Service; /** * Class to implement the Tool Settings service * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class ToolSettings extends Service { /** * Settings at current level mode. */ const MODE_CURRENT_LEVEL = 1; /** * Settings at all levels mode. */ const MODE_ALL_LEVELS = 2; /** * Settings with distinct names at all levels mode. */ const MODE_DISTINCT_NAMES = 3; /** * Names of LTI parameters to be retained in the consumer settings property. * * @var array $LEVEL_NAMES */ private static $LEVEL_NAMES = array('ToolProxy' => 'system', 'ToolProxyBinding' => 'context', 'LtiLink' => 'link'); /** * The object to which the settings apply (ResourceLink, Context or ToolConsumer). * * @var object $source */ private $source; /** * Whether to use the simple JSON format. * * @var boolean $simple */ private $simple; /** * Class constructor. * * @param object $source The object to which the settings apply (ResourceLink, Context or ToolConsumer) * @param string $endpoint Service endpoint * @param boolean $simple True if the simple media type is to be used (optional, default is true) */ public function __construct($source, $endpoint, $simple = true) { if (is_a($source, 'IMSGlobal\LTI\ToolProvider\ToolConsumer')) { $consumer = $source; } else { $consumer = $source->getConsumer(); } if ($simple) { $mediaType = 'application/vnd.ims.lti.v2.toolsettings.simple+json'; } else { $mediaType = 'application/vnd.ims.lti.v2.toolsettings+json'; } parent::__construct($consumer, $endpoint, $mediaType); $this->source = $source; $this->simple = $simple; } /** * Get the tool settings. * * @param int $mode Mode for request (optional, default is current level only) * * @return mixed The array of settings if successful, otherwise false */ public function get($mode = self::MODE_CURRENT_LEVEL) { $parameter = array(); if ($mode === self::MODE_ALL_LEVELS) { $parameter['bubble'] = 'all'; } else if ($mode === self::MODE_DISTINCT_NAMES) { $parameter['bubble'] = 'distinct'; } $http = $this->send('GET', $parameter); if (!$http->ok) { $response = false; } else if ($this->simple) { $response = json_decode($http->response, true); } else if (isset($http->responseJson->{'@graph'})) { $response = array(); foreach ($http->responseJson->{'@graph'} as $level) { $settings = json_decode(json_encode($level->custom), true); unset($settings['@id']); $response[self::$LEVEL_NAMES[$level->{'@type'}]] = $settings; } } return $response; } /** * Set the tool settings. * * @param array $settings An associative array of settings (optional, default is null) * * @return HTTPMessage HTTP object containing request and response details */ public function set($settings) { if (!$this->simple) { if (is_a($this->source, 'ToolConsumer')) { $type = 'ToolProxy'; } else if (is_a($this->source, 'ToolConsumer')) { $type = 'ToolProxyBinding'; } else { $type = 'LtiLink'; } $obj = new \stdClass(); $obj->{'@context'} = 'http://purl.imsglobal.org/ctx/lti/v2/ToolSettings'; $obj->{'@graph'} = array(); $level = new \stdClass(); $level->{'@type'} = $type; $level->{'@id'} = $this->endpoint; $level->{'custom'} = $settings; $obj->{'@graph'}[] = $level; $body = json_encode($obj); } else { $body = json_encode($settings); } $response = parent::send('PUT', null, $body); return $response->ok; } } ToolProvider/Service/Service.php 0000644 00000005112 15152003527 0012704 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\Service; use IMSGlobal\LTI\ToolProvider; use IMSGlobal\LTI\HTTPMessage; /** * Class to implement a service * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class Service { /** * Whether service request should be sent unsigned. * * @var boolean $unsigned */ public $unsigned = false; /** * Service endpoint. * * @var string $endpoint */ protected $endpoint; /** * Tool Consumer for this service request. * * @var ToolConsumer $consumer */ private $consumer; /** * Media type of message body. * * @var string $mediaType */ private $mediaType; /** * Class constructor. * * @param ToolConsumer $consumer Tool consumer object for this service request * @param string $endpoint Service endpoint * @param string $mediaType Media type of message body */ public function __construct($consumer, $endpoint, $mediaType) { $this->consumer = $consumer; $this->endpoint = $endpoint; $this->mediaType = $mediaType; } /** * Send a service request. * * @param string $method The action type constant (optional, default is GET) * @param array $parameters Query parameters to add to endpoint (optional, default is none) * @param string $body Body of request (optional, default is null) * * @return HTTPMessage HTTP object containing request and response details */ public function send($method, $parameters = array(), $body = null) { $url = $this->endpoint; if (!empty($parameters)) { if (strpos($url, '?') === false) { $sep = '?'; } else { $sep = '&'; } foreach ($parameters as $name => $value) { $url .= $sep . urlencode($name) . '=' . urlencode($value); $sep = '&'; } } if (!$this->unsigned) { $header = ToolProvider\ToolConsumer::addSignature($url, $this->consumer->getKey(), $this->consumer->secret, $body, $method, $this->mediaType); } else { $header = null; } // Connect to tool consumer $http = new HTTPMessage($url, $method, $body, $header); // Parse JSON response if ($http->send() && !empty($http->response)) { $http->responseJson = json_decode($http->response); $http->ok = !is_null($http->responseJson); } return $http; } } ToolProvider/Service/Membership.php 0000644 00000010472 15152003527 0013404 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\Service; use IMSGlobal\LTI\ToolProvider; /** * Class to implement the Membership service * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class Membership extends Service { /** * The object to which the settings apply (ResourceLink, Context or ToolConsumer). * * @var object $source */ private $source; /** * Class constructor. * * @param object $source The object to which the memberships apply (ResourceLink or Context) * @param string $endpoint Service endpoint */ public function __construct($source, $endpoint) { $consumer = $source->getConsumer(); parent::__construct($consumer, $endpoint, 'application/vnd.ims.lis.v2.membershipcontainer+json'); $this->source = $source; } /** * Get the memberships. * * @param string $role Role for which memberships are to be requested (optional, default is all roles) * @param int $limit Limit on the number of memberships to be returned (optional, default is all) * * @return mixed The array of User objects if successful, otherwise false */ public function get($role = null, $limit = 0) { $isLink = is_a($this->source, 'IMSGlobal\LTI\ToolProvider\ResourceLink'); $parameters = array(); if (!empty($role)) { $parameters['role'] = $role; } if ($limit > 0) { $parameters['limit'] = strval($limit); } if ($isLink) { $parameters['rlid'] = $this->source->getId(); } $http = $this->send('GET', $parameters); if (!$http->ok) { $users = false; } else { $users = array(); if ($isLink) { $oldUsers = $this->source->getUserResultSourcedIDs(true, ToolProvider\ToolProvider::ID_SCOPE_RESOURCE); } foreach ($http->responseJson->pageOf->membershipSubject->membership as $membership) { $member = $membership->member; if ($isLink) { $user = ToolProvider\User::fromResourceLink($this->source, $member->userId); } else { $user = new ToolProvider\User(); $user->ltiUserId = $member->userId; } // Set the user name $firstname = (isset($member->givenName)) ? $member->givenName : ''; $lastname = (isset($member->familyName)) ? $member->familyName : ''; $fullname = (isset($member->name)) ? $member->name : ''; $user->setNames($firstname, $lastname, $fullname); // Set the user email $email = (isset($member->email)) ? $member->email : ''; $user->setEmail($email, $this->source->getConsumer()->defaultEmail); // Set the user roles if (isset($membership->role)) { $user->roles = ToolProvider\ToolProvider::parseRoles($membership->role); } // If a result sourcedid is provided save the user if ($isLink) { if (isset($member->message)) { foreach ($member->message as $message) { if (isset($message->message_type) && ($message->message_type === 'basic-lti-launch-request')) { if (isset($message->lis_result_sourcedid)) { $user->ltiResultSourcedId = $message->lis_result_sourcedid; $user->save(); } break; } } } } $users[] = $user; // Remove old user (if it exists) if ($isLink) { unset($oldUsers[$user->getId(ToolProvider\ToolProvider::ID_SCOPE_RESOURCE)]); } } // Delete any old users which were not in the latest list from the tool consumer if ($isLink) { foreach ($oldUsers as $id => $user) { $user->delete(); } } } return $users; } } ToolProvider/ResourceLinkShareKey.php 0000644 00000010035 15152003527 0013745 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; use IMSGlobal\LTI\ToolProvider\DataConnector\DataConnector; /** * Class to represent a tool consumer resource link share key * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class ResourceLinkShareKey { /** * Maximum permitted life for a share key value. */ const MAX_SHARE_KEY_LIFE = 168; // in hours (1 week) /** * Default life for a share key value. */ const DEFAULT_SHARE_KEY_LIFE = 24; // in hours /** * Minimum length for a share key value. */ const MIN_SHARE_KEY_LENGTH = 5; /** * Maximum length for a share key value. */ const MAX_SHARE_KEY_LENGTH = 32; /** * ID for resource link being shared. * * @var string $resourceLinkId */ public $resourceLinkId = null; /** * Length of share key. * * @var int $length */ public $length = null; /** * Life of share key. * * @var int $life */ public $life = null; // in hours /** * Whether the sharing arrangement should be automatically approved when first used. * * @var boolean $autoApprove */ public $autoApprove = false; /** * Date/time when the share key expires. * * @var int $expires */ public $expires = null; /** * Share key value. * * @var string $id */ private $id = null; /** * Data connector. * * @var DataConnector $dataConnector */ private $dataConnector = null; /** * Class constructor. * * @param ResourceLink $resourceLink Resource_Link object * @param string $id Value of share key (optional, default is null) */ public function __construct($resourceLink, $id = null) { $this->initialize(); $this->dataConnector = $resourceLink->getDataConnector(); $this->resourceLinkId = $resourceLink->getRecordId(); $this->id = $id; if (!empty($id)) { $this->load(); } } /** * Initialise the resource link share key. */ public function initialize() { $this->length = null; $this->life = null; $this->autoApprove = false; $this->expires = null; } /** * Initialise the resource link share key. * * Pseudonym for initialize(). */ public function initialise() { $this->initialize(); } /** * Save the resource link share key to the database. * * @return boolean True if the share key was successfully saved */ public function save() { if (empty($this->life)) { $this->life = self::DEFAULT_SHARE_KEY_LIFE; } else { $this->life = max(min($this->life, self::MAX_SHARE_KEY_LIFE), 0); } $this->expires = time() + ($this->life * 60 * 60); if (empty($this->id)) { if (empty($this->length) || !is_numeric($this->length)) { $this->length = self::MAX_SHARE_KEY_LENGTH; } else { $this->length = max(min($this->length, self::MAX_SHARE_KEY_LENGTH), self::MIN_SHARE_KEY_LENGTH); } $this->id = DataConnector::getRandomString($this->length); } return $this->dataConnector->saveResourceLinkShareKey($this); } /** * Delete the resource link share key from the database. * * @return boolean True if the share key was successfully deleted */ public function delete() { return $this->dataConnector->deleteResourceLinkShareKey($this); } /** * Get share key value. * * @return string Share key value */ public function getId() { return $this->id; } ### ### PRIVATE METHOD ### /** * Load the resource link share key from the database. */ private function load() { $this->initialize(); $this->dataConnector->loadResourceLinkShareKey($this); if (!is_null($this->id)) { $this->length = strlen($this->id); } if (!is_null($this->expires)) { $this->life = ($this->expires - time()) / 60 / 60; } } } ToolProvider/ToolProxy.php 0000644 00000006652 15152003527 0011675 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; use IMSGlobal\LTI\ToolProvider\DataConnector\DataConnector; use IMSGlobal\LTI\ToolProvider\MediaType; /** * Class to represent an LTI Tool Proxy * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license GNU Lesser General Public License, version 3 (<http://www.gnu.org/licenses/lgpl.html>) */ class ToolProxy { /** * Local id of tool consumer. * * @var string $id */ public $id = null; /** * Tool Consumer for this tool proxy. * * @var ToolConsumer $consumer */ private $consumer = null; /** * Tool Consumer ID for this tool proxy. * * @var int $consumerId */ private $consumerId = null; /** * Consumer ID value. * * @var int $id */ private $recordId = null; /** * Data connector object. * * @var DataConnector $dataConnector */ private $dataConnector = null; /** * Tool Proxy document. * * @var MediaType\ToolProxy $toolProxy */ private $toolProxy = null; /** * Class constructor. * * @param DataConnector $dataConnector Data connector * @param string $id Tool Proxy ID (optional, default is null) */ public function __construct($dataConnector, $id = null) { $this->initialize(); $this->dataConnector = $dataConnector; if (!empty($id)) { $this->load($id); } else { $this->recordId = DataConnector::getRandomString(32); } } /** * Initialise the tool proxy. */ public function initialize() { $this->id = null; $this->recordId = null; $this->toolProxy = null; $this->created = null; $this->updated = null; } /** * Initialise the tool proxy. * * Pseudonym for initialize(). */ public function initialise() { $this->initialize(); } /** * Get the tool proxy record ID. * * @return int Tool Proxy record ID value */ public function getRecordId() { return $this->recordId; } /** * Sets the tool proxy record ID. * * @param int $recordId Tool Proxy record ID value */ public function setRecordId($recordId) { $this->recordId = $recordId; } /** * Get tool consumer. * * @return ToolConsumer Tool consumer object for this context. */ public function getConsumer() { if (is_null($this->consumer)) { $this->consumer = ToolConsumer::fromRecordId($this->consumerId, $this->getDataConnector()); } return $this->consumer; } /** * Set tool consumer ID. * * @param int $consumerId Tool Consumer ID for this resource link. */ public function setConsumerId($consumerId) { $this->consumer = null; $this->consumerId = $consumerId; } /** * Get the data connector. * * @return DataConnector Data connector object */ public function getDataConnector() { return $this->dataConnector; } ### ### PRIVATE METHOD ### /** * Load the tool proxy from the database. * * @param string $id The tool proxy id value * * @return boolean True if the tool proxy was successfully loaded */ private function load($id) { $this->initialize(); $this->id = $id; $ok = $this->dataConnector->loadToolProxy($this); if (!$ok) { $this->enabled = $autoEnable; } return $ok; } } ToolProvider/ContentItemImage.php 0000644 00000001460 15152003527 0013102 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; /** * Class to represent a content-item image object * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class ContentItemImage { /** * Class constructor. * * @param string $id URL of image * @param int $height Height of image in pixels (optional) * @param int $width Width of image in pixels (optional) */ function __construct($id, $height = null, $width = null) { $this->{'@id'} = $id; if (!is_null($height)) { $this->height = $height; } if (!is_null($width)) { $this->width = $width; } } } ToolProvider/Context.php 0000644 00000021622 15152003527 0011334 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; use IMSGlobal\LTI\ToolProvider\DataConnector\DataConnector; use IMSGlobal\LTI\ToolProvider\Service; /** * Class to represent a tool consumer context * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class Context { /** * Context ID as supplied in the last connection request. * * @var string $ltiContextId */ public $ltiContextId = null; /** * Context title. * * @var string $title */ public $title = null; /** * Setting values (LTI parameters, custom parameters and local parameters). * * @var array $settings */ public $settings = null; /** * Context type. * * @var string $type */ public $type = null; /** * Date/time when the object was created. * * @var int $created */ public $created = null; /** * Date/time when the object was last updated. * * @var int $updated */ public $updated = null; /** * Tool Consumer for this context. * * @var ToolConsumer $consumer */ private $consumer = null; /** * Tool Consumer ID for this context. * * @var int $consumerId */ private $consumerId = null; /** * ID for this context. * * @var int $id */ private $id = null; /** * Whether the settings value have changed since last saved. * * @var boolean $settingsChanged */ private $settingsChanged = false; /** * Data connector object or string. * * @var mixed $dataConnector */ private $dataConnector = null; /** * Class constructor. */ public function __construct() { $this->initialize(); } /** * Initialise the context. */ public function initialize() { $this->title = ''; $this->settings = array(); $this->created = null; $this->updated = null; } /** * Initialise the context. * * Pseudonym for initialize(). */ public function initialise() { $this->initialize(); } /** * Save the context to the database. * * @return boolean True if the context was successfully saved. */ public function save() { $ok = $this->getDataConnector()->saveContext($this); if ($ok) { $this->settingsChanged = false; } return $ok; } /** * Delete the context from the database. * * @return boolean True if the context was successfully deleted. */ public function delete() { return $this->getDataConnector()->deleteContext($this); } /** * Get tool consumer. * * @return ToolConsumer Tool consumer object for this context. */ public function getConsumer() { if (is_null($this->consumer)) { $this->consumer = ToolConsumer::fromRecordId($this->consumerId, $this->getDataConnector()); } return $this->consumer; } /** * Set tool consumer ID. * * @param int $consumerId Tool Consumer ID for this resource link. */ public function setConsumerId($consumerId) { $this->consumer = null; $this->consumerId = $consumerId; } /** * Get tool consumer key. * * @return string Consumer key value for this context. */ public function getKey() { return $this->getConsumer()->getKey(); } /** * Get context ID. * * @return string ID for this context. */ public function getId() { return $this->ltiContextId; } /** * Get the context record ID. * * @return int Context record ID value */ public function getRecordId() { return $this->id; } /** * Sets the context record ID. * * @return int $id Context record ID value */ public function setRecordId($id) { $this->id = $id; } /** * Get the data connector. * * @return mixed Data connector object or string */ public function getDataConnector() { return $this->dataConnector; } /** * Get a setting value. * * @param string $name Name of setting * @param string $default Value to return if the setting does not exist (optional, default is an empty string) * * @return string Setting value */ public function getSetting($name, $default = '') { if (array_key_exists($name, $this->settings)) { $value = $this->settings[$name]; } else { $value = $default; } return $value; } /** * Set a setting value. * * @param string $name Name of setting * @param string $value Value to set, use an empty value to delete a setting (optional, default is null) */ public function setSetting($name, $value = null) { $old_value = $this->getSetting($name); if ($value !== $old_value) { if (!empty($value)) { $this->settings[$name] = $value; } else { unset($this->settings[$name]); } $this->settingsChanged = true; } } /** * Get an array of all setting values. * * @return array Associative array of setting values */ public function getSettings() { return $this->settings; } /** * Set an array of all setting values. * * @param array $settings Associative array of setting values */ public function setSettings($settings) { $this->settings = $settings; } /** * Save setting values. * * @return boolean True if the settings were successfully saved */ public function saveSettings() { if ($this->settingsChanged) { $ok = $this->save(); } else { $ok = true; } return $ok; } /** * Check if the Tool Settings service is supported. * * @return boolean True if this context supports the Tool Settings service */ public function hasToolSettingsService() { $url = $this->getSetting('custom_context_setting_url'); return !empty($url); } /** * Get Tool Settings. * * @param int $mode Mode for request (optional, default is current level only) * @param boolean $simple True if all the simple media type is to be used (optional, default is true) * * @return mixed The array of settings if successful, otherwise false */ public function getToolSettings($mode = Service\ToolSettings::MODE_CURRENT_LEVEL, $simple = true) { $url = $this->getSetting('custom_context_setting_url'); $service = new Service\ToolSettings($this, $url, $simple); $response = $service->get($mode); return $response; } /** * Perform a Tool Settings service request. * * @param array $settings An associative array of settings (optional, default is none) * * @return boolean True if action was successful, otherwise false */ public function setToolSettings($settings = array()) { $url = $this->getSetting('custom_context_setting_url'); $service = new Service\ToolSettings($this, $url); $response = $service->set($settings); return $response; } /** * Check if the Membership service is supported. * * @return boolean True if this context supports the Membership service */ public function hasMembershipService() { $url = $this->getSetting('custom_context_memberships_url'); return !empty($url); } /** * Get Memberships. * * @return mixed The array of User objects if successful, otherwise false */ public function getMembership() { $url = $this->getSetting('custom_context_memberships_url'); $service = new Service\Membership($this, $url); $response = $service->get(); return $response; } /** * Load the context from the database. * * @param int $id Record ID of context * @param DataConnector $dataConnector Database connection object * * @return Context Context object */ public static function fromRecordId($id, $dataConnector) { $context = new Context(); $context->dataConnector = $dataConnector; $context->load($id); return $context; } /** * Class constructor from consumer. * * @param ToolConsumer $consumer Consumer instance * @param string $ltiContextId LTI Context ID value * @return Context */ public static function fromConsumer($consumer, $ltiContextId) { $context = new Context(); $context->consumer = $consumer; $context->dataConnector = $consumer->getDataConnector(); $context->ltiContextId = $ltiContextId; if (!empty($ltiContextId)) { $context->load(); } return $context; } ### ### PRIVATE METHODS ### /** * Load the context from the database. * * @param int $id Record ID of context (optional, default is null) * * @return boolean True if context was successfully loaded */ private function load($id = null) { $this->initialize(); $this->id = $id; return $this->getDataConnector()->loadContext($this); } } ToolProvider/Outcome.php 0000644 00000002666 15152003527 0011332 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; /** * Class to represent an outcome * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class Outcome { /** * Language value. * * @var string $language */ public $language = null; /** * Outcome status value. * * @var string $status */ public $status = null; /** * Outcome date value. * * @var string $date */ public $date = null; /** * Outcome type value. * * @var string $type */ public $type = null; /** * Outcome data source value. * * @var string $dataSource */ public $dataSource = null; /** * Outcome value. * * @var string $value */ private $value = null; /** * Class constructor. * * @param string $value Outcome value (optional, default is none) */ public function __construct($value = null) { $this->value = $value; $this->language = 'en-US'; $this->date = gmdate('Y-m-d\TH:i:s\Z', time()); $this->type = 'decimal'; } /** * Get the outcome value. * * @return string Outcome value */ public function getValue() { return $this->value; } /** * Set the outcome value. * * @param string $value Outcome value */ public function setValue($value) { $this->value = $value; } } ToolProvider/OAuthDataStore.php 0000644 00000005216 15152003527 0012540 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; use IMSGlobal\LTI\OAuth; /** * Class to represent an OAuth datastore * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class OAuthDataStore extends OAuth\OAuthDataStore { /** * Tool Provider object. * * @var ToolProvider $toolProvider */ private $toolProvider = null; /** * Class constructor. * * @param ToolProvider $toolProvider Tool_Provider object */ public function __construct($toolProvider) { $this->toolProvider = $toolProvider; } /** * Create an OAuthConsumer object for the tool consumer. * * @param string $consumerKey Consumer key value * * @return OAuthConsumer OAuthConsumer object */ function lookup_consumer($consumerKey) { return new OAuth\OAuthConsumer($this->toolProvider->consumer->getKey(), $this->toolProvider->consumer->secret); } /** * Create an OAuthToken object for the tool consumer. * * @param string $consumer OAuthConsumer object * @param string $tokenType Token type * @param string $token Token value * * @return OAuthToken OAuthToken object */ function lookup_token($consumer, $tokenType, $token) { return new OAuth\OAuthToken($consumer, ''); } /** * Lookup nonce value for the tool consumer. * * @param OAuthConsumer $consumer OAuthConsumer object * @param string $token Token value * @param string $value Nonce value * @param string $timestamp Date/time of request * * @return boolean True if the nonce value already exists */ function lookup_nonce($consumer, $token, $value, $timestamp) { $nonce = new ConsumerNonce($this->toolProvider->consumer, $value); $ok = !$nonce->load(); if ($ok) { $ok = $nonce->save(); } if (!$ok) { $this->toolProvider->reason = 'Invalid nonce.'; } return !$ok; } /** * Get new request token. * * @param OAuthConsumer $consumer OAuthConsumer object * @param string $callback Callback URL * * @return string Null value */ function new_request_token($consumer, $callback = null) { return null; } /** * Get new access token. * * @param string $token Token value * @param OAuthConsumer $consumer OAuthConsumer object * @param string $verifier Verification code * * @return string Null value */ function new_access_token($token, $consumer, $verifier = null) { return null; } } ToolProvider/ContentItemPlacement.php 0000644 00000002146 15152003527 0013772 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; /** * Class to represent a content-item placement object * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class ContentItemPlacement { /** * Class constructor. * * @param int $displayWidth Width of item location * @param int $displayHeight Height of item location * @param string $documentTarget Location to open content in * @param string $windowTarget Name of window target */ function __construct($displayWidth, $displayHeight, $documentTarget, $windowTarget) { if (!empty($displayWidth)) { $this->displayWidth = $displayWidth; } if (!empty($displayHeight)) { $this->displayHeight = $displayHeight; } if (!empty($documentTarget)) { $this->documentTarget = $documentTarget; } if (!empty($windowTarget)) { $this->windowTarget = $windowTarget; } } } ToolProvider/ResourceLink.php 0000644 00000113035 15152003527 0012315 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; use DOMDocument; use DOMElement; use IMSGlobal\LTI\ToolProvider\DataConnector\DataConnector; use IMSGlobal\LTI\ToolProvider\Service; use IMSGlobal\LTI\HTTPMessage; use IMSGlobal\LTI\OAuth; /** * Class to represent a tool consumer resource link * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class ResourceLink { /** * Read action. */ const EXT_READ = 1; /** * Write (create/update) action. */ const EXT_WRITE = 2; /** * Delete action. */ const EXT_DELETE = 3; /** * Create action. */ const EXT_CREATE = 4; /** * Update action. */ const EXT_UPDATE = 5; /** * Decimal outcome type. */ const EXT_TYPE_DECIMAL = 'decimal'; /** * Percentage outcome type. */ const EXT_TYPE_PERCENTAGE = 'percentage'; /** * Ratio outcome type. */ const EXT_TYPE_RATIO = 'ratio'; /** * Letter (A-F) outcome type. */ const EXT_TYPE_LETTER_AF = 'letteraf'; /** * Letter (A-F) with optional +/- outcome type. */ const EXT_TYPE_LETTER_AF_PLUS = 'letterafplus'; /** * Pass/fail outcome type. */ const EXT_TYPE_PASS_FAIL = 'passfail'; /** * Free text outcome type. */ const EXT_TYPE_TEXT = 'freetext'; /** * Context title. * * @var string $title */ public $title = null; /** * Resource link ID as supplied in the last connection request. * * @var string $ltiResourceLinkId */ public $ltiResourceLinkId = null; /** * User group sets (null if the consumer does not support the groups enhancement) * * @var array $groupSets */ public $groupSets = null; /** * User groups (null if the consumer does not support the groups enhancement) * * @var array $groups */ public $groups = null; /** * Request for last service request. * * @var string $extRequest */ public $extRequest = null; /** * Request headers for last service request. * * @var array $extRequestHeaders */ public $extRequestHeaders = null; /** * Response from last service request. * * @var string $extResponse */ public $extResponse = null; /** * Response header from last service request. * * @var array $extResponseHeaders */ public $extResponseHeaders = null; /** * Consumer key value for resource link being shared (if any). * * @var string $primaryResourceLinkId */ public $primaryResourceLinkId = null; /** * Whether the sharing request has been approved by the primary resource link. * * @var boolean $shareApproved */ public $shareApproved = null; /** * Date/time when the object was created. * * @var int $created */ public $created = null; /** * Date/time when the object was last updated. * * @var int $updated */ public $updated = null; /** * Record ID for this resource link. * * @var int $id */ private $id = null; /** * Tool Consumer for this resource link. * * @var ToolConsumer $consumer */ private $consumer = null; /** * Tool Consumer ID for this resource link. * * @var int $consumerId */ private $consumerId = null; /** * Context for this resource link. * * @var Context $context */ private $context = null; /** * Context ID for this resource link. * * @var int $contextId */ private $contextId = null; /** * Setting values (LTI parameters, custom parameters and local parameters). * * @var array $settings */ private $settings = null; /** * Whether the settings value have changed since last saved. * * @var boolean $settingsChanged */ private $settingsChanged = false; /** * XML document for the last extension service request. * * @var string $extDoc */ private $extDoc = null; /** * XML node array for the last extension service request. * * @var array $extNodes */ private $extNodes = null; /** * Data connector object or string. * * @var mixed $dataConnector */ private $dataConnector = null; /** * Class constructor. */ public function __construct() { $this->initialize(); } /** * Initialise the resource link. */ public function initialize() { $this->title = ''; $this->settings = array(); $this->groupSets = null; $this->groups = null; $this->primaryResourceLinkId = null; $this->shareApproved = null; $this->created = null; $this->updated = null; } /** * Initialise the resource link. * * Pseudonym for initialize(). */ public function initialise() { $this->initialize(); } /** * Save the resource link to the database. * * @return boolean True if the resource link was successfully saved. */ public function save() { $ok = $this->getDataConnector()->saveResourceLink($this); if ($ok) { $this->settingsChanged = false; } return $ok; } /** * Delete the resource link from the database. * * @return boolean True if the resource link was successfully deleted. */ public function delete() { return $this->getDataConnector()->deleteResourceLink($this); } /** * Get tool consumer. * * @return ToolConsumer Tool consumer object for this resource link. */ public function getConsumer() { if (is_null($this->consumer)) { if (!is_null($this->context) || !is_null($this->contextId)) { $this->consumer = $this->getContext()->getConsumer(); } else { $this->consumer = ToolConsumer::fromRecordId($this->consumerId, $this->getDataConnector()); } } return $this->consumer; } /** * Set tool consumer ID. * * @param int $consumerId Tool Consumer ID for this resource link. */ public function setConsumerId($consumerId) { $this->consumer = null; $this->consumerId = $consumerId; } /** * Get context. * * @return object LTIContext object for this resource link. */ public function getContext() { if (is_null($this->context) && !is_null($this->contextId)) { $this->context = Context::fromRecordId($this->contextId, $this->getDataConnector()); } return $this->context; } /** * Get context record ID. * * @return int Context record ID for this resource link. */ public function getContextId() { return $this->contextId; } /** * Set context ID. * * @param int $contextId Context ID for this resource link. */ public function setContextId($contextId) { $this->context = null; $this->contextId = $contextId; } /** * Get tool consumer key. * * @return string Consumer key value for this resource link. */ public function getKey() { return $this->getConsumer()->getKey(); } /** * Get resource link ID. * * @return string ID for this resource link. */ public function getId() { return $this->ltiResourceLinkId; } /** * Get resource link record ID. * * @return int Record ID for this resource link. */ public function getRecordId() { return $this->id; } /** * Set resource link record ID. * * @param int $id Record ID for this resource link. */ public function setRecordId($id) { $this->id = $id; } /** * Get the data connector. * * @return mixed Data connector object or string */ public function getDataConnector() { return $this->dataConnector; } /** * Get a setting value. * * @param string $name Name of setting * @param string $default Value to return if the setting does not exist (optional, default is an empty string) * * @return string Setting value */ public function getSetting($name, $default = '') { if (array_key_exists($name, $this->settings)) { $value = $this->settings[$name]; } else { $value = $default; } return $value; } /** * Set a setting value. * * @param string $name Name of setting * @param string $value Value to set, use an empty value to delete a setting (optional, default is null) */ public function setSetting($name, $value = null) { $old_value = $this->getSetting($name); if ($value !== $old_value) { if (!empty($value)) { $this->settings[$name] = $value; } else { unset($this->settings[$name]); } $this->settingsChanged = true; } } /** * Get an array of all setting values. * * @return array Associative array of setting values */ public function getSettings() { return $this->settings; } /** * Set an array of all setting values. * * @param array $settings Associative array of setting values */ public function setSettings($settings) { $this->settings = $settings; } /** * Save setting values. * * @return boolean True if the settings were successfully saved */ public function saveSettings() { if ($this->settingsChanged) { $ok = $this->save(); } else { $ok = true; } return $ok; } /** * Check if the Outcomes service is supported. * * @return boolean True if this resource link supports the Outcomes service (either the LTI 1.1 or extension service) */ public function hasOutcomesService() { $url = $this->getSetting('ext_ims_lis_basic_outcome_url') . $this->getSetting('lis_outcome_service_url'); return !empty($url); } /** * Check if the Memberships extension service is supported. * * @return boolean True if this resource link supports the Memberships extension service */ public function hasMembershipsService() { $url = $this->getSetting('ext_ims_lis_memberships_url'); return !empty($url); } /** * Check if the Setting extension service is supported. * * @return boolean True if this resource link supports the Setting extension service */ public function hasSettingService() { $url = $this->getSetting('ext_ims_lti_tool_setting_url'); return !empty($url); } /** * Perform an Outcomes service request. * * @param int $action The action type constant * @param Outcome $ltiOutcome Outcome object * @param User $user User object * * @return boolean True if the request was successfully processed */ public function doOutcomesService($action, $ltiOutcome, $user) { $response = false; $this->extResponse = null; // Lookup service details from the source resource link appropriate to the user (in case the destination is being shared) $sourceResourceLink = $user->getResourceLink(); $sourcedId = $user->ltiResultSourcedId; // Use LTI 1.1 service in preference to extension service if it is available $urlLTI11 = $sourceResourceLink->getSetting('lis_outcome_service_url'); $urlExt = $sourceResourceLink->getSetting('ext_ims_lis_basic_outcome_url'); if ($urlExt || $urlLTI11) { switch ($action) { case self::EXT_READ: if ($urlLTI11 && ($ltiOutcome->type === self::EXT_TYPE_DECIMAL)) { $do = 'readResult'; } else if ($urlExt) { $urlLTI11 = null; $do = 'basic-lis-readresult'; } break; case self::EXT_WRITE: if ($urlLTI11 && $this->checkValueType($ltiOutcome, array(self::EXT_TYPE_DECIMAL))) { $do = 'replaceResult'; } else if ($this->checkValueType($ltiOutcome)) { $urlLTI11 = null; $do = 'basic-lis-updateresult'; } break; case self::EXT_DELETE: if ($urlLTI11 && ($ltiOutcome->type === self::EXT_TYPE_DECIMAL)) { $do = 'deleteResult'; } else if ($urlExt) { $urlLTI11 = null; $do = 'basic-lis-deleteresult'; } break; } } if (isset($do)) { $value = $ltiOutcome->getValue(); if (is_null($value)) { $value = ''; } if ($urlLTI11) { $xml = ''; if ($action === self::EXT_WRITE) { $xml = <<<EOF <result> <resultScore> <language>{$ltiOutcome->language}</language> <textString>{$value}</textString> </resultScore> </result> EOF; } $sourcedId = htmlentities($sourcedId); $xml = <<<EOF <resultRecord> <sourcedGUID> <sourcedId>{$sourcedId}</sourcedId> </sourcedGUID>{$xml} </resultRecord> EOF; if ($this->doLTI11Service($do, $urlLTI11, $xml)) { switch ($action) { case self::EXT_READ: if (!isset($this->extNodes['imsx_POXBody']["{$do}Response"]['result']['resultScore']['textString'])) { break; } else { $ltiOutcome->setValue($this->extNodes['imsx_POXBody']["{$do}Response"]['result']['resultScore']['textString']); } case self::EXT_WRITE: case self::EXT_DELETE: $response = true; break; } } } else { $params = array(); $params['sourcedid'] = $sourcedId; $params['result_resultscore_textstring'] = $value; if (!empty($ltiOutcome->language)) { $params['result_resultscore_language'] = $ltiOutcome->language; } if (!empty($ltiOutcome->status)) { $params['result_statusofresult'] = $ltiOutcome->status; } if (!empty($ltiOutcome->date)) { $params['result_date'] = $ltiOutcome->date; } if (!empty($ltiOutcome->type)) { $params['result_resultvaluesourcedid'] = $ltiOutcome->type; } if (!empty($ltiOutcome->data_source)) { $params['result_datasource'] = $ltiOutcome->data_source; } if ($this->doService($do, $urlExt, $params)) { switch ($action) { case self::EXT_READ: if (isset($this->extNodes['result']['resultscore']['textstring'])) { $response = $this->extNodes['result']['resultscore']['textstring']; } break; case self::EXT_WRITE: case self::EXT_DELETE: $response = true; break; } } } if (is_array($response) && (count($response) <= 0)) { $response = ''; } } return $response; } /** * Perform a Memberships service request. * * The user table is updated with the new list of user objects. * * @param boolean $withGroups True is group information is to be requested as well * * @return mixed Array of User objects or False if the request was not successful */ public function doMembershipsService($withGroups = false) { $users = array(); $oldUsers = $this->getUserResultSourcedIDs(true, ToolProvider::ID_SCOPE_RESOURCE); $this->extResponse = null; $url = $this->getSetting('ext_ims_lis_memberships_url'); $params = array(); $params['id'] = $this->getSetting('ext_ims_lis_memberships_id'); $ok = false; if ($withGroups) { $ok = $this->doService('basic-lis-readmembershipsforcontextwithgroups', $url, $params); } if ($ok) { $this->groupSets = array(); $this->groups = array(); } else { $ok = $this->doService('basic-lis-readmembershipsforcontext', $url, $params); } if ($ok) { if (!isset($this->extNodes['memberships']['member'])) { $members = array(); } else if (!isset($this->extNodes['memberships']['member'][0])) { $members = array(); $members[0] = $this->extNodes['memberships']['member']; } else { $members = $this->extNodes['memberships']['member']; } for ($i = 0; $i < count($members); $i++) { $user = User::fromResourceLink($this, $members[$i]['user_id']); // Set the user name $firstname = (isset($members[$i]['person_name_given'])) ? $members[$i]['person_name_given'] : ''; $lastname = (isset($members[$i]['person_name_family'])) ? $members[$i]['person_name_family'] : ''; $fullname = (isset($members[$i]['person_name_full'])) ? $members[$i]['person_name_full'] : ''; $user->setNames($firstname, $lastname, $fullname); // Set the user email $email = (isset($members[$i]['person_contact_email_primary'])) ? $members[$i]['person_contact_email_primary'] : ''; $user->setEmail($email, $this->getConsumer()->defaultEmail); /// Set the user roles if (isset($members[$i]['roles'])) { $user->roles = ToolProvider::parseRoles($members[$i]['roles']); } // Set the user groups if (!isset($members[$i]['groups']['group'])) { $groups = array(); } else if (!isset($members[$i]['groups']['group'][0])) { $groups = array(); $groups[0] = $members[$i]['groups']['group']; } else { $groups = $members[$i]['groups']['group']; } for ($j = 0; $j < count($groups); $j++) { $group = $groups[$j]; if (isset($group['set'])) { $set_id = $group['set']['id']; if (!isset($this->groupSets[$set_id])) { $this->groupSets[$set_id] = array('title' => $group['set']['title'], 'groups' => array(), 'num_members' => 0, 'num_staff' => 0, 'num_learners' => 0); } $this->groupSets[$set_id]['num_members']++; if ($user->isStaff()) { $this->groupSets[$set_id]['num_staff']++; } if ($user->isLearner()) { $this->groupSets[$set_id]['num_learners']++; } if (!in_array($group['id'], $this->groupSets[$set_id]['groups'])) { $this->groupSets[$set_id]['groups'][] = $group['id']; } $this->groups[$group['id']] = array('title' => $group['title'], 'set' => $set_id); } else { $this->groups[$group['id']] = array('title' => $group['title']); } $user->groups[] = $group['id']; } // If a result sourcedid is provided save the user if (isset($members[$i]['lis_result_sourcedid'])) { $user->ltiResultSourcedId = $members[$i]['lis_result_sourcedid']; $user->save(); } $users[] = $user; // Remove old user (if it exists) unset($oldUsers[$user->getId(ToolProvider::ID_SCOPE_RESOURCE)]); } // Delete any old users which were not in the latest list from the tool consumer foreach ($oldUsers as $id => $user) { $user->delete(); } } else { $users = false; } return $users; } /** * Perform a Setting service request. * * @param int $action The action type constant * @param string $value The setting value (optional, default is null) * * @return mixed The setting value for a read action, true if a write or delete action was successful, otherwise false */ public function doSettingService($action, $value = null) { $response = false; $this->extResponse = null; switch ($action) { case self::EXT_READ: $do = 'basic-lti-loadsetting'; break; case self::EXT_WRITE: $do = 'basic-lti-savesetting'; break; case self::EXT_DELETE: $do = 'basic-lti-deletesetting'; break; } if (isset($do)) { $url = $this->getSetting('ext_ims_lti_tool_setting_url'); $params = array(); $params['id'] = $this->getSetting('ext_ims_lti_tool_setting_id'); if (is_null($value)) { $value = ''; } $params['setting'] = $value; if ($this->doService($do, $url, $params)) { switch ($action) { case self::EXT_READ: if (isset($this->extNodes['setting']['value'])) { $response = $this->extNodes['setting']['value']; if (is_array($response)) { $response = ''; } } break; case self::EXT_WRITE: $this->setSetting('ext_ims_lti_tool_setting', $value); $this->saveSettings(); $response = true; break; case self::EXT_DELETE: $response = true; break; } } } return $response; } /** * Check if the Tool Settings service is supported. * * @return boolean True if this resource link supports the Tool Settings service */ public function hasToolSettingsService() { $url = $this->getSetting('custom_link_setting_url'); return !empty($url); } /** * Get Tool Settings. * * @param int $mode Mode for request (optional, default is current level only) * @param boolean $simple True if all the simple media type is to be used (optional, default is true) * * @return mixed The array of settings if successful, otherwise false */ public function getToolSettings($mode = Service\ToolSettings::MODE_CURRENT_LEVEL, $simple = true) { $url = $this->getSetting('custom_link_setting_url'); $service = new Service\ToolSettings($this, $url, $simple); $response = $service->get($mode); return $response; } /** * Perform a Tool Settings service request. * * @param array $settings An associative array of settings (optional, default is none) * * @return boolean True if action was successful, otherwise false */ public function setToolSettings($settings = array()) { $url = $this->getSetting('custom_link_setting_url'); $service = new Service\ToolSettings($this, $url); $response = $service->set($settings); return $response; } /** * Check if the Membership service is supported. * * @return boolean True if this resource link supports the Membership service */ public function hasMembershipService() { $has = !empty($this->contextId); if ($has) { $has = !empty($this->getContext()->getSetting('custom_context_memberships_url')); } return $has; } /** * Get Memberships. * * @return mixed The array of User objects if successful, otherwise false */ public function getMembership() { $response = false; if (!empty($this->contextId)) { $url = $this->getContext()->getSetting('custom_context_memberships_url'); if (!empty($url)) { $service = new Service\Membership($this, $url); $response = $service->get(); } } return $response; } /** * Obtain an array of User objects for users with a result sourcedId. * * The array may include users from other resource links which are sharing this resource link. * It may also be optionally indexed by the user ID of a specified scope. * * @param boolean $localOnly True if only users from this resource link are to be returned, not users from shared resource links (optional, default is false) * @param int $idScope Scope to use for ID values (optional, default is null for consumer default) * * @return array Array of User objects */ public function getUserResultSourcedIDs($localOnly = false, $idScope = null) { return $this->getDataConnector()->getUserResultSourcedIDsResourceLink($this, $localOnly, $idScope); } /** * Get an array of ResourceLinkShare objects for each resource link which is sharing this context. * * @return array Array of ResourceLinkShare objects */ public function getShares() { return $this->getDataConnector()->getSharesResourceLink($this); } /** * Class constructor from consumer. * * @param ToolConsumer $consumer Consumer object * @param string $ltiResourceLinkId Resource link ID value * @param string $tempId Temporary Resource link ID value (optional, default is null) * @return ResourceLink */ public static function fromConsumer($consumer, $ltiResourceLinkId, $tempId = null) { $resourceLink = new ResourceLink(); $resourceLink->consumer = $consumer; $resourceLink->dataConnector = $consumer->getDataConnector(); $resourceLink->ltiResourceLinkId = $ltiResourceLinkId; if (!empty($ltiResourceLinkId)) { $resourceLink->load(); if (is_null($resourceLink->id) && !empty($tempId)) { $resourceLink->ltiResourceLinkId = $tempId; $resourceLink->load(); $resourceLink->ltiResourceLinkId = $ltiResourceLinkId; } } return $resourceLink; } /** * Class constructor from context. * * @param Context $context Context object * @param string $ltiResourceLinkId Resource link ID value * @param string $tempId Temporary Resource link ID value (optional, default is null) * @return ResourceLink */ public static function fromContext($context, $ltiResourceLinkId, $tempId = null) { $resourceLink = new ResourceLink(); $resourceLink->setContextId($context->getRecordId()); $resourceLink->context = $context; $resourceLink->dataConnector = $context->getDataConnector(); $resourceLink->ltiResourceLinkId = $ltiResourceLinkId; if (!empty($ltiResourceLinkId)) { $resourceLink->load(); if (is_null($resourceLink->id) && !empty($tempId)) { $resourceLink->ltiResourceLinkId = $tempId; $resourceLink->load(); $resourceLink->ltiResourceLinkId = $ltiResourceLinkId; } } return $resourceLink; } /** * Load the resource link from the database. * * @param int $id Record ID of resource link * @param DataConnector $dataConnector Database connection object * * @return ResourceLink ResourceLink object */ public static function fromRecordId($id, $dataConnector) { $resourceLink = new ResourceLink(); $resourceLink->dataConnector = $dataConnector; $resourceLink->load($id); return $resourceLink; } ### ### PRIVATE METHODS ### /** * Load the resource link from the database. * * @param int $id Record ID of resource link (optional, default is null) * * @return boolean True if resource link was successfully loaded */ private function load($id = null) { $this->initialize(); $this->id = $id; return $this->getDataConnector()->loadResourceLink($this); } /** * Convert data type of value to a supported type if possible. * * @param Outcome $ltiOutcome Outcome object * @param string[] $supportedTypes Array of outcome types to be supported (optional, default is null to use supported types reported in the last launch for this resource link) * * @return boolean True if the type/value are valid and supported */ private function checkValueType($ltiOutcome, $supportedTypes = null) { if (empty($supportedTypes)) { $supportedTypes = explode(',', str_replace(' ', '', strtolower($this->getSetting('ext_ims_lis_resultvalue_sourcedids', self::EXT_TYPE_DECIMAL)))); } $type = $ltiOutcome->type; $value = $ltiOutcome->getValue(); // Check whether the type is supported or there is no value $ok = in_array($type, $supportedTypes) || (strlen($value) <= 0); if (!$ok) { // Convert numeric values to decimal if ($type === self::EXT_TYPE_PERCENTAGE) { if (substr($value, -1) === '%') { $value = substr($value, 0, -1); } $ok = is_numeric($value) && ($value >= 0) && ($value <= 100); if ($ok) { $ltiOutcome->setValue($value / 100); $ltiOutcome->type = self::EXT_TYPE_DECIMAL; } } else if ($type === self::EXT_TYPE_RATIO) { $parts = explode('/', $value, 2); $ok = (count($parts) === 2) && is_numeric($parts[0]) && is_numeric($parts[1]) && ($parts[0] >= 0) && ($parts[1] > 0); if ($ok) { $ltiOutcome->setValue($parts[0] / $parts[1]); $ltiOutcome->type = self::EXT_TYPE_DECIMAL; } // Convert letter_af to letter_af_plus or text } else if ($type === self::EXT_TYPE_LETTER_AF) { if (in_array(self::EXT_TYPE_LETTER_AF_PLUS, $supportedTypes)) { $ok = true; $ltiOutcome->type = self::EXT_TYPE_LETTER_AF_PLUS; } else if (in_array(self::EXT_TYPE_TEXT, $supportedTypes)) { $ok = true; $ltiOutcome->type = self::EXT_TYPE_TEXT; } // Convert letter_af_plus to letter_af or text } else if ($type === self::EXT_TYPE_LETTER_AF_PLUS) { if (in_array(self::EXT_TYPE_LETTER_AF, $supportedTypes) && (strlen($value) === 1)) { $ok = true; $ltiOutcome->type = self::EXT_TYPE_LETTER_AF; } else if (in_array(self::EXT_TYPE_TEXT, $supportedTypes)) { $ok = true; $ltiOutcome->type = self::EXT_TYPE_TEXT; } // Convert text to decimal } else if ($type === self::EXT_TYPE_TEXT) { $ok = is_numeric($value) && ($value >= 0) && ($value <=1); if ($ok) { $ltiOutcome->type = self::EXT_TYPE_DECIMAL; } else if (substr($value, -1) === '%') { $value = substr($value, 0, -1); $ok = is_numeric($value) && ($value >= 0) && ($value <=100); if ($ok) { if (in_array(self::EXT_TYPE_PERCENTAGE, $supportedTypes)) { $ltiOutcome->type = self::EXT_TYPE_PERCENTAGE; } else { $ltiOutcome->setValue($value / 100); $ltiOutcome->type = self::EXT_TYPE_DECIMAL; } } } } } return $ok; } /** * Send a service request to the tool consumer. * * @param string $type Message type value * @param string $url URL to send request to * @param array $params Associative array of parameter values to be passed * * @return boolean True if the request successfully obtained a response */ private function doService($type, $url, $params) { $ok = false; $this->extRequest = null; $this->extRequestHeaders = ''; $this->extResponse = null; $this->extResponseHeaders = ''; if (!empty($url)) { $params = $this->getConsumer()->signParameters($url, $type, $this->getConsumer()->ltiVersion, $params); // Connect to tool consumer $http = new HTTPMessage($url, 'POST', $params); // Parse XML response if ($http->send()) { $this->extResponse = $http->response; $this->extResponseHeaders = $http->responseHeaders; try { $this->extDoc = new DOMDocument(); $this->extDoc->loadXML($http->response); $this->extNodes = $this->domnodeToArray($this->extDoc->documentElement); if (isset($this->extNodes['statusinfo']['codemajor']) && ($this->extNodes['statusinfo']['codemajor'] === 'Success')) { $ok = true; } } catch (\Exception $e) { } } $this->extRequest = $http->request; $this->extRequestHeaders = $http->requestHeaders; } return $ok; } /** * Send a service request to the tool consumer. * * @param string $type Message type value * @param string $url URL to send request to * @param string $xml XML of message request * * @return boolean True if the request successfully obtained a response */ private function doLTI11Service($type, $url, $xml) { $ok = false; $this->extRequest = null; $this->extRequestHeaders = ''; $this->extResponse = null; $this->extResponseHeaders = ''; if (!empty($url)) { $id = uniqid(); $xmlRequest = <<< EOD <?xml version = "1.0" encoding = "UTF-8"?> <imsx_POXEnvelopeRequest xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> <imsx_POXHeader> <imsx_POXRequestHeaderInfo> <imsx_version>V1.0</imsx_version> <imsx_messageIdentifier>{$id}</imsx_messageIdentifier> </imsx_POXRequestHeaderInfo> </imsx_POXHeader> <imsx_POXBody> <{$type}Request> {$xml} </{$type}Request> </imsx_POXBody> </imsx_POXEnvelopeRequest> EOD; // Calculate body hash $hash = base64_encode(sha1($xmlRequest, true)); $params = array('oauth_body_hash' => $hash); // Add OAuth signature $hmacMethod = new OAuth\OAuthSignatureMethod_HMAC_SHA1(); $consumer = new OAuth\OAuthConsumer($this->getConsumer()->getKey(), $this->getConsumer()->secret, null); $req = OAuth\OAuthRequest::from_consumer_and_token($consumer, null, 'POST', $url, $params); $req->sign_request($hmacMethod, $consumer, null); $params = $req->get_parameters(); $header = $req->to_header(); $header .= "\nContent-Type: application/xml"; // Connect to tool consumer $http = new HTTPMessage($url, 'POST', $xmlRequest, $header); // Parse XML response if ($http->send()) { $this->extResponse = $http->response; $this->extResponseHeaders = $http->responseHeaders; try { $this->extDoc = new DOMDocument(); $this->extDoc->loadXML($http->response); $this->extNodes = $this->domnodeToArray($this->extDoc->documentElement); if (isset($this->extNodes['imsx_POXHeader']['imsx_POXResponseHeaderInfo']['imsx_statusInfo']['imsx_codeMajor']) && ($this->extNodes['imsx_POXHeader']['imsx_POXResponseHeaderInfo']['imsx_statusInfo']['imsx_codeMajor'] === 'success')) { $ok = true; } } catch (\Exception $e) { } } $this->extRequest = $http->request; $this->extRequestHeaders = $http->requestHeaders; } return $ok; } /** * Convert DOM nodes to array. * * @param DOMElement $node XML element * * @return array Array of XML document elements */ private function domnodeToArray($node) { $output = ''; switch ($node->nodeType) { case XML_CDATA_SECTION_NODE: case XML_TEXT_NODE: $output = trim($node->textContent); break; case XML_ELEMENT_NODE: for ($i = 0; $i < $node->childNodes->length; $i++) { $child = $node->childNodes->item($i); $v = $this->domnodeToArray($child); if (isset($child->tagName)) { $t = $child->tagName; if (!isset($output[$t])) { $output[$t] = array(); } $output[$t][] = $v; } else { $s = (string) $v; if (strlen($s) > 0) { $output = $s; } } } if (is_array($output)) { if ($node->attributes->length) { $a = array(); foreach ($node->attributes as $attrName => $attrNode) { $a[$attrName] = (string) $attrNode->value; } $output['@attributes'] = $a; } foreach ($output as $t => $v) { if (is_array($v) && count($v)==1 && $t!='@attributes') { $output[$t] = $v[0]; } } } break; } return $output; } } ToolProvider/ResourceLinkShare.php 0000644 00000001544 15152003527 0013301 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; /** * Class to represent a tool consumer resource link share * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class ResourceLinkShare { /** * Consumer key value. * * @var string $consumerKey */ public $consumerKey = null; /** * Resource link ID value. * * @var string $resourceLinkId */ public $resourceLinkId = null; /** * Title of sharing context. * * @var string $title */ public $title = null; /** * Whether sharing request is to be automatically approved on first use. * * @var boolean $approved */ public $approved = null; /** * Class constructor. */ public function __construct() { } } ToolProvider/DataConnector/DataConnector_pdo.php 0000644 00000142534 15152003527 0016030 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\DataConnector; use IMSGlobal\LTI\ToolProvider; use IMSGlobal\LTI\ToolProvider\ConsumerNonce; use IMSGlobal\LTI\ToolProvider\Context; use IMSGlobal\LTI\ToolProvider\ResourceLink; use IMSGlobal\LTI\ToolProvider\ResourceLinkShareKey; use IMSGlobal\LTI\ToolProvider\ToolConsumer; use IMSGlobal\LTI\ToolProvider\User; use PDO; /** * Class to represent an LTI Data Connector for PDO connections * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class DataConnector_pdo extends DataConnector { /** * Class constructor * * @param object $db Database connection object * @param string $dbTableNamePrefix Prefix for database table names (optional, default is none) */ public function __construct($db, $dbTableNamePrefix = '') { parent::__construct($db, $dbTableNamePrefix); if ($db->getAttribute(PDO::ATTR_DRIVER_NAME) == 'oci') { $this->date_format = 'd-M-Y'; } } ### ### ToolConsumer methods ### /** * Load tool consumer object. * * @param ToolConsumer $consumer ToolConsumer object * * @return boolean True if the tool consumer object was successfully loaded */ public function loadToolConsumer($consumer) { $ok = false; if (!empty($consumer->getRecordId())) { $sql = 'SELECT consumer_pk, name, consumer_key256, consumer_key, secret, lti_version, ' . 'consumer_name, consumer_version, consumer_guid, ' . 'profile, tool_proxy, settings, protected, enabled, ' . 'enable_from, enable_until, last_access, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' ' . 'WHERE consumer_pk = :id'; $query = $this->db->prepare($sql); $id = $consumer->getRecordId(); $query->bindValue('id', $id, PDO::PARAM_INT); } else { $sql = 'SELECT consumer_pk, name, consumer_key256, consumer_key, secret, lti_version, ' . 'consumer_name, consumer_version, consumer_guid, ' . 'profile, tool_proxy, settings, protected, enabled, ' . 'enable_from, enable_until, last_access, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' ' . 'WHERE consumer_key256 = :key256'; $query = $this->db->prepare($sql); $key256 = DataConnector::getConsumerKey($consumer->getKey()); $query->bindValue('key256', $key256, PDO::PARAM_STR); } if ($query->execute()) { while ($row = $query->fetch(PDO::FETCH_ASSOC)) { $row = array_change_key_case($row); if (empty($key256) || empty($row['consumer_key']) || ($consumer->getKey() === $row['consumer_key'])) { $consumer->setRecordId(intval($row['consumer_pk'])); $consumer->name = $row['name']; $consumer->setkey(empty($row['consumer_key']) ? $row['consumer_key256'] : $row['consumer_key']); $consumer->secret = $row['secret']; $consumer->ltiVersion = $row['lti_version']; $consumer->consumerName = $row['consumer_name']; $consumer->consumerVersion = $row['consumer_version']; $consumer->consumerGuid = $row['consumer_guid']; $consumer->profile = json_decode($row['profile']); $consumer->toolProxy = $row['tool_proxy']; $settings = unserialize($row['settings']); if (!is_array($settings)) { $settings = array(); } $consumer->setSettings($settings); $consumer->protected = (intval($row['protected']) === 1); $consumer->enabled = (intval($row['enabled']) === 1); $consumer->enableFrom = null; if (!is_null($row['enable_from'])) { $consumer->enableFrom = strtotime($row['enable_from']); } $consumer->enableUntil = null; if (!is_null($row['enable_until'])) { $consumer->enableUntil = strtotime($row['enable_until']); } $consumer->lastAccess = null; if (!is_null($row['last_access'])) { $consumer->lastAccess = strtotime($row['last_access']); } $consumer->created = strtotime($row['created']); $consumer->updated = strtotime($row['updated']); $ok = true; break; } } } return $ok; } /** * Save tool consumer object. * * @param ToolConsumer $consumer Consumer object * * @return boolean True if the tool consumer object was successfully saved */ public function saveToolConsumer($consumer) { $id = $consumer->getRecordId(); $key = $consumer->getKey(); $key256 = $this->getConsumerKey($key); if ($key === $key256) { $key = null; } $protected = ($consumer->protected) ? 1 : 0; $enabled = ($consumer->enabled)? 1 : 0; $profile = (!empty($consumer->profile)) ? json_encode($consumer->profile) : null; $settingsValue = serialize($consumer->getSettings()); $time = time(); $now = date("{$this->dateFormat} {$this->timeFormat}", $time); $from = null; if (!is_null($consumer->enableFrom)) { $from = date("{$this->dateFormat} {$this->timeFormat}", $consumer->enableFrom); } $until = null; if (!is_null($consumer->enableUntil)) { $until = date("{$this->dateFormat} {$this->timeFormat}", $consumer->enableUntil); } $last = null; if (!is_null($consumer->lastAccess)) { $last = date($this->dateFormat, $consumer->lastAccess); } if (empty($id)) { $sql = "INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' (consumer_key256, consumer_key, name, ' . 'secret, lti_version, consumer_name, consumer_version, consumer_guid, profile, tool_proxy, settings, protected, enabled, ' . 'enable_from, enable_until, last_access, created, updated) ' . 'VALUES (:key256, :key, :name, :secret, :lti_version, :consumer_name, :consumer_version, :consumer_guid, :profile, :tool_proxy, :settings, ' . ':protected, :enabled, :enable_from, :enable_until, :last_access, :created, :updated)'; $query = $this->db->prepare($sql); $query->bindValue('key256', $key256, PDO::PARAM_STR); $query->bindValue('key', $key, PDO::PARAM_STR); $query->bindValue('name', $consumer->name, PDO::PARAM_STR); $query->bindValue('secret', $consumer->secret, PDO::PARAM_STR); $query->bindValue('lti_version', $consumer->ltiVersion, PDO::PARAM_STR); $query->bindValue('consumer_name', $consumer->consumerName, PDO::PARAM_STR); $query->bindValue('consumer_version', $consumer->consumerVersion, PDO::PARAM_STR); $query->bindValue('consumer_guid', $consumer->consumerGuid, PDO::PARAM_STR); $query->bindValue('profile', $profile, PDO::PARAM_STR); $query->bindValue('tool_proxy', $consumer->toolProxy, PDO::PARAM_STR); $query->bindValue('settings', $settingsValue, PDO::PARAM_STR); $query->bindValue('protected', $protected, PDO::PARAM_INT); $query->bindValue('enabled', $enabled, PDO::PARAM_INT); $query->bindValue('enable_from', $from, PDO::PARAM_STR); $query->bindValue('enable_until', $until, PDO::PARAM_STR); $query->bindValue('last_access', $last, PDO::PARAM_STR); $query->bindValue('created', $now, PDO::PARAM_STR); $query->bindValue('updated', $now, PDO::PARAM_STR); } else { $sql = 'UPDATE ' . $this->dbTableNamePrefix . DataConnector::CONSUMER_TABLE_NAME . ' ' . 'SET consumer_key256 = :key256, consumer_key = :key, name = :name, secret = :secret, lti_version = :lti_version, ' . 'consumer_name = :consumer_name, consumer_version = :consumer_version, consumer_guid = :consumer_guid, ' . 'profile = :profile, tool_proxy = :tool_proxy, settings = :settings, ' . 'protected = :protected, enabled = :enabled, enable_from = :enable_from, enable_until = :enable_until, last_access = :last_access, updated = :updated ' . 'WHERE consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('key256', $key256, PDO::PARAM_STR); $query->bindValue('key', $key, PDO::PARAM_STR); $query->bindValue('name', $consumer->name, PDO::PARAM_STR); $query->bindValue('secret', $consumer->secret, PDO::PARAM_STR); $query->bindValue('lti_version', $consumer->ltiVersion, PDO::PARAM_STR); $query->bindValue('consumer_name', $consumer->consumerName, PDO::PARAM_STR); $query->bindValue('consumer_version', $consumer->consumerVersion, PDO::PARAM_STR); $query->bindValue('consumer_guid', $consumer->consumerGuid, PDO::PARAM_STR); $query->bindValue('profile', $profile, PDO::PARAM_STR); $query->bindValue('tool_proxy', $consumer->toolProxy, PDO::PARAM_STR); $query->bindValue('settings', $settingsValue, PDO::PARAM_STR); $query->bindValue('protected', $protected, PDO::PARAM_INT); $query->bindValue('enabled', $enabled, PDO::PARAM_INT); $query->bindValue('enable_from', $from, PDO::PARAM_STR); $query->bindValue('enable_until', $until, PDO::PARAM_STR); $query->bindValue('last_access', $last, PDO::PARAM_STR); $query->bindValue('updated', $now, PDO::PARAM_STR); $query->bindValue('id', $id, PDO::PARAM_INT); } $ok = $query->execute(); if ($ok) { if (empty($id)) { $consumer->setRecordId(intval($this->db->lastInsertId())); $consumer->created = $time; } $consumer->updated = $time; } return $ok; } /** * Delete tool consumer object. * * @param ToolConsumer $consumer Consumer object * * @return boolean True if the tool consumer object was successfully deleted */ public function deleteToolConsumer($consumer) { $id = $consumer->getRecordId(); // Delete any nonce values for this consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::NONCE_TABLE_NAME . ' WHERE consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any outstanding share keys for resource links for this consumer $sql = 'DELETE sk ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' sk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON sk.resource_link_pk = rl.resource_link_pk ' . 'WHERE rl.consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any outstanding share keys for resource links for contexts in this consumer $sql = 'DELETE sk ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' sk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON sk.resource_link_pk = rl.resource_link_pk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ON rl.context_pk = c.context_pk ' . 'WHERE c.consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any users in resource links for this consumer $sql = 'DELETE u ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' u ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON u.resource_link_pk = rl.resource_link_pk ' . 'WHERE rl.consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any users in resource links for contexts in this consumer $sql = 'DELETE u ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' u ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON u.resource_link_pk = rl.resource_link_pk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ON rl.context_pk = c.context_pk ' . 'WHERE c.consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Update any resource links for which this consumer is acting as a primary resource link $sql = "UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' prl ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON prl.primary_resource_link_pk = rl.resource_link_pk ' . 'SET prl.primary_resource_link_pk = NULL, prl.share_approved = NULL ' . 'WHERE rl.consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Update any resource links for contexts in which this consumer is acting as a primary resource link $sql = "UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' prl ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON prl.primary_resource_link_pk = rl.resource_link_pk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ON rl.context_pk = c.context_pk ' . 'SET prl.primary_resource_link_pk = NULL, prl.share_approved = NULL ' . 'WHERE c.consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any resource links for this consumer $sql = 'DELETE rl ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . 'WHERE rl.consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any resource links for contexts in this consumer $sql = 'DELETE rl ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ON rl.context_pk = c.context_pk ' . 'WHERE c.consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any contexts for this consumer $sql = 'DELETE c ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ' . 'WHERE c.consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete consumer $sql = 'DELETE c ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' c ' . 'WHERE c.consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $ok = $query->execute(); if ($ok) { $consumer->initialize(); } return $ok; } ### # Load all tool consumers from the database ### public function getToolConsumers() { $consumers = array(); $sql = 'SELECT consumer_pk, name, consumer_key256, consumer_key, secret, lti_version, ' . 'consumer_name, consumer_version, consumer_guid, ' . 'profile, tool_proxy, settings, protected, enabled, ' . 'enable_from, enable_until, last_access, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' ' . 'ORDER BY name'; $query = $this->db->prepare($sql); $ok = ($query !== FALSE); if ($ok) { $ok = $query->execute(); } if ($ok) { while ($row = $query->fetch(PDO::FETCH_ASSOC)) { $row = array_change_key_case($row); $key = empty($row['consumer_key']) ? $row['consumer_key256'] : $row['consumer_key']; $consumer = new ToolProvider\ToolConsumer($key, $this); $consumer->setRecordId(intval($row['consumer_pk'])); $consumer->name = $row['name']; $consumer->secret = $row['secret']; $consumer->ltiVersion = $row['lti_version']; $consumer->consumerName = $row['consumer_name']; $consumer->consumerVersion = $row['consumer_version']; $consumer->consumerGuid = $row['consumer_guid']; $consumer->profile = json_decode($row['profile']); $consumer->toolProxy = $row['tool_proxy']; $settings = unserialize($row['settings']); if (!is_array($settings)) { $settings = array(); } $consumer->setSettings($settings); $consumer->protected = (intval($row['protected']) === 1); $consumer->enabled = (intval($row['enabled']) === 1); $consumer->enableFrom = null; if (!is_null($row['enable_from'])) { $consumer->enableFrom = strtotime($row['enable_from']); } $consumer->enableUntil = null; if (!is_null($row['enable_until'])) { $consumer->enableUntil = strtotime($row['enable_until']); } $consumer->lastAccess = null; if (!is_null($row['last_access'])) { $consumer->lastAccess = strtotime($row['last_access']); } $consumer->created = strtotime($row['created']); $consumer->updated = strtotime($row['updated']); $consumers[] = $consumer; } } return $consumers; } ### ### ToolProxy methods ### ### # Load the tool proxy from the database ### public function loadToolProxy($toolProxy) { return false; } ### # Save the tool proxy to the database ### public function saveToolProxy($toolProxy) { return false; } ### # Delete the tool proxy from the database ### public function deleteToolProxy($toolProxy) { return false; } ### ### Context methods ### /** * Load context object. * * @param Context $context Context object * * @return boolean True if the context object was successfully loaded */ public function loadContext($context) { $ok = false; if (!empty($context->getRecordId())) { $sql = 'SELECT context_pk, consumer_pk, lti_context_id, type, settings, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' ' . 'WHERE (context_pk = :id)'; $query = $this->db->prepare($sql); $query->bindValue('id', $context->getRecordId(), PDO::PARAM_INT); } else { $sql = 'SELECT context_pk, consumer_pk, lti_context_id, type, settings, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' ' . 'WHERE (consumer_pk = :cid) AND (lti_context_id = :ctx)'; $query = $this->db->prepare($sql); $query->bindValue('cid', $context->getConsumer()->getRecordId(), PDO::PARAM_INT); $query->bindValue('ctx', $context->ltiContextId, PDO::PARAM_STR); } $ok = $query->execute(); if ($ok) { $row = $query->fetch(PDO::FETCH_ASSOC); $ok = ($row !== FALSE); } if ($ok) { $row = array_change_key_case($row); $context->setRecordId(intval($row['context_pk'])); $context->setConsumerId(intval($row['consumer_pk'])); $context->ltiContextId = $row['lti_context_id']; $context->type = $row['type']; $settings = unserialize($row['settings']); if (!is_array($settings)) { $settings = array(); } $context->setSettings($settings); $context->created = strtotime($row['created']); $context->updated = strtotime($row['updated']); } return $ok; } /** * Save context object. * * @param Context $context Context object * * @return boolean True if the context object was successfully saved */ public function saveContext($context) { $time = time(); $now = date("{$this->dateFormat} {$this->timeFormat}", $time); $settingsValue = serialize($context->getSettings()); $id = $context->getRecordId(); $consumer_pk = $context->getConsumer()->getRecordId(); if (empty($id)) { $sql = "INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' (consumer_pk, lti_context_id, ' . 'type, settings, created, updated) ' . 'VALUES (:cid, :ctx, :type, :settings, :created, :updated)'; $query = $this->db->prepare($sql); $query->bindValue('cid', $consumer_pk, PDO::PARAM_INT); $query->bindValue('ctx', $context->ltiContextId, PDO::PARAM_STR); $query->bindValue('type', $context->type, PDO::PARAM_STR); $query->bindValue('settings', $settingsValue, PDO::PARAM_STR); $query->bindValue('created', $now, PDO::PARAM_STR); $query->bindValue('updated', $now, PDO::PARAM_STR); } else { $sql = "UPDATE {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' SET ' . 'lti_context_id = :ctx, type = :type, settings = :settings, '. 'updated = :updated ' . 'WHERE (consumer_pk = :cid) AND (context_pk = :ctxid)'; $query = $this->db->prepare($sql); $query->bindValue('ctx', $context->ltiContextId, PDO::PARAM_STR); $query->bindValue('type', $context->type, PDO::PARAM_STR); $query->bindValue('settings', $settingsValue, PDO::PARAM_STR); $query->bindValue('updated', $now, PDO::PARAM_STR); $query->bindValue('cid', $consumer_pk, PDO::PARAM_INT); $query->bindValue('ctxid', $id, PDO::PARAM_INT); } $ok = $query->execute(); if ($ok) { if (empty($id)) { $context->setRecordId(intval($this->db->lastInsertId())); $context->created = $time; } $context->updated = $time; } return $ok; } /** * Delete context object. * * @param Context $context Context object * * @return boolean True if the Context object was successfully deleted */ public function deleteContext($context) { $id = $context->getRecordId(); // Delete any outstanding share keys for resource links for this context $sql = 'DELETE sk ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' sk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON sk.resource_link_pk = rl.resource_link_pk ' . 'WHERE rl.context_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any users in resource links for this context $sql = 'DELETE u ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' u ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON u.resource_link_pk = rl.resource_link_pk ' . 'WHERE rl.context_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Update any resource links for which this consumer is acting as a primary resource link $sql = "UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' prl ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON prl.primary_resource_link_pk = rl.resource_link_pk ' . 'SET prl.primary_resource_link_pk = null, prl.share_approved = null ' . 'WHERE rl.context_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any resource links for this consumer $sql = 'DELETE rl ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . 'WHERE rl.context_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete context $sql = 'DELETE c ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ' . 'WHERE c.context_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $ok = $query->execute(); if ($ok) { $context->initialize(); } return $ok; } ### ### ResourceLink methods ### /** * Load resource link object. * * @param ResourceLink $resourceLink Resource_Link object * * @return boolean True if the resource link object was successfully loaded */ public function loadResourceLink($resourceLink) { if (!empty($resourceLink->getRecordId())) { $sql = 'SELECT resource_link_pk, context_pk, consumer_pk, lti_resource_link_id, settings, primary_resource_link_pk, share_approved, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'WHERE (resource_link_pk = :id)'; $query = $this->db->prepare($sql); $query->bindValue('id', $resourceLink->getRecordId(), PDO::PARAM_INT); } else if (!empty($resourceLink->getContext())) { $sql = 'SELECT resource_link_pk, context_pk, consumer_pk, lti_resource_link_id, settings, primary_resource_link_pk, share_approved, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'WHERE (context_pk = :id) AND (lti_resource_link_id = :rlid)'; $query = $this->db->prepare($sql); $query->bindValue('id', $resourceLink->getContext()->getRecordId(), PDO::PARAM_INT); $query->bindValue('rlid', $resourceLink->getId(), PDO::PARAM_STR); } else { $sql = 'SELECT r.resource_link_pk, r.context_pk, r.consumer_pk, r.lti_resource_link_id, r.settings, r.primary_resource_link_pk, r.share_approved, r.created, r.updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' r LEFT OUTER JOIN ' . $this->dbTableNamePrefix . DataConnector::CONTEXT_TABLE_NAME . ' c ON r.context_pk = c.context_pk ' . ' WHERE ((r.consumer_pk = :id1) OR (c.consumer_pk = :id2)) AND (lti_resource_link_id = :rlid)'; $query = $this->db->prepare($sql); $query->bindValue('id1', $resourceLink->getConsumer()->getRecordId(), PDO::PARAM_INT); $query->bindValue('id2', $resourceLink->getConsumer()->getRecordId(), PDO::PARAM_INT); $query->bindValue('rlid', $resourceLink->getId(), PDO::PARAM_STR); } $ok = $query->execute(); if ($ok) { $row = $query->fetch(PDO::FETCH_ASSOC); $ok = ($row !== FALSE); } if ($ok) { $row = array_change_key_case($row); $resourceLink->setRecordId(intval($row['resource_link_pk'])); if (!is_null($row['context_pk'])) { $resourceLink->setContextId(intval($row['context_pk'])); } else { $resourceLink->setContextId(null); } if (!is_null($row['consumer_pk'])) { $resourceLink->setConsumerId(intval($row['consumer_pk'])); } else { $resourceLink->setConsumerId(null); } $resourceLink->ltiResourceLinkId = $row['lti_resource_link_id']; $settings = unserialize($row['settings']); if (!is_array($settings)) { $settings = array(); } $resourceLink->setSettings($settings); if (!is_null($row['primary_resource_link_pk'])) { $resourceLink->primaryResourceLinkId = intval($row['primary_resource_link_pk']); } else { $resourceLink->primaryResourceLinkId = null; } $resourceLink->shareApproved = (is_null($row['share_approved'])) ? null : (intval($row['share_approved']) === 1); $resourceLink->created = strtotime($row['created']); $resourceLink->updated = strtotime($row['updated']); } return $ok; } /** * Save resource link object. * * @param ResourceLink $resourceLink Resource_Link object * * @return boolean True if the resource link object was successfully saved */ public function saveResourceLink($resourceLink) { $time = time(); $now = date("{$this->dateFormat} {$this->timeFormat}", $time); $settingsValue = serialize($resourceLink->getSettings()); if (!empty($resourceLink->getContext())) { $consumerId = null; $contextId = strval($resourceLink->getContext()->getRecordId()); } else if (!empty($resourceLink->getContextId())) { $consumerId = null; $contextId = strval($resourceLink->getContextId()); } else { $consumerId = strval($resourceLink->getConsumer()->getRecordId()); $contextId = null; } if (empty($resourceLink->primaryResourceLinkId)) { $primaryResourceLinkId = null; } else { $primaryResourceLinkId = $resourceLink->primaryResourceLinkId; } $id = $resourceLink->getRecordId(); if (empty($id)) { $sql = "INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' (consumer_pk, context_pk, ' . 'lti_resource_link_id, settings, primary_resource_link_pk, share_approved, created, updated) ' . 'VALUES (:cid, :ctx, :rlid, :settings, :prlid, :share_approved, :created, :updated)'; $query = $this->db->prepare($sql); $query->bindValue('cid', $consumerId, PDO::PARAM_INT); $query->bindValue('ctx', $contextId, PDO::PARAM_INT); $query->bindValue('rlid', $resourceLink->getId(), PDO::PARAM_STR); $query->bindValue('settings', $settingsValue, PDO::PARAM_STR); $query->bindValue('prlid', $primaryResourceLinkId, PDO::PARAM_INT); $query->bindValue('share_approved', $resourceLink->shareApproved, PDO::PARAM_INT); $query->bindValue('created', $now, PDO::PARAM_STR); $query->bindValue('updated', $now, PDO::PARAM_STR); } else if (!is_null($contextId)) { $sql = "UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' SET ' . 'consumer_pk = NULL, context_pk = :ctx, lti_resource_link_id = :rlid, settings = :settings, '. 'primary_resource_link_pk = :prlid, share_approved = :share_approved, updated = :updated ' . 'WHERE (resource_link_pk = :id)'; $query = $this->db->prepare($sql); $query->bindValue('ctx', $contextId, PDO::PARAM_INT); $query->bindValue('rlid', $resourceLink->getId(), PDO::PARAM_STR); $query->bindValue('settings', $settingsValue, PDO::PARAM_STR); $query->bindValue('prlid', $primaryResourceLinkId, PDO::PARAM_INT); $query->bindValue('share_approved', $resourceLink->shareApproved, PDO::PARAM_INT); $query->bindValue('updated', $now, PDO::PARAM_STR); $query->bindValue('id', $id, PDO::PARAM_INT); } else { $sql = "UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' SET ' . 'context_pk = :ctx, lti_resource_link_id = :rlid, settings = :settings, '. 'primary_resource_link_pk = :prlid, share_approved = :share_approved, updated = :updated ' . 'WHERE (consumer_pk = :cid) AND (resource_link_pk = :id)'; $query = $this->db->prepare($sql); $query->bindValue('ctx', $contextId, PDO::PARAM_INT); $query->bindValue('rlid', $resourceLink->getId(), PDO::PARAM_STR); $query->bindValue('settings', $settingsValue, PDO::PARAM_STR); $query->bindValue('prlid', $primaryResourceLinkId, PDO::PARAM_INT); $query->bindValue('share_approved', $resourceLink->shareApproved, PDO::PARAM_INT); $query->bindValue('updated', $now, PDO::PARAM_STR); $query->bindValue('cid', $consumerId, PDO::PARAM_INT); $query->bindValue('id', $id, PDO::PARAM_INT); } $ok = $query->execute(); if ($ok) { if (empty($id)) { $resourceLink->setRecordId(intval($this->db->lastInsertId())); $resourceLink->created = $time; } $resourceLink->updated = $time; } return $ok; } /** * Delete resource link object. * * @param ResourceLink $resourceLink Resource_Link object * * @return boolean True if the resource link object was successfully deleted */ public function deleteResourceLink($resourceLink) { $id = $resourceLink->getRecordId(); // Delete any outstanding share keys for resource links for this consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' ' . 'WHERE (resource_link_pk = :id)'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $ok = $query->execute(); // Delete users if ($ok) { $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . 'WHERE (resource_link_pk = :id)'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $ok = $query->execute(); } // Update any resource links for which this is the primary resource link if ($ok) { $sql = "UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'SET primary_resource_link_pk = NULL ' . 'WHERE (primary_resource_link_pk = :id)'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $ok = $query->execute(); } // Delete resource link if ($ok) { $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'WHERE (resource_link_pk = :id)'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $ok = $query->execute(); } if ($ok) { $resourceLink->initialize(); } return $ok; } /** * Get array of user objects. * * Obtain an array of User objects for users with a result sourcedId. The array may include users from other * resource links which are sharing this resource link. It may also be optionally indexed by the user ID of a specified scope. * * @param ResourceLink $resourceLink Resource link object * @param boolean $localOnly True if only users within the resource link are to be returned (excluding users sharing this resource link) * @param int $idScope Scope value to use for user IDs * * @return array Array of User objects */ public function getUserResultSourcedIDsResourceLink($resourceLink, $localOnly, $idScope) { $id = $resourceLink->getRecordId(); $users = array(); if ($localOnly) { $sql = 'SELECT u.user_pk, u.lti_result_sourcedid, u.lti_user_id, u.created, u.updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' AS u ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' AS rl ' . 'ON u.resource_link_pk = rl.resource_link_pk ' . 'WHERE (rl.resource_link_pk = :id) AND (rl.primary_resource_link_pk IS NULL)'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); } else { $sql = 'SELECT u.user_pk, u.lti_result_sourcedid, u.lti_user_id, u.created, u.updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' AS u ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' AS rl ' . 'ON u.resource_link_pk = rl.resource_link_pk ' . 'WHERE ((rl.resource_link_pk = :id) AND (rl.primary_resource_link_pk IS NULL)) OR ' . '((rl.primary_resource_link_pk = :pid) AND (share_approved = 1))'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->bindValue('pid', $id, PDO::PARAM_INT); } if ($query->execute()) { while ($row = $query->fetch(PDO::FETCH_ASSOC)) { $row = array_change_key_case($row); $user = ToolProvider\User::fromRecordId($row['user_pk'], $resourceLink->getDataConnector()); if (is_null($idScope)) { $users[] = $user; } else { $users[$user->getId($idScope)] = $user; } } } return $users; } /** * Get array of shares defined for this resource link. * * @param ResourceLink $resourceLink Resource_Link object * * @return array Array of ResourceLinkShare objects */ public function getSharesResourceLink($resourceLink) { $id = $resourceLink->getRecordId(); $shares = array(); $sql = 'SELECT consumer_pk, resource_link_pk, share_approved ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'WHERE (primary_resource_link_pk = :id) ' . 'ORDER BY consumer_pk'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); if ($query->execute()) { while ($row = $query->fetch(PDO::FETCH_ASSOC)) { $row = array_change_key_case($row); $share = new ToolProvider\ResourceLinkShare(); $share->resourceLinkId = intval($row['resource_link_pk']); $share->approved = (intval($row['share_approved']) === 1); $shares[] = $share; } } return $shares; } ### ### ConsumerNonce methods ### /** * Load nonce object. * * @param ConsumerNonce $nonce Nonce object * * @return boolean True if the nonce object was successfully loaded */ public function loadConsumerNonce($nonce) { $ok = true; // Delete any expired nonce values $now = date("{$this->dateFormat} {$this->timeFormat}", time()); $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::NONCE_TABLE_NAME . ' WHERE expires <= :now'; $query = $this->db->prepare($sql); $query->bindValue('now', $now, PDO::PARAM_STR); $query->execute(); // Load the nonce $id = $nonce->getConsumer()->getRecordId(); $value = $nonce->getValue(); $sql = "SELECT value T FROM {$this->dbTableNamePrefix}" . DataConnector::NONCE_TABLE_NAME . ' WHERE (consumer_pk = :id) AND (value = :value)'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->bindValue('value', $value, PDO::PARAM_STR); $ok = $query->execute(); if ($ok) { $row = $query->fetch(PDO::FETCH_ASSOC); if ($row === false) { $ok = false; } } return $ok; } /** * Save nonce object. * * @param ConsumerNonce $nonce Nonce object * * @return boolean True if the nonce object was successfully saved */ public function saveConsumerNonce($nonce) { $id = $nonce->getConsumer()->getRecordId(); $value = $nonce->getValue(); $expires = date("{$this->dateFormat} {$this->timeFormat}", $nonce->expires); $sql = "INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::NONCE_TABLE_NAME . ' (consumer_pk, value, expires) VALUES (:id, :value, :expires)'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->bindValue('value', $value, PDO::PARAM_STR); $query->bindValue('expires', $expires, PDO::PARAM_STR); $ok = $query->execute(); return $ok; } ### ### ResourceLinkShareKey methods ### /** * Load resource link share key object. * * @param ResourceLinkShareKey $shareKey Resource_Link share key object * * @return boolean True if the resource link share key object was successfully loaded */ public function loadResourceLinkShareKey($shareKey) { $ok = false; // Clear expired share keys $now = date("{$this->dateFormat} {$this->timeFormat}", time()); $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' WHERE expires <= :now'; $query = $this->db->prepare($sql); $query->bindValue('now', $now, PDO::PARAM_STR); $query->execute(); // Load share key $id = $shareKey->getId(); $sql = 'SELECT resource_link_pk, auto_approve, expires ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' ' . 'WHERE share_key_id = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_STR); if ($query->execute()) { $row = $query->fetch(PDO::FETCH_ASSOC); if ($row !== FALSE) { $row = array_change_key_case($row); if (intval($row['resource_link_pk']) === $shareKey->resourceLinkId) { $shareKey->autoApprove = ($row['auto_approve'] === 1); $shareKey->expires = strtotime($row['expires']); $ok = true; } } } return $ok; } /** * Save resource link share key object. * * @param ResourceLinkShareKey $shareKey Resource link share key object * * @return boolean True if the resource link share key object was successfully saved */ public function saveResourceLinkShareKey($shareKey) { $id = $shareKey->getId(); $expires = date("{$this->dateFormat} {$this->timeFormat}", $shareKey->expires); $sql = "INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' ' . '(share_key_id, resource_link_pk, auto_approve, expires) ' . 'VALUES (:id, :prlid, :approve, :expires)'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_STR); $query->bindValue('prlid', $shareKey->resourceLinkId, PDO::PARAM_INT); $query->bindValue('approve', $shareKey->autoApprove, PDO::PARAM_INT); $query->bindValue('expires', $expires, PDO::PARAM_STR); $ok = $query->execute(); return $ok; } /** * Delete resource link share key object. * * @param ResourceLinkShareKey $shareKey Resource link share key object * * @return boolean True if the resource link share key object was successfully deleted */ public function deleteResourceLinkShareKey($shareKey) { $id = $shareKey->getId(); $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' WHERE share_key_id = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_STR); $ok = $query->execute(); if ($ok) { $shareKey->initialize(); } return $ok; } ### ### User methods ### /** * Load user object. * * @param User $user User object * * @return boolean True if the user object was successfully loaded */ public function loadUser($user) { $ok = false; if (!empty($user->getRecordId())) { $id = $user->getRecordId(); $sql = 'SELECT user_pk, resource_link_pk, lti_user_id, lti_result_sourcedid, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . 'WHERE (user_pk = :id)'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); } else { $id = $user->getResourceLink()->getRecordId(); $uid = $user->getId(ToolProvider\ToolProvider::ID_SCOPE_ID_ONLY); $sql = 'SELECT user_pk, resource_link_pk, lti_user_id, lti_result_sourcedid, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . 'WHERE (resource_link_pk = :id) AND (lti_user_id = :uid)'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->bindValue('uid', $uid, PDO::PARAM_STR); } if ($query->execute()) { $row = $query->fetch(PDO::FETCH_ASSOC); if ($row !== false) { $row = array_change_key_case($row); $user->setRecordId(intval($row['user_pk'])); $user->setResourceLinkId(intval($row['resource_link_pk'])); $user->ltiUserId = $row['lti_user_id']; $user->ltiResultSourcedId = $row['lti_result_sourcedid']; $user->created = strtotime($row['created']); $user->updated = strtotime($row['updated']); $ok = true; } } return $ok; } /** * Save user object. * * @param User $user User object * * @return boolean True if the user object was successfully saved */ public function saveUser($user) { $time = time(); $now = date("{$this->dateFormat} {$this->timeFormat}", $time); if (is_null($user->created)) { $sql = "INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' (resource_link_pk, ' . 'lti_user_id, lti_result_sourcedid, created, updated) ' . 'VALUES (:rlid, :uid, :sourcedid, :created, :updated)'; $query = $this->db->prepare($sql); $query->bindValue('rlid', $user->getResourceLink()->getRecordId(), PDO::PARAM_INT); $query->bindValue('uid', $user->getId(ToolProvider\ToolProvider::ID_SCOPE_ID_ONLY), PDO::PARAM_STR); $query->bindValue('sourcedid', $user->ltiResultSourcedId, PDO::PARAM_STR); $query->bindValue('created', $now, PDO::PARAM_STR); $query->bindValue('updated', $now, PDO::PARAM_STR); } else { $sql = "UPDATE {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . 'SET lti_result_sourcedid = :sourcedid, updated = :updated ' . 'WHERE (user_pk = :id)'; $query = $this->db->prepare($sql); $query->bindValue('sourcedid', $user->ltiResultSourcedId, PDO::PARAM_STR); $query->bindValue('updated', $now, PDO::PARAM_STR); $query->bindValue('id', $user->getRecordId(), PDO::PARAM_INT); } $ok = $query->execute(); if ($ok) { if (is_null($user->created)) { $user->setRecordId(intval($this->db->lastInsertId())); $user->created = $time; } $user->updated = $time; } return $ok; } /** * Delete user object. * * @param User $user User object * * @return boolean True if the user object was successfully deleted */ public function deleteUser($user) { $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . 'WHERE (user_pk = :id)'; $query = $this->db->prepare($sql); $query->bindValue('id', $user->getRecordId(), PDO::PARAM_INT); $ok = $query->execute(); if ($ok) { $user->initialize(); } return $ok; } } ToolProvider/DataConnector/DataConnector_mysql.php 0000644 00000127700 15152003527 0016411 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\DataConnector; use IMSGlobal\LTI\ToolProvider; use IMSGlobal\LTI\ToolProvider\ConsumerNonce; use IMSGlobal\LTI\ToolProvider\Context; use IMSGlobal\LTI\ToolProvider\ResourceLink; use IMSGlobal\LTI\ToolProvider\ResourceLinkShareKey; use IMSGlobal\LTI\ToolProvider\ToolConsumer; use IMSGlobal\LTI\ToolProvider\User; /** * Class to represent an LTI Data Connector for MySQL * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ ### # NB This class assumes that a MySQL connection has already been opened to the appropriate schema ### class DataConnector_mysql extends DataConnector { ### ### ToolConsumer methods ### /** * Load tool consumer object. * * @param ToolConsumer $consumer ToolConsumer object * * @return boolean True if the tool consumer object was successfully loaded */ public function loadToolConsumer($consumer) { $ok = false; if (!empty($consumer->getRecordId())) { $sql = sprintf('SELECT consumer_pk, name, consumer_key256, consumer_key, secret, lti_version, ' . 'consumer_name, consumer_version, consumer_guid, ' . 'profile, tool_proxy, settings, protected, enabled, ' . 'enable_from, enable_until, last_access, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' ' . "WHERE consumer_pk = %d", $consumer->getRecordId()); } else { $key256 = DataConnector::getConsumerKey($consumer->getKey()); $sql = sprintf('SELECT consumer_pk, name, consumer_key256, consumer_key, secret, lti_version, ' . 'consumer_name, consumer_version, consumer_guid, ' . 'profile, tool_proxy, settings, protected, enabled, ' . 'enable_from, enable_until, last_access, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' ' . "WHERE consumer_key256 = %s", DataConnector::quoted($key256)); } $rsConsumer = mysql_query($sql); if ($rsConsumer) { while ($row = mysql_fetch_object($rsConsumer)) { if (empty($key256) || empty($row->consumer_key) || ($consumer->getKey() === $row->consumer_key)) { $consumer->setRecordId(intval($row->consumer_pk)); $consumer->name = $row->name; $consumer->setkey(empty($row->consumer_key) ? $row->consumer_key256 : $row->consumer_key); $consumer->secret = $row->secret; $consumer->ltiVersion = $row->lti_version; $consumer->consumerName = $row->consumer_name; $consumer->consumerVersion = $row->consumer_version; $consumer->consumerGuid = $row->consumer_guid; $consumer->profile = json_decode($row->profile); $consumer->toolProxy = $row->tool_proxy; $settings = unserialize($row->settings); if (!is_array($settings)) { $settings = array(); } $consumer->setSettings($settings); $consumer->protected = (intval($row->protected) === 1); $consumer->enabled = (intval($row->enabled) === 1); $consumer->enableFrom = null; if (!is_null($row->enable_from)) { $consumer->enableFrom = strtotime($row->enable_from); } $consumer->enableUntil = null; if (!is_null($row->enable_until)) { $consumer->enableUntil = strtotime($row->enable_until); } $consumer->lastAccess = null; if (!is_null($row->last_access)) { $consumer->lastAccess = strtotime($row->last_access); } $consumer->created = strtotime($row->created); $consumer->updated = strtotime($row->updated); $ok = true; break; } } mysql_free_result($rsConsumer); } return $ok; } /** * Save tool consumer object. * * @param ToolConsumer $consumer Consumer object * * @return boolean True if the tool consumer object was successfully saved */ public function saveToolConsumer($consumer) { $id = $consumer->getRecordId(); $key = $consumer->getKey(); $key256 = DataConnector::getConsumerKey($key); if ($key === $key256) { $key = null; } $protected = ($consumer->protected) ? 1 : 0; $enabled = ($consumer->enabled)? 1 : 0; $profile = (!empty($consumer->profile)) ? json_encode($consumer->profile) : null; $settingsValue = serialize($consumer->getSettings()); $time = time(); $now = date("{$this->dateFormat} {$this->timeFormat}", $time); $from = null; if (!is_null($consumer->enableFrom)) { $from = date("{$this->dateFormat} {$this->timeFormat}", $consumer->enableFrom); } $until = null; if (!is_null($consumer->enableUntil)) { $until = date("{$this->dateFormat} {$this->timeFormat}", $consumer->enableUntil); } $last = null; if (!is_null($consumer->lastAccess)) { $last = date($this->dateFormat, $consumer->lastAccess); } if (empty($id)) { $sql = sprintf("INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' (consumer_key256, consumer_key, name, ' . 'secret, lti_version, consumer_name, consumer_version, consumer_guid, profile, tool_proxy, settings, protected, enabled, ' . 'enable_from, enable_until, last_access, created, updated) ' . 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %d, %d, %s, %s, %s, %s, %s)', DataConnector::quoted($key256), DataConnector::quoted($key), DataConnector::quoted($consumer->name), DataConnector::quoted($consumer->secret), DataConnector::quoted($consumer->ltiVersion), DataConnector::quoted($consumer->consumerName), DataConnector::quoted($consumer->consumerVersion), DataConnector::quoted($consumer->consumerGuid), DataConnector::quoted($profile), DataConnector::quoted($consumer->toolProxy), DataConnector::quoted($settingsValue), $protected, $enabled, DataConnector::quoted($from), DataConnector::quoted($until), DataConnector::quoted($last), DataConnector::quoted($now), DataConnector::quoted($now)); } else { $sql = sprintf("UPDATE {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' SET ' . 'consumer_key256 = %s, consumer_key = %s, ' . 'name = %s, secret= %s, lti_version = %s, consumer_name = %s, consumer_version = %s, consumer_guid = %s, ' . 'profile = %s, tool_proxy = %s, settings = %s, ' . 'protected = %d, enabled = %d, enable_from = %s, enable_until = %s, last_access = %s, updated = %s ' . 'WHERE consumer_pk = %d', DataConnector::quoted($key256), DataConnector::quoted($key), DataConnector::quoted($consumer->name), DataConnector::quoted($consumer->secret), DataConnector::quoted($consumer->ltiVersion), DataConnector::quoted($consumer->consumerName), DataConnector::quoted($consumer->consumerVersion), DataConnector::quoted($consumer->consumerGuid), DataConnector::quoted($profile), DataConnector::quoted($consumer->toolProxy), DataConnector::quoted($settingsValue), $protected, $enabled, DataConnector::quoted($from), DataConnector::quoted($until), DataConnector::quoted($last), DataConnector::quoted($now), $consumer->getRecordId()); } $ok = mysql_query($sql); if ($ok) { if (empty($id)) { $consumer->setRecordId(mysql_insert_id()); $consumer->created = $time; } $consumer->updated = $time; } return $ok; } /** * Delete tool consumer object. * * @param ToolConsumer $consumer Consumer object * * @return boolean True if the tool consumer object was successfully deleted */ public function deleteToolConsumer($consumer) { // Delete any nonce values for this consumer $sql = sprintf("DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::NONCE_TABLE_NAME . ' WHERE consumer_pk = %d', $consumer->getRecordId()); mysql_query($sql); // Delete any outstanding share keys for resource links for this consumer $sql = sprintf('DELETE sk ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' sk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON sk.resource_link_pk = rl.resource_link_pk ' . 'WHERE rl.consumer_pk = %d', $consumer->getRecordId()); mysql_query($sql); // Delete any outstanding share keys for resource links for contexts in this consumer $sql = sprintf('DELETE sk ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' sk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON sk.resource_link_pk = rl.resource_link_pk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ON rl.context_pk = c.context_pk ' . 'WHERE c.consumer_pk = %d', $consumer->getRecordId()); mysql_query($sql); // Delete any users in resource links for this consumer $sql = sprintf('DELETE u ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' u ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON u.resource_link_pk = rl.resource_link_pk ' . 'WHERE rl.consumer_pk = %d', $consumer->getRecordId()); mysql_query($sql); // Delete any users in resource links for contexts in this consumer $sql = sprintf('DELETE u ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' u ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON u.resource_link_pk = rl.resource_link_pk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ON rl.context_pk = c.context_pk ' . 'WHERE c.consumer_pk = %d', $consumer->getRecordId()); mysql_query($sql); // Update any resource links for which this consumer is acting as a primary resource link $sql = sprintf("UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' prl ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON prl.primary_resource_link_pk = rl.resource_link_pk ' . 'SET prl.primary_resource_link_pk = NULL, prl.share_approved = NULL ' . 'WHERE rl.consumer_pk = %d', $consumer->getRecordId()); $ok = mysql_query($sql); // Update any resource links for contexts in which this consumer is acting as a primary resource link $sql = sprintf("UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' prl ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON prl.primary_resource_link_pk = rl.resource_link_pk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ON rl.context_pk = c.context_pk ' . 'SET prl.primary_resource_link_pk = NULL, prl.share_approved = NULL ' . 'WHERE c.consumer_pk = %d', $consumer->getRecordId()); $ok = mysql_query($sql); // Delete any resource links for this consumer $sql = sprintf('DELETE rl ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . 'WHERE rl.consumer_pk = %d', $consumer->getRecordId()); mysql_query($sql); // Delete any resource links for contexts in this consumer $sql = sprintf('DELETE rl ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ON rl.context_pk = c.context_pk ' . 'WHERE c.consumer_pk = %d', $consumer->getRecordId()); mysql_query($sql); // Delete any contexts for this consumer $sql = sprintf('DELETE c ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ' . 'WHERE c.consumer_pk = %d', $consumer->getRecordId()); mysql_query($sql); // Delete consumer $sql = sprintf('DELETE c ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' c ' . 'WHERE c.consumer_pk = %d', $consumer->getRecordId()); $ok = mysql_query($sql); if ($ok) { $consumer->initialize(); } return $ok; } ### # Load all tool consumers from the database ### public function getToolConsumers() { $consumers = array(); $sql = 'SELECT consumer_pk, consumer_key, consumer_key, name, secret, lti_version, consumer_name, consumer_version, consumer_guid, ' . 'profile, tool_proxy, settings, ' . 'protected, enabled, enable_from, enable_until, last_access, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' ' . 'ORDER BY name'; $rsConsumers = mysql_query($sql); if ($rsConsumers) { while ($row = mysql_fetch_object($rsConsumers)) { $consumer = new ToolProvider\ToolConsumer($row->consumer_key, $this); $consumer->setRecordId(intval($row->consumer_pk)); $consumer->name = $row->name; $consumer->secret = $row->secret; $consumer->ltiVersion = $row->lti_version; $consumer->consumerName = $row->consumer_name; $consumer->consumerVersion = $row->consumer_version; $consumer->consumerGuid = $row->consumer_guid; $consumer->profile = json_decode($row->profile); $consumer->toolProxy = $row->tool_proxy; $settings = unserialize($row->settings); if (!is_array($settings)) { $settings = array(); } $consumer->setSettings($settings); $consumer->protected = (intval($row->protected) === 1); $consumer->enabled = (intval($row->enabled) === 1); $consumer->enableFrom = null; if (!is_null($row->enable_from)) { $consumer->enableFrom = strtotime($row->enable_from); } $consumer->enableUntil = null; if (!is_null($row->enable_until)) { $consumer->enableUntil = strtotime($row->enable_until); } $consumer->lastAccess = null; if (!is_null($row->last_access)) { $consumer->lastAccess = strtotime($row->last_access); } $consumer->created = strtotime($row->created); $consumer->updated = strtotime($row->updated); $consumers[] = $consumer; } mysql_free_result($rsConsumers); } return $consumers; } ### ### ToolProxy methods ### ### # Load the tool proxy from the database ### public function loadToolProxy($toolProxy) { return false; } ### # Save the tool proxy to the database ### public function saveToolProxy($toolProxy) { return false; } ### # Delete the tool proxy from the database ### public function deleteToolProxy($toolProxy) { return false; } ### ### Context methods ### /** * Load context object. * * @param Context $context Context object * * @return boolean True if the context object was successfully loaded */ public function loadContext($context) { $ok = false; if (!empty($context->getRecordId())) { $sql = sprintf('SELECT context_pk, consumer_pk, lti_context_id, type, settings, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' ' . 'WHERE (context_pk = %d)', $context->getRecordId()); } else { $sql = sprintf('SELECT context_pk, consumer_pk, lti_context_id, type, settings, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' ' . 'WHERE (consumer_pk = %d) AND (lti_context_id = %s)', $context->getConsumer()->getRecordId(), DataConnector::quoted($context->ltiContextId)); } $rs_context = mysql_query($sql); if ($rs_context) { $row = mysql_fetch_object($rs_context); if ($row) { $context->setRecordId(intval($row->context_pk)); $context->setConsumerId(intval($row->consumer_pk)); $context->ltiContextId = $row->lti_context_id; $context->type = $row->type; $settings = unserialize($row->settings); if (!is_array($settings)) { $settings = array(); } $context->setSettings($settings); $context->created = strtotime($row->created); $context->updated = strtotime($row->updated); $ok = true; } } return $ok; } /** * Save context object. * * @param Context $context Context object * * @return boolean True if the context object was successfully saved */ public function saveContext($context) { $time = time(); $now = date("{$this->dateFormat} {$this->timeFormat}", $time); $settingsValue = serialize($context->getSettings()); $id = $context->getRecordId(); $consumer_pk = $context->getConsumer()->getRecordId(); if (empty($id)) { $sql = sprintf("INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' (consumer_pk, lti_context_id, ' . 'type, settings, created, updated) ' . 'VALUES (%d, %s, %s, %s, %s, %s)', $consumer_pk, DataConnector::quoted($context->ltiContextId), DataConnector::quoted($context->type), DataConnector::quoted($settingsValue), DataConnector::quoted($now), DataConnector::quoted($now)); } else { $sql = sprintf("UPDATE {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' SET ' . 'lti_context_id = %s, type = %s, settings = %s, '. 'updated = %s' . 'WHERE (consumer_pk = %d) AND (context_pk = %d)', DataConnector::quoted($context->ltiContextId), DataConnector::quoted($context->type), DataConnector::quoted($settingsValue), DataConnector::quoted($now), $consumer_pk, $id); } $ok = mysql_query($sql); if ($ok) { if (empty($id)) { $context->setRecordId(mysql_insert_id()); $context->created = $time; } $context->updated = $time; } return $ok; } /** * Delete context object. * * @param Context $context Context object * * @return boolean True if the Context object was successfully deleted */ public function deleteContext($context) { // Delete any outstanding share keys for resource links for this context $sql = sprintf('DELETE sk ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' sk ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON sk.resource_link_pk = rl.resource_link_pk ' . 'WHERE rl.context_pk = %d', $context->getRecordId()); mysql_query($sql); // Delete any users in resource links for this context $sql = sprintf('DELETE u ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' u ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON u.resource_link_pk = rl.resource_link_pk ' . 'WHERE rl.context_pk = %d', $context->getRecordId()); mysql_query($sql); // Update any resource links for which this consumer is acting as a primary resource link $sql = sprintf("UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' prl ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ON prl.primary_resource_link_pk = rl.resource_link_pk ' . 'SET prl.primary_resource_link_pk = null, prl.share_approved = null ' . 'WHERE rl.context_pk = %d', $context->getRecordId()); $ok = mysql_query($sql); // Delete any resource links for this consumer $sql = sprintf('DELETE rl ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . 'WHERE rl.context_pk = %d', $context->getRecordId()); mysql_query($sql); // Delete context $sql = sprintf('DELETE c ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ', 'WHERE c.context_pk = %d', $context->getRecordId()); $ok = mysql_query($sql); if ($ok) { $context->initialize(); } return $ok; } ### ### ResourceLink methods ### /** * Load resource link object. * * @param ResourceLink $resourceLink Resource_Link object * * @return boolean True if the resource link object was successfully loaded */ public function loadResourceLink($resourceLink) { $ok = false; if (!empty($resourceLink->getRecordId())) { $sql = sprintf('SELECT resource_link_pk, context_pk, consumer_pk, lti_resource_link_id, settings, primary_resource_link_pk, share_approved, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'WHERE (resource_link_pk = %d)', $resourceLink->getRecordId()); } else if (!empty($resourceLink->getContext())) { $sql = sprintf('SELECT resource_link_pk, context_pk, consumer_pk, lti_resource_link_id, settings, primary_resource_link_pk, share_approved, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'WHERE (context_pk = %d) AND (lti_resource_link_id = %s)', $resourceLink->getContext()->getRecordId(), DataConnector::quoted($resourceLink->getId())); } else { $sql = sprintf('SELECT r.resource_link_pk, r.context_pk, r.consumer_pk, r.lti_resource_link_id, r.settings, r.primary_resource_link_pk, r.share_approved, r.created, r.updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' r LEFT OUTER JOIN ' . $this->dbTableNamePrefix . DataConnector::CONTEXT_TABLE_NAME . ' c ON r.context_pk = c.context_pk ' . ' WHERE ((r.consumer_pk = %d) OR (c.consumer_pk = %d)) AND (lti_resource_link_id = %s)', $resourceLink->getConsumer()->getRecordId(), $resourceLink->getConsumer()->getRecordId(), DataConnector::quoted($resourceLink->getId())); } $rsContext = mysql_query($sql); if ($rsContext) { $row = mysql_fetch_object($rsContext); if ($row) { $resourceLink->setRecordId(intval($row->resource_link_pk)); if (!is_null($row->context_pk)) { $resourceLink->setContextId(intval($row->context_pk)); } else { $resourceLink->setContextId(null); } if (!is_null($row->consumer_pk)) { $resourceLink->setConsumerId(intval($row->consumer_pk)); } else { $resourceLink->setConsumerId(null); } $resourceLink->ltiResourceLinkId = $row->lti_resource_link_id; $settings = unserialize($row->settings); if (!is_array($settings)) { $settings = array(); } $resourceLink->setSettings($settings); if (!is_null($row->primary_resource_link_pk)) { $resourceLink->primaryResourceLinkId = intval($row->primary_resource_link_pk); } else { $resourceLink->primaryResourceLinkId = null; } $resourceLink->shareApproved = (is_null($row->share_approved)) ? null : (intval($row->share_approved) === 1); $resourceLink->created = strtotime($row->created); $resourceLink->updated = strtotime($row->updated); $ok = true; } } return $ok; } /** * Save resource link object. * * @param ResourceLink $resourceLink Resource_Link object * * @return boolean True if the resource link object was successfully saved */ public function saveResourceLink($resourceLink) { if (is_null($resourceLink->shareApproved)) { $approved = 'NULL'; } else if ($resourceLink->shareApproved) { $approved = '1'; } else { $approved = '0'; } if (empty($resourceLink->primaryResourceLinkId)) { $primaryResourceLinkId = 'NULL'; } else { $primaryResourceLinkId = strval($resourceLink->primaryResourceLinkId); } $time = time(); $now = date("{$this->dateFormat} {$this->timeFormat}", $time); $settingsValue = serialize($resourceLink->getSettings()); if (!empty($resourceLink->getContext())) { $consumerId = 'NULL'; $contextId = strval($resourceLink->getContext()->getRecordId()); } else if (!empty($resourceLink->getContextId())) { $consumerId = 'NULL'; $contextId = strval($resourceLink->getContextId()); } else { $consumerId = strval($resourceLink->getConsumer()->getRecordId()); $contextId = 'NULL'; } $id = $resourceLink->getRecordId(); if (empty($id)) { $sql = sprintf("INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' (consumer_pk, context_pk, ' . 'lti_resource_link_id, settings, primary_resource_link_pk, share_approved, created, updated) ' . 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s)', $consumerId, $contextId, DataConnector::quoted($resourceLink->getId()), DataConnector::quoted($settingsValue), $primaryResourceLinkId, $approved, DataConnector::quoted($now), DataConnector::quoted($now)); } else if ($contextId !== 'NULL') { $sql = sprintf("UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' SET ' . 'consumer_pk = %s, lti_resource_link_id = %s, settings = %s, '. 'primary_resource_link_pk = %s, share_approved = %s, updated = %s ' . 'WHERE (context_pk = %s) AND (resource_link_pk = %d)', $consumerId, DataConnector::quoted($resourceLink->getId()), DataConnector::quoted($settingsValue), $primaryResourceLinkId, $approved, DataConnector::quoted($now), $contextId, $id); } else { $sql = sprintf("UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' SET ' . 'context_pk = %s, lti_resource_link_id = %s, settings = %s, '. 'primary_resource_link_pk = %s, share_approved = %s, updated = %s ' . 'WHERE (consumer_pk = %s) AND (resource_link_pk = %d)', $contextId, DataConnector::quoted($resourceLink->getId()), DataConnector::quoted($settingsValue), $primaryResourceLinkId, $approved, DataConnector::quoted($now), $consumerId, $id); } $ok = mysql_query($sql); if ($ok) { if (empty($id)) { $resourceLink->setRecordId(mysql_insert_id()); $resourceLink->created = $time; } $resourceLink->updated = $time; } return $ok; } /** * Delete resource link object. * * @param ResourceLink $resourceLink Resource_Link object * * @return boolean True if the resource link object was successfully deleted */ public function deleteResourceLink($resourceLink) { // Delete any outstanding share keys for resource links for this consumer $sql = sprintf("DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' ' . 'WHERE (resource_link_pk = %d)', $resourceLink->getRecordId()); $ok = mysql_query($sql); // Delete users if ($ok) { $sql = sprintf("DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . 'WHERE (resource_link_pk = %d)', $resourceLink->getRecordId()); $ok = mysql_query($sql); } // Update any resource links for which this is the primary resource link if ($ok) { $sql = sprintf("UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'SET primary_resource_link_pk = NULL ' . 'WHERE (primary_resource_link_pk = %d)', $resourceLink->getRecordId()); $ok = mysql_query($sql); } // Delete resource link if ($ok) { $sql = sprintf("DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'WHERE (resource_link_pk = %s)', $resourceLink->getRecordId()); $ok = mysql_query($sql); } if ($ok) { $resourceLink->initialize(); } return $ok; } /** * Get array of user objects. * * Obtain an array of User objects for users with a result sourcedId. The array may include users from other * resource links which are sharing this resource link. It may also be optionally indexed by the user ID of a specified scope. * * @param ResourceLink $resourceLink Resource link object * @param boolean $localOnly True if only users within the resource link are to be returned (excluding users sharing this resource link) * @param int $idScope Scope value to use for user IDs * * @return array Array of User objects */ public function getUserResultSourcedIDsResourceLink($resourceLink, $localOnly, $idScope) { $users = array(); if ($localOnly) { $sql = sprintf('SELECT u.user_pk, u.lti_result_sourcedid, u.lti_user_id, u.created, u.updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' AS u ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' AS rl ' . 'ON u.resource_link_pk = rl.resource_link_pk ' . "WHERE (rl.resource_link_pk = %d) AND (rl.primary_resource_link_pk IS NULL)", $resourceLink->getRecordId()); } else { $sql = sprintf('SELECT u.user_pk, u.lti_result_sourcedid, u.lti_user_id, u.created, u.updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' AS u ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' AS rl ' . 'ON u.resource_link_pk = rl.resource_link_pk ' . 'WHERE ((rl.resource_link_pk = %d) AND (rl.primary_resource_link_pk IS NULL)) OR ' . '((rl.primary_resource_link_pk = %d) AND (share_approved = 1))', $resourceLink->getRecordId(), $resourceLink->getRecordId()); } $rsUser = mysql_query($sql); if ($rsUser) { while ($row = mysql_fetch_object($rsUser)) { $user = ToolProvider\User::fromResourceLink($resourceLink, $row->lti_user_id); $user->setRecordId(intval($row->user_pk)); $user->ltiResultSourcedId = $row->lti_result_sourcedid; $user->created = strtotime($row->created); $user->updated = strtotime($row->updated); if (is_null($idScope)) { $users[] = $user; } else { $users[$user->getId($idScope)] = $user; } } } return $users; } /** * Get array of shares defined for this resource link. * * @param ResourceLink $resourceLink Resource_Link object * * @return array Array of ResourceLinkShare objects */ public function getSharesResourceLink($resourceLink) { $shares = array(); $sql = sprintf('SELECT consumer_pk, resource_link_pk, share_approved ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'WHERE (primary_resource_link_pk = %d) ' . 'ORDER BY consumer_pk', $resourceLink->getRecordId()); $rsShare = mysql_query($sql); if ($rsShare) { while ($row = mysql_fetch_object($rsShare)) { $share = new ToolProvider\ResourceLinkShare(); $share->resourceLinkId = intval($row->resource_link_pk); $share->approved = (intval($row->share_approved) === 1); $shares[] = $share; } } return $shares; } ### ### ConsumerNonce methods ### /** * Load nonce object. * * @param ConsumerNonce $nonce Nonce object * * @return boolean True if the nonce object was successfully loaded */ public function loadConsumerNonce($nonce) { $ok = true; // Delete any expired nonce values $now = date("{$this->dateFormat} {$this->timeFormat}", time()); $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::NONCE_TABLE_NAME . " WHERE expires <= '{$now}'"; mysql_query($sql); // Load the nonce $sql = sprintf("SELECT value AS T FROM {$this->dbTableNamePrefix}" . DataConnector::NONCE_TABLE_NAME . ' WHERE (consumer_pk = %d) AND (value = %s)', $nonce->getConsumer()->getRecordId(), DataConnector::quoted($nonce->getValue())); $rs_nonce = mysql_query($sql); if ($rs_nonce) { $row = mysql_fetch_object($rs_nonce); if ($row === false) { $ok = false; } } return $ok; } /** * Save nonce object. * * @param ConsumerNonce $nonce Nonce object * * @return boolean True if the nonce object was successfully saved */ public function saveConsumerNonce($nonce) { $expires = date("{$this->dateFormat} {$this->timeFormat}", $nonce->expires); $sql = sprintf("INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::NONCE_TABLE_NAME . " (consumer_pk, value, expires) VALUES (%d, %s, %s)", $nonce->getConsumer()->getRecordId(), DataConnector::quoted($nonce->getValue()), DataConnector::quoted($expires)); $ok = mysql_query($sql); return $ok; } ### ### ResourceLinkShareKey methods ### /** * Load resource link share key object. * * @param ResourceLinkShareKey $shareKey Resource_Link share key object * * @return boolean True if the resource link share key object was successfully loaded */ public function loadResourceLinkShareKey($shareKey) { $ok = false; // Clear expired share keys $now = date("{$this->dateFormat} {$this->timeFormat}", time()); $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . " WHERE expires <= '{$now}'"; mysql_query($sql); // Load share key $id = mysql_real_escape_string($shareKey->getId()); $sql = 'SELECT resource_link_pk, auto_approve, expires ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' ' . "WHERE share_key_id = '{$id}'"; $rsShareKey = mysql_query($sql); if ($rsShareKey) { $row = mysql_fetch_object($rsShareKey); if ($row && (intval($row->resource_link_pk) === $shareKey->resourceLinkId)) { $shareKey->autoApprove = (intval($row->auto_approve) === 1); $shareKey->expires = strtotime($row->expires); $ok = true; } } return $ok; } /** * Save resource link share key object. * * @param ResourceLinkShareKey $shareKey Resource link share key object * * @return boolean True if the resource link share key object was successfully saved */ public function saveResourceLinkShareKey($shareKey) { if ($shareKey->autoApprove) { $approve = 1; } else { $approve = 0; } $expires = date("{$this->dateFormat} {$this->timeFormat}", $shareKey->expires); $sql = sprintf("INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' ' . '(share_key_id, resource_link_pk, auto_approve, expires) ' . "VALUES (%s, %d, {$approve}, '{$expires}')", DataConnector::quoted($shareKey->getId()), $shareKey->resourceLinkId); $ok = mysql_query($sql); return $ok; } /** * Delete resource link share key object. * * @param ResourceLinkShareKey $shareKey Resource link share key object * * @return boolean True if the resource link share key object was successfully deleted */ public function deleteResourceLinkShareKey($shareKey) { $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . " WHERE share_key_id = '{$shareKey->getId()}'"; $ok = mysql_query($sql); if ($ok) { $shareKey->initialize(); } return $ok; } ### ### User methods ### /** * Load user object. * * @param User $user User object * * @return boolean True if the user object was successfully loaded */ public function loadUser($user) { $ok = false; if (!empty($user->getRecordId())) { $sql = sprintf('SELECT user_pk, resource_link_pk, lti_user_id, lti_result_sourcedid, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . 'WHERE (user_pk = %d)', $user->getRecordId()); } else { $sql = sprintf('SELECT user_pk, resource_link_pk, lti_user_id, lti_result_sourcedid, created, updated ' . "FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . 'WHERE (resource_link_pk = %d) AND (lti_user_id = %s)', $user->getResourceLink()->getRecordId(), DataConnector::quoted($user->getId(ToolProvider\ToolProvider::ID_SCOPE_ID_ONLY))); } $rsUser = mysql_query($sql); if ($rsUser) { $row = mysql_fetch_object($rsUser); if ($row) { $user->setRecordId(intval($row->user_pk)); $user->setResourceLinkId(intval($row->resource_link_pk)); $user->ltiUserId = $row->lti_user_id; $user->ltiResultSourcedId = $row->lti_result_sourcedid; $user->created = strtotime($row->created); $user->updated = strtotime($row->updated); $ok = true; } } return $ok; } /** * Save user object. * * @param User $user User object * * @return boolean True if the user object was successfully saved */ public function saveUser($user) { $time = time(); $now = date("{$this->dateFormat} {$this->timeFormat}", $time); if (is_null($user->created)) { $sql = sprintf("INSERT INTO {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' (resource_link_pk, ' . 'lti_user_id, lti_result_sourcedid, created, updated) ' . 'VALUES (%d, %s, %s, %s, %s)', $user->getResourceLink()->getRecordId(), DataConnector::quoted($user->getId(ToolProvider\ToolProvider::ID_SCOPE_ID_ONLY)), DataConnector::quoted($user->ltiResultSourcedId), DataConnector::quoted($now), DataConnector::quoted($now)); } else { $sql = sprintf("UPDATE {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . 'SET lti_result_sourcedid = %s, updated = %s ' . 'WHERE (user_pk = %d)', DataConnector::quoted($user->ltiResultSourcedId), DataConnector::quoted($now), $user->getRecordId()); } $ok = mysql_query($sql); if ($ok) { if (is_null($user->created)) { $user->setRecordId(mysql_insert_id()); $user->created = $time; } $user->updated = $time; } return $ok; } /** * Delete user object. * * @param User $user User object * * @return boolean True if the user object was successfully deleted */ public function deleteUser($user) { $sql = sprintf("DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . 'WHERE (user_pk = %d)', $user->getRecordId()); $ok = mysql_query($sql); if ($ok) { $user->initialize(); } return $ok; } } ToolProvider/DataConnector/DataConnector_pdo_sqlite.php 0000644 00000023116 15152003527 0017403 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\DataConnector; use IMSGlobal\LTI\ToolProvider; use IMSGlobal\LTI\ToolProvider\Context; use IMSGlobal\LTI\ToolProvider\ToolConsumer; use PDO; /** * Class to represent an LTI Data Connector for PDO variations for SQLite connections * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class DataConnector_pdo_sqlite extends DataConnector_pdo { ### ### ToolConsumer methods ### /** * Delete tool consumer object. * * @param ToolConsumer $consumer Consumer object * * @return boolean True if the tool consumer object was successfully deleted */ public function deleteToolConsumer($consumer) { $id = $consumer->getRecordId(); // Delete any nonce values for this consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::NONCE_TABLE_NAME . ' WHERE consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any outstanding share keys for resource links for this consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' ' . "WHERE EXISTS (SELECT * FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . "WHERE ({$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . '.resource_link_pk = rl.resource_link_pk) AND (rl.consumer_pk = :id))'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any outstanding share keys for resource links for contexts in this consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' ' . "WHERE EXISTS (SELECT * FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ON rl.context_pk = c.context_pk ' . "WHERE ({$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . '.resource_link_pk = rl.resource_link_pk) AND (c.consumer_pk = :id))'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any users in resource links for this consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . "WHERE EXISTS (SELECT * FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . "WHERE ({$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . '.resource_link_pk = rl.resource_link_pk) AND (rl.consumer_pk = :id))'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any users in resource links for contexts in this consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . "WHERE EXISTS (SELECT * FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ON rl.context_pk = c.context_pk ' . "WHERE ({$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . '.resource_link_pk = rl.resource_link_pk) AND (c.consumer_pk = :id))'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Update any resource links for which this consumer is acting as a primary resource link $sql = "UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'SET primary_resource_link_pk = NULL, share_approved = NULL ' . "WHERE EXISTS (SELECT * FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . "WHERE ({$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . '.primary_resource_link_pk = rl.resource_link_pk) AND (rl.consumer_pk = :id))'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Update any resource links for contexts in which this consumer is acting as a primary resource link $sql = "UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'SET primary_resource_link_pk = NULL, share_approved = NULL ' . "WHERE EXISTS (SELECT * FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . "INNER JOIN {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ON rl.context_pk = c.context_pk ' . "WHERE ({$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . '.primary_resource_link_pk = rl.resource_link_pk) AND (c.consumer_pk = :id))'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any resource links for this consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'WHERE consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any resource links for contexts in this consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . "WHERE EXISTS (SELECT * FROM {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' c ' . "WHERE ({$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . '.context_pk = c.context_pk) AND (c.consumer_pk = :id))'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any contexts for this consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' ' . 'WHERE consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::CONSUMER_TABLE_NAME . ' ' . 'WHERE consumer_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $ok = $query->execute(); if ($ok) { $consumer->initialize(); } return $ok; } ### ### Context methods ### /** * Delete context object. * * @param Context $context Context object * * @return boolean True if the Context object was successfully deleted */ public function deleteContext($context) { $id = $context->getRecordId(); // Delete any outstanding share keys for resource links for this context $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . ' ' . "WHERE EXISTS (SELECT * FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . "WHERE ({$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_SHARE_KEY_TABLE_NAME . '.resource_link_pk = rl.resource_link_pk) AND (rl.context_pk = :id))'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any users in resource links for this context $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . ' ' . "WHERE EXISTS (SELECT * FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . "WHERE ({$this->dbTableNamePrefix}" . DataConnector::USER_RESULT_TABLE_NAME . '.resource_link_pk = rl.resource_link_pk) AND (rl.context_pk = :id))'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Update any resource links for which this consumer is acting as a primary resource link $sql = "UPDATE {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'SET primary_resource_link_pk = null, share_approved = null ' . "WHERE EXISTS (SELECT * FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' rl ' . "WHERE ({$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . '.primary_resource_link_pk = rl.resource_link_pk) AND (rl.context_pk = :id))'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete any resource links for this consumer $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::RESOURCE_LINK_TABLE_NAME . ' ' . 'WHERE context_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $query->execute(); // Delete context $sql = "DELETE FROM {$this->dbTableNamePrefix}" . DataConnector::CONTEXT_TABLE_NAME . ' ' . 'WHERE context_pk = :id'; $query = $this->db->prepare($sql); $query->bindValue('id', $id, PDO::PARAM_INT); $ok = $query->execute(); if ($ok) { $context->initialize(); } return $ok; } } ToolProvider/DataConnector/DataConnector.php 0000644 00000032070 15152003527 0015157 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\DataConnector; use IMSGlobal\LTI\ToolProvider\ConsumerNonce; use IMSGlobal\LTI\ToolProvider\Context; use IMSGlobal\LTI\ToolProvider\ResourceLink; use IMSGlobal\LTI\ToolProvider\ResourceLinkShareKey; use IMSGlobal\LTI\ToolProvider\ToolConsumer; use IMSGlobal\LTI\ToolProvider\ToolProxy; use IMSGlobal\LTI\ToolProvider\User; use PDO; /** * Class to provide a connection to a persistent store for LTI objects * * This class assumes no data persistence - it should be extended for specific database connections. * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class DataConnector { /** * Default name for database table used to store tool consumers. */ const CONSUMER_TABLE_NAME = 'lti2_consumer'; /** * Default name for database table used to store pending tool proxies. */ const TOOL_PROXY_TABLE_NAME = 'lti2_tool_proxy'; /** * Default name for database table used to store contexts. */ const CONTEXT_TABLE_NAME = 'lti2_context'; /** * Default name for database table used to store resource links. */ const RESOURCE_LINK_TABLE_NAME = 'lti2_resource_link'; /** * Default name for database table used to store users. */ const USER_RESULT_TABLE_NAME = 'lti2_user_result'; /** * Default name for database table used to store resource link share keys. */ const RESOURCE_LINK_SHARE_KEY_TABLE_NAME = 'lti2_share_key'; /** * Default name for database table used to store nonce values. */ const NONCE_TABLE_NAME = 'lti2_nonce'; /** * Database object. * * @var object $db */ protected $db = null; /** * Prefix for database table names. * * @var string $dbTableNamePrefix */ protected $dbTableNamePrefix = ''; /** * SQL date format (default = 'Y-m-d') * * @var string $dateFormat */ protected $dateFormat = 'Y-m-d'; /** * SQL time format (default = 'H:i:s') * * @var string $timeFormat */ protected $timeFormat = 'H:i:s'; /** * Class constructor * * @param object $db Database connection object * @param string $dbTableNamePrefix Prefix for database table names (optional, default is none) */ public function __construct($db, $dbTableNamePrefix = '') { $this->db = $db; $this->dbTableNamePrefix = $dbTableNamePrefix; } ### ### ToolConsumer methods ### /** * Load tool consumer object. * * @param ToolConsumer $consumer ToolConsumer object * * @return boolean True if the tool consumer object was successfully loaded */ public function loadToolConsumer($consumer) { $consumer->secret = 'secret'; $consumer->enabled = true; $now = time(); $consumer->created = $now; $consumer->updated = $now; return true; } /** * Save tool consumer object. * * @param ToolConsumer $consumer Consumer object * * @return boolean True if the tool consumer object was successfully saved */ public function saveToolConsumer($consumer) { $consumer->updated = time(); return true; } /** * Delete tool consumer object. * * @param ToolConsumer $consumer Consumer object * * @return boolean True if the tool consumer object was successfully deleted */ public function deleteToolConsumer($consumer) { $consumer->initialize(); return true; } /** * Load tool consumer objects. * * @return array Array of all defined ToolConsumer objects */ public function getToolConsumers() { return array(); } ### ### ToolProxy methods ### /** * Load tool proxy object. * * @param ToolProxy $toolProxy ToolProxy object * * @return boolean True if the tool proxy object was successfully loaded */ public function loadToolProxy($toolProxy) { $now = time(); $toolProxy->created = $now; $toolProxy->updated = $now; return true; } /** * Save tool proxy object. * * @param ToolProxy $toolProxy ToolProxy object * * @return boolean True if the tool proxy object was successfully saved */ public function saveToolProxy($toolProxy) { $toolProxy->updated = time(); return true; } /** * Delete tool proxy object. * * @param ToolProxy $toolProxy ToolProxy object * * @return boolean True if the tool proxy object was successfully deleted */ public function deleteToolProxy($toolProxy) { $toolProxy->initialize(); return true; } ### ### Context methods ### /** * Load context object. * * @param Context $context Context object * * @return boolean True if the context object was successfully loaded */ public function loadContext($context) { $now = time(); $context->created = $now; $context->updated = $now; return true; } /** * Save context object. * * @param Context $context Context object * * @return boolean True if the context object was successfully saved */ public function saveContext($context) { $context->updated = time(); return true; } /** * Delete context object. * * @param Context $context Context object * * @return boolean True if the Context object was successfully deleted */ public function deleteContext($context) { $context->initialize(); return true; } ### ### ResourceLink methods ### /** * Load resource link object. * * @param ResourceLink $resourceLink Resource_Link object * * @return boolean True if the resource link object was successfully loaded */ public function loadResourceLink($resourceLink) { $now = time(); $resourceLink->created = $now; $resourceLink->updated = $now; return true; } /** * Save resource link object. * * @param ResourceLink $resourceLink Resource_Link object * * @return boolean True if the resource link object was successfully saved */ public function saveResourceLink($resourceLink) { $resourceLink->updated = time(); return true; } /** * Delete resource link object. * * @param ResourceLink $resourceLink Resource_Link object * * @return boolean True if the resource link object was successfully deleted */ public function deleteResourceLink($resourceLink) { $resourceLink->initialize(); return true; } /** * Get array of user objects. * * Obtain an array of User objects for users with a result sourcedId. The array may include users from other * resource links which are sharing this resource link. It may also be optionally indexed by the user ID of a specified scope. * * @param ResourceLink $resourceLink Resource link object * @param boolean $localOnly True if only users within the resource link are to be returned (excluding users sharing this resource link) * @param int $idScope Scope value to use for user IDs * * @return array Array of User objects */ public function getUserResultSourcedIDsResourceLink($resourceLink, $localOnly, $idScope) { return array(); } /** * Get array of shares defined for this resource link. * * @param ResourceLink $resourceLink Resource_Link object * * @return array Array of ResourceLinkShare objects */ public function getSharesResourceLink($resourceLink) { return array(); } ### ### ConsumerNonce methods ### /** * Load nonce object. * * @param ConsumerNonce $nonce Nonce object * * @return boolean True if the nonce object was successfully loaded */ public function loadConsumerNonce($nonce) { return false; // assume the nonce does not already exist } /** * Save nonce object. * * @param ConsumerNonce $nonce Nonce object * * @return boolean True if the nonce object was successfully saved */ public function saveConsumerNonce($nonce) { return true; } ### ### ResourceLinkShareKey methods ### /** * Load resource link share key object. * * @param ResourceLinkShareKey $shareKey Resource_Link share key object * * @return boolean True if the resource link share key object was successfully loaded */ public function loadResourceLinkShareKey($shareKey) { return true; } /** * Save resource link share key object. * * @param ResourceLinkShareKey $shareKey Resource link share key object * * @return boolean True if the resource link share key object was successfully saved */ public function saveResourceLinkShareKey($shareKey) { return true; } /** * Delete resource link share key object. * * @param ResourceLinkShareKey $shareKey Resource link share key object * * @return boolean True if the resource link share key object was successfully deleted */ public function deleteResourceLinkShareKey($shareKey) { return true; } ### ### User methods ### /** * Load user object. * * @param User $user User object * * @return boolean True if the user object was successfully loaded */ public function loadUser($user) { $now = time(); $user->created = $now; $user->updated = $now; return true; } /** * Save user object. * * @param User $user User object * * @return boolean True if the user object was successfully saved */ public function saveUser($user) { $user->updated = time(); return true; } /** * Delete user object. * * @param User $user User object * * @return boolean True if the user object was successfully deleted */ public function deleteUser($user) { $user->initialize(); return true; } ### ### Other methods ### /** * Return a hash of a consumer key for values longer than 255 characters. * * @param string $key * @return string */ protected static function getConsumerKey($key) { $len = strlen($key); if ($len > 255) { $key = 'sha512:' . hash('sha512', $key); } return $key; } /** * Create data connector object. * * A data connector provides access to persistent storage for the different objects. * * Names of tables may be given a prefix to allow multiple versions to share the same schema. A separate sub-class is defined for * each different database connection - the class to use is determined by inspecting the database object passed, but this can be overridden * (for example, to use a bespoke connector) by specifying a type. If no database is passed then this class is used which acts as a dummy * connector with no persistence. * * @param string $dbTableNamePrefix Prefix for database table names (optional, default is none) * @param object $db A database connection object or string (optional, default is no persistence) * @param string $type The type of data connector (optional, default is based on $db parameter) * * @return DataConnector Data connector object */ public static function getDataConnector($dbTableNamePrefix = '', $db = null, $type = '') { if (is_null($dbTableNamePrefix)) { $dbTableNamePrefix = ''; } if (!is_null($db) && empty($type)) { if (is_object($db)) { $type = get_class($db); } } $type = strtolower($type); if (($type === 'pdo') && ($db->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite')) { $type .= '_sqlite'; } if (!empty($type)) { $type ="DataConnector_{$type}"; } else { $type ='DataConnector'; } $type = "\\IMSGlobal\\LTI\\ToolProvider\\DataConnector\\{$type}"; $dataConnector = new $type($db, $dbTableNamePrefix); return $dataConnector; } /** * Generate a random string. * * The generated string will only comprise letters (upper- and lower-case) and digits. * * @param int $length Length of string to be generated (optional, default is 8 characters) * * @return string Random string */ static function getRandomString($length = 8) { $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; $value = ''; $charsLength = strlen($chars) - 1; for ($i = 1 ; $i <= $length; $i++) { $value .= $chars[rand(0, $charsLength)]; } return $value; } /** * Quote a string for use in a database query. * * Any single quotes in the value passed will be replaced with two single quotes. If a null value is passed, a string * of 'null' is returned (which will never be enclosed in quotes irrespective of the value of the $addQuotes parameter. * * @param string $value Value to be quoted * @param bool $addQuotes If true the returned string will be enclosed in single quotes (optional, default is true) * @return string The quoted string. */ static function quoted($value, $addQuotes = true) { if (is_null($value)) { $value = 'null'; } else { $value = str_replace('\'', '\'\'', $value); if ($addQuotes) { $value = "'{$value}'"; } } return $value; } } ToolProvider/User.php 0000644 00000024176 15152003527 0010635 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider; use IMSGlobal\LTI\ToolProvider\DataConnector\DataConnector; /** * Class to represent a tool consumer user * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.2 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class User { /** * User's first name. * * @var string $firstname */ public $firstname = ''; /** * User's last name (surname or family name). * * @var string $lastname */ public $lastname = ''; /** * User's fullname. * * @var string $fullname */ public $fullname = ''; /** * User's email address. * * @var string $email */ public $email = ''; /** * User's image URI. * * @var string $image */ public $image = ''; /** * Roles for user. * * @var array $roles */ public $roles = array(); /** * Groups for user. * * @var array $groups */ public $groups = array(); /** * User's result sourcedid. * * @var string $ltiResultSourcedId */ public $ltiResultSourcedId = null; /** * Date/time the record was created. * * @var object $created */ public $created = null; /** * Date/time the record was last updated. * * @var object $updated */ public $updated = null; /** * Resource link object. * * @var ResourceLink $resourceLink */ private $resourceLink = null; /** * Resource link record ID. * * @var int $resourceLinkId */ private $resourceLinkId = null; /** * User record ID value. * * @var string $id */ private $id = null; /** * user ID as supplied in the last connection request. * * @var string $ltiUserId */ public $ltiUserId = null; /** * Data connector object or string. * * @var mixed $dataConnector */ private $dataConnector = null; /** * Class constructor. */ public function __construct() { $this->initialize(); } /** * Initialise the user. */ public function initialize() { $this->firstname = ''; $this->lastname = ''; $this->fullname = ''; $this->email = ''; $this->image = ''; $this->roles = array(); $this->groups = array(); $this->ltiResultSourcedId = null; $this->created = null; $this->updated = null; } /** * Initialise the user. * * Pseudonym for initialize(). */ public function initialise() { $this->initialize(); } /** * Save the user to the database. * * @return boolean True if the user object was successfully saved */ public function save() { if (!empty($this->ltiResultSourcedId) && !is_null($this->resourceLinkId)) { $ok = $this->getDataConnector()->saveUser($this); } else { $ok = true; } return $ok; } /** * Delete the user from the database. * * @return boolean True if the user object was successfully deleted */ public function delete() { $ok = $this->getDataConnector()->deleteUser($this); return $ok; } /** * Get resource link. * * @return ResourceLink Resource link object */ public function getResourceLink() { if (is_null($this->resourceLink) && !is_null($this->resourceLinkId)) { $this->resourceLink = ResourceLink::fromRecordId($this->resourceLinkId, $this->getDataConnector()); } return $this->resourceLink; } /** * Get record ID of user. * * @return int Record ID of user */ public function getRecordId() { return $this->id; } /** * Set record ID of user. * * @param int $id Record ID of user */ public function setRecordId($id) { $this->id = $id; } /** * Set resource link ID of user. * * @param int $resourceLinkId Resource link ID of user */ public function setResourceLinkId($resourceLinkId) { $this->resourceLinkId = $resourceLinkId; } /** * Get the data connector. * * @return mixed Data connector object or string */ public function getDataConnector() { return $this->dataConnector; } /** * Get the user ID (which may be a compound of the tool consumer and resource link IDs). * * @param int $idScope Scope to use for user ID (optional, default is null for consumer default setting) * * @return string User ID value */ public function getId($idScope = null) { if (empty($idScope)) { if (!is_null($this->resourceLink)) { $idScope = $this->resourceLink->getConsumer()->idScope; } else { $idScope = ToolProvider::ID_SCOPE_ID_ONLY; } } switch ($idScope) { case ToolProvider::ID_SCOPE_GLOBAL: $id = $this->getResourceLink()->getKey() . ToolProvider::ID_SCOPE_SEPARATOR . $this->ltiUserId; break; case ToolProvider::ID_SCOPE_CONTEXT: $id = $this->getResourceLink()->getKey(); if ($this->resourceLink->ltiContextId) { $id .= ToolProvider::ID_SCOPE_SEPARATOR . $this->resourceLink->ltiContextId; } $id .= ToolProvider::ID_SCOPE_SEPARATOR . $this->ltiUserId; break; case ToolProvider::ID_SCOPE_RESOURCE: $id = $this->getResourceLink()->getKey(); if ($this->resourceLink->ltiResourceLinkId) { $id .= ToolProvider::ID_SCOPE_SEPARATOR . $this->resourceLink->ltiResourceLinkId; } $id .= ToolProvider::ID_SCOPE_SEPARATOR . $this->ltiUserId; break; default: $id = $this->ltiUserId; break; } return $id; } /** * Set the user's name. * * @param string $firstname User's first name. * @param string $lastname User's last name. * @param string $fullname User's full name. */ public function setNames($firstname, $lastname, $fullname) { $names = array(0 => '', 1 => ''); if (!empty($fullname)) { $this->fullname = trim($fullname); $names = preg_split("/[\s]+/", $this->fullname, 2); } if (!empty($firstname)) { $this->firstname = trim($firstname); $names[0] = $this->firstname; } else if (!empty($names[0])) { $this->firstname = $names[0]; } else { $this->firstname = 'User'; } if (!empty($lastname)) { $this->lastname = trim($lastname); $names[1] = $this->lastname; } else if (!empty($names[1])) { $this->lastname = $names[1]; } else { $this->lastname = $this->ltiUserId; } if (empty($this->fullname)) { $this->fullname = "{$this->firstname} {$this->lastname}"; } } /** * Set the user's email address. * * @param string $email Email address value * @param string $defaultEmail Value to use if no email is provided (optional, default is none) */ public function setEmail($email, $defaultEmail = null) { if (!empty($email)) { $this->email = $email; } else if (!empty($defaultEmail)) { $this->email = $defaultEmail; if (substr($this->email, 0, 1) === '@') { $this->email = $this->getId() . $this->email; } } else { $this->email = ''; } } /** * Check if the user is an administrator (at any of the system, institution or context levels). * * @return boolean True if the user has a role of administrator */ public function isAdmin() { return $this->hasRole('Administrator') || $this->hasRole('urn:lti:sysrole:ims/lis/SysAdmin') || $this->hasRole('urn:lti:sysrole:ims/lis/Administrator') || $this->hasRole('urn:lti:instrole:ims/lis/Administrator'); } /** * Check if the user is staff. * * @return boolean True if the user has a role of instructor, contentdeveloper or teachingassistant */ public function isStaff() { return ($this->hasRole('Instructor') || $this->hasRole('ContentDeveloper') || $this->hasRole('TeachingAssistant')); } /** * Check if the user is a learner. * * @return boolean True if the user has a role of learner */ public function isLearner() { return $this->hasRole('Learner'); } /** * Load the user from the database. * * @param int $id Record ID of user * @param DataConnector $dataConnector Database connection object * * @return User User object */ public static function fromRecordId($id, $dataConnector) { $user = new User(); $user->dataConnector = $dataConnector; $user->load($id); return $user; } /** * Class constructor from resource link. * * @param ResourceLink $resourceLink Resource_Link object * @param string $ltiUserId User ID value * @return User */ public static function fromResourceLink($resourceLink, $ltiUserId) { $user = new User(); $user->resourceLink = $resourceLink; if (!is_null($resourceLink)) { $user->resourceLinkId = $resourceLink->getRecordId(); $user->dataConnector = $resourceLink->getDataConnector(); } $user->ltiUserId = $ltiUserId; if (!empty($ltiUserId)) { $user->load(); } return $user; } ### ### PRIVATE METHODS ### /** * Check whether the user has a specified role name. * * @param string $role Name of role * * @return boolean True if the user has the specified role */ private function hasRole($role) { if (substr($role, 0, 4) !== 'urn:') { $role = 'urn:lti:role:ims/lis/' . $role; } return in_array($role, $this->roles); } /** * Load the user from the database. * * @param int $id Record ID of user (optional, default is null) * * @return boolean True if the user object was successfully loaded */ private function load($id = null) { $this->initialize(); $this->id = $id; $dataConnector = $this->getDataConnector(); if (!is_null($dataConnector)) { return $dataConnector->loadUser($this); } return false; } } ToolProvider/MediaType/SecurityContract.php 0000644 00000005717 15152003527 0015105 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\MediaType; use IMSGlobal\LTI\ToolProvider\ToolProvider; /** * Class to represent an LTI Security Contract document * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license GNU Lesser General Public License, version 3 (<http://www.gnu.org/licenses/lgpl.html>) */ class SecurityContract { /** * Class constructor. * * @param ToolProvider $toolProvider Tool Provider instance * @param string $secret Shared secret */ function __construct($toolProvider, $secret) { $tcContexts = array(); foreach ($toolProvider->consumer->profile->{'@context'} as $context) { if (is_object($context)) { $tcContexts = array_merge(get_object_vars($context), $tcContexts); } } $this->shared_secret = $secret; $toolServices = array(); foreach ($toolProvider->requiredServices as $requiredService) { foreach ($requiredService->formats as $format) { $service = $toolProvider->findService($format, $requiredService->actions); if (($service !== false) && !array_key_exists($service->{'@id'}, $toolServices)) { $id = $service->{'@id'}; $parts = explode(':', $id, 2); if (count($parts) > 1) { if (array_key_exists($parts[0], $tcContexts)) { $id = "{$tcContexts[$parts[0]]}{$parts[1]}"; } } $toolService = new \stdClass; $toolService->{'@type'} = 'RestServiceProfile'; $toolService->service = $id; $toolService->action = $requiredService->actions; $toolServices[$service->{'@id'}] = $toolService; } } } foreach ($toolProvider->optionalServices as $optionalService) { foreach ($optionalService->formats as $format) { $service = $toolProvider->findService($format, $optionalService->actions); if (($service !== false) && !array_key_exists($service->{'@id'}, $toolServices)) { $id = $service->{'@id'}; $parts = explode(':', $id, 2); if (count($parts) > 1) { if (array_key_exists($parts[0], $tcContexts)) { $id = "{$tcContexts[$parts[0]]}{$parts[1]}"; } } $toolService = new \stdClass; $toolService->{'@type'} = 'RestServiceProfile'; $toolService->service = $id; $toolService->action = $optionalService->actions; $toolServices[$service->{'@id'}] = $toolService; } } } $this->tool_service = array_values($toolServices); } } ToolProvider/MediaType/ResourceHandler.php 0000644 00000003761 15152003527 0014662 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\MediaType; use IMSGlobal\LTI\ToolProvider\ToolProvider; use IMSGlobal\LTI\Profile\ResourceHandler as ProfileResourceHandler; /** * Class to represent an LTI Resource Handler * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license GNU Lesser General Public License, version 3 (<http://www.gnu.org/licenses/lgpl.html>) */ class ResourceHandler { /** * Class constructor. * * @param ToolProvider $toolProvider Tool Provider object * @param ProfileResourceHandler $resourceHandler Resource handler object */ function __construct($toolProvider, $resourceHandler) { $this->resource_type = new \stdClass; $this->resource_type->code = $resourceHandler->item->id; $this->resource_name = new \stdClass; $this->resource_name->default_value = $resourceHandler->item->name; $this->resource_name->key = "{$resourceHandler->item->id}.resource.name"; $this->description = new \stdClass; $this->description->default_value = $resourceHandler->item->description; $this->description->key = "{$resourceHandler->item->id}.resource.description"; $this->icon_info = new \stdClass; $this->icon_info->default_location = new \stdClass; $this->icon_info->default_location->path = $resourceHandler->icon; $this->icon_info->key = "{$resourceHandler->item->id}.icon.path"; $this->message = array(); foreach ($resourceHandler->requiredMessages as $message) { $this->message[] = new Message($message, $toolProvider->consumer->profile->capability_offered); } foreach ($resourceHandler->optionalMessages as $message) { if (in_array($message->type, $toolProvider->consumer->profile->capability_offered)) { $this->message[] = new Message($message, $toolProvider->consumer->profile->capability_offered); } } } } ToolProvider/MediaType/ToolProxy.php 0000644 00000002347 15152003527 0013553 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\MediaType; use IMSGlobal\LTI\Profile\ServiceDefinition; use IMSGlobal\LTI\ToolProvider\ToolProvider; /** * Class to represent an LTI Tool Proxy media type * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license GNU Lesser General Public License, version 3 (<http://www.gnu.org/licenses/lgpl.html>) */ class ToolProxy { /** * Class constructor. * * @param ToolProvider $toolProvider Tool Provider object * @param ServiceDefinition $toolProxyService Tool Proxy service * @param string $secret Shared secret */ function __construct($toolProvider, $toolProxyService, $secret) { $contexts = array(); $this->{'@context'} = array_merge(array('http://purl.imsglobal.org/ctx/lti/v2/ToolProxy'), $contexts); $this->{'@type'} = 'ToolProxy'; $this->{'@id'} = "{$toolProxyService->endpoint}"; $this->lti_version = 'LTI-2p0'; $this->tool_consumer_profile = $toolProvider->consumer->profile->{'@id'}; $this->tool_profile = new ToolProfile($toolProvider); $this->security_contract = new SecurityContract($toolProvider, $secret); } } ToolProvider/MediaType/ToolProfile.php 0000644 00000007666 15152003527 0014043 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\MediaType; use IMSGlobal\LTI\ToolProvider\ToolProvider; /** * Class to represent an LTI Tool Profile * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license GNU Lesser General Public License, version 3 (<http://www.gnu.org/licenses/lgpl.html>) */ class ToolProfile { public $product_instance; /** * Class constructor. * * @param ToolProvider $toolProvider Tool Provider object */ function __construct($toolProvider) { $this->lti_version = 'LTI-2p0'; if (!empty($toolProvider->product)) { $this->product_instance = new \stdClass; } if (!empty($toolProvider->product->id)) { $this->product_instance->guid = $toolProvider->product->id; } if (!empty($toolProvider->product->name)) { $this->product_instance->product_info = new \stdClass; $this->product_instance->product_info->product_name = new \stdClass; $this->product_instance->product_info->product_name->default_value = $toolProvider->product->name; $this->product_instance->product_info->product_name->key = 'tool.name'; } if (!empty($toolProvider->product->description)) { $this->product_instance->product_info->description = new \stdClass; $this->product_instance->product_info->description->default_value = $toolProvider->product->description; $this->product_instance->product_info->description->key = 'tool.description'; } if (!empty($toolProvider->product->url)) { $this->product_instance->guid = $toolProvider->product->url; } if (!empty($toolProvider->product->version)) { $this->product_instance->product_info->product_version = $toolProvider->product->version; } if (!empty($toolProvider->vendor)) { $this->product_instance->product_info->product_family = new \stdClass; $this->product_instance->product_info->product_family->vendor = new \stdClass; } if (!empty($toolProvider->vendor->id)) { $this->product_instance->product_info->product_family->vendor->code = $toolProvider->vendor->id; } if (!empty($toolProvider->vendor->name)) { $this->product_instance->product_info->product_family->vendor->vendor_name = new \stdClass; $this->product_instance->product_info->product_family->vendor->vendor_name->default_value = $toolProvider->vendor->name; $this->product_instance->product_info->product_family->vendor->vendor_name->key = 'tool.vendor.name'; } if (!empty($toolProvider->vendor->description)) { $this->product_instance->product_info->product_family->vendor->description = new \stdClass; $this->product_instance->product_info->product_family->vendor->description->default_value = $toolProvider->vendor->description; $this->product_instance->product_info->product_family->vendor->description->key = 'tool.vendor.description'; } if (!empty($toolProvider->vendor->url)) { $this->product_instance->product_info->product_family->vendor->website = $toolProvider->vendor->url; } if (!empty($toolProvider->vendor->timestamp)) { $this->product_instance->product_info->product_family->vendor->timestamp = date('Y-m-d\TH:i:sP', $toolProvider->vendor->timestamp); } $this->resource_handler = array(); foreach ($toolProvider->resourceHandlers as $resourceHandler) { $this->resource_handler[] = new ResourceHandler($toolProvider, $resourceHandler); } if (!empty($toolProvider->baseUrl)) { $this->base_url_choice = array(); $this->base_url_choice[] = new \stdClass; $this->base_url_choice[0]->default_base_url = $toolProvider->baseUrl; } } } ToolProvider/MediaType/Message.php 0000644 00000002745 15152003527 0013162 0 ustar 00 <?php namespace IMSGlobal\LTI\ToolProvider\MediaType; /** * Class to represent an LTI Message * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license GNU Lesser General Public License, version 3 (<http://www.gnu.org/licenses/lgpl.html>) */ class Message { /** * Class constructor. * * @param Message $message Message object * @param array $capabilitiesOffered Capabilities offered */ function __construct($message, $capabilitiesOffered) { $this->message_type = $message->type; $this->path = $message->path; $this->enabled_capability = array(); foreach ($message->capabilities as $capability) { if (in_array($capability, $capabilitiesOffered)) { $this->enabled_capability[] = $capability; } } $this->parameter = array(); foreach ($message->constants as $name => $value) { $parameter = new \stdClass; $parameter->name = $name; $parameter->fixed = $value; $this->parameter[] = $parameter; } foreach ($message->variables as $name => $value) { if (in_array($value, $capabilitiesOffered)) { $parameter = new \stdClass; $parameter->name = $name; $parameter->variable = $value; $this->parameter[] = $parameter; } } } } Profile/Item.php 0000644 00000002761 15152003527 0007561 0 ustar 00 <?php namespace IMSGlobal\LTI\Profile; /** * Class to represent a generic item object * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class Item { /** * ID of item. * * @var string $id */ public $id = null; /** * Name of item. * * @var string $name */ public $name = null; /** * Description of item. * * @var string $description */ public $description = null; /** * URL of item. * * @var string $url */ public $url = null; /** * Version of item. * * @var string $version */ public $version = null; /** * Timestamp of item. * * @var int $timestamp */ public $timestamp = null; /** * Class constructor. * * @param string $id ID of item (optional) * @param string $name Name of item (optional) * @param string $description Description of item (optional) * @param string $url URL of item (optional) * @param string $version Version of item (optional) * @param int $timestamp Timestamp of item (optional) */ function __construct($id = null, $name = null, $description = null, $url = null, $version = null, $timestamp = null) { $this->id = $id; $this->name = $name; $this->description = $description; $this->url = $url; $this->version = $version; $this->timestamp = $timestamp; } } Profile/ResourceHandler.php 0000644 00000002525 15152003527 0011746 0 ustar 00 <?php namespace IMSGlobal\LTI\Profile; /** * Class to represent a resource handler object * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class ResourceHandler { /** * General details of resource handler. * * @var Item $item */ public $item = null; /** * URL of icon. * * @var string $icon */ public $icon = null; /** * Required Message objects for resource handler. * * @var array $requiredMessages */ public $requiredMessages = null; /** * Optional Message objects for resource handler. * * @var array $optionalMessages */ public $optionalMessages = null; /** * Class constructor. * * @param Item $item General details of resource handler * @param string $icon URL of icon * @param array $requiredMessages Array of required Message objects for resource handler * @param array $optionalMessages Array of optional Message objects for resource handler */ function __construct($item, $icon, $requiredMessages, $optionalMessages) { $this->item = $item; $this->icon = $icon; $this->requiredMessages = $requiredMessages; $this->optionalMessages = $optionalMessages; } } Profile/ServiceDefinition.php 0000644 00000002404 15152003527 0012266 0 ustar 00 <?php namespace IMSGlobal\LTI\Profile; /** * Class to represent an LTI service object * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class ServiceDefinition { /** * Media types supported by service. * * @var array $formats */ public $formats = null; /** * HTTP actions accepted by service. * * @var array $actions */ public $actions = null; /** * ID of service. * * @var string $id */ public $id = null; /** * URL for service requests. * * @var string $endpoint */ public $endpoint = null; /** * Class constructor. * * @param array $formats Array of media types supported by service * @param array $actions Array of HTTP actions accepted by service * @param string $id ID of service (optional) * @param string $endpoint URL for service requests (optional) */ function __construct($formats, $actions, $id = null, $endpoint = null) { $this->formats = $formats; $this->actions = $actions; $this->id = $id; $this->endpoint = $endpoint; } function setId($id) { $this->id = $id; } } Profile/Message.php 0000644 00000003134 15152003527 0010242 0 ustar 00 <?php namespace IMSGlobal\LTI\Profile; /** * Class to represent a resource handler message object * * @author Stephen P Vickers <svickers@imsglobal.org> * @copyright IMS Global Learning Consortium Inc * @date 2016 * @version 3.0.0 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 */ class Message { /** * LTI message type. * * @var string $type */ public $type = null; /** * Path to send message request to (used in conjunction with a base URL for the Tool Provider). * * @var string $path */ public $path = null; /** * Capabilities required by message. * * @var array $capabilities */ public $capabilities = null; /** * Variable parameters to accompany message request. * * @var array $variables */ public $variables = null; /** * Fixed parameters to accompany message request. * * @var array $constants */ public $constants = null; /** * Class constructor. * * @param string $type LTI message type * @param string $path Path to send message request to * @param array $capabilities Array of capabilities required by message * @param array $variables Array of variable parameters to accompany message request * @param array $constants Array of fixed parameters to accompany message request */ function __construct($type, $path, $capabilities = array(), $variables = array(), $constants = array()) { $this->type = $type; $this->path = $path; $this->capabilities = $capabilities; $this->variables = $variables; $this->constants = $constants; } } Interfaces/IMessageValidator.php 0000644 00000000256 15152044611 0012705 0 ustar 00 <?php namespace Packback\Lti1p3\Interfaces; interface IMessageValidator { public function validate(array $jwtBody); public function canValidate(array $jwtBody); } Interfaces/IHttpClient.php 0000644 00000000242 15152044611 0011524 0 ustar 00 <?php namespace Packback\Lti1p3\Interfaces; Interface IHttpClient { public function request(string $method, string $url, array $options) : IHttpResponse; } Interfaces/IDatabase.php 0000644 00000000330 15152044611 0011150 0 ustar 00 <?php namespace Packback\Lti1p3\Interfaces; interface IDatabase { public function findRegistrationByIssuer($iss, $clientId = null); public function findDeployment($iss, $deploymentId, $clientId = null); } Interfaces/IHttpException.php 0000644 00000000220 15152044611 0012240 0 ustar 00 <?php namespace Packback\Lti1p3\Interfaces; interface IHttpException extends \Throwable { public function getResponse(): IHttpResponse; } Interfaces/IHttpResponse.php 0000644 00000000274 15152044611 0012111 0 ustar 00 <?php namespace Packback\Lti1p3\Interfaces; interface IHttpResponse { public function getBody(); public function getHeaders(): array; public function getStatusCode(): int; } Interfaces/ICache.php 0000644 00000001031 15152044611 0010446 0 ustar 00 <?php namespace Packback\Lti1p3\Interfaces; interface ICache { public function getLaunchData(string $key): ?array; public function cacheLaunchData(string $key, array $jwtBody): void; public function cacheNonce(string $nonce, string $state): void; public function checkNonceIsValid(string $nonce, string $state): bool; public function cacheAccessToken(string $key, string $accessToken): void; public function getAccessToken(string $key): ?string; public function clearAccessToken(string $key): void; } Interfaces/ILtiRegistration.php 0000644 00000001362 15152044611 0012575 0 ustar 00 <?php namespace Packback\Lti1p3\Interfaces; interface ILtiRegistration { public function getIssuer(); public function setIssuer($issuer); public function getClientId(); public function setClientId($clientId); public function getKeySetUrl(); public function setKeySetUrl($keySetUrl); public function getAuthTokenUrl(); public function setAuthTokenUrl($authTokenUrl); public function getAuthLoginUrl(); public function setAuthLoginUrl($authLoginUrl); public function getAuthServer(); public function setAuthServer($authServer); public function getToolPrivateKey(); public function setToolPrivateKey($toolPrivateKey); public function getKid(); public function setKid($kid); } Interfaces/ICookie.php 0000644 00000000331 15152044611 0010656 0 ustar 00 <?php namespace Packback\Lti1p3\Interfaces; interface ICookie { public function getCookie(string $name): ?string; public function setCookie(string $name, string $value, $exp = 3600, $options = []): void; } Interfaces/ILtiServiceConnector.php 0000644 00000001273 15152044611 0013377 0 ustar 00 <?php namespace Packback\Lti1p3\Interfaces; interface ILtiServiceConnector { public function getAccessToken(ILtiRegistration $registration, array $scopes); public function makeRequest(IServiceRequest $request); public function getResponseBody(IHttpResponse $response): ?array; public function makeServiceRequest( ILtiRegistration $registration, array $scopes, IServiceRequest $request, bool $shouldRetry = true ): array; public function getAll( ILtiRegistration $registration, array $scopes, IServiceRequest $request, string $key ): array; public function setDebuggingMode(bool $enable): void; } Interfaces/IServiceRequest.php 0000644 00000001015 15152044611 0012416 0 ustar 00 <?php namespace Packback\Lti1p3\Interfaces; interface IServiceRequest { public function getMethod(): string; public function getUrl(): string; public function getPayload(): array; public function setUrl(string $url): self; public function setAccessToken(string $accessToken): self; public function setBody(string $body): self; public function setAccept(string $accept): self; public function setContentType(string $contentType): self; public function getErrorPrefix(): string; } ImsStorage/ImsCache.php 0000644 00000003430 15152044611 0011005 0 ustar 00 <?php namespace Packback\Lti1p3\ImsStorage; use Packback\Lti1p3\Interfaces\ICache; class ImsCache implements ICache { private $cache; public function getLaunchData(string $key): ?array { $this->loadCache(); return $this->cache[$key] ?? null; } public function cacheLaunchData(string $key, array $jwtBody): void { $this->loadCache(); $this->cache[$key] = $jwtBody; $this->saveCache(); } public function cacheNonce(string $nonce, string $state): void { $this->loadCache(); $this->cache['nonce'][$nonce] = $state; $this->saveCache(); } public function checkNonceIsValid(string $nonce, string $state): bool { $this->loadCache(); return isset($this->cache['nonce'][$nonce]) && $this->cache['nonce'][$nonce] === $state; } public function cacheAccessToken(string $key, string $accessToken): void { $this->loadCache(); $this->cache[$key] = $accessToken; $this->saveCache(); } public function getAccessToken(string $key): ?string { $this->loadCache(); return $this->cache[$key] ?? null; } public function clearAccessToken(string $key): void { $this->loadCache(); unset($this->cache[$key]); $this->saveCache(); } private function loadCache() { $cache = file_get_contents(sys_get_temp_dir().'/lti_cache.txt'); if (empty($cache)) { file_put_contents(sys_get_temp_dir().'/lti_cache.txt', '{}'); $this->cache = []; } $this->cache = json_decode($cache, true); } private function saveCache() { file_put_contents(sys_get_temp_dir().'/lti_cache.txt', json_encode($this->cache)); } } ImsStorage/ImsCookie.php 0000644 00000002154 15152044611 0011215 0 ustar 00 <?php namespace Packback\Lti1p3\ImsStorage; use Packback\Lti1p3\Interfaces\ICookie; class ImsCookie implements ICookie { public function getCookie(string $name): ?string { if (isset($_COOKIE[$name])) { return $_COOKIE[$name]; } // Look for backup cookie if same site is not supported by the user's browser. if (isset($_COOKIE['LEGACY_'.$name])) { return $_COOKIE['LEGACY_'.$name]; } return null; } public function setCookie(string $name, string $value, $exp = 3600, $options = []): void { $cookie_options = [ 'expires' => time() + $exp, ]; // SameSite none and secure will be required for tools to work inside iframes $same_site_options = [ 'samesite' => 'None', 'secure' => true, ]; setcookie($name, $value, array_merge($cookie_options, $same_site_options, $options)); // Set a second fallback cookie in the event that "SameSite" is not supported setcookie('LEGACY_'.$name, $value, array_merge($cookie_options, $options)); } } LtiException.php 0000644 00000000134 15152044611 0007661 0 ustar 00 <?php namespace Packback\Lti1p3; use Exception; class LtiException extends Exception { } Helpers/Helpers.php 0000644 00000000311 15152044611 0010253 0 ustar 00 <?php namespace Packback\Lti1p3\Helpers; class Helpers { /** * @param $value */ public static function checkIfNullValue($value): bool { return !is_null($value); } } LtiDeepLinkResource.php 0000644 00000006442 15152044611 0011136 0 ustar 00 <?php namespace Packback\Lti1p3; class LtiDeepLinkResource { private $type = 'ltiResourceLink'; private $title; private $text; private $url; private $line_item; private $icon; private $thumbnail; private $custom_params = []; private $target = 'iframe'; public static function new(): LtiDeepLinkResource { return new LtiDeepLinkResource(); } public function getType(): string { return $this->type; } public function setType(string $value): LtiDeepLinkResource { $this->type = $value; return $this; } public function getTitle(): ?string { return $this->title; } public function setTitle(?string $value): LtiDeepLinkResource { $this->title = $value; return $this; } public function getText(): ?string { return $this->text; } public function setText(?string $value): LtiDeepLinkResource { $this->text = $value; return $this; } public function getUrl(): ?string { return $this->url; } public function setUrl(?string $value): LtiDeepLinkResource { $this->url = $value; return $this; } public function getLineItem(): ?LtiLineitem { return $this->line_item; } public function setLineItem(?LtiLineitem $value): LtiDeepLinkResource { $this->line_item = $value; return $this; } public function setIcon(?LtiDeepLinkResourceIcon $icon): LtiDeepLinkResource { $this->icon = $icon; return $this; } public function getIcon(): ?LtiDeepLinkResourceIcon { return $this->icon; } public function setThumbnail(?LtiDeepLinkResourceIcon $thumbnail): LtiDeepLinkResource { $this->thumbnail = $thumbnail; return $this; } public function getThumbnail(): ?LtiDeepLinkResourceIcon { return $this->thumbnail; } public function getCustomParams(): array { return $this->custom_params; } public function setCustomParams(array $value): LtiDeepLinkResource { $this->custom_params = $value; return $this; } public function getTarget(): string { return $this->target; } public function setTarget(string $value): LtiDeepLinkResource { $this->target = $value; return $this; } public function toArray(): array { $resource = [ 'type' => $this->type, 'title' => $this->title, 'text' => $this->text, 'url' => $this->url, 'presentation' => [ 'documentTarget' => $this->target, ], ]; if (!empty($this->custom_params)) { $resource['custom'] = $this->custom_params; } if (isset($this->icon)) { $resource['icon'] = $this->icon->toArray(); } if (isset($this->thumbnail)) { $resource['thumbnail'] = $this->thumbnail->toArray(); } if ($this->line_item !== null) { $resource['lineItem'] = [ 'scoreMaximum' => $this->line_item->getScoreMaximum(), 'label' => $this->line_item->getLabel(), ]; } return $resource; } } LtiOidcLogin.php 0000644 00000011457 15152044611 0007604 0 ustar 00 <?php namespace Packback\Lti1p3; use Packback\Lti1p3\Interfaces\ICache; use Packback\Lti1p3\Interfaces\ICookie; use Packback\Lti1p3\Interfaces\IDatabase; class LtiOidcLogin { public const COOKIE_PREFIX = 'lti1p3_'; public const ERROR_MSG_LAUNCH_URL = 'No launch URL configured'; public const ERROR_MSG_ISSUER = 'Could not find issuer'; public const ERROR_MSG_LOGIN_HINT = 'Could not find login hint'; private $db; private $cache; private $cookie; /** * Constructor. * * @param IDatabase $database instance of the database interface used for looking up registrations and deployments * @param ICache $cache Instance of the Cache interface used to loading and storing launches. If non is provided launch data will be store in $_SESSION. * @param ICookie $cookie Instance of the Cookie interface used to set and read cookies. Will default to using $_COOKIE and setcookie. */ public function __construct(IDatabase $database, ICache $cache = null, ICookie $cookie = null) { $this->db = $database; $this->cache = $cache; $this->cookie = $cookie; } /** * Static function to allow for method chaining without having to assign to a variable first. */ public static function new(IDatabase $database, ICache $cache = null, ICookie $cookie = null) { return new LtiOidcLogin($database, $cache, $cookie); } /** * Calculate the redirect location to return to based on an OIDC third party initiated login request. * * @param string $launch_url URL to redirect back to after the OIDC login. This URL must match exactly a URL white listed in the platform. * @param array|string $request An array of request parameters. If not set will default to $_REQUEST. * * @return Redirect returns a redirect object containing the fully formed OIDC login URL */ public function doOidcLoginRedirect($launch_url, array $request = null) { if ($request === null) { $request = $_REQUEST; } if (empty($launch_url)) { throw new OidcException(static::ERROR_MSG_LAUNCH_URL, 1); } // Validate Request Data. $registration = $this->validateOidcLogin($request); /* * Build OIDC Auth Response. */ // Generate State. // Set cookie (short lived) $state = static::secureRandomString('state-'); $this->cookie->setCookie(static::COOKIE_PREFIX.$state, $state, 60); // Generate Nonce. $nonce = static::secureRandomString('nonce-'); $this->cache->cacheNonce($nonce, $state); // Build Response. $auth_params = [ 'scope' => 'openid', // OIDC Scope. 'response_type' => 'id_token', // OIDC response is always an id token. 'response_mode' => 'form_post', // OIDC response is always a form post. 'prompt' => 'none', // Don't prompt user on redirect. 'client_id' => $registration->getClientId(), // Registered client id. 'redirect_uri' => $launch_url, // URL to return to after login. 'state' => $state, // State to identify browser session. 'nonce' => $nonce, // Prevent replay attacks. 'login_hint' => $request['login_hint'], // Login hint to identify platform session. ]; // Pass back LTI message hint if we have it. if (isset($request['lti_message_hint'])) { // LTI message hint to identify LTI context within the platform. $auth_params['lti_message_hint'] = $request['lti_message_hint']; } $auth_login_return_url = $registration->getAuthLoginUrl().'?'.http_build_query($auth_params, '', '&'); // Return auth redirect. return new Redirect($auth_login_return_url, http_build_query($request, '', '&')); } public function validateOidcLogin($request) { // Validate Issuer. if (empty($request['iss'])) { throw new OidcException(static::ERROR_MSG_ISSUER, 1); } // Validate Login Hint. if (empty($request['login_hint'])) { throw new OidcException(static::ERROR_MSG_LOGIN_HINT, 1); } // Fetch Registration Details. $clientId = $request['client_id'] ?? null; $registration = $this->db->findRegistrationByIssuer($request['iss'], $clientId); // Check we got something. if (empty($registration)) { $errorMsg = LtiMessageLaunch::getMissingRegistrationErrorMsg($request['iss'], $clientId); throw new OidcException($errorMsg, 1); } // Return Registration. return $registration; } public static function secureRandomString(string $prefix = ''): string { return $prefix.hash('sha256', random_bytes(64)); } } LtiRegistration.php 0000644 00000005305 15152044611 0010402 0 ustar 00 <?php namespace Packback\Lti1p3; use Packback\Lti1p3\Interfaces\ILtiRegistration; class LtiRegistration implements ILtiRegistration { private $issuer; private $clientId; private $keySetUrl; private $authTokenUrl; private $authLoginUrl; private $authServer; private $toolPrivateKey; private $kid; public function __construct(array $registration = []) { $this->issuer = $registration['issuer'] ?? null; $this->clientId = $registration['clientId'] ?? null; $this->keySetUrl = $registration['keySetUrl'] ?? null; $this->authTokenUrl = $registration['authTokenUrl'] ?? null; $this->authLoginUrl = $registration['authLoginUrl'] ?? null; $this->authServer = $registration['authServer'] ?? null; $this->toolPrivateKey = $registration['toolPrivateKey'] ?? null; $this->kid = $registration['kid'] ?? null; } public static function new(array $registration = []) { return new LtiRegistration($registration); } public function getIssuer() { return $this->issuer; } public function setIssuer($issuer) { $this->issuer = $issuer; return $this; } public function getClientId() { return $this->clientId; } public function setClientId($clientId) { $this->clientId = $clientId; return $this; } public function getKeySetUrl() { return $this->keySetUrl; } public function setKeySetUrl($keySetUrl) { $this->keySetUrl = $keySetUrl; return $this; } public function getAuthTokenUrl() { return $this->authTokenUrl; } public function setAuthTokenUrl($authTokenUrl) { $this->authTokenUrl = $authTokenUrl; return $this; } public function getAuthLoginUrl() { return $this->authLoginUrl; } public function setAuthLoginUrl($authLoginUrl) { $this->authLoginUrl = $authLoginUrl; return $this; } public function getAuthServer() { return empty($this->authServer) ? $this->authTokenUrl : $this->authServer; } public function setAuthServer($authServer) { $this->authServer = $authServer; return $this; } public function getToolPrivateKey() { return $this->toolPrivateKey; } public function setToolPrivateKey($toolPrivateKey) { $this->toolPrivateKey = $toolPrivateKey; return $this; } public function getKid() { return $this->kid ?? hash('sha256', trim($this->issuer.$this->clientId)); } public function setKid($kid) { $this->kid = $kid; return $this; } } OidcException.php 0000644 00000000135 15152044611 0010010 0 ustar 00 <?php namespace Packback\Lti1p3; use Exception; class OidcException extends Exception { } JwksEndpoint.php 0000644 00000003023 15152044611 0007671 0 ustar 00 <?php namespace Packback\Lti1p3; use Firebase\JWT\JWT; use Packback\Lti1p3\Interfaces\IDatabase; use Packback\Lti1p3\Interfaces\ILtiRegistration; class JwksEndpoint { private $keys; public function __construct(array $keys) { $this->keys = $keys; } public static function new(array $keys) { return new JwksEndpoint($keys); } public static function fromIssuer(IDatabase $database, $issuer) { $registration = $database->findRegistrationByIssuer($issuer); return new JwksEndpoint([$registration->getKid() => $registration->getToolPrivateKey()]); } public static function fromRegistration(ILtiRegistration $registration) { return new JwksEndpoint([$registration->getKid() => $registration->getToolPrivateKey()]); } public function getPublicJwks() { $jwks = []; foreach ($this->keys as $kid => $private_key) { $key_res = openssl_pkey_get_private($private_key); $key_details = openssl_pkey_get_details($key_res); $components = [ 'kty' => 'RSA', 'alg' => 'RS256', 'use' => 'sig', 'e' => JWT::urlsafeB64Encode($key_details['rsa']['e']), 'n' => JWT::urlsafeB64Encode($key_details['rsa']['n']), 'kid' => $kid, ]; $jwks[] = $components; } return ['keys' => $jwks]; } public function outputJwks() { echo json_encode($this->getPublicJwks()); } } LtiServiceConnector.php 0000644 00000015353 15152044611 0011207 0 ustar 00 <?php namespace Packback\Lti1p3; use Firebase\JWT\JWT; use Packback\Lti1p3\Interfaces\ICache; use Packback\Lti1p3\Interfaces\IHttpClient; use Packback\Lti1p3\Interfaces\IHttpException; use Packback\Lti1p3\Interfaces\IHttpResponse; use Packback\Lti1p3\Interfaces\ILtiRegistration; use Packback\Lti1p3\Interfaces\ILtiServiceConnector; use Packback\Lti1p3\Interfaces\IServiceRequest; class LtiServiceConnector implements ILtiServiceConnector { public const NEXT_PAGE_REGEX = '/<([^>]*)>; ?rel="next"/i'; private $cache; private $client; private $debuggingMode = false; public function __construct( ICache $cache, IHttpClient $client ) { $this->cache = $cache; $this->client = $client; } public function setDebuggingMode(bool $enable): void { $this->debuggingMode = $enable; } public function getAccessToken(ILtiRegistration $registration, array $scopes) { // Get a unique cache key for the access token $accessTokenKey = $this->getAccessTokenCacheKey($registration, $scopes); // Get access token from cache if it exists $accessToken = $this->cache->getAccessToken($accessTokenKey); if ($accessToken) { return $accessToken; } // Build up JWT to exchange for an auth token $clientId = $registration->getClientId(); $jwtClaim = [ 'iss' => $clientId, 'sub' => $clientId, 'aud' => $registration->getAuthServer(), 'iat' => time() - 5, 'exp' => time() + 60, 'jti' => 'lti-service-token'.hash('sha256', random_bytes(64)), ]; // Sign the JWT with our private key (given by the platform on registration) $jwt = JWT::encode($jwtClaim, $registration->getToolPrivateKey(), 'RS256', $registration->getKid()); // Build auth token request headers $authRequest = [ 'grant_type' => 'client_credentials', 'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 'client_assertion' => $jwt, 'scope' => implode(' ', $scopes), ]; // Get Access $request = new ServiceRequest( ServiceRequest::METHOD_POST, $registration->getAuthTokenUrl(), ServiceRequest::TYPE_AUTH ); $request->setPayload(['form_params' => $authRequest]); $response = $this->makeRequest($request); $tokenData = $this->getResponseBody($response); // Cache access token $this->cache->cacheAccessToken($accessTokenKey, $tokenData['access_token']); return $tokenData['access_token']; } public function makeRequest(IServiceRequest $request) { $response = $this->client->request( $request->getMethod(), $request->getUrl(), $request->getPayload() ); if ($this->debuggingMode) { $this->logRequest( $request, $this->getResponseHeaders($response), $this->getResponseBody($response) ); } return $response; } public function getResponseHeaders(IHttpResponse $response): ?array { $responseHeaders = $response->getHeaders(); array_walk($responseHeaders, function (&$value) { $value = $value[0]; }); return $responseHeaders; } public function getResponseBody(IHttpResponse $response): ?array { $responseBody = (string) $response->getBody(); return json_decode($responseBody, true); } public function makeServiceRequest( ILtiRegistration $registration, array $scopes, IServiceRequest $request, bool $shouldRetry = true ): array { $request->setAccessToken($this->getAccessToken($registration, $scopes)); try { $response = $this->makeRequest($request); } catch (IHttpException $e) { $status = $e->getResponse()->getStatusCode(); // If the error was due to invalid authentication and the request // should be retried, clear the access token and retry it. if ($status === 401 && $shouldRetry) { $key = $this->getAccessTokenCacheKey($registration, $scopes); $this->cache->clearAccessToken($key); return $this->makeServiceRequest($registration, $scopes, $request, false); } throw $e; } return [ 'headers' => $this->getResponseHeaders($response), 'body' => $this->getResponseBody($response), 'status' => $response->getStatusCode(), ]; } public function getAll( ILtiRegistration $registration, array $scopes, IServiceRequest $request, string $key = null ): array { if ($request->getMethod() !== ServiceRequest::METHOD_GET) { throw new \Exception('An invalid method was specified by an LTI service requesting all items.'); } $results = []; $nextUrl = $request->getUrl(); while ($nextUrl) { $response = $this->makeServiceRequest($registration, $scopes, $request); $page_results = $key === null ? ($response['body'] ?? []) : ($response['body'][$key] ?? []); $results = array_merge($results, $page_results); $nextUrl = $this->getNextUrl($response['headers']); if ($nextUrl) { $request->setUrl($nextUrl); } } return $results; } private function logRequest( IServiceRequest $request, array $responseHeaders, ?array $responseBody ): void { $contextArray = [ 'request_method' => $request->getMethod(), 'request_url' => $request->getUrl(), 'response_headers' => $responseHeaders, 'response_body' => json_encode($responseBody), ]; $requestBody = $request->getPayload()['body'] ?? null; if (!empty($requestBody)) { $contextArray['request_body'] = $requestBody; } error_log(implode(' ', array_filter([ $request->getErrorPrefix(), json_decode($requestBody)->userId ?? null, print_r($contextArray, true), ]))); } private function getAccessTokenCacheKey(ILtiRegistration $registration, array $scopes) { sort($scopes); $scopeKey = md5(implode('|', $scopes)); return $registration->getIssuer().$registration->getClientId().$scopeKey; } private function getNextUrl(array $headers) { $subject = $headers['Link'] ?? ''; preg_match(static::NEXT_PAGE_REGEX, $subject, $matches); return $matches[1] ?? null; } } LtiDeployment.php 0000644 00000000610 15152044611 0010042 0 ustar 00 <?php namespace Packback\Lti1p3; class LtiDeployment { private $deployment_id; public static function new() { return new LtiDeployment(); } public function getDeploymentId() { return $this->deployment_id; } public function setDeploymentId($deployment_id) { $this->deployment_id = $deployment_id; return $this; } } LtiNamesRolesProvisioningService.php 0000644 00000001304 15152044611 0013723 0 ustar 00 <?php namespace Packback\Lti1p3; class LtiNamesRolesProvisioningService extends LtiAbstractService { public const CONTENTTYPE_MEMBERSHIPCONTAINER = 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json'; public function getScope(): array { return [LtiConstants::NRPS_SCOPE_MEMBERSHIP_READONLY]; } public function getMembers(): array { $request = new ServiceRequest( ServiceRequest::METHOD_GET, $this->getServiceData()['context_memberships_url'], ServiceRequest::TYPE_GET_MEMBERSHIPS ); $request->setAccept(static::CONTENTTYPE_MEMBERSHIPCONTAINER); return $this->getAll($request, 'members'); } } LtiCourseGroupsService.php 0000644 00000003772 15152044611 0011717 0 ustar 00 <?php namespace Packback\Lti1p3; class LtiCourseGroupsService extends LtiAbstractService { public const CONTENTTYPE_CONTEXTGROUPCONTAINER = 'application/vnd.ims.lti-gs.v1.contextgroupcontainer+json'; public function getScope(): array { return $this->getServiceData()['scope']; } public function getGroups(): array { $request = new ServiceRequest( ServiceRequest::METHOD_GET, $this->getServiceData()['context_groups_url'], ServiceRequest::TYPE_GET_GROUPS ); $request->setAccept(static::CONTENTTYPE_CONTEXTGROUPCONTAINER); return $this->getAll($request, 'groups'); } public function getSets(): array { // Sets are optional. if (!isset($this->getServiceData()['context_group_sets_url'])) { return []; } $request = new ServiceRequest( ServiceRequest::METHOD_GET, $this->getServiceData()['context_group_sets_url'], ServiceRequest::TYPE_GET_SETS ); $request->setAccept(static::CONTENTTYPE_CONTEXTGROUPCONTAINER); return $this->getAll($request, 'sets'); } public function getGroupsBySet() { $groups = $this->getGroups(); $sets = $this->getSets(); $groupsBySet = []; $unsetted = []; foreach ($sets as $key => $set) { $groupsBySet[$set['id']] = $set; $groupsBySet[$set['id']]['groups'] = []; } foreach ($groups as $key => $group) { if (isset($group['set_id']) && isset($groupsBySet[$group['set_id']])) { $groupsBySet[$group['set_id']]['groups'][$group['id']] = $group; } else { $unsetted[$group['id']] = $group; } } if (!empty($unsetted)) { $groupsBySet['none'] = [ 'name' => 'None', 'id' => 'none', 'groups' => $unsetted, ]; } return $groupsBySet; } } LtiLineitem.php 0000644 00000006110 15152044611 0007471 0 ustar 00 <?php namespace Packback\Lti1p3; class LtiLineitem { private $id; private $score_maximum; private $label; private $resource_id; private $resource_link_id; private $tag; private $start_date_time; private $end_date_time; public function __construct(array $lineitem = null) { $this->id = $lineitem['id'] ?? null; $this->score_maximum = $lineitem['scoreMaximum'] ?? null; $this->label = $lineitem['label'] ?? null; $this->resource_id = $lineitem['resourceId'] ?? null; $this->resource_link_id = $lineitem['resourceLinkId'] ?? null; $this->tag = $lineitem['tag'] ?? null; $this->start_date_time = $lineitem['startDateTime'] ?? null; $this->end_date_time = $lineitem['endDateTime'] ?? null; } public function __toString() { // Additionally, includes the call back to filter out only NULL values return json_encode(array_filter([ 'id' => $this->id, 'scoreMaximum' => $this->score_maximum, 'label' => $this->label, 'resourceId' => $this->resource_id, 'resourceLinkId' => $this->resource_link_id, 'tag' => $this->tag, 'startDateTime' => $this->start_date_time, 'endDateTime' => $this->end_date_time, ], '\Packback\Lti1p3\Helpers\Helpers::checkIfNullValue')); } /** * Static function to allow for method chaining without having to assign to a variable first. */ public static function new() { return new LtiLineitem(); } public function getId() { return $this->id; } public function setId($value) { $this->id = $value; return $this; } public function getLabel() { return $this->label; } public function setLabel($value) { $this->label = $value; return $this; } public function getScoreMaximum() { return $this->score_maximum; } public function setScoreMaximum($value) { $this->score_maximum = $value; return $this; } public function getResourceId() { return $this->resource_id; } public function setResourceId($value) { $this->resource_id = $value; return $this; } public function getResourceLinkId() { return $this->resource_link_id; } public function setResourceLinkId($value) { $this->resource_link_id = $value; return $this; } public function getTag() { return $this->tag; } public function setTag($value) { $this->tag = $value; return $this; } public function getStartDateTime() { return $this->start_date_time; } public function setStartDateTime($value) { $this->start_date_time = $value; return $this; } public function getEndDateTime() { return $this->end_date_time; } public function setEndDateTime($value) { $this->end_date_time = $value; return $this; } } LtiAbstractService.php 0000644 00000002563 15152044611 0011017 0 ustar 00 <?php namespace Packback\Lti1p3; use Packback\Lti1p3\Interfaces\ILtiRegistration; use Packback\Lti1p3\Interfaces\ILtiServiceConnector; use Packback\Lti1p3\Interfaces\IServiceRequest; abstract class LtiAbstractService { private $serviceConnector; private $registration; private $serviceData; public function __construct( ILtiServiceConnector $serviceConnector, ILtiRegistration $registration, array $serviceData) { $this->serviceConnector = $serviceConnector; $this->registration = $registration; $this->serviceData = $serviceData; } public function getServiceData(): array { return $this->serviceData; } public function setServiceData(array $serviceData): self { $this->serviceData = $serviceData; return $this; } abstract public function getScope(): array; protected function makeServiceRequest(IServiceRequest $request): array { return $this->serviceConnector->makeServiceRequest( $this->registration, $this->getScope(), $request, ); } protected function getAll(IServiceRequest $request, string $key = null): array { return $this->serviceConnector->getAll( $this->registration, $this->getScope(), $request, $key ); } } MessageValidators/SubmissionReviewMessageValidator.php 0000644 00000002134 15152044611 0017361 0 ustar 00 <?php namespace Packback\Lti1p3\MessageValidators; use Packback\Lti1p3\Interfaces\IMessageValidator; use Packback\Lti1p3\LtiConstants; use Packback\Lti1p3\LtiException; class SubmissionReviewMessageValidator implements IMessageValidator { public function canValidate(array $jwtBody) { return $jwtBody[LtiConstants::MESSAGE_TYPE] === 'LtiSubmissionReviewRequest'; } public function validate(array $jwtBody) { if (empty($jwtBody['sub'])) { throw new LtiException('Must have a user (sub)'); } if ($jwtBody[LtiConstants::VERSION] !== LtiConstants::V1_3) { throw new LtiException('Incorrect version, expected 1.3.0'); } if (!isset($jwtBody[LtiConstants::ROLES])) { throw new LtiException('Missing Roles Claim'); } if (empty($jwtBody[LtiConstants::RESOURCE_LINK]['id'])) { throw new LtiException('Missing Resource Link Id'); } if (empty($jwtBody[LtiConstants::FOR_USER])) { throw new LtiException('Missing For User'); } return true; } } MessageValidators/ResourceMessageValidator.php 0000644 00000002123 15152044611 0015631 0 ustar 00 <?php namespace Packback\Lti1p3\MessageValidators; use Packback\Lti1p3\Interfaces\IMessageValidator; use Packback\Lti1p3\LtiConstants; use Packback\Lti1p3\LtiException; class ResourceMessageValidator implements IMessageValidator { public function canValidate(array $jwtBody) { return $jwtBody[LtiConstants::MESSAGE_TYPE] === 'LtiResourceLinkRequest'; } public function validate(array $jwtBody) { if (empty($jwtBody['sub'])) { throw new LtiException('Must have a user (sub)'); } if (!isset($jwtBody[LtiConstants::VERSION])) { throw new LtiException('Missing LTI Version'); } if ($jwtBody[LtiConstants::VERSION] !== LtiConstants::V1_3) { throw new LtiException('Incorrect version, expected 1.3.0'); } if (!isset($jwtBody[LtiConstants::ROLES])) { throw new LtiException('Missing Roles Claim'); } if (empty($jwtBody[LtiConstants::RESOURCE_LINK]['id'])) { throw new LtiException('Missing Resource Link Id'); } return true; } } MessageValidators/DeepLinkMessageValidator.php 0000644 00000003074 15152044611 0015543 0 ustar 00 <?php namespace Packback\Lti1p3\MessageValidators; use Packback\Lti1p3\Interfaces\IMessageValidator; use Packback\Lti1p3\LtiConstants; use Packback\Lti1p3\LtiException; class DeepLinkMessageValidator implements IMessageValidator { public function canValidate(array $jwtBody) { return $jwtBody[LtiConstants::MESSAGE_TYPE] === 'LtiDeepLinkingRequest'; } public function validate(array $jwtBody) { if (empty($jwtBody['sub'])) { throw new LtiException('Must have a user (sub)'); } if ($jwtBody[LtiConstants::VERSION] !== LtiConstants::V1_3) { throw new LtiException('Incorrect version, expected 1.3.0'); } if (!isset($jwtBody[LtiConstants::ROLES])) { throw new LtiException('Missing Roles Claim'); } if (empty($jwtBody[LtiConstants::DL_DEEP_LINK_SETTINGS])) { throw new LtiException('Missing Deep Linking Settings'); } $deep_link_settings = $jwtBody[LtiConstants::DL_DEEP_LINK_SETTINGS]; if (empty($deep_link_settings['deep_link_return_url'])) { throw new LtiException('Missing Deep Linking Return URL'); } if (empty($deep_link_settings['accept_types']) || !in_array('ltiResourceLink', $deep_link_settings['accept_types'])) { throw new LtiException('Must support resource link placement types'); } if (empty($deep_link_settings['accept_presentation_document_targets'])) { throw new LtiException('Must support a presentation type'); } return true; } } LtiConstants.php 0000644 00000016522 15152044611 0007707 0 ustar 00 <?php namespace Packback\Lti1p3; class LtiConstants { public const V1_3 = '1.3.0'; // Required message claims public const MESSAGE_TYPE = 'https://purl.imsglobal.org/spec/lti/claim/message_type'; public const VERSION = 'https://purl.imsglobal.org/spec/lti/claim/version'; public const DEPLOYMENT_ID = 'https://purl.imsglobal.org/spec/lti/claim/deployment_id'; public const TARGET_LINK_URI = 'https://purl.imsglobal.org/spec/lti/claim/target_link_uri'; public const RESOURCE_LINK = 'https://purl.imsglobal.org/spec/lti/claim/resource_link'; public const ROLES = 'https://purl.imsglobal.org/spec/lti/claim/roles'; public const FOR_USER = 'https://purl.imsglobal.org/spec/lti/claim/for_user'; // Optional message claims public const CONTEXT = 'https://purl.imsglobal.org/spec/lti/claim/context'; public const TOOL_PLATFORM = 'https://purl.imsglobal.org/spec/lti/claim/tool_platform'; public const ROLE_SCOPE_MENTOR = 'https://purlimsglobal.org/spec/lti/claim/role_scope_mentor'; public const LAUNCH_PRESENTATION = 'https://purl.imsglobal.org/spec/lti/claim/launch_presentation'; public const LIS = 'https://purl.imsglobal.org/spec/lti/claim/lis'; public const CUSTOM = 'https://purl.imsglobal.org/spec/lti/claim/custom'; // LTI DL public const DL_CONTENT_ITEMS = 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items'; public const DL_DATA = 'https://purl.imsglobal.org/spec/lti-dl/claim/data'; public const DL_DEEP_LINK_SETTINGS = 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'; // LTI NRPS public const NRPS_CLAIM_SERVICE = 'https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice'; public const NRPS_SCOPE_MEMBERSHIP_READONLY = 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'; // LTI AGS public const AGS_CLAIM_ENDPOINT = 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'; public const AGS_SCOPE_LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'; public const AGS_SCOPE_LINEITEM_READONLY = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly'; public const AGS_SCOPE_RESULT_READONLY = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'; public const AGS_SCOPE_SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score'; // LTI GS public const GS_CLAIM_SERVICE = 'https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice'; // User Vocab public const SYSTEM_ADMINISTRATOR = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator'; public const SYSTEM_NONE = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#None'; public const SYSTEM_ACCOUNTADMIN = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#AccountAdmin'; public const SYSTEM_CREATOR = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Creator'; public const SYSTEM_SYSADMIN = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#SysAdmin'; public const SYSTEM_SYSSUPPORT = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#SysSupport'; public const SYSTEM_USER = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#User'; public const INSTITUTION_ADMINISTRATOR = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator'; public const INSTITUTION_FACULTY = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Faculty'; public const INSTITUTION_GUEST = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Guest'; public const INSTITUTION_NONE = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#None'; public const INSTITUTION_OTHER = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Other'; public const INSTITUTION_STAFF = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Staff'; public const INSTITUTION_STUDENT = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student'; public const INSTITUTION_ALUMNI = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Alumni'; public const INSTITUTION_INSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor'; public const INSTITUTION_LEARNER = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Learner'; public const INSTITUTION_MEMBER = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Member'; public const INSTITUTION_MENTOR = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Mentor'; public const INSTITUTION_OBSERVER = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Observer'; public const INSTITUTION_PROSPECTIVESTUDENT = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#ProspectiveStudent'; public const MEMBERSHIP_ADMINISTRATOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Administrator'; public const MEMBERSHIP_CONTENTDEVELOPER = 'http://purl.imsglobal.org/vocab/lis/v2/membership#ContentDeveloper'; public const MEMBERSHIP_INSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'; public const MEMBERSHIP_LEARNER = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'; public const MEMBERSHIP_MENTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Mentor'; public const MEMBERSHIP_MANAGER = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Manager'; public const MEMBERSHIP_MEMBER = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Member'; public const MEMBERSHIP_OFFICER = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Officer'; // Context sub-roles public const MEMBERSHIP_EXTERNALINSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#ExternalInstructor'; public const MEMBERSHIP_GRADER = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#Grader'; public const MEMBERSHIP_GUESTINSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#GuestInstructor'; public const MEMBERSHIP_LECTURER = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#Lecturer'; public const MEMBERSHIP_PRIMARYINSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#PrimaryInstructor'; public const MEMBERSHIP_SECONDARYINSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#SecondaryInstructor'; public const MEMBERSHIP_TA = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistant'; public const MEMBERSHIP_TAGROUP = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistantGroup'; public const MEMBERSHIP_TAOFFERING = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistantOffering'; public const MEMBERSHIP_TASECTION = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistantSection'; public const MEMBERSHIP_TASECTIONASSOCIATION = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistantSectionAssociation'; public const MEMBERSHIP_TATEMPLATE = 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistantTemplate'; // Context Vocab public const COURSE_TEMPLATE = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseTemplate'; public const COURSE_OFFERING = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering'; public const COURSE_SECTION = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseSection'; public const COURSE_GROUP = 'http://purl.imsglobal.org/vocab/lis/v2/course#Group'; } LtiAssignmentsGradesService.php 0000644 00000015233 15152044611 0012673 0 ustar 00 <?php namespace Packback\Lti1p3; class LtiAssignmentsGradesService extends LtiAbstractService { public const CONTENTTYPE_SCORE = 'application/vnd.ims.lis.v1.score+json'; public const CONTENTTYPE_LINEITEM = 'application/vnd.ims.lis.v2.lineitem+json'; public const CONTENTTYPE_LINEITEMCONTAINER = 'application/vnd.ims.lis.v2.lineitemcontainer+json'; public const CONTENTTYPE_RESULTCONTAINER = 'application/vnd.ims.lis.v2.resultcontainer+json'; public function getScope(): array { return $this->getServiceData()['scope']; } // https://www.imsglobal.org/spec/lti-ags/v2p0#assignment-and-grade-service-claim // When an LTI message is launching a resource associated to one and only one lineitem, // the claim must include the endpoint URL for accessing the associated line item; // in all other cases, this property must be either blank or not included in the claim. public function getResourceLaunchLineItem(): ?LtiLineitem { $serviceData = $this->getServiceData(); if (empty($serviceData['lineitem'])) { return null; } return LtiLineitem::new()->setId($serviceData['lineitem']); } public function putGrade(LtiGrade $grade, LtiLineitem $lineitem = null) { if (!in_array(LtiConstants::AGS_SCOPE_SCORE, $this->getScope())) { throw new LtiException('Missing required scope', 1); } $lineitem = $this->ensureLineItemExists($lineitem); $scoreUrl = $lineitem->getId(); // Place '/scores' before url params $pos = strpos($scoreUrl, '?'); $scoreUrl = $pos === false ? $scoreUrl.'/scores' : substr_replace($scoreUrl, '/scores', $pos, 0); $request = new ServiceRequest( ServiceRequest::METHOD_POST, $scoreUrl, ServiceRequest::TYPE_SYNC_GRADE ); $request->setBody($grade); $request->setContentType(static::CONTENTTYPE_SCORE); return $this->makeServiceRequest($request); } public function findLineItem(LtiLineitem $newLineItem): ?LtiLineitem { $lineitems = $this->getLineItems(); foreach ($lineitems as $lineitem) { if ($this->isMatchingLineitem($lineitem, $newLineItem)) { return new LtiLineitem($lineitem); } } return null; } public function updateLineitem(LtiLineItem $lineitemToUpdate): LtiLineitem { $request = new ServiceRequest( ServiceRequest::METHOD_PUT, $this->getServiceData()['lineitems'], ServiceRequest::TYPE_UPDATE_LINEITEM ); $request->setBody($lineitemToUpdate) ->setContentType(static::CONTENTTYPE_LINEITEM) ->setAccept(static::CONTENTTYPE_LINEITEM); $updatedLineitem = $this->makeServiceRequest($request); return new LtiLineitem($updatedLineitem['body']); } public function createLineitem(LtiLineitem $newLineItem): LtiLineitem { $request = new ServiceRequest( ServiceRequest::METHOD_POST, $this->getServiceData()['lineitems'], ServiceRequest::TYPE_CREATE_LINEITEM ); $request->setBody($newLineItem) ->setContentType(static::CONTENTTYPE_LINEITEM) ->setAccept(static::CONTENTTYPE_LINEITEM); $createdLineItem = $this->makeServiceRequest($request); return new LtiLineitem($createdLineItem['body']); } public function findOrCreateLineitem(LtiLineitem $newLineItem): LtiLineitem { return $this->findLineItem($newLineItem) ?? $this->createLineitem($newLineItem); } public function getGrades(LtiLineitem $lineitem = null) { $lineitem = $this->ensureLineItemExists($lineitem); $resultsUrl = $lineitem->getId(); // Place '/results' before url params $pos = strpos($resultsUrl, '?'); $resultsUrl = $pos === false ? $resultsUrl.'/results' : substr_replace($resultsUrl, '/results', $pos, 0); $request = new ServiceRequest( ServiceRequest::METHOD_GET, $resultsUrl, ServiceRequest::TYPE_GET_GRADES ); $request->setAccept(static::CONTENTTYPE_RESULTCONTAINER); $scores = $this->makeServiceRequest($request); return $scores['body']; } public function getLineItems(): array { if (!in_array(LtiConstants::AGS_SCOPE_LINEITEM, $this->getScope())) { throw new LtiException('Missing required scope', 1); } $request = new ServiceRequest( ServiceRequest::METHOD_GET, $this->getServiceData()['lineitems'], ServiceRequest::TYPE_GET_LINEITEMS ); $request->setAccept(static::CONTENTTYPE_LINEITEMCONTAINER); $lineitems = $this->getAll($request); // If there is only one item, then wrap it in an array so the foreach works if (isset($lineitems['body']['id'])) { $lineitems['body'] = [$lineitems['body']]; } return $lineitems; } public function getLineItem(string $url): LtiLineitem { if (!in_array(LtiConstants::AGS_SCOPE_LINEITEM, $this->getScope())) { throw new LtiException('Missing required scope', 1); } $request = new ServiceRequest( ServiceRequest::METHOD_GET, $url, ServiceRequest::TYPE_GET_LINEITEM ); $request->setAccept(static::CONTENTTYPE_LINEITEM); $response = $this->makeServiceRequest($request)['body']; return new LtiLineitem($response); } private function ensureLineItemExists(LtiLineitem $lineitem = null): LtiLineitem { // If no line item is passed in, attempt to use the one associated with // this launch. if (!isset($lineitem)) { $lineitem = $this->getResourceLaunchLineItem(); } // If none exists still, create a default line item. if (!isset($lineitem)) { $defaultLineitem = LtiLineitem::new() ->setLabel('default') ->setScoreMaximum(100); $lineitem = $this->createLineitem($defaultLineitem); } // If the line item does not contain an ID, find or create it. if (empty($lineitem->getId())) { $lineitem = $this->findOrCreateLineitem($lineitem); } return $lineitem; } private function isMatchingLineitem(array $lineitem, LtiLineitem $newLineItem): bool { return $newLineItem->getTag() == ($lineitem['tag'] ?? null) && $newLineItem->getResourceId() == ($lineitem['resourceId'] ?? null) && $newLineItem->getResourceLinkId() == ($lineitem['resourceLinkId'] ?? null); } } LtiMessageLaunch.php 0000644 00000043467 15152044611 0010462 0 ustar 00 <?php namespace Packback\Lti1p3; use Firebase\JWT\ExpiredException; use Firebase\JWT\JWK; use Firebase\JWT\JWT; use Packback\Lti1p3\Interfaces\ICache; use Packback\Lti1p3\Interfaces\ICookie; use Packback\Lti1p3\Interfaces\IDatabase; use Packback\Lti1p3\Interfaces\IHttpException; use Packback\Lti1p3\Interfaces\ILtiServiceConnector; use Packback\Lti1p3\MessageValidators\DeepLinkMessageValidator; use Packback\Lti1p3\MessageValidators\ResourceMessageValidator; use Packback\Lti1p3\MessageValidators\SubmissionReviewMessageValidator; class LtiMessageLaunch { public const TYPE_DEEPLINK = 'LtiDeepLinkingRequest'; public const TYPE_SUBMISSIONREVIEW = 'LtiSubmissionReviewRequest'; public const TYPE_RESOURCELINK = 'LtiResourceLinkRequest'; public const ERR_FETCH_PUBLIC_KEY = 'Failed to fetch public key.'; public const ERR_NO_PUBLIC_KEY = 'Unable to find public key.'; public const ERR_STATE_NOT_FOUND = 'Please make sure you have cookies enabled in this browser and that you are not in private or incognito mode'; public const ERR_MISSING_ID_TOKEN = 'Missing id_token.'; public const ERR_INVALID_ID_TOKEN = 'Invalid id_token, JWT must contain 3 parts'; public const ERR_MISSING_NONCE = 'Missing Nonce.'; public const ERR_INVALID_NONCE = 'Invalid Nonce.'; /** * :issuerUrl and :clientId are used to substitute the queried issuerUrl * and clientId. Do not change those substrings without changing how the * error message is built. */ public const ERR_MISSING_REGISTRATION = 'LTI 1.3 Registration not found for Issuer :issuerUrl and Client ID :clientId. Please make sure the LMS has provided the right information, and that the LMS has been registered correctly in the tool.'; public const ERR_CLIENT_NOT_REGISTERED = 'Client id not registered for this issuer.'; public const ERR_NO_KID = 'No KID specified in the JWT Header.'; public const ERR_INVALID_SIGNATURE = 'Invalid signature on id_token'; public const ERR_MISSING_DEPLOYEMENT_ID = 'No deployment ID was specified'; public const ERR_NO_DEPLOYMENT = 'Unable to find deployment.'; public const ERR_INVALID_MESSAGE_TYPE = 'Invalid message type'; public const ERR_VALIDATOR_CONFLICT = 'Validator conflict.'; public const ERR_UNRECOGNIZED_MESSAGE_TYPE = 'Unrecognized message type.'; public const ERR_INVALID_MESSAGE = 'Message validation failed.'; public const ERR_INVALID_ALG = 'Invalid alg was specified in the JWT header.'; public const ERR_MISMATCHED_ALG_KEY = 'The alg specified in the JWT header is incompatible with the JWK key type.'; private $db; private $cache; private $cookie; private $serviceConnector; private $request; private $jwt; private $registration; private $launch_id; // See https://www.imsglobal.org/spec/security/v1p1#approved-jwt-signing-algorithms. private static $ltiSupportedAlgs = [ 'RS256' => 'RSA', 'RS384' => 'RSA', 'RS512' => 'RSA', 'ES256' => 'EC', 'ES384' => 'EC', 'ES512' => 'EC', ]; /** * Constructor. * * @param IDatabase $database instance of the database interface used for looking up registrations and deployments * @param ICache $cache instance of the Cache interface used to loading and storing launches * @param ICookie $cookie instance of the Cookie interface used to set and read cookies * @param ILtiServiceConnector $serviceConnector instance of the LtiServiceConnector used to by LTI services to make API requests */ public function __construct( IDatabase $database, ICache $cache = null, ICookie $cookie = null, ILtiServiceConnector $serviceConnector = null ) { $this->db = $database; $this->launch_id = uniqid('lti1p3_launch_', true); $this->cache = $cache; $this->cookie = $cookie; $this->serviceConnector = $serviceConnector; } /** * Static function to allow for method chaining without having to assign to a variable first. */ public static function new( IDatabase $database, ICache $cache = null, ICookie $cookie = null, ILtiServiceConnector $serviceConnector = null ) { return new LtiMessageLaunch($database, $cache, $cookie, $serviceConnector); } /** * Load an LtiMessageLaunch from a Cache using a launch id. * * @param string $launch_id the launch id of the LtiMessageLaunch object that is being pulled from the cache * @param IDatabase $database instance of the database interface used for looking up registrations and deployments * @param ICache $cache Instance of the Cache interface used to loading and storing launches. If non is provided launch data will be store in $_SESSION. * * @throws LtiException will throw an LtiException if validation fails or launch cannot be found * * @return LtiMessageLaunch a populated and validated LtiMessageLaunch */ public static function fromCache($launch_id, IDatabase $database, ICache $cache = null, ILtiServiceConnector $serviceConnector = null) { $new = new LtiMessageLaunch($database, $cache, null, $serviceConnector); $new->launch_id = $launch_id; $new->jwt = ['body' => $new->cache->getLaunchData($launch_id)]; return $new->validateRegistration(); } /** * Validates all aspects of an incoming LTI message launch and caches the launch if successful. * * @param array|string $request An array of post request parameters. If not set will default to $_POST. * * @throws LtiException will throw an LtiException if validation fails * * @return LtiMessageLaunch will return $this if validation is successful */ public function validate(array $request = null) { if ($request === null) { $request = $_POST; } $this->request = $request; return $this->validateState() ->validateJwtFormat() ->validateNonce() ->validateRegistration() ->validateJwtSignature() ->validateDeployment() ->validateMessage() ->cacheLaunchData(); } /** * Returns whether or not the current launch can use the names and roles service. * * @return bool returns a boolean indicating the availability of names and roles */ public function hasNrps() { return !empty($this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]['context_memberships_url']); } /** * Fetches an instance of the names and roles service for the current launch. * * @return LtiNamesRolesProvisioningService an instance of the names and roles service that can be used to make calls within the scope of the current launch */ public function getNrps() { return new LtiNamesRolesProvisioningService( $this->serviceConnector, $this->registration, $this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]); } /** * Returns whether or not the current launch can use the groups service. * * @return bool returns a boolean indicating the availability of groups */ public function hasGs() { return !empty($this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]['context_groups_url']); } /** * Fetches an instance of the groups service for the current launch. * * @return LtiCourseGroupsService an instance of the groups service that can be used to make calls within the scope of the current launch */ public function getGs() { return new LtiCourseGroupsService( $this->serviceConnector, $this->registration, $this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]); } /** * Returns whether or not the current launch can use the assignments and grades service. * * @return bool returns a boolean indicating the availability of assignments and grades */ public function hasAgs() { return !empty($this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]); } /** * Fetches an instance of the assignments and grades service for the current launch. * * @return LtiAssignmentsGradesService an instance of the assignments an grades service that can be used to make calls within the scope of the current launch */ public function getAgs() { return new LtiAssignmentsGradesService( $this->serviceConnector, $this->registration, $this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]); } /** * Returns whether or not the current launch is a deep linking launch. * * @return bool returns true if the current launch is a deep linking launch */ public function isDeepLinkLaunch() { return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_DEEPLINK; } /** * Fetches a deep link that can be used to construct a deep linking response. * * @return LtiDeepLink an instance of a deep link to construct a deep linking response for the current launch */ public function getDeepLink() { return new LtiDeepLink( $this->registration, $this->jwt['body'][LtiConstants::DEPLOYMENT_ID], $this->jwt['body'][LtiConstants::DL_DEEP_LINK_SETTINGS]); } /** * Returns whether or not the current launch is a submission review launch. * * @return bool returns true if the current launch is a submission review launch */ public function isSubmissionReviewLaunch() { return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_SUBMISSIONREVIEW; } /** * Returns whether or not the current launch is a resource launch. * * @return bool returns true if the current launch is a resource launch */ public function isResourceLaunch() { return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_RESOURCELINK; } /** * Fetches the decoded body of the JWT used in the current launch. * * @return array|object returns the decoded json body of the launch as an array */ public function getLaunchData() { return $this->jwt['body']; } /** * Get the unique launch id for the current launch. * * @return string a unique identifier used to re-reference the current launch in subsequent requests */ public function getLaunchId() { return $this->launch_id; } public static function getMissingRegistrationErrorMsg(string $issuerUrl, ?string $clientId = null): string { // Guard against client ID being null if (!isset($clientId)) { $clientId = '(N/A)'; } $search = [':issuerUrl', ':clientId']; $replace = [$issuerUrl, $clientId]; return str_replace($search, $replace, static::ERR_MISSING_REGISTRATION); } private function getPublicKey() { $request = new ServiceRequest( ServiceRequest::METHOD_GET, $this->registration->getKeySetUrl(), ServiceRequest::TYPE_GET_KEYSET ); // Download key set try { $response = $this->serviceConnector->makeRequest($request); } catch (IHttpException $e) { throw new LtiException(static::ERR_NO_PUBLIC_KEY); } $publicKeySet = $this->serviceConnector->getResponseBody($response); if (empty($publicKeySet)) { // Failed to fetch public keyset from URL. throw new LtiException(static::ERR_FETCH_PUBLIC_KEY); } // Find key used to sign the JWT (matches the KID in the header) foreach ($publicKeySet['keys'] as $key) { if ($key['kid'] == $this->jwt['header']['kid']) { $key['alg'] = $this->getKeyAlgorithm($key); try { $keySet = JWK::parseKeySet([ 'keys' => [$key], ]); } catch (\Exception $e) { // Do nothing } if (isset($keySet[$key['kid']])) { return $keySet[$key['kid']]; } } } // Could not find public key with a matching kid and alg. throw new LtiException(static::ERR_NO_PUBLIC_KEY); } /** * If alg is omitted from the JWK, infer it from the JWT header alg. * See https://datatracker.ietf.org/doc/html/rfc7517#section-4.4. */ private function getKeyAlgorithm(array $key): string { if (isset($key['alg'])) { return $key['alg']; } // The header alg must match the key type (family) specified in the JWK's kty. if ($this->jwtAlgMatchesJwkKty($key)) { return $this->jwt['header']['alg']; } throw new LtiException(static::ERR_MISMATCHED_ALG_KEY); } private function jwtAlgMatchesJwkKty($key): bool { $jwtAlg = $this->jwt['header']['alg']; return isset(static::$ltiSupportedAlgs[$jwtAlg]) && static::$ltiSupportedAlgs[$jwtAlg] === $key['kty']; } private function cacheLaunchData() { $this->cache->cacheLaunchData($this->launch_id, $this->jwt['body']); return $this; } private function validateState() { // Check State for OIDC. if ($this->cookie->getCookie(LtiOidcLogin::COOKIE_PREFIX.$this->request['state']) !== $this->request['state']) { // Error if state doesn't match throw new LtiException(static::ERR_STATE_NOT_FOUND); } return $this; } private function validateJwtFormat() { $jwt = $this->request['id_token'] ?? null; if (empty($jwt)) { throw new LtiException(static::ERR_MISSING_ID_TOKEN); } // Get parts of JWT. $jwt_parts = explode('.', $jwt); if (count($jwt_parts) !== 3) { // Invalid number of parts in JWT. throw new LtiException(static::ERR_INVALID_ID_TOKEN); } // Decode JWT headers. $this->jwt['header'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[0]), true); // Decode JWT Body. $this->jwt['body'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[1]), true); return $this; } private function validateNonce() { if (!isset($this->jwt['body']['nonce'])) { throw new LtiException(static::ERR_MISSING_NONCE); } if (!$this->cache->checkNonceIsValid($this->jwt['body']['nonce'], $this->request['state'])) { throw new LtiException(static::ERR_INVALID_NONCE); } return $this; } private function validateRegistration() { // Find registration. $clientId = is_array($this->jwt['body']['aud']) ? $this->jwt['body']['aud'][0] : $this->jwt['body']['aud']; $issuerUrl = $this->jwt['body']['iss']; $this->registration = $this->db->findRegistrationByIssuer($issuerUrl, $clientId); if (empty($this->registration)) { throw new LtiException($this->getMissingRegistrationErrorMsg($issuerUrl, $clientId)); } // Check client id. if ($clientId !== $this->registration->getClientId()) { // Client not registered. throw new LtiException(static::ERR_CLIENT_NOT_REGISTERED); } return $this; } private function validateJwtSignature() { if (!isset($this->jwt['header']['kid'])) { throw new LtiException(static::ERR_NO_KID); } // Fetch public key. $public_key = $this->getPublicKey(); // Validate JWT signature try { JWT::decode($this->request['id_token'], $public_key, ['RS256']); } catch (ExpiredException $e) { // Error validating signature. throw new LtiException(static::ERR_INVALID_SIGNATURE); } return $this; } private function validateDeployment() { if (!isset($this->jwt['body'][LtiConstants::DEPLOYMENT_ID])) { throw new LtiException(static::ERR_MISSING_DEPLOYEMENT_ID); } // Find deployment. $client_id = is_array($this->jwt['body']['aud']) ? $this->jwt['body']['aud'][0] : $this->jwt['body']['aud']; $deployment = $this->db->findDeployment($this->jwt['body']['iss'], $this->jwt['body'][LtiConstants::DEPLOYMENT_ID], $client_id); if (empty($deployment)) { // deployment not recognized. throw new LtiException(static::ERR_NO_DEPLOYMENT); } return $this; } private function validateMessage() { if (empty($this->jwt['body'][LtiConstants::MESSAGE_TYPE])) { // Unable to identify message type. throw new LtiException(static::ERR_INVALID_MESSAGE_TYPE); } /** * @todo Fix this nonsense */ // Create instances of all validators $validators = [ new DeepLinkMessageValidator(), new ResourceMessageValidator(), new SubmissionReviewMessageValidator(), ]; $message_validator = false; foreach ($validators as $validator) { if ($validator->canValidate($this->jwt['body'])) { if ($message_validator !== false) { // Can't have more than one validator apply at a time. throw new LtiException(static::ERR_VALIDATOR_CONFLICT); } $message_validator = $validator; } } if ($message_validator === false) { throw new LtiException(static::ERR_UNRECOGNIZED_MESSAGE_TYPE); } if (!$message_validator->validate($this->jwt['body'])) { throw new LtiException(static::ERR_INVALID_MESSAGE); } return $this; } } LtiGradeSubmissionReview.php 0000644 00000003466 15152044611 0012216 0 ustar 00 <?php namespace Packback\Lti1p3; class LtiGradeSubmissionReview { private $reviewable_status; private $label; private $url; private $custom; public function __construct(array $gradeSubmission = null) { $this->reviewable_status = $gradeSubmission['reviewableStatus'] ?? null; $this->label = $gradeSubmission['label'] ?? null; $this->url = $gradeSubmission['url'] ?? null; $this->custom = $gradeSubmission['custom'] ?? null; } public function __toString() { // Additionally, includes the call back to filter out only NULL values return json_encode(array_filter([ 'reviewableStatus' => $this->reviewable_status, 'label' => $this->label, 'url' => $this->url, 'custom' => $this->custom, ], '\Packback\Lti1p3\Helpers\Helpers::checkIfNullValue')); } /** * Static function to allow for method chaining without having to assign to a variable first. */ public static function new() { return new LtiGradeSubmissionReview(); } public function getReviewableStatus() { return $this->reviewable_status; } public function setReviewableStatus($value) { $this->reviewable_status = $value; return $this; } public function getLabel() { return $this->label; } public function setLabel($value) { $this->label = $value; return $this; } public function getUrl() { return $this->url; } public function setUrl($url) { $this->url = $url; return $this; } public function getCustom() { return $this->custom; } public function setCustom($value) { $this->custom = $value; return $this; } } LtiGrade.php 0000644 00000007366 15152044611 0006763 0 ustar 00 <?php namespace Packback\Lti1p3; class LtiGrade { private $score_given; private $score_maximum; private $comment; private $activity_progress; private $grading_progress; private $timestamp; private $user_id; private $submission_review; public function __construct(array $grade = null) { $this->score_given = $grade['scoreGiven'] ?? null; $this->score_maximum = $grade['scoreMaximum'] ?? null; $this->comment = $grade['comment'] ?? null; $this->activity_progress = $grade['activityProgress'] ?? null; $this->grading_progress = $grade['gradingProgress'] ?? null; $this->timestamp = $grade['timestamp'] ?? null; $this->user_id = $grade['userId'] ?? null; $this->submission_review = $grade['submissionReview'] ?? null; $this->canvas_extension = $grade['https://canvas.instructure.com/lti/submission'] ?? null; } public function __toString() { // Additionally, includes the call back to filter out only NULL values $request = array_filter([ 'scoreGiven' => $this->score_given, 'scoreMaximum' => $this->score_maximum, 'comment' => $this->comment, 'activityProgress' => $this->activity_progress, 'gradingProgress' => $this->grading_progress, 'timestamp' => $this->timestamp, 'userId' => $this->user_id, 'submissionReview' => $this->submission_review, 'https://canvas.instructure.com/lti/submission' => $this->canvas_extension, ], '\Packback\Lti1p3\Helpers\Helpers::checkIfNullValue'); return json_encode($request); } /** * Static function to allow for method chaining without having to assign to a variable first. */ public static function new() { return new LtiGrade(); } public function getScoreGiven() { return $this->score_given; } public function setScoreGiven($value) { $this->score_given = $value; return $this; } public function getScoreMaximum() { return $this->score_maximum; } public function setScoreMaximum($value) { $this->score_maximum = $value; return $this; } public function getComment() { return $this->comment; } public function setComment($comment) { $this->comment = $comment; return $this; } public function getActivityProgress() { return $this->activity_progress; } public function setActivityProgress($value) { $this->activity_progress = $value; return $this; } public function getGradingProgress() { return $this->grading_progress; } public function setGradingProgress($value) { $this->grading_progress = $value; return $this; } public function getTimestamp() { return $this->timestamp; } public function setTimestamp($value) { $this->timestamp = $value; return $this; } public function getUserId() { return $this->user_id; } public function setUserId($value) { $this->user_id = $value; return $this; } public function getSubmissionReview() { return $this->submission_review; } public function setSubmissionReview($value) { $this->submission_review = $value; return $this; } public function getCanvasExtension() { return $this->canvas_extension; } // Custom Extension for Canvas. // https://documentation.instructure.com/doc/api/score.html public function setCanvasExtension($value) { $this->canvas_extension = $value; return $this; } } LtiDeepLinkResourceIcon.php 0000644 00000002420 15152044611 0011737 0 ustar 00 <?php namespace Packback\Lti1p3; class LtiDeepLinkResourceIcon { private $url; private $width; private $height; public function __construct(string $url, int $width, int $height) { $this->url = $url; $this->width = $width; $this->height = $height; } public static function new(string $url, int $width, int $height): LtiDeepLinkResourceIcon { return new LtiDeepLinkResourceIcon($url, $width, $height); } public function setUrl(string $url): LtiDeepLinkResourceIcon { $this->url = $url; return $this; } public function getUrl(): string { return $this->url; } public function setWidth(int $width): LtiDeepLinkResourceIcon { $this->width = $width; return $this; } public function getWidth(): int { return $this->width; } public function setHeight(int $height): LtiDeepLinkResourceIcon { $this->height = $height; return $this; } public function getHeight(): int { return $this->height; } public function toArray(): array { return [ 'url' => $this->url, 'width' => $this->width, 'height' => $this->height, ]; } } LtiDeepLink.php 0000644 00000004126 15152044611 0007423 0 ustar 00 <?php namespace Packback\Lti1p3; use Firebase\JWT\JWT; use Packback\Lti1p3\Interfaces\ILtiRegistration; class LtiDeepLink { private $registration; private $deployment_id; private $deep_link_settings; public function __construct(ILtiRegistration $registration, string $deployment_id, array $deep_link_settings) { $this->registration = $registration; $this->deployment_id = $deployment_id; $this->deep_link_settings = $deep_link_settings; } public function getResponseJwt($resources) { $message_jwt = [ 'iss' => $this->registration->getClientId(), 'aud' => [$this->registration->getIssuer()], 'exp' => time() + 600, 'iat' => time(), 'nonce' => LtiOidcLogin::secureRandomString('nonce-'), LtiConstants::DEPLOYMENT_ID => $this->deployment_id, LtiConstants::MESSAGE_TYPE => 'LtiDeepLinkingResponse', LtiConstants::VERSION => LtiConstants::V1_3, LtiConstants::DL_CONTENT_ITEMS => array_map(function ($resource) { return $resource->toArray(); }, $resources), ]; // https://www.imsglobal.org/spec/lti-dl/v2p0/#deep-linking-request-message // 'data' is an optional property which, if it exists, must be returned by the tool if (isset($this->deep_link_settings['data'])) { $message_jwt[LtiConstants::DL_DATA] = $this->deep_link_settings['data']; } return JWT::encode($message_jwt, $this->registration->getToolPrivateKey(), 'RS256', $this->registration->getKid()); } public function outputResponseForm($resources) { $jwt = $this->getResponseJwt($resources); /* * @todo Fix this */ ?> <form id="auto_submit" action="<?php echo $this->deep_link_settings['deep_link_return_url']; ?>" method="POST"> <input type="hidden" name="JWT" value="<?php echo $jwt; ?>" /> <input type="submit" name="Go" /> </form> <script> document.getElementById('auto_submit').submit(); </script> <?php } } ServiceRequest.php 0000644 00000010220 15152044611 0010220 0 ustar 00 <?php namespace Packback\Lti1p3; use Packback\Lti1p3\Interfaces\IServiceRequest; class ServiceRequest implements IServiceRequest { // Request methods public const METHOD_GET = 'GET'; public const METHOD_POST = 'POST'; public const METHOD_PUT = 'PUT'; // Request types public const TYPE_UNSUPPORTED = 'unsupported'; public const TYPE_AUTH = 'auth'; // MessageLaunch public const TYPE_GET_KEYSET = 'get_keyset'; // AGS public const TYPE_GET_GRADES = 'get_grades'; public const TYPE_SYNC_GRADE = 'sync_grades'; public const TYPE_CREATE_LINEITEM = 'create_lineitem'; public const TYPE_GET_LINEITEMS = 'get_lineitems'; public const TYPE_GET_LINEITEM = 'get_lineitem'; public const TYPE_UPDATE_LINEITEM = 'update_lineitem'; // CGS public const TYPE_GET_GROUPS = 'get_groups'; public const TYPE_GET_SETS = 'get_sets'; // NRPS public const TYPE_GET_MEMBERSHIPS = 'get_memberships'; private $method; private $url; private $type; private $body; private $payload; private $accessToken; private $contentType = 'application/json'; private $accept = 'application/json'; public function __construct(string $method, string $url, $type = self::UNSUPPORTED) { $this->method = $method; $this->url = $url; $this->type = $type; } public function getMethod(): string { return strtoupper($this->method); } public function getUrl(): string { return $this->url; } public function getPayload(): array { if (isset($this->payload)) { return $this->payload; } $payload = [ 'headers' => $this->getHeaders(), ]; $body = $this->getBody(); if ($body) { $payload['body'] = $body; } return $payload; } public function setUrl(string $url): IServiceRequest { $this->url = $url; return $this; } public function setAccessToken(string $accessToken): IServiceRequest { $this->accessToken = 'Bearer '.$accessToken; return $this; } public function setBody(string $body): IServiceRequest { $this->body = $body; return $this; } public function setPayload(array $payload): IServiceRequest { $this->payload = $payload; return $this; } public function setAccept(string $accept): IServiceRequest { $this->accept = $accept; return $this; } public function setContentType(string $contentType): IServiceRequest { $this->contentType = $contentType; return $this; } public function getErrorPrefix(): string { $defaultMessage = 'Logging request data:'; $errorMessages = [ static::TYPE_UNSUPPORTED => $defaultMessage, static::TYPE_AUTH => 'Authenticating:', static::TYPE_GET_KEYSET => 'Getting key set:', static::TYPE_GET_GRADES => 'Getting grades:', static::TYPE_SYNC_GRADE => 'Syncing grade for this lti_user_id:', static::TYPE_CREATE_LINEITEM => 'Creating lineitem:', static::TYPE_GET_LINEITEMS => 'Getting lineitems:', static::TYPE_GET_LINEITEM => 'Getting a lineitem:', static::TYPE_UPDATE_LINEITEM => 'Updating lineitem:', static::TYPE_GET_GROUPS => 'Getting groups:', static::TYPE_GET_SETS => 'Getting sets:', static::TYPE_GET_MEMBERSHIPS => 'Getting memberships:', ]; return $errorMessages[$this->type] ?? $defaultMessage; } private function getHeaders(): array { $headers = [ 'Accept' => $this->accept, ]; if (isset($this->accessToken)) { $headers['Authorization'] = $this->accessToken; } // Include Content-Type for POST and PUT requests if (in_array($this->getMethod(), [ServiceRequest::METHOD_POST, ServiceRequest::METHOD_PUT])) { $headers['Content-Type'] = $this->contentType; } return $headers; } private function getBody(): ?string { return $this->body; } } Redirect.php 0000644 00000004726 15152044611 0007026 0 ustar 00 <?php namespace Packback\Lti1p3; use Packback\Lti1p3\Interfaces\ICookie; class Redirect { private $location; private $referer_query; private static $CAN_302_COOKIE = 'LTI_302_Redirect'; public function __construct(string $location, string $referer_query = null) { $this->location = $location; $this->referer_query = $referer_query; } public function doRedirect() { header('Location: '.$this->location, true, 302); exit; } public function doHybridRedirect(ICookie $cookie) { if (!empty($cookie->getCookie(self::$CAN_302_COOKIE))) { return $this->doRedirect(); } $cookie->setCookie(self::$CAN_302_COOKIE, 'true'); $this->doJsRedirect(); } public function getRedirectUrl() { return $this->location; } public function doJsRedirect() { ?> <a id="try-again" target="_blank">If you are not automatically redirected, click here to continue</a> <script> document.getElementById('try-again').href=<?php if (empty($this->referer_query)) { echo 'window.location.href'; } else { echo "window.location.origin + window.location.pathname + '?".$this->referer_query."'"; } ?>; var canAccessCookies = function() { if (!navigator.cookieEnabled) { // We don't have access return false; } // Firefox returns true even if we don't actually have access try { if (!document.cookie || document.cookie == "" || document.cookie.indexOf('<?php echo self::$CAN_302_COOKIE; ?>') === -1) { return false; } } catch (e) { return false; } return true; }; if (canAccessCookies()) { // We have access, continue with redirect window.location = '<?php echo $this->location; ?>'; } else { // We don't have access, reopen flow in a new window. var opened = window.open(document.getElementById('try-again').href, '_blank'); if (opened) { document.getElementById('try-again').innerText = "New window opened, click to reopen"; } else { document.getElementById('try-again').innerText = "Popup blocked, click to open in a new window"; } } </script> <?php } } chart_output_base.js 0000644 00000004035 15152050146 0010616 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart output base. * * This takes a chart object and draws it. * * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @module core/chart_output_base */ define(['jquery'], function($) { /** * Chart output base. * * The constructor of an output class must instantly generate and display the * chart. It is also the responsability of the output module to check that * the node received is of the appropriate type, if not a new node can be * added within. * * The output module has total control over the content of the node and can * clear it or output anything to it at will. A node should not be shared by * two simultaneous output modules. * * @class * @param {Node} node The node to output with/in. * @param {Chart} chart A chart object. */ function Base(node, chart) { this._node = $(node); this._chart = chart; } /** * Update method. * * This is the public method through which an output instance in informed * that the chart instance has been updated and they need to update the * chart rendering. * * @abstract * @return {Void} */ Base.prototype.update = function() { throw new Error('Not supported.'); }; return Base; }); paged_content_paging_bar_limit_selector.js 0000644 00000004554 15152050146 0015172 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Javascript for dynamically changing the page limits. * * @module core/paged_content_paging_bar_limit_selector * @copyright 2018 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define( [ 'jquery', 'core/custom_interaction_events', 'core/paged_content_events', 'core/pubsub' ], function( $, CustomEvents, PagedContentEvents, PubSub ) { var SELECTORS = { ROOT: '[data-region="paging-control-limit-container"]', LIMIT_OPTION: '[data-limit]', LIMIT_TOGGLE: '[data-action="limit-toggle"]', }; /** * Trigger the SET_ITEMS_PER_PAGE_LIMIT event when the page limit option * is modified. * * @param {object} root The root element. * @param {string} id A unique id for this instance. */ var init = function(root, id) { root = $(root); CustomEvents.define(root, [ CustomEvents.events.activate ]); root.on(CustomEvents.events.activate, SELECTORS.LIMIT_OPTION, function(e, data) { var optionElement = $(e.target).closest(SELECTORS.LIMIT_OPTION); if (optionElement.hasClass('active')) { // Don't do anything if it was the active option selected. return; } var limit = parseInt(optionElement.attr('data-limit'), 10); // Tell the rest of the pagination components that the limit has changed. PubSub.publish(id + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT, limit); data.originalEvent.preventDefault(); }); }; return { init: init, rootSelector: SELECTORS.ROOT }; }); tooltip.js 0000644 00000010130 15152050146 0006566 0 ustar 00 define(['jquery', 'core/aria'], function($, Aria) { /** * Tooltip class. * * @param {String} selector The css selector for the node(s) to enhance with tooltips. */ var Tooltip = function(selector) { // Tooltip code matches: http://www.w3.org/WAI/PF/aria-practices/#tooltip this._regionSelector = selector; // For each node matching the selector - find an aria-describedby attribute pointing to an role="tooltip" element. $(this._regionSelector).each(function(index, element) { var tooltipId = $(element).attr('aria-describedby'); if (tooltipId) { var tooltipele = document.getElementById(tooltipId); if (tooltipele) { var correctRole = $(tooltipele).attr('role') == 'tooltip'; if (correctRole) { $(tooltipele).hide(); // Ensure the trigger for the tooltip is keyboard focusable. $(element).attr('tabindex', '0'); } // Attach listeners. $(element).on('focus', this._handleFocus.bind(this)); $(element).on('mouseover', this._handleMouseOver.bind(this)); $(element).on('mouseout', this._handleMouseOut.bind(this)); $(element).on('blur', this._handleBlur.bind(this)); $(element).on('keydown', this._handleKeyDown.bind(this)); } } }.bind(this)); }; /** @property {String} Selector for the page region containing the user navigation. */ Tooltip.prototype._regionSelector = null; /** * Find the tooltip referred to by this element and show it. * * @param {Event} e */ Tooltip.prototype._showTooltip = function(e) { var triggerElement = $(e.target); var tooltipId = triggerElement.attr('aria-describedby'); if (tooltipId) { var tooltipele = $(document.getElementById(tooltipId)); tooltipele.show(); Aria.unhide(tooltipele); if (!tooltipele.is('.tooltip')) { // Change the markup to a bootstrap tooltip. var inner = $('<div class="tooltip-inner"></div>'); inner.append(tooltipele.contents()); tooltipele.append(inner); tooltipele.addClass('tooltip'); tooltipele.addClass('bottom'); tooltipele.append('<div class="tooltip-arrow"></div>'); } var pos = triggerElement.offset(); pos.top += triggerElement.height() + 10; $(tooltipele).offset(pos); } }; /** * Find the tooltip referred to by this element and hide it. * * @param {Event} e */ Tooltip.prototype._hideTooltip = function(e) { var triggerElement = $(e.target); var tooltipId = triggerElement.attr('aria-describedby'); if (tooltipId) { var tooltipele = document.getElementById(tooltipId); $(tooltipele).hide(); Aria.hide(tooltipele); } }; /** * Listener for focus events. * @param {Event} e */ Tooltip.prototype._handleFocus = function(e) { this._showTooltip(e); }; /** * Listener for keydown events. * @param {Event} e */ Tooltip.prototype._handleKeyDown = function(e) { if (e.which == 27) { this._hideTooltip(e); } }; /** * Listener for mouseover events. * @param {Event} e */ Tooltip.prototype._handleMouseOver = function(e) { this._showTooltip(e); }; /** * Listener for mouseout events. * @param {Event} e */ Tooltip.prototype._handleMouseOut = function(e) { var triggerElement = $(e.target); if (!triggerElement.is(":focus")) { this._hideTooltip(e); } }; /** * Listener for blur events. * @param {Event} e */ Tooltip.prototype._handleBlur = function(e) { this._hideTooltip(e); }; return Tooltip; }); toast.js 0000644 00000006512 15152050146 0006237 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A system for displaying small snackbar notifications to users which disappear shortly after they are shown. * * @module core/toast * @copyright 2019 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Templates from 'core/templates'; import Notification from 'core/notification'; import Pending from 'core/pending'; /** * Add a new region to place toasts in, taking in a parent element. * * @method * @param {HTMLElement} parent */ export const addToastRegion = async(parent) => { const pendingPromise = new Pending('addToastRegion'); try { const {html, js} = await Templates.renderForPromise('core/local/toast/wrapper', {}); Templates.prependNodeContents(parent, html, js); } catch (e) { Notification.exception(e); } pendingPromise.resolve(); }; /** * Add a new toast or snackbar notification to the page. * * @method * @param {String|Promise<string>} message * @param {Object} configuration * @param {String} [configuration.title] * @param {String} [configuration.subtitle] * @param {String} [configuration.type=info] Optional type of the toast notification ('success', 'info', 'warning' or 'danger') * @param {Boolean} [configuration.autohide=true] * @param {Boolean} [configuration.closeButton=false] * @param {Number} [configuration.delay=4000] * * @example * import {add as addToast} from 'core/toast'; * import {get_string as getString} from 'core/str'; * * addToast('Example string', { * type: 'warning', * autohide: false, * closeButton: true, * }); * * addToast(getString('example', 'mod_myexample'), { * type: 'warning', * autohide: false, * closeButton: true, * }); */ export const add = async(message, configuration) => { const pendingPromise = new Pending('addToastRegion'); configuration = { type: 'info', closeButton: false, autohide: true, delay: 4000, ...configuration, }; const templateName = `core/local/toast/message`; try { const targetNode = await getTargetNode(); const {html, js} = await Templates.renderForPromise(templateName, { message: await message, ...configuration }); Templates.prependNodeContents(targetNode, html, js); } catch (e) { Notification.exception(e); } pendingPromise.resolve(); }; const getTargetNode = async() => { const regions = document.querySelectorAll('.toast-wrapper'); if (regions.length) { return regions[regions.length - 1]; } await addToastRegion(document.body, 'fixed-bottom'); return getTargetNode(); }; log.js 0000644 00000003371 15152050146 0005666 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * This is an empty module, that is required before all other modules. * Because every module is returned from a request for any other module, this * forces the loading of all modules with a single request. * * @module core/log * @copyright 2015 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['core/loglevel'], function(log) { var originalFactory = log.methodFactory; log.methodFactory = function(methodName, logLevel) { var rawMethod = originalFactory(methodName, logLevel); return function(message, source) { if (source) { rawMethod(source + ": " + message); } else { rawMethod(message); } }; }; /** * Set default config settings. * * @param {Object} config including the level to use. * @method setConfig */ log.setConfig = function(config) { if (typeof config.level !== "undefined") { log.setLevel(config.level); } }; return log; }); chartjs.js 0000644 00000001621 15152050146 0006537 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart.js loader. * * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['core/chartjs-lazy'], function(ChartJS) { return ChartJS; }); autoscroll.js 0000644 00000014770 15152050146 0007301 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /* * JavaScript to provide automatic scrolling, e.g. during a drag operation. * * Note: this module is defined statically. It is a singleton. You * can only have one use of it active at any time. However, since this * is usually used in relation to drag-drop, and since you only ever * drag one thing at a time, this is not a problem in practice. * * @module core/autoscroll * @copyright 2016 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.6 */ define(['jquery'], function($) { /** * @alias module:core/autoscroll */ var autoscroll = { /** * Size of area near edge of screen that triggers scrolling. * @private */ SCROLL_THRESHOLD: 30, /** * How frequently to scroll window. * @private */ SCROLL_FREQUENCY: 1000 / 60, /** * How many pixels to scroll per unit (1 = max scroll 30). * @private */ SCROLL_SPEED: 0.5, /** * Set if currently scrolling up/down. * @private */ scrollingId: null, /** * Speed we are supposed to scroll (range 1 to SCROLL_THRESHOLD). * @private */ scrollAmount: 0, /** * Optional callback called when it scrolls * @private */ callback: null, /** * Starts automatically scrolling if user moves near edge of window. * This should be called in response to mouse down or touch start. * * @public * @param {Function} callback Optional callback that is called every time it scrolls */ start: function(callback) { $(window).on('mousemove', autoscroll.mouseMove); $(window).on('touchmove', autoscroll.touchMove); autoscroll.callback = callback; }, /** * Stops automatically scrolling. This should be called in response to mouse up or touch end. * * @public */ stop: function() { $(window).off('mousemove', autoscroll.mouseMove); $(window).off('touchmove', autoscroll.touchMove); if (autoscroll.scrollingId !== null) { autoscroll.stopScrolling(); } }, /** * Event handler for touch move. * * @private * @param {Object} e Event */ touchMove: function(e) { for (var i = 0; i < e.changedTouches.length; i++) { autoscroll.handleMove(e.changedTouches[i].clientX, e.changedTouches[i].clientY); } }, /** * Event handler for mouse move. * * @private * @param {Object} e Event */ mouseMove: function(e) { autoscroll.handleMove(e.clientX, e.clientY); }, /** * Handles user moving. * * @private * @param {number} clientX X * @param {number} clientY Y */ handleMove: function(clientX, clientY) { // If near the bottom or top, start auto-scrolling. if (clientY < autoscroll.SCROLL_THRESHOLD) { autoscroll.scrollAmount = -Math.min(autoscroll.SCROLL_THRESHOLD - clientY, autoscroll.SCROLL_THRESHOLD); } else if (clientY > $(window).height() - autoscroll.SCROLL_THRESHOLD) { autoscroll.scrollAmount = Math.min(clientY - ($(window).height() - autoscroll.SCROLL_THRESHOLD), autoscroll.SCROLL_THRESHOLD); } else { autoscroll.scrollAmount = 0; } if (autoscroll.scrollAmount && autoscroll.scrollingId === null) { autoscroll.startScrolling(); } else if (!autoscroll.scrollAmount && autoscroll.scrollingId !== null) { autoscroll.stopScrolling(); } }, /** * Starts automatic scrolling. * * @private */ startScrolling: function() { var maxScroll = $(document).height() - $(window).height(); autoscroll.scrollingId = window.setInterval(function() { // Work out how much to scroll. var y = $(window).scrollTop(); var offset = Math.round(autoscroll.scrollAmount * autoscroll.SCROLL_SPEED); if (y + offset < 0) { offset = -y; } if (y + offset > maxScroll) { offset = maxScroll - y; } if (offset === 0) { return; } // Scroll. $(window).scrollTop(y + offset); var realOffset = $(window).scrollTop() - y; if (realOffset === 0) { return; } // Inform callback if (autoscroll.callback) { autoscroll.callback(realOffset); } }, autoscroll.SCROLL_FREQUENCY); }, /** * Stops the automatic scrolling. * * @private */ stopScrolling: function() { window.clearInterval(autoscroll.scrollingId); autoscroll.scrollingId = null; } }; return { /** * Starts automatic scrolling if user moves near edge of window. * This should be called in response to mouse down or touch start. * * @public * @param {Function} callback Optional callback that is called every time it scrolls */ start: autoscroll.start, /** * Stops automatic scrolling. This should be called in response to mouse up or touch end. * * @public */ stop: autoscroll.stop }; }); copy_to_clipboard.js 0000644 00000016621 15152050146 0010602 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A JavaScript module that enhances a button and text container to support copy-to-clipboard functionality. * * This module needs to be loaded by pages/templates/modules that require this functionality. * * To enable copy-to-clipboard functionality, we need a trigger element (usually a button) and a copy target element * (e.g. a div, span, text input, or text area). * * In the trigger element, we need to declare the <code>data-action="copytoclipboard"</code> attribute and set the * <code>data-clipboard-target</code> attribute which is the CSS selector that points to the target element that contains the text * to be copied. * * When the text is successfully copied to the clipboard, a toast message that indicates that the copy operation was a success * will be shown. This success message can be customised by setting the <code>data-clipboard-success-message</code> attribute in the * trigger element. * * @module core/copy_to_clipboard * @copyright 2021 Jun Pataleta * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * * @example <caption>Markup for the trigger and target elements</caption> * <input type="text" id="textinputtocopy" class="form-control" value="Copy me!" readonly /> * <button id="copybutton" data-action="copytoclipboard" data-clipboard-target="#textinputtocopy" * data-clipboard-success-message="Success!" class="btn btn-secondary"> * Copy to clipboard * </button> */ import {get_string as getString} from 'core/str'; import {add as addToast} from 'core/toast'; import {prefetchStrings} from 'core/prefetch'; /** * Add event listeners to trigger elements through event delegation. * * @private */ const addEventListeners = () => { document.addEventListener('click', e => { const copyButton = e.target.closest('[data-action="copytoclipboard"]'); if (!copyButton) { return; } if (!copyButton.dataset.clipboardTarget) { return; } const copyTarget = document.querySelector(copyButton.dataset.clipboardTarget); if (!copyTarget) { return; } // This is a copy target and there is content. // Prevent the default action. e.preventDefault(); // We have a copy target - great. Let's copy its content. const textToCopy = getTextFromContainer(copyTarget); if (!textToCopy) { displayFailureToast(); return; } if (navigator.clipboard) { navigator.clipboard.writeText(textToCopy) .then(() => displaySuccessToast(copyButton)).catch(); return; } // The clipboard API is not available. // This may happen when the page is not served over SSL. // Try to fall back to document.execCommand() approach of copying the text. // WARNING: This is deprecated functionality that may get dropped at anytime by browsers. if (copyTarget instanceof HTMLInputElement || copyTarget instanceof HTMLTextAreaElement) { // Focus and select the text in the target element. // If the execCommand fails, at least the user can readily copy the text. copyTarget.focus(); if (copyNodeContentToClipboard(copyButton, copyTarget)) { // If the copy was successful then focus back on the copy button. copyButton.focus(); } } else { // This copyTarget is not an input, or text area so cannot be used with the execCommand('copy') command. // To work around this we create a new textarea and copy that. // This textarea must be part of the DOM and must be visible. // We (ab)use the sr-only tag to ensure that it is considered visible to the browser, whilst being // hidden from view by the user. const copyRegion = document.createElement('textarea'); copyRegion.value = textToCopy; copyRegion.classList.add('sr-only'); document.body.appendChild(copyRegion); copyNodeContentToClipboard(copyButton, copyRegion); // After copying, remove the temporary element and move focus back to the triggering button. copyRegion.remove(); copyButton.focus(); } }); }; /** * Copy the content of the selected element to the clipboard, and display a notifiction if successful. * * @param {HTMLElement} copyButton * @param {HTMLElement} copyTarget * @returns {boolean} * @private */ const copyNodeContentToClipboard = (copyButton, copyTarget) => { copyTarget.select(); // Try to copy the text from the target element. if (document.execCommand('copy')) { displaySuccessToast(copyButton); return true; } displayFailureToast(); return false; }; /** * Displays a toast containing the success message. * * @param {HTMLElement} copyButton The element that copies the text from the container. * @returns {Promise<void>} * @private */ const displaySuccessToast = copyButton => getSuccessText(copyButton) .then(successMessage => addToast(successMessage, {})); /** * Displays a toast containing the failure message. * * @returns {Promise<void>} * @private */ const displayFailureToast = () => getFailureText() .then(message => addToast(message, {type: 'warning'})); /** * Fetches the failure message to show to the user. * * @returns {Promise} * @private */ const getFailureText = () => getString('unabletocopytoclipboard', 'core'); /** * Fetches the success message to show to the user. * * @param {HTMLElement} copyButton The element that copies the text from the container. This may contain the custom success message * via its data-clipboard-success-message attribute. * @returns {Promise|*} * @private */ const getSuccessText = copyButton => { if (copyButton.dataset.clipboardSuccessMessage) { return Promise.resolve(copyButton.dataset.clipboardSuccessMessage); } return getString('textcopiedtoclipboard', 'core'); }; /** * Fetches the text to be copied from the container. * * @param {HTMLElement} container The element containing the text to be copied. * @returns {null|string} * @private */ const getTextFromContainer = container => { if (container.value) { // For containers which are form elements (e.g. text area, text input), get the element's value. return container.value; } else if (container.innerText) { // For other elements, try to use the innerText attribute. return container.innerText; } return null; }; let loaded = false; if (!loaded) { prefetchStrings('core', [ 'textcopiedtoclipboard', 'unabletocopytoclipboard', ]); // Add event listeners. addEventListeners(); loaded = true; } auto_rows.js 0000644 00000006730 15152050146 0007131 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Enhance a textarea with auto growing rows to fit the content. * * @module core/auto_rows * @copyright 2016 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.2 */ define(['jquery'], function($) { var SELECTORS = { ELEMENT: '[data-auto-rows]' }; var EVENTS = { ROW_CHANGE: 'autorows:rowchange', }; /** * Determine how many rows should be set for the given element. * * @method calculateRows * @param {jQuery} element The textarea element * @return {int} The number of rows for the element * @private */ var calculateRows = function(element) { var currentRows = element.attr('rows'); var minRows = element.data('min-rows'); var maxRows = element.attr('data-max-rows'); var height = element.height(); var innerHeight = element.innerHeight(); var padding = innerHeight - height; var scrollHeight = element[0].scrollHeight; var rows = (scrollHeight - padding) / (height / currentRows); // Remove the height styling to let the height be calculated automatically // based on the row attribute. element.css('height', ''); if (rows < minRows) { return minRows; } else if (maxRows && rows >= maxRows) { return maxRows; } else { return rows; } }; /** * Listener for change events to trigger resizing of the element. * * @method changeListener * @param {Event} e The triggered event. * @private */ var changeListener = function(e) { var element = $(e.target); var minRows = element.data('min-rows'); var currentRows = element.attr('rows'); if (typeof minRows === "undefined") { element.data('min-rows', currentRows); } // Reset element to single row so that the scroll height of the // element is correctly calculated each time. element.attr('rows', 1); var rows = calculateRows(element); element.attr('rows', rows); if (rows != currentRows) { element.trigger(EVENTS.ROW_CHANGE); } }; /** * Add the event listeners for all text areas within the given element. * * @method init * @param {jQuery|selector} root The container element of all enhanced text areas * @public */ var init = function(root) { if ($(root).data('auto-rows')) { $(root).on('input propertychange', changeListener.bind(this)); } else { $(root).on('input propertychange', SELECTORS.ELEMENT, changeListener.bind(this)); } }; return /** @module core/auto_rows */ { init: init, events: EVENTS, }; }); utility.js 0000644 00000013301 15152050146 0006602 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Javascript handling for HTML attributes. This module gets autoloaded on page load. * * With the appropriate HTML attributes, various functionalities defined in this module can be used such as a displaying * an alert or a confirmation modal, etc. * * @module core/utility * @copyright 2021 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 4.0 * * @example <caption>Calling the confirmation modal to delete a block</caption> * * // The following is an example of how to use this module via an indirect PHP call with a button. * * $controls[] = new action_menu_link_secondary( * $deleteactionurl, * new pix_icon('t/delete', $str, 'moodle', array('class' => 'iconsmall', 'title' => '')), * $str, * [ * 'class' => 'editing_delete', * 'data-modal' => 'confirmation', // Needed so this module will pick it up in the click handler. * 'data-modal-title-str' => json_encode(['deletecheck_modal', 'block']), * 'data-modal-content-str' => json_encode(['deleteblockcheck', 'block', $blocktitle]), * 'data-modal-yes-button-str' => json_encode(['delete', 'core']), * 'data-modal-toast' => 'true', // Can be set to inform the user that their action was a success. * 'data-modal-toast-confirmation-str' => json_encode(['deleteblockinprogress', 'block', $blocktitle]), * 'data-modal-destination' => $deleteconfirmationurl->out(false), // Where do you want to direct the user? * ] * ); */ import * as Str from 'core/str'; import Pending from 'core/pending'; import {add as addToast} from 'core/toast'; import {saveCancelPromise, exception} from 'core/notification'; // We want to ensure that we only initialize the listeners only once. let registered = false; /** * Either fetch the string or return it from the dom node. * * @method getConfirmationString * @private * @param {HTMLElement} dataset The page element to fetch dataset items in * @param {String} type The type of string to fetch * @param {String} field The dataset field name to fetch the contents of * @return {Promise} * */ const getModalString = (dataset, type, field) => { if (dataset[`${type}${field}Str`]) { return Str.get_string.apply(null, JSON.parse(dataset[`${type}${field}Str`])); } return Promise.resolve(dataset[`${type}${field}`]); }; /** * Display a save/cancel confirmation. * * @private * @param {HTMLElement} source The title of the confirmation * @param {String} type The content of the confirmation * @returns {Promise} */ const displayConfirmation = (source, type) => { return saveCancelPromise( getModalString(source.dataset, type, 'Title'), getModalString(source.dataset, type, 'Content'), getModalString(source.dataset, type, 'YesButton'), ) .then(() => { if (source.dataset[`${type}Toast`] === 'true') { const stringForToast = getModalString(source.dataset, type, 'ToastConfirmation'); if (typeof stringForToast === "string") { addToast(stringForToast); } else { stringForToast.then(str => addToast(str)).catch(e => exception(e)); } } window.location.href = source.dataset[`${type}Destination`]; return; }).catch(() => { return; }); }; /** * Display an alert and return the promise from it. * * @private * @param {String} title The title of the alert * @param {String} content The content of the alert * @returns {Promise} */ const displayAlert = async(title, content) => { const pendingPromise = new Pending('core/confirm:alert'); const ModalFactory = await import('core/modal_factory'); return ModalFactory.create({ type: ModalFactory.types.ALERT, title: title, body: content, removeOnClose: true, }) .then(function(modal) { modal.show(); pendingPromise.resolve(); return modal; }); }; /** * Set up the listeners for the confirmation modal widget within the page. * * @method registerConfirmationListeners * @private */ const registerConfirmationListeners = () => { document.addEventListener('click', e => { const confirmRequest = e.target.closest('[data-confirmation="modal"]'); if (confirmRequest) { e.preventDefault(); displayConfirmation(confirmRequest, 'confirmation'); } const modalConfirmation = e.target.closest('[data-modal="confirmation"]'); if (modalConfirmation) { e.preventDefault(); displayConfirmation(modalConfirmation, 'modal'); } const alertRequest = e.target.closest('[data-modal="alert"]'); if (alertRequest) { e.preventDefault(); displayAlert( getModalString(alertRequest.dataset, 'modal', 'Title'), getModalString(alertRequest.dataset, 'modal', 'Content'), ); } }); }; if (!registered) { registerConfirmationListeners(); registered = true; } page_global.js 0000644 00000012602 15152050146 0007336 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Provide global helper code to enhance page elements. * * @module core/page_global * @copyright 2018 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define( [ 'jquery', 'core/custom_interaction_events', 'core/str', 'core/network' ], function( $, CustomEvents, Str, Network ) { /** * Add an event handler for dropdown menus that wish to show their active item * in the dropdown toggle element. * * By default the handler will add the "active" class to the selected dropdown * item and set it's text as the HTML for the dropdown toggle. * * The behaviour of this handler is controlled by adding data attributes to * the HTML and requires the typically Bootstrap dropdown markup. * * data-show-active-item - Add to the .dropdown-menu element to enable default * functionality. * data-skip-active-class - Add to the .dropdown-menu to prevent this code from * adding the active class to the dropdown items * data-active-item-text - Add to an element within the data-toggle="dropdown" element * to use it as the active option text placeholder otherwise the * data-toggle="dropdown" element itself will be used. * data-active-item-button-aria-label-components - String components to set the aria * lable on the dropdown button. The string will be given the * active item text. */ var initActionOptionDropdownHandler = function() { var body = $('body'); CustomEvents.define(body, [CustomEvents.events.activate]); body.on(CustomEvents.events.activate, '[data-show-active-item]', function(e) { // The dropdown item that the user clicked on. var option = $(e.target).closest('.dropdown-item'); // The dropdown menu element. var menuContainer = option.closest('[data-show-active-item]'); if (!option.hasClass('dropdown-item')) { // Ignore non Bootstrap dropdowns. return; } if (option.hasClass('active')) { // If it's already active then we don't need to do anything. return; } // Clear the active class from all other options. var dropdownItems = menuContainer.find('.dropdown-item'); dropdownItems.removeClass('active'); dropdownItems.removeAttr('aria-current'); if (!menuContainer.attr('data-skip-active-class')) { // Make this option active unless configured to ignore it. // Some code, for example the Bootstrap tabs, may want to handle // adding the active class itself. option.addClass('active'); } // Update aria attribute for active item. option.attr('aria-current', true); var activeOptionText = option.text(); var dropdownToggle = menuContainer.parent().find('[data-toggle="dropdown"]'); var dropdownToggleText = dropdownToggle.find('[data-active-item-text]'); if (dropdownToggleText.length) { // We have a specific placeholder for the active item text so // use that. dropdownToggleText.html(activeOptionText); } else { // Otherwise just replace all of the toggle text with the active item. dropdownToggle.html(activeOptionText); } var activeItemAriaLabelComponent = menuContainer.attr('data-active-item-button-aria-label-components'); if (activeItemAriaLabelComponent) { // If we have string components for the aria label then load the string // and set the label on the dropdown toggle. var strParams = activeItemAriaLabelComponent.split(','); strParams.push(activeOptionText); Str.get_string(strParams[0].trim(), strParams[1].trim(), strParams[2].trim()) .then(function(string) { dropdownToggle.attr('aria-label', string); return string; }) .catch(function() { // Silently ignore that we couldn't load the string. return false; }); } }); }; /** * Initialise the global helper functions. */ var init = function() { initActionOptionDropdownHandler(); Network.init(); }; return { init: init }; }); form-cohort-selector.js 0000644 00000004725 15152050146 0011166 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Course selector adaptor for auto-complete form element. * * @module core/form-cohort-selector * @copyright 2016 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.1 */ define(['core/ajax', 'jquery'], function(ajax, $) { return { // Public variables and functions. processResults: function(selector, data) { // Mangle the results into an array of objects. var results = []; var i = 0; var excludelist = String($(selector).data('exclude')).split(','); for (i = 0; i < data.cohorts.length; i++) { if (excludelist.indexOf(String(data.cohorts[i].id)) === -1) { results.push({value: data.cohorts[i].id, label: data.cohorts[i].name}); } } return results; }, transport: function(selector, query, success, failure) { var el = $(selector); // Parse some data-attributes from the form element. // Build the query. var promises = null; if (typeof query === "undefined") { query = ''; } var contextid = el.data('contextid'); var searchargs = { query: query, includes: 'parents', limitfrom: 0, limitnum: 100, context: {contextid: contextid} }; var calls = [{ methodname: 'core_cohort_search_cohorts', args: searchargs }]; // Go go go! promises = ajax.call(calls); $.when.apply($.when, promises).done(function(data) { success(data); }).fail(failure); } }; }); checkbox-toggleall.js 0000644 00000031432 15152050146 0010642 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A module to help with toggle select/deselect all. * * @module core/checkbox-toggleall * @copyright 2019 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery', 'core/pubsub'], function($, PubSub) { /** * Whether event listeners have already been registered. * * @private * @type {boolean} */ var registered = false; /** * List of custom events that this module publishes. * * @private * @type {{checkboxToggled: string}} */ var events = { checkboxToggled: 'core/checkbox-toggleall:checkboxToggled', }; /** * Fetches elements that are member of a given toggle group. * * @private * @param {jQuery} root The root jQuery element. * @param {string} toggleGroup The toggle group name that we're searching form. * @param {boolean} exactMatch Whether we want an exact match we just want to match toggle groups that start with the given * toggle group name. * @returns {jQuery} The elements matching the given toggle group. */ var getToggleGroupElements = function(root, toggleGroup, exactMatch) { if (exactMatch) { return root.find('[data-action="toggle"][data-togglegroup="' + toggleGroup + '"]'); } else { return root.find('[data-action="toggle"][data-togglegroup^="' + toggleGroup + '"]'); } }; /** * Fetches the slave checkboxes for a given toggle group. * * @private * @param {jQuery} root The root jQuery element. * @param {string} toggleGroup The toggle group name. * @returns {jQuery} The slave checkboxes belonging to the toggle group. */ var getAllSlaveCheckboxes = function(root, toggleGroup) { return getToggleGroupElements(root, toggleGroup, false).filter('[data-toggle="slave"]'); }; /** * Fetches the master elements (checkboxes or buttons) that control the slave checkboxes in a given toggle group. * * @private * @param {jQuery} root The root jQuery element. * @param {string} toggleGroup The toggle group name. * @param {boolean} exactMatch * @returns {jQuery} The control elements belonging to the toggle group. */ var getControlCheckboxes = function(root, toggleGroup, exactMatch) { return getToggleGroupElements(root, toggleGroup, exactMatch).filter('[data-toggle="master"]'); }; /** * Fetches the action elements that perform actions on the selected checkboxes in a given toggle group. * * @private * @param {jQuery} root The root jQuery element. * @param {string} toggleGroup The toggle group name. * @returns {jQuery} The action elements belonging to the toggle group. */ var getActionElements = function(root, toggleGroup) { return getToggleGroupElements(root, toggleGroup, true).filter('[data-toggle="action"]'); }; /** * Toggles the slave checkboxes in a given toggle group when a master element in that toggle group is toggled. * * @private * @param {Object} e The event object. */ var toggleSlavesFromMasters = function(e) { var root = e.data.root; var target = $(e.target); var toggleGroupName = target.data('togglegroup'); var targetState; if (target.is(':checkbox')) { targetState = target.is(':checked'); } else { targetState = target.data('checkall') === 1; } toggleSlavesToState(root, toggleGroupName, targetState); }; /** * Toggles the slave checkboxes from the masters. * * @param {HTMLElement} root * @param {String} toggleGroupName */ var updateSlavesFromMasterState = function(root, toggleGroupName) { // Normalise to jQuery Object. root = $(root); var target = getControlCheckboxes(root, toggleGroupName, false); var targetState; if (target.is(':checkbox')) { targetState = target.is(':checked'); } else { targetState = target.data('checkall') === 1; } toggleSlavesToState(root, toggleGroupName, targetState); }; /** * Toggles the master checkboxes and action elements in a given toggle group. * * @param {jQuery} root The root jQuery element. * @param {String} toggleGroupName The name of the toggle group */ var toggleMastersAndActionElements = function(root, toggleGroupName) { var toggleGroupSlaves = getAllSlaveCheckboxes(root, toggleGroupName); if (toggleGroupSlaves.length > 0) { var toggleGroupCheckedSlaves = toggleGroupSlaves.filter(':checked'); var targetState = toggleGroupSlaves.length === toggleGroupCheckedSlaves.length; // Make sure to toggle the exact master checkbox in the given toggle group. setMasterStates(root, toggleGroupName, targetState, true); // Enable the action elements if there's at least one checkbox checked in the given toggle group. // Disable otherwise. setActionElementStates(root, toggleGroupName, !toggleGroupCheckedSlaves.length); } }; /** * Returns an array containing every toggle group level of a given toggle group. * * @param {String} toggleGroupName The name of the toggle group * @return {Array} toggleGroupLevels Array that contains every toggle group level of a given toggle group */ var getToggleGroupLevels = function(toggleGroupName) { var toggleGroups = toggleGroupName.split(' '); var toggleGroupLevels = []; var toggleGroupLevel = ''; toggleGroups.forEach(function(toggleGroupName) { toggleGroupLevel += ' ' + toggleGroupName; toggleGroupLevels.push(toggleGroupLevel.trim()); }); return toggleGroupLevels; }; /** * Toggles the slave checkboxes to a specific state. * * @param {HTMLElement} root * @param {String} toggleGroupName * @param {Bool} targetState */ var toggleSlavesToState = function(root, toggleGroupName, targetState) { var slaves = getAllSlaveCheckboxes(root, toggleGroupName); // Set the slave checkboxes from the masters and manually trigger the native 'change' event. slaves.prop('checked', targetState).trigger('change'); // Get all checked slaves after the change of state. var checkedSlaves = slaves.filter(':checked'); // Toggle the master checkbox in the given toggle group. setMasterStates(root, toggleGroupName, targetState, false); // Enable the action elements if there's at least one checkbox checked in the given toggle group. Disable otherwise. setActionElementStates(root, toggleGroupName, !checkedSlaves.length); // Get all toggle group levels and toggle accordingly all parent master checkboxes and action elements from each // level. Exclude the given toggle group (toggleGroupName) as the master checkboxes and action elements from this // level have been already toggled. var toggleGroupLevels = getToggleGroupLevels(toggleGroupName) .filter(toggleGroupLevel => toggleGroupLevel !== toggleGroupName); toggleGroupLevels.forEach(function(toggleGroupLevel) { // Toggle the master checkboxes action elements in the given toggle group level. toggleMastersAndActionElements(root, toggleGroupLevel); }); PubSub.publish(events.checkboxToggled, { root: root, toggleGroupName: toggleGroupName, slaves: slaves, checkedSlaves: checkedSlaves, anyChecked: targetState, }); }; /** * Set the state for an entire group of checkboxes. * * @param {HTMLElement} root * @param {String} toggleGroupName * @param {Bool} targetState */ var setGroupState = function(root, toggleGroupName, targetState) { // Normalise to jQuery Object. root = $(root); // Set the master and slaves. setMasterStates(root, toggleGroupName, targetState, true); toggleSlavesToState(root, toggleGroupName, targetState); }; /** * Toggles the master checkboxes in a given toggle group when all or none of the slave checkboxes in the same toggle group * have been selected. * * @private * @param {Object} e The event object. */ var toggleMastersFromSlaves = function(e) { var root = e.data.root; var target = $(e.target); var toggleGroupName = target.data('togglegroup'); var slaves = getAllSlaveCheckboxes(root, toggleGroupName); var checkedSlaves = slaves.filter(':checked'); // Get all toggle group levels for the given toggle group and toggle accordingly all master checkboxes // and action elements from each level. var toggleGroupLevels = getToggleGroupLevels(toggleGroupName); toggleGroupLevels.forEach(function(toggleGroupLevel) { // Toggle the master checkboxes action elements in the given toggle group level. toggleMastersAndActionElements(root, toggleGroupLevel); }); PubSub.publish(events.checkboxToggled, { root: root, toggleGroupName: toggleGroupName, slaves: slaves, checkedSlaves: checkedSlaves, anyChecked: !!checkedSlaves.length, }); }; /** * Enables or disables the action elements. * * @private * @param {jQuery} root The root jQuery element. * @param {string} toggleGroupName The toggle group name of the action element(s). * @param {boolean} disableActionElements Whether to disable or to enable the action elements. */ var setActionElementStates = function(root, toggleGroupName, disableActionElements) { getActionElements(root, toggleGroupName).prop('disabled', disableActionElements); }; /** * Selects or deselects the master elements. * * @private * @param {jQuery} root The root jQuery element. * @param {string} toggleGroupName The toggle group name of the master element(s). * @param {boolean} targetState Whether to select (true) or deselect (false). * @param {boolean} exactMatch Whether to do an exact match for the toggle group name or not. */ var setMasterStates = function(root, toggleGroupName, targetState, exactMatch) { // Set the master checkboxes value and ARIA labels.. var masters = getControlCheckboxes(root, toggleGroupName, exactMatch); masters.prop('checked', targetState); masters.each(function(i, masterElement) { masterElement = $(masterElement); var targetString; if (targetState) { targetString = masterElement.data('toggle-deselectall'); } else { targetString = masterElement.data('toggle-selectall'); } if (masterElement.is(':checkbox')) { var masterLabel = root.find('[for="' + masterElement.attr('id') + '"]'); if (masterLabel.length) { if (masterLabel.html() !== targetString) { masterLabel.html(targetString); } } } else { masterElement.text(targetString); // Set the checkall data attribute. masterElement.data('checkall', targetState ? 0 : 1); } }); }; /** * Registers the event listeners. * * @private */ var registerListeners = function() { if (!registered) { registered = true; var root = $(document.body); root.on('click', '[data-action="toggle"][data-toggle="master"]', {root: root}, toggleSlavesFromMasters); root.on('click', '[data-action="toggle"][data-toggle="slave"]', {root: root}, toggleMastersFromSlaves); } }; return { init: function() { registerListeners(); }, events: events, setGroupState: setGroupState, updateSlavesFromMasterState: updateSlavesFromMasterState, }; }); loadingicon.js 0000644 00000007573 15152050146 0007403 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Contain the logic for the loading icon. * * @module core/loading_icon * @class loading_icon * @copyright 2019 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery', 'core/templates'], function($, Templates) { var TEMPLATES = { LOADING: 'core/loading', }; var getIcon = function() { return Templates.render(TEMPLATES.LOADING, {}); }; /** * Add a loading icon to the end of the specified container and return an unresolved promise. * * Resolution of the returned promise causes the icon to be faded out and removed. * * @method addIconToContainer * @param {jQuery} container The element to add the spinner to * @return {Promise} The Promise used to create the icon. */ var addIconToContainer = function(container) { return getIcon() .then(function(html) { var loadingIcon = $(html).hide(); $(container).append(loadingIcon); loadingIcon.fadeIn(150); return loadingIcon; }); }; /** * Add a loading icon to the end of the specified container and return an unresolved promise. * * Resolution of the returned promise causes the icon to be faded out and removed. * * @method addIconToContainerWithPromise * @param {jQuery} container The element to add the spinner to * @param {Promise} loadingIconPromise The jQuery Promise which determines the removal of the icon * @return {jQuery} The Promise used to create and then remove the icon. */ var addIconToContainerRemoveOnCompletion = function(container, loadingIconPromise) { return getIcon() .then(function(html) { var loadingIcon = $(html).hide(); $(container).append(loadingIcon); loadingIcon.fadeIn(150); return $.when(loadingIcon.promise(), loadingIconPromise); }) .then(function(loadingIcon) { // Once the content has finished loading and // the loading icon has been shown then we can // fade the icon away to reveal the content. return loadingIcon.fadeOut(100).promise(); }) .then(function(loadingIcon) { loadingIcon.remove(); return; }); }; /** * Add a loading icon to the end of the specified container and return an unresolved promise. * * Resolution of the returned promise causes the icon to be faded out and removed. * * @method addIconToContainerWithPromise * @param {jQuery} container The element to add the spinner to * @return {Promise} A jQuery Promise to resolve when ready */ var addIconToContainerWithPromise = function(container) { var loadingIconPromise = $.Deferred(); addIconToContainerRemoveOnCompletion(container, loadingIconPromise); return loadingIconPromise; }; return { getIcon: getIcon, addIconToContainer: addIconToContainer, addIconToContainerWithPromise: addIconToContainerWithPromise, addIconToContainerRemoveOnCompletion: addIconToContainerRemoveOnCompletion, }; }); chart_output_htmltable.js 0000644 00000006460 15152050146 0011664 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart output for HTML table. * * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @module core/chart_output_htmltable */ define([ 'jquery', 'core/chart_output_base', ], function($, Base) { /** * Render a chart as an HTML table. * * @class * @extends {module:core/chart_output_base} */ function Output() { Base.prototype.constructor.apply(this, arguments); this._build(); } Output.prototype = Object.create(Base.prototype); /** * Attach the table to the document. * * @protected */ Output.prototype._build = function() { this._node.empty(); this._node.append(this._makeTable()); }; /** * Builds the table node. * * @protected * @return {Jquery} */ Output.prototype._makeTable = function() { var tbl = $('<table>'), c = this._chart, node, value, labels = c.getLabels(), hasLabel = labels.length > 0, series = c.getSeries(), seriesLabels, rowCount = series[0].getCount(); // Identify the table. tbl.addClass('chart-output-htmltable generaltable'); // Set the caption. if (c.getTitle() !== null) { tbl.append($('<caption>').text(c.getTitle())); } // Write the column headers. node = $('<tr>'); if (hasLabel) { node.append($('<td>')); } series.forEach(function(serie) { node.append( $('<th>') .text(serie.getLabel()) .attr('scope', 'col') ); }); tbl.append(node); // Write rows. for (var rowId = 0; rowId < rowCount; rowId++) { node = $('<tr>'); if (labels.length > 0) { node.append( $('<th>') .text(labels[rowId]) .attr('scope', 'row') ); } for (var serieId = 0; serieId < series.length; serieId++) { value = series[serieId].getValues()[rowId]; seriesLabels = series[serieId].getLabels(); if (seriesLabels !== null) { value = series[serieId].getLabels()[rowId]; } node.append($('<td>').text(value)); } tbl.append(node); } return tbl; }; /** @override */ Output.prototype.update = function() { this._build(); }; return Output; }); first.js 0000644 00000002560 15152050146 0006233 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * This is an empty module, that is required before all other modules. * Because every module is returned from a request for any other module, this * forces the loading of all modules with a single request. * * This function also sets up the listeners for ajax requests so we can tell * if any requests are still in progress. * * @module core/first * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 2.9 */ define(['jquery'], function($) { $(document).bind("ajaxStart", function() { M.util.js_pending('jq'); }).bind("ajaxStop", function() { M.util.js_complete('jq'); }); }); user_date.js 0000644 00000022514 15152050146 0007060 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Fetch and render dates from timestamps. * * @module core/user_date * @copyright 2017 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'], function($, Ajax, Storage, Config) { var SECONDS_IN_DAY = 86400; /** @var {object} promisesCache Store all promises we've seen so far. */ var promisesCache = {}; /** * Generate a cache key for the given request. The request should * have a timestamp and format key. * * @param {object} request * @return {string} */ var getKey = function(request) { var language = $('html').attr('lang').replace(/-/g, '_'); return 'core_user_date/' + language + '/' + Config.usertimezone + '/' + request.timestamp + '/' + request.format; }; /** * Retrieve a transformed date from the browser's storage. * * @param {string} key * @return {string} */ var getFromLocalStorage = function(key) { return Storage.get(key); }; /** * Save the transformed date in the browser's storage. * * @param {string} key * @param {string} value */ var addToLocalStorage = function(key, value) { Storage.set(key, value); }; /** * Check if a key is in the module's cache. * * @param {string} key * @return {bool} */ var inPromisesCache = function(key) { return (typeof promisesCache[key] !== 'undefined'); }; /** * Retrieve a promise from the module's cache. * * @param {string} key * @return {object} jQuery promise */ var getFromPromisesCache = function(key) { return promisesCache[key]; }; /** * Save the given promise in the module's cache. * * @param {string} key * @param {object} promise */ var addToPromisesCache = function(key, promise) { promisesCache[key] = promise; }; /** * Send a request to the server for each of the required timestamp * and format combinations. * * Resolves the date's deferred with the values returned from the * server and saves the value in local storage. * * @param {array} dates * @return {object} jQuery promise */ var loadDatesFromServer = function(dates) { var args = dates.map(function(data) { var fixDay = data.hasOwnProperty('fixday') ? data.fixday : 1; var fixHour = data.hasOwnProperty('fixhour') ? data.fixhour : 1; return { timestamp: data.timestamp, format: data.format, type: data.type || null, fixday: fixDay, fixhour: fixHour }; }); var request = { methodname: 'core_get_user_dates', args: { contextid: Config.contextid, timestamps: args } }; return Ajax.call([request], true, true)[0].then(function(results) { results.dates.forEach(function(value, index) { var date = dates[index]; var key = getKey(date); addToLocalStorage(key, value); date.deferred.resolve(value); }); return; }) .catch(function(ex) { // If we failed to retrieve the dates then reject the date's // deferred objects to make sure they don't hang. dates.forEach(function(date) { date.deferred.reject(ex); }); }); }; /** * Takes an array of request objects and returns a promise that * is resolved with an array of formatted dates. * * The values in the returned array will be ordered the same as * the request array. * * This function will check both the module's static promises cache * and the browser's session storage to see if the user dates have * already been loaded in order to avoid sending a network request * if possible. * * Only dates not found in either cache will be sent to the server * for transforming. * * A request object must have a timestamp key and a format key and * optionally may have a type key. * * E.g. * var request = [ * { * timestamp: 1293876000, * format: '%d %B %Y' * }, * { * timestamp: 1293876000, * format: '%A, %d %B %Y, %I:%M %p', * type: 'gregorian', * fixday: false, * fixhour: false * } * ]; * * UserDate.get(request).done(function(dates) { * console.log(dates[0]); // prints "1 January 2011". * console.log(dates[1]); // prints "Saturday, 1 January 2011, 10:00 AM". * }); * * @param {array} requests * @return {object} jQuery promise */ var get = function(requests) { var ajaxRequests = []; var promises = []; // Loop over each of the requested timestamp/format combos // and add a promise to the promises array for them. requests.forEach(function(request) { var key = getKey(request); // If we've already got a promise then use it. if (inPromisesCache(key)) { promises.push(getFromPromisesCache(key)); } else { var deferred = $.Deferred(); var cached = getFromLocalStorage(key); if (cached) { // If we were able to get the value from session storage // then we can resolve the deferred with that value. No // need to ask the server to transform it for us. deferred.resolve(cached); } else { // Add this request to the list of ones we need to load // from the server. Include the deferred so that it can // be resolved when the server has responded with the // transformed values. request.deferred = deferred; ajaxRequests.push(request); } // Remember this promise for next time so that we can // bail out early if it is requested again. addToPromisesCache(key, deferred.promise()); promises.push(deferred.promise()); } }); // If we have any requests that we couldn't resolve from the caches // then let's ask the server to get them for us. if (ajaxRequests.length) { loadDatesFromServer(ajaxRequests); } // Wait for all of the promises to resolve. Some of them may be waiting // for a response from the server. return $.when.apply($, promises).then(function() { // This looks complicated but it's just converting an unknown // length of arguments into an array for the promise to resolve // with. return arguments.length === 1 ? [arguments[0]] : Array.apply(null, arguments); }); }; /** * For a given timestamp get the midnight value in the user's timezone. * * The calculation is performed relative to the user's midnight timestamp * for today to ensure that timezones are preserved. * * E.g. * Input: * timestamp: 1514836800 (01/01/2018 8pm GMT)(02/01/2018 4am GMT+8) * midnight: 1514851200 (02/01/2018 midnight GMT) * Output: * 1514764800 (01/01/2018 midnight GMT) * * Input: * timestamp: 1514836800 (01/01/2018 8pm GMT)(02/01/2018 4am GMT+8) * midnight: 1514822400 (02/01/2018 midnight GMT+8) * Output: * 1514822400 (02/01/2018 midnight GMT+8) * * @param {Number} timestamp The timestamp to calculate from * @param {Number} todayMidnight The user's midnight timestamp * @return {Number} The midnight value of the user's timestamp */ var getUserMidnightForTimestamp = function(timestamp, todayMidnight) { var future = timestamp > todayMidnight; var diffSeconds = Math.abs(timestamp - todayMidnight); var diffDays = future ? Math.floor(diffSeconds / SECONDS_IN_DAY) : Math.ceil(diffSeconds / SECONDS_IN_DAY); var diffDaysInSeconds = diffDays * SECONDS_IN_DAY; // Is the timestamp in the future or past? var dayTimestamp = future ? todayMidnight + diffDaysInSeconds : todayMidnight - diffDaysInSeconds; return dayTimestamp; }; return { get: get, getUserMidnightForTimestamp: getUserMidnightForTimestamp }; }); datafilter.js 0000644 00000036441 15152050146 0007230 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Data filter management. * * @module core/datafilter * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import CourseFilter from 'core/datafilter/filtertypes/courseid'; import GenericFilter from 'core/datafilter/filtertype'; import {get_strings as getStrings} from 'core/str'; import Notification from 'core/notification'; import Pending from 'core/pending'; import Selectors from 'core/datafilter/selectors'; import Templates from 'core/templates'; import CustomEvents from 'core/custom_interaction_events'; import jQuery from 'jquery'; export default class { /** * Initialise the filter on the element with the given filterSet and callback. * * @param {HTMLElement} filterSet The filter element. * @param {Function} applyCallback Callback function when updateTableFromFilter */ constructor(filterSet, applyCallback) { this.filterSet = filterSet; this.applyCallback = applyCallback; // Keep a reference to all of the active filters. this.activeFilters = { courseid: new CourseFilter('courseid', filterSet), }; } /** * Initialise event listeners to the filter. */ init() { // Add listeners for the main actions. this.filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => { if (e.target.closest(Selectors.filterset.actions.addRow)) { e.preventDefault(); this.addFilterRow(); } if (e.target.closest(Selectors.filterset.actions.applyFilters)) { e.preventDefault(); this.updateTableFromFilter(); } if (e.target.closest(Selectors.filterset.actions.resetFilters)) { e.preventDefault(); this.removeAllFilters(); } }); // Add the listener to remove a single filter. this.filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => { if (e.target.closest(Selectors.filter.actions.remove)) { e.preventDefault(); this.removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region), true); } }); // Add listeners for the filter type selection. let filterRegion = jQuery(this.getFilterRegion()); CustomEvents.define(filterRegion, [CustomEvents.events.accessibleChange]); filterRegion.on(CustomEvents.events.accessibleChange, e => { const typeField = e.target.closest(Selectors.filter.fields.type); if (typeField && typeField.value) { const filter = e.target.closest(Selectors.filter.region); this.addFilter(filter, typeField.value); } }); this.filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => { this.filterSet.dataset.filterverb = e.target.value; }); } /** * Get the filter list region. * * @return {HTMLElement} */ getFilterRegion() { return this.filterSet.querySelector(Selectors.filterset.regions.filterlist); } /** * Add an unselected filter row. * * @return {Promise} */ addFilterRow() { const pendingPromise = new Pending('core/datafilter:addFilterRow'); const rownum = 1 + this.getFilterRegion().querySelectorAll(Selectors.filter.region).length; return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rownum}) .then(({html, js}) => { const newContentNodes = Templates.appendNodeContents(this.getFilterRegion(), html, js); return newContentNodes; }) .then(filterRow => { // Note: This is a nasty hack. // We should try to find a better way of doing this. // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy // it in place. const typeList = this.filterSet.querySelector(Selectors.data.typeList); filterRow.forEach(contentNode => { const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type); if (contentTypeList) { contentTypeList.innerHTML = typeList.innerHTML; } }); return filterRow; }) .then(filterRow => { this.updateFiltersOptions(); return filterRow; }) .then(result => { pendingPromise.resolve(); return result; }) .catch(Notification.exception); } /** * Get the filter data source node fro the specified filter type. * * @param {String} filterType * @return {HTMLElement} */ getFilterDataSource(filterType) { const filterDataNode = this.filterSet.querySelector(Selectors.filterset.regions.datasource); return filterDataNode.querySelector(Selectors.data.fields.byName(filterType)); } /** * Add a filter to the list of active filters, performing any necessary setup. * * @param {HTMLElement} filterRow * @param {String} filterType * @param {Array} initialFilterValues The initially selected values for the filter * @returns {Filter} */ async addFilter(filterRow, filterType, initialFilterValues) { // Name the filter on the filter row. filterRow.dataset.filterType = filterType; const filterDataNode = this.getFilterDataSource(filterType); // Instantiate the Filter class. let Filter = GenericFilter; if (filterDataNode.dataset.filterTypeClass) { Filter = await import(filterDataNode.dataset.filterTypeClass); } this.activeFilters[filterType] = new Filter(filterType, this.filterSet, initialFilterValues); // Disable the select. const typeField = filterRow.querySelector(Selectors.filter.fields.type); typeField.value = filterType; typeField.disabled = 'disabled'; // Update the list of available filter types. this.updateFiltersOptions(); return this.activeFilters[filterType]; } /** * Get the registered filter class for the named filter. * * @param {String} name * @return {Object} See the Filter class. */ getFilterObject(name) { return this.activeFilters[name]; } /** * Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row, * that it is replaced instead of being removed. * * @param {HTMLElement} filterRow * @param {Bool} refreshContent Whether to refresh the table content when removing */ removeOrReplaceFilterRow(filterRow, refreshContent) { const filterCount = this.getFilterRegion().querySelectorAll(Selectors.filter.region).length; if (filterCount === 1) { this.replaceFilterRow(filterRow, refreshContent); } else { this.removeFilterRow(filterRow, refreshContent); } } /** * Remove the specified filter row and associated class. * * @param {HTMLElement} filterRow * @param {Bool} refreshContent Whether to refresh the table content when removing */ async removeFilterRow(filterRow, refreshContent = true) { const filterType = filterRow.querySelector(Selectors.filter.fields.type); const hasFilterValue = !!filterType.value; // Remove the filter object. this.removeFilterObject(filterRow.dataset.filterType); // Remove the actual filter HTML. filterRow.remove(); // Update the list of available filter types. this.updateFiltersOptions(); if (hasFilterValue && refreshContent) { // Refresh the table if there was any content in this row. this.updateTableFromFilter(); } // Update filter fieldset legends. const filterLegends = await this.getAvailableFilterLegends(); this.getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => { filterRow.querySelector('legend').innerText = filterLegends[index]; }); } /** * Replace the specified filter row with a new one. * * @param {HTMLElement} filterRow * @param {Bool} refreshContent Whether to refresh the table content when removing * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter). * @return {Promise} */ replaceFilterRow(filterRow, refreshContent = true, rowNum = 1) { // Remove the filter object. this.removeFilterObject(filterRow.dataset.filterType); return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rowNum}) .then(({html, js}) => { const newContentNodes = Templates.replaceNode(filterRow, html, js); return newContentNodes; }) .then(filterRow => { // Note: This is a nasty hack. // We should try to find a better way of doing this. // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy // it in place. const typeList = this.filterSet.querySelector(Selectors.data.typeList); filterRow.forEach(contentNode => { const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type); if (contentTypeList) { contentTypeList.innerHTML = typeList.innerHTML; } }); return filterRow; }) .then(filterRow => { this.updateFiltersOptions(); return filterRow; }) .then(filterRow => { // Refresh the table. if (refreshContent) { return this.updateTableFromFilter(); } else { return filterRow; } }) .catch(Notification.exception); } /** * Remove the Filter Object from the register. * * @param {string} filterName The name of the filter to be removed */ removeFilterObject(filterName) { if (filterName) { const filter = this.getFilterObject(filterName); if (filter) { filter.tearDown(); // Remove from the list of active filters. delete this.activeFilters[filterName]; } } } /** * Remove all filters. * * @returns {Promise} */ removeAllFilters() { const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region); filters.forEach(filterRow => this.removeOrReplaceFilterRow(filterRow, false)); // Refresh the table. return this.updateTableFromFilter(); } /** * Remove any empty filters. */ removeEmptyFilters() { const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region); filters.forEach(filterRow => { const filterType = filterRow.querySelector(Selectors.filter.fields.type); if (!filterType.value) { this.removeOrReplaceFilterRow(filterRow, false); } }); } /** * Update the list of filter types to filter out those already selected. */ updateFiltersOptions() { const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region); filters.forEach(filterRow => { const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option'); options.forEach(option => { if (option.value === filterRow.dataset.filterType) { option.classList.remove('hidden'); option.disabled = false; } else if (this.activeFilters[option.value]) { option.classList.add('hidden'); option.disabled = true; } else { option.classList.remove('hidden'); option.disabled = false; } }); }); // Configure the state of the "Add row" button. // This button is disabled when there is a filter row available for each condition. const addRowButton = this.filterSet.querySelector(Selectors.filterset.actions.addRow); const filterDataNode = this.filterSet.querySelectorAll(Selectors.data.fields.all); if (filterDataNode.length <= filters.length) { addRowButton.setAttribute('disabled', 'disabled'); } else { addRowButton.removeAttribute('disabled'); } if (filters.length === 1) { this.filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.add('hidden'); this.filterSet.querySelector(Selectors.filterset.fields.join).value = 2; this.filterSet.dataset.filterverb = 2; } else { this.filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden'); } } /** * Update the Dynamic table based upon the current filter. */ updateTableFromFilter() { const pendingPromise = new Pending('core/datafilter:updateTableFromFilter'); const filters = {}; Object.values(this.activeFilters).forEach(filter => { filters[filter.filterValue.name] = filter.filterValue; }); if (this.applyCallback) { this.applyCallback(filters, pendingPromise); } } /** * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible. * * @return {array} */ async getAvailableFilterLegends() { const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1; let requests = []; [...Array(maxFilters)].forEach((_, rowIndex) => { requests.push({ "key": "filterrowlegend", "component": "core", // Add 1 since rows begin at 1 (index begins at zero). "param": rowIndex + 1 }); }); const legendStrings = await getStrings(requests) .then(fetchedStrings => { return fetchedStrings; }) .catch(Notification.exception); return legendStrings; } } tree.js 0000644 00000044622 15152050146 0006050 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Implement an accessible aria tree widget, from a nested unordered list. * Based on http://oaa-accessibility.org/example/41/. * * @module core/tree * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery'], function($) { // Private variables and functions. var SELECTORS = { ITEM: '[role=treeitem]', GROUP: '[role=treeitem]:has([role=group]), [role=treeitem][aria-owns], [role=treeitem][data-requires-ajax=true]', CLOSED_GROUP: '[role=treeitem]:has([role=group])[aria-expanded=false], [role=treeitem][aria-owns][aria-expanded=false], ' + '[role=treeitem][data-requires-ajax=true][aria-expanded=false]', FIRST_ITEM: '[role=treeitem]:first', VISIBLE_ITEM: '[role=treeitem]:visible', UNLOADED_AJAX_ITEM: '[role=treeitem][data-requires-ajax=true][data-loaded=false][aria-expanded=true]' }; /** * Constructor. * * @param {String} selector * @param {function} selectCallback Called when the active node is changed. */ var Tree = function(selector, selectCallback) { this.treeRoot = $(selector); this.treeRoot.data('activeItem', null); this.selectCallback = selectCallback; this.keys = { tab: 9, enter: 13, space: 32, pageup: 33, pagedown: 34, end: 35, home: 36, left: 37, up: 38, right: 39, down: 40, asterisk: 106 }; // Apply the standard default initialisation for all nodes, starting with the tree root. this.initialiseNodes(this.treeRoot); // Make the first item the active item for the tree so that it is added to the tab order. this.setActiveItem(this.treeRoot.find(SELECTORS.FIRST_ITEM)); // Create the cache of the visible items. this.refreshVisibleItemsCache(); // Create the event handlers for the tree. this.bindEventHandlers(); }; Tree.prototype.registerEnterCallback = function(callback) { this.enterCallback = callback; }; /** * Find all visible tree items and save a cache of them on the tree object. * * @method refreshVisibleItemsCache */ Tree.prototype.refreshVisibleItemsCache = function() { this.treeRoot.data('visibleItems', this.treeRoot.find(SELECTORS.VISIBLE_ITEM)); }; /** * Get all visible tree items. * * @method getVisibleItems * @return {Object} visible items */ Tree.prototype.getVisibleItems = function() { return this.treeRoot.data('visibleItems'); }; /** * Mark the given item as active within the tree and fire the callback for when the active item is set. * * @method setActiveItem * @param {object} item jquery object representing an item on the tree. */ Tree.prototype.setActiveItem = function(item) { var currentActive = this.treeRoot.data('activeItem'); if (item === currentActive) { return; } // Remove previous active from tab order. if (currentActive) { currentActive.attr('tabindex', '-1'); currentActive.attr('aria-selected', 'false'); } item.attr('tabindex', '0'); item.attr('aria-selected', 'true'); // Set the new active item. this.treeRoot.data('activeItem', item); if (typeof this.selectCallback === 'function') { this.selectCallback(item); } }; /** * Determines if the given item is a group item (contains child tree items) in the tree. * * @method isGroupItem * @param {object} item jquery object representing an item on the tree. * @returns {bool} */ Tree.prototype.isGroupItem = function(item) { return item.is(SELECTORS.GROUP); }; /** * Determines if the given item is a group item (contains child tree items) in the tree. * * @method isGroupItem * @param {object} item jquery object representing an item on the tree. * @returns {bool} */ Tree.prototype.getGroupFromItem = function(item) { var ariaowns = this.treeRoot.find('#' + item.attr('aria-owns')); var plain = item.children('[role=group]'); if (ariaowns.length > plain.length) { return ariaowns; } else { return plain; } }; /** * Determines if the given group item (contains child tree items) is collapsed. * * @method isGroupCollapsed * @param {object} item jquery object representing a group item on the tree. * @returns {bool} */ Tree.prototype.isGroupCollapsed = function(item) { return item.attr('aria-expanded') === 'false'; }; /** * Determines if the given group item (contains child tree items) can be collapsed. * * @method isGroupCollapsible * @param {object} item jquery object representing a group item on the tree. * @returns {bool} */ Tree.prototype.isGroupCollapsible = function(item) { return item.attr('data-collapsible') !== 'false'; }; /** * Performs the tree initialisation for all child items from the given node, * such as removing everything from the tab order and setting aria selected * on items. * * @method initialiseNodes * @param {object} node jquery object representing a node. */ Tree.prototype.initialiseNodes = function(node) { this.removeAllFromTabOrder(node); this.setAriaSelectedFalseOnItems(node); // Get all ajax nodes that have been rendered as expanded but haven't loaded the child items yet. var thisTree = this; node.find(SELECTORS.UNLOADED_AJAX_ITEM).each(function() { var unloadedNode = $(this); // Collapse and then expand to trigger the ajax loading. thisTree.collapseGroup(unloadedNode); thisTree.expandGroup(unloadedNode); }); }; /** * Removes all child DOM elements of the given node from the tab order. * * @method removeAllFromTabOrder * @param {object} node jquery object representing a node. */ Tree.prototype.removeAllFromTabOrder = function(node) { node.find('*').attr('tabindex', '-1'); this.getGroupFromItem($(node)).find('*').attr('tabindex', '-1'); }; /** * Find all child tree items from the given node and set the aria selected attribute to false. * * @method setAriaSelectedFalseOnItems * @param {object} node jquery object representing a node. */ Tree.prototype.setAriaSelectedFalseOnItems = function(node) { node.find(SELECTORS.ITEM).attr('aria-selected', 'false'); }; /** * Expand all group nodes within the tree. * * @method expandAllGroups */ Tree.prototype.expandAllGroups = function() { var thisTree = this; this.treeRoot.find(SELECTORS.CLOSED_GROUP).each(function() { var groupNode = $(this); thisTree.expandGroup($(this)).done(function() { thisTree.expandAllChildGroups(groupNode); }); }); }; /** * Find all child group nodes from the given node and expand them. * * @method expandAllChildGroups * @param {Object} item is the jquery id of the group. */ Tree.prototype.expandAllChildGroups = function(item) { var thisTree = this; this.getGroupFromItem(item).find(SELECTORS.CLOSED_GROUP).each(function() { var groupNode = $(this); thisTree.expandGroup($(this)).done(function() { thisTree.expandAllChildGroups(groupNode); }); }); }; /** * Expand a collapsed group. * * Handles expanding nodes that are ajax loaded (marked with a data-requires-ajax attribute). * * @method expandGroup * @param {Object} item is the jquery id of the parent item of the group. * @return {Object} a promise that is resolved when the group has been expanded. */ Tree.prototype.expandGroup = function(item) { var promise = $.Deferred(); // Ignore nodes that are explicitly maked as not expandable or are already expanded. if (item.attr('data-expandable') !== 'false' && this.isGroupCollapsed(item)) { // If this node requires ajax load and we haven't already loaded it. if (item.attr('data-requires-ajax') === 'true' && item.attr('data-loaded') !== 'true') { item.attr('data-loaded', false); // Get the closes ajax loading module specificed in the tree. var moduleName = item.closest('[data-ajax-loader]').attr('data-ajax-loader'); var thisTree = this; // Flag this node as loading. const p = item.find('p'); p.addClass('loading'); // Require the ajax module (must be AMD) and try to load the items. require([moduleName], function(loader) { // All ajax module must implement a "load" method. loader.load(item).done(function() { item.attr('data-loaded', true); // Set defaults on the newly constructed part of the tree. thisTree.initialiseNodes(item); thisTree.finishExpandingGroup(item); // Make sure no child elements of the item we just loaded are tabbable. p.removeClass('loading'); promise.resolve(); }); }); } else { this.finishExpandingGroup(item); promise.resolve(); } } else { promise.resolve(); } return promise; }; /** * Perform the necessary DOM changes to display a group item. * * @method finishExpandingGroup * @param {Object} item is the jquery id of the parent item of the group. */ Tree.prototype.finishExpandingGroup = function(item) { // Expand the group. var group = this.getGroupFromItem(item); group.removeAttr('aria-hidden'); item.attr('aria-expanded', 'true'); // Update the list of visible items. this.refreshVisibleItemsCache(); }; /** * Collapse an expanded group. * * @method collapseGroup * @param {Object} item is the jquery id of the parent item of the group. */ Tree.prototype.collapseGroup = function(item) { // If the item is not collapsible or already collapsed then do nothing. if (!this.isGroupCollapsible(item) || this.isGroupCollapsed(item)) { return; } // Collapse the group. var group = this.getGroupFromItem(item); group.attr('aria-hidden', 'true'); item.attr('aria-expanded', 'false'); // Update the list of visible items. this.refreshVisibleItemsCache(); }; /** * Expand or collapse a group. * * @method toggleGroup * @param {Object} item is the jquery id of the parent item of the group. */ Tree.prototype.toggleGroup = function(item) { if (item.attr('aria-expanded') === 'true') { this.collapseGroup(item); } else { this.expandGroup(item); } }; /** * Handle a key down event - ie navigate the tree. * * @method handleKeyDown * @param {Event} e The event. */ // This function should be simplified. In the meantime.. // eslint-disable-next-line complexity Tree.prototype.handleKeyDown = function(e) { var item = $(e.target); var currentIndex = this.getVisibleItems()?.index(item); if ((e.altKey || e.ctrlKey || e.metaKey) || (e.shiftKey && e.keyCode != this.keys.tab)) { // Do nothing. return; } switch (e.keyCode) { case this.keys.home: { // Jump to first item in tree. this.getVisibleItems().first().focus(); e.preventDefault(); return; } case this.keys.end: { // Jump to last visible item. this.getVisibleItems().last().focus(); e.preventDefault(); return; } case this.keys.enter: { var links = item.children('a').length ? item.children('a') : item.children().not(SELECTORS.GROUP).find('a'); if (links.length) { if (links.first().data('overrides-tree-activation-key-handler')) { // If the link overrides handling of activation keys, let it do so. links.first().triggerHandler(e); } else if (typeof this.enterCallback === 'function') { // Use callback if there is one. this.enterCallback(item); } else { window.location.href = links.first().attr('href'); } } else if (this.isGroupItem(item)) { this.toggleGroup(item, true); } e.preventDefault(); return; } case this.keys.space: { if (this.isGroupItem(item)) { this.toggleGroup(item, true); } else if (item.children('a').length) { var firstLink = item.children('a').first(); if (firstLink.data('overrides-tree-activation-key-handler')) { firstLink.triggerHandler(e); } } e.preventDefault(); return; } case this.keys.left: { var focusParent = function(tree) { // Get the immediate visible parent group item that contains this element. tree.getVisibleItems().filter(function() { return tree.getGroupFromItem($(this)).has(item).length; }).focus(); }; // If this is a group item then collapse it and focus the parent group // in accordance with the aria spec. if (this.isGroupItem(item)) { if (this.isGroupCollapsed(item)) { focusParent(this); } else { this.collapseGroup(item); } } else { focusParent(this); } e.preventDefault(); return; } case this.keys.right: { // If this is a group item then expand it and focus the first child item // in accordance with the aria spec. if (this.isGroupItem(item)) { if (this.isGroupCollapsed(item)) { this.expandGroup(item); } else { // Move to the first item in the child group. this.getGroupFromItem(item).find(SELECTORS.ITEM).first().focus(); } } e.preventDefault(); return; } case this.keys.up: { if (currentIndex > 0) { var prev = this.getVisibleItems().eq(currentIndex - 1); prev.focus(); } e.preventDefault(); return; } case this.keys.down: { if (currentIndex < this.getVisibleItems().length - 1) { var next = this.getVisibleItems().eq(currentIndex + 1); next.focus(); } e.preventDefault(); return; } case this.keys.asterisk: { // Expand all groups. this.expandAllGroups(); e.preventDefault(); return; } } }; /** * Handle an item click. * * @param {Event} event the click event * @param {jQuery} item the item clicked */ Tree.prototype.handleItemClick = function(event, item) { // Update the active item. item.focus(); // If the item is a group node. if (this.isGroupItem(item)) { this.toggleGroup(item); } }; /** * Handle a click (select). * * @method handleClick * @param {Event} event The event. */ Tree.prototype.handleClick = function(event) { if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) { // Do nothing. return; } // Get the closest tree item from the event target. var item = $(event.target).closest('[role="treeitem"]'); if (!item.is(event.currentTarget)) { return; } this.handleItemClick(event, item); }; /** * Handle a focus event. * * @method handleFocus * @param {Event} e The event. */ Tree.prototype.handleFocus = function(e) { this.setActiveItem($(e.target)); }; /** * Bind the event listeners we require. * * @method bindEventHandlers */ Tree.prototype.bindEventHandlers = function() { // Bind event handlers to the tree items. Use event delegates to allow // for dynamically loaded parts of the tree. this.treeRoot.on({ click: this.handleClick.bind(this), keydown: this.handleKeyDown.bind(this), focus: this.handleFocus.bind(this), }, SELECTORS.ITEM); }; return /** @alias module:core/tree */ Tree; }); chart_base.js 0000644 00000026556 15152050146 0007212 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart base. * * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @module core/chart_base */ define(['core/chart_series', 'core/chart_axis'], function(Series, Axis) { /** * Chart base. * * The constructor of a chart must never take any argument. * * {@link module:core/chart_base#_setDefault} to set the defaults on instantiation. * * @class */ function Base() { this._series = []; this._labels = []; this._xaxes = []; this._yaxes = []; this._setDefaults(); } /** * The series constituting this chart. * * @protected * @type {module:core/chart_series[]} */ Base.prototype._series = null; /** * The labels of the X axis when categorised. * * @protected * @type {String[]} */ Base.prototype._labels = null; /** * Options for chart legend display. * * @protected * @type {Object} */ Base.prototype._legendOptions = null; /** * The title of the chart. * * @protected * @type {String} */ Base.prototype._title = null; /** * The X axes. * * @protected * @type {module:core/chart_axis[]} */ Base.prototype._xaxes = null; /** * The Y axes. * * @protected * @type {module:core/chart_axis[]} */ Base.prototype._yaxes = null; /** * Colours to pick from when automatically assigning them. * * @const * @type {String[]} */ Base.prototype.COLORSET = ['#f3c300', '#875692', '#f38400', '#a1caf1', '#be0032', '#c2b280', '#7f180d', '#008856', '#e68fac', '#0067a5']; /** * Set of colours defined by setting $CFG->chart_colorset to be picked when automatically assigning them. * * @type {String[]} * @protected */ Base.prototype._configColorSet = null; /** * The type of chart. * * @abstract * @type {String} * @const */ Base.prototype.TYPE = null; /** * Add a series to the chart. * * This will automatically assign a color to the series if it does not have one. * * @param {module:core/chart_series} series The series to add. */ Base.prototype.addSeries = function(series) { this._validateSeries(series); this._series.push(series); // Give a default color from the set. if (series.getColor() === null) { var configColorSet = this.getConfigColorSet() || Base.prototype.COLORSET; series.setColor(configColorSet[this._series.length % configColorSet.length]); } }; /** * Create a new instance of a chart from serialised data. * * the serialised attributes they offer and support. * * @static * @method create * @param {module:core/chart_base} Klass The class oject representing the type of chart to instantiate. * @param {Object} data The data of the chart. * @return {module:core/chart_base} */ Base.prototype.create = function(Klass, data) { // TODO Not convinced about the usage of Klass here but I can't figure out a way // to have a reference to the class in the sub classes, in PHP I'd do new self(). var Chart = new Klass(); Chart.setConfigColorSet(data.config_colorset); Chart.setLabels(data.labels); Chart.setTitle(data.title); if (data.legend_options) { Chart.setLegendOptions(data.legend_options); } data.series.forEach(function(seriesData) { Chart.addSeries(Series.prototype.create(seriesData)); }); data.axes.x.forEach(function(axisData, i) { Chart.setXAxis(Axis.prototype.create(axisData), i); }); data.axes.y.forEach(function(axisData, i) { Chart.setYAxis(Axis.prototype.create(axisData), i); }); return Chart; }; /** * Get an axis. * * @private * @param {String} xy Accepts the values 'x' or 'y'. * @param {Number} [index=0] The index of the axis of its type. * @param {Bool} [createIfNotExists=false] When true, create an instance if it does not exist. * @return {module:core/chart_axis} */ Base.prototype.__getAxis = function(xy, index, createIfNotExists) { var axes = xy === 'x' ? this._xaxes : this._yaxes, setAxis = (xy === 'x' ? this.setXAxis : this.setYAxis).bind(this), axis; index = typeof index === 'undefined' ? 0 : index; createIfNotExists = typeof createIfNotExists === 'undefined' ? false : createIfNotExists; axis = axes[index]; if (typeof axis === 'undefined') { if (!createIfNotExists) { throw new Error('Unknown axis.'); } axis = new Axis(); setAxis(axis, index); } return axis; }; /** * Get colours defined by setting. * * @return {String[]} */ Base.prototype.getConfigColorSet = function() { return this._configColorSet; }; /** * Get the labels of the X axis. * * @return {String[]} */ Base.prototype.getLabels = function() { return this._labels; }; /** * Get whether to display the chart legend. * * @return {Bool} */ Base.prototype.getLegendOptions = function() { return this._legendOptions; }; /** * Get the series. * * @return {module:core/chart_series[]} */ Base.prototype.getSeries = function() { return this._series; }; /** * Get the title of the chart. * * @return {String} */ Base.prototype.getTitle = function() { return this._title; }; /** * Get the type of chart. * * @see module:core/chart_base#TYPE * @return {String} */ Base.prototype.getType = function() { if (!this.TYPE) { throw new Error('The TYPE property has not been set.'); } return this.TYPE; }; /** * Get the X axes. * * @return {module:core/chart_axis[]} */ Base.prototype.getXAxes = function() { return this._xaxes; }; /** * Get an X axis. * * @param {Number} [index=0] The index of the axis. * @param {Bool} [createIfNotExists=false] Create the instance of it does not exist at index. * @return {module:core/chart_axis} */ Base.prototype.getXAxis = function(index, createIfNotExists) { return this.__getAxis('x', index, createIfNotExists); }; /** * Get the Y axes. * * @return {module:core/chart_axis[]} */ Base.prototype.getYAxes = function() { return this._yaxes; }; /** * Get an Y axis. * * @param {Number} [index=0] The index of the axis. * @param {Bool} [createIfNotExists=false] Create the instance of it does not exist at index. * @return {module:core/chart_axis} */ Base.prototype.getYAxis = function(index, createIfNotExists) { return this.__getAxis('y', index, createIfNotExists); }; /** * Set colours defined by setting. * * @param {String[]} colorset An array of css colours. * @protected */ Base.prototype.setConfigColorSet = function(colorset) { this._configColorSet = colorset; }; /** * Set the defaults for this chart type. * * Child classes can extend this to set defaults values on instantiation. * * emphasize and self-document the defaults values set by the chart type. * * @protected */ Base.prototype._setDefaults = function() { // For the children to extend. }; /** * Set the labels of the X axis. * * This requires for each series to contain strictly as many values as there * are labels. * * @param {String[]} labels The labels. */ Base.prototype.setLabels = function(labels) { if (labels.length && this._series.length && this._series[0].length != labels.length) { throw new Error('Series must match label values.'); } this._labels = labels; }; /** * Set options for chart legend display. * * @param {Object} legendOptions */ Base.prototype.setLegendOptions = function(legendOptions) { if (typeof legendOptions !== 'object') { throw new Error('Setting legend with non-object value:' + legendOptions); } this._legendOptions = legendOptions; }; /** * Set the title of the chart. * * @param {String} title The title. */ Base.prototype.setTitle = function(title) { this._title = title; }; /** * Set an X axis. * * Note that this will override any predefined axis without warning. * * @param {module:core/chart_axis} axis The axis. * @param {Number} [index=0] The index of the axis. */ Base.prototype.setXAxis = function(axis, index) { index = typeof index === 'undefined' ? 0 : index; this._validateAxis('x', axis, index); this._xaxes[index] = axis; }; /** * Set a Y axis. * * Note that this will override any predefined axis without warning. * * @param {module:core/chart_axis} axis The axis. * @param {Number} [index=0] The index of the axis. */ Base.prototype.setYAxis = function(axis, index) { index = typeof index === 'undefined' ? 0 : index; this._validateAxis('y', axis, index); this._yaxes[index] = axis; }; /** * Validate an axis. * * @protected * @param {String} xy X or Y axis. * @param {module:core/chart_axis} axis The axis to validate. * @param {Number} [index=0] The index of the axis. */ Base.prototype._validateAxis = function(xy, axis, index) { index = typeof index === 'undefined' ? 0 : index; if (index > 0) { var axes = xy == 'x' ? this._xaxes : this._yaxes; if (typeof axes[index - 1] === 'undefined') { throw new Error('Missing ' + xy + ' axis at index lower than ' + index); } } }; /** * Validate a series. * * @protected * @param {module:core/chart_series} series The series to validate. */ Base.prototype._validateSeries = function(series) { if (this._series.length && this._series[0].getCount() != series.getCount()) { throw new Error('Series do not have an equal number of values.'); } else if (this._labels.length && this._labels.length != series.getCount()) { throw new Error('Series must match label values.'); } }; return Base; }); config.js 0000644 00000001752 15152050146 0006353 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Expose the M.cfg global variable. * * @module core/config * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 2.9 */ define(function() { // This module exposes only the raw data from M.cfg; return M.cfg; }); chart_series.js 0000644 00000020544 15152050146 0007561 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart series. * * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @module core/chart_series */ define([], function() { /** * Chart data series. * * @class * @param {String} label The series label. * @param {Number[]} values The values. */ function Series(label, values) { if (typeof label !== 'string') { throw new Error('Invalid label for series.'); } else if (typeof values !== 'object') { throw new Error('Values for a series must be an array.'); } else if (values.length < 1) { throw new Error('Invalid values received for series.'); } this._colors = []; this._label = label; this._values = values; } /** * The default type of series. * * @type {Null} * @const */ Series.prototype.TYPE_DEFAULT = null; /** * Type of series 'line'. * * @type {String} * @const */ Series.prototype.TYPE_LINE = 'line'; /** * The colors of the series. * * @type {String[]} * @protected */ Series.prototype._colors = null; /** * The fill mode of the series. * * @type {Object} * @protected */ Series.prototype._fill = false; /** * The label of the series. * * @type {String} * @protected */ Series.prototype._label = null; /** * The labels for the values of the series. * * @type {String[]} * @protected */ Series.prototype._labels = null; /** * Whether the line of the serie should be smooth or not. * * @type {Bool} * @protected */ Series.prototype._smooth = false; /** * The type of the series. * * @type {String} * @protected */ Series.prototype._type = Series.prototype.TYPE_DEFAULT; /** * The values in the series. * * @type {Number[]} * @protected */ Series.prototype._values = null; /** * The index of the X axis. * * @type {Number[]} * @protected */ Series.prototype._xaxis = null; /** * The index of the Y axis. * * @type {Number[]} * @protected */ Series.prototype._yaxis = null; /** * Create a new instance of a series from serialised data. * * @static * @method create * @param {Object} obj The data of the series. * @return {module:core/chart_series} */ Series.prototype.create = function(obj) { var s = new Series(obj.label, obj.values); s.setType(obj.type); s.setXAxis(obj.axes.x); s.setYAxis(obj.axes.y); s.setLabels(obj.labels); // Colors are exported as an array with 1, or n values. if (obj.colors && obj.colors.length > 1) { s.setColors(obj.colors); } else { s.setColor(obj.colors[0]); } s.setFill(obj.fill); s.setSmooth(obj.smooth); return s; }; /** * Get the color. * * @return {String} */ Series.prototype.getColor = function() { return this._colors[0] || null; }; /** * Get the colors for each value in the series. * * @return {String[]} */ Series.prototype.getColors = function() { return this._colors; }; /** * Get the number of values in the series. * * @return {Number} */ Series.prototype.getCount = function() { return this._values.length; }; /** * Get the fill mode of the series. * * @return {Object} */ Series.prototype.getFill = function() { return this._fill; }; /** * Get the series label. * * @return {String} */ Series.prototype.getLabel = function() { return this._label; }; /** * Get labels for the values of the series. * * @return {String[]} */ Series.prototype.getLabels = function() { return this._labels; }; /** * Get whether the line of the serie should be smooth or not. * * @returns {Bool} */ Series.prototype.getSmooth = function() { return this._smooth; }; /** * Get the series type. * * @return {String} */ Series.prototype.getType = function() { return this._type; }; /** * Get the series values. * * @return {Number[]} */ Series.prototype.getValues = function() { return this._values; }; /** * Get the index of the X axis. * * @return {Number} */ Series.prototype.getXAxis = function() { return this._xaxis; }; /** * Get the index of the Y axis. * * @return {Number} */ Series.prototype.getYAxis = function() { return this._yaxis; }; /** * Whether there is a color per value. * * @return {Bool} */ Series.prototype.hasColoredValues = function() { return this._colors.length == this.getCount(); }; /** * Set the series color. * * @param {String} color A CSS-compatible color. */ Series.prototype.setColor = function(color) { this._colors = [color]; }; /** * Set a color for each value in the series. * * @param {String[]} colors CSS-compatible colors. */ Series.prototype.setColors = function(colors) { if (colors && colors.length != this.getCount()) { throw new Error('When setting multiple colors there must be one per value.'); } this._colors = colors || []; }; /** * Set the fill mode for the series. * * @param {Object} fill */ Series.prototype.setFill = function(fill) { this._fill = typeof fill === 'undefined' ? null : fill; }; /** * Set the labels for the values of the series. * * @param {String[]} labels the labels of the series values. */ Series.prototype.setLabels = function(labels) { this._validateLabels(labels); labels = typeof labels === 'undefined' ? null : labels; this._labels = labels; }; /** * Set Whether the line of the serie should be smooth or not. * * Only applicable for line chart or a line series, if null it assumes the chart default (not smooth). * * @param {Bool} smooth True if the lines should be smooth, false for tensioned lines. */ Series.prototype.setSmooth = function(smooth) { smooth = typeof smooth === 'undefined' ? null : smooth; this._smooth = smooth; }; /** * Set the type of the series. * * @param {String} type A type constant value. */ Series.prototype.setType = function(type) { if (type != this.TYPE_DEFAULT && type != this.TYPE_LINE) { throw new Error('Invalid serie type.'); } this._type = type || null; }; /** * Set the index of the X axis. * * @param {Number} index The index. */ Series.prototype.setXAxis = function(index) { this._xaxis = index || null; }; /** * Set the index of the Y axis. * * @param {Number} index The index. */ Series.prototype.setYAxis = function(index) { this._yaxis = index || null; }; /** * Validate series labels. * * @protected * @param {String[]} labels The labels of the serie. */ Series.prototype._validateLabels = function(labels) { if (labels && labels.length > 0 && labels.length != this.getCount()) { throw new Error('Series labels must match series values.'); } }; return Series; }); str.js 0000644 00000017530 15152050146 0005717 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Fetch and return language strings. * * @module core/str * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 2.9 * */ import $ from 'jquery'; import Ajax from 'core/ajax'; import LocalStorage from 'core/localstorage'; // Module cache for the promises so that we don't make multiple // unnecessary requests. let promiseCache = []; /* eslint-disable no-restricted-properties */ /** * Return a Promise that resolves to a string. * * If the string has previously been cached, then the Promise will be resolved immediately, otherwise it will be fetched * from the server and resolved when available. * * @method get_string * @param {string} key The language string key * @param {string} component The language string component * @param {string} param The param for variable expansion in the string. * @param {string} lang The users language - if not passed it is deduced. * @return {Promise} * * @example <caption>Fetching a string</caption> * * import {get_string as getString} from 'core/str'; * get_string('cannotfindteacher', 'error') * .then(str => { * window.console.log(str); // Cannot find teacher * }) * .catch(); */ // eslint-disable-next-line camelcase export const get_string = (key, component, param, lang) => { return get_strings([{key, component, param, lang}]) .then(results => results[0]); }; /** * Make a batch request to load a set of strings. * * Any missing string will be fetched from the server. * The Promise will only be resolved once all strings are available, or an attempt has been made to fetch them. * * @method get_strings * @param {Object[]} requests List of strings to fetch * @param {string} requests.key The string identifer to fetch * @param {string} [requests.component='core'] The componet to fetch from * @param {string} [requests.lang] The language to fetch a string for. Defaults to current page language. * @param {object|string} [requests.param] The param for variable expansion in the string. * @return {Promise[]} * * @example <caption>Fetching a set of strings</caption> * * import {get_strings as getStrings} from 'core/str'; * get_strings([ * { * key: 'cannotfindteacher', * component: 'error', * }, * { * key: 'yes', * component: 'core', * }, * { * key: 'no', * component: 'core', * }, * ]) * .then((cannotFindTeacher, yes, no) => { * window.console.log(cannotFindTeacher); // Cannot find teacher * window.console.log(yes); // Yes * window.console.log(no); // No * }) * .catch(); */ // eslint-disable-next-line camelcase export const get_strings = (requests) => { let requestData = []; const pageLang = $('html').attr('lang').replace(/-/g, '_'); // Helper function to construct the cache key. const getCacheKey = ({key, component, lang = pageLang}) => `core_str/${key}/${component}/${lang}`; const stringPromises = requests.map((request) => { let {component, key, param, lang = pageLang} = request; if (!component) { component = 'core'; } const cacheKey = getCacheKey({key, component, lang}); // Helper function to add the promise to cache. const buildReturn = (promise) => { // Make sure the promise cache contains our promise. promiseCache[cacheKey] = promise; return promise; }; // Check if we can serve the string straight from M.str. if (component in M.str && key in M.str[component]) { return buildReturn(new Promise((resolve) => { resolve(M.util.get_string(key, component, param, lang)); })); } // Check if the string is in the browser's local storage. const cached = LocalStorage.get(cacheKey); if (cached) { M.str[component] = {...M.str[component], [key]: cached}; return buildReturn(new Promise((resolve) => { resolve(M.util.get_string(key, component, param, lang)); })); } // Check if we've already loaded this string from the server. if (cacheKey in promiseCache) { return buildReturn(promiseCache[cacheKey]).then(() => { return M.util.get_string(key, component, param, lang); }); } else { // We're going to have to ask the server for the string so // add this string to the list of requests to be sent. return buildReturn(new Promise((resolve, reject) => { requestData.push({ methodname: 'core_get_string', args: { stringid: key, stringparams: [], component, lang, }, done: (str) => { // When we get the response from the server // we should update M.str and the browser's // local storage before resolving this promise. M.str[component] = {...M.str[component], [key]: str}; LocalStorage.set(cacheKey, str); resolve(M.util.get_string(key, component, param, lang)); }, fail: reject }); })); } }); if (requestData.length) { // If we need to load any strings from the server then send // off the request. Ajax.call(requestData, true, false, false, 0, M.cfg.langrev); } // We need to use jQuery here because some calling code uses the // .done handler instead of the .then handler. return $.when.apply($, stringPromises) .then((...strings) => strings); }; /** * Add a list of strings to the caches. * * This function should typically only be called from core APIs to pre-cache values. * * @method cache_strings * @protected * @param {Object[]} strings List of strings to fetch * @param {string} strings.key The string identifer to fetch * @param {string} strings.value The string value * @param {string} [strings.component='core'] The componet to fetch from * @param {string} [strings.lang] The language to fetch a string for. Defaults to current page language. */ // eslint-disable-next-line camelcase export const cache_strings = (strings) => { const defaultLang = $('html').attr('lang').replace(/-/g, '_'); strings.forEach(({key, component, value, lang = defaultLang}) => { const cacheKey = ['core_str', key, component, lang].join('/'); // Check M.str caching. if (!(component in M.str) || !(key in M.str[component])) { if (!(component in M.str)) { M.str[component] = {}; } M.str[component][key] = value; } // Check local storage. if (!LocalStorage.get(cacheKey)) { LocalStorage.set(cacheKey, value); } // Check the promises cache. if (!(cacheKey in promiseCache)) { promiseCache[cacheKey] = $.Deferred().resolve(value).promise(); } }); }; /* eslint-enable no-restricted-properties */ mustache.js 0000644 00000071374 15152050146 0006726 0 ustar 00 // The MIT License // // Copyright (c) 2009 Chris Wanstrath (Ruby) // Copyright (c) 2010-2014 Jan Lehnardt (JavaScript) // Copyright (c) 2010-2015 The mustache.js community // // Permission is hereby granted, free of charge, to any person obtaining // a copy of this software and associated documentation files (the // "Software"), to deal in the Software without restriction, including // without limitation the rights to use, copy, modify, merge, publish, // distribute, sublicense, and/or sell copies of the Software, and to // permit persons to whom the Software is furnished to do so, subject to // the following conditions: // // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. // IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY // CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, // TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // // Description of import into Moodle: // Checkout from https://github.com/moodle/custom-mustache.js Branch: LAMBDA_ARGS (see note below) // Rebase onto latest release tag from https://github.com/janl/mustache.js // Copy mustache.js into lib/amd/src/ in Moodle folder. // Add the license as a comment to the file and these instructions. // Make sure that you have not removed the custom code for '$' and '<'. // Run unit tests. // NOTE: // Check if pull request from branch lambdaUpgrade420 has been accepted // by moodle/custom-mustache.js repo. If not, create one and use lambdaUpgrade420 // as your branch in place of LAMBDA_ARGS. /*! * mustache.js - Logic-less {{mustache}} templates with JavaScript * http://github.com/janl/mustache.js */ var objectToString = Object.prototype.toString; var isArray = Array.isArray || function isArrayPolyfill (object) { return objectToString.call(object) === '[object Array]'; }; function isFunction (object) { return typeof object === 'function'; } /** * More correct typeof string handling array * which normally returns typeof 'object' */ function typeStr (obj) { return isArray(obj) ? 'array' : typeof obj; } function escapeRegExp (string) { return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&'); } /** * Null safe way of checking whether or not an object, * including its prototype, has a given property */ function hasProperty (obj, propName) { return obj != null && typeof obj === 'object' && (propName in obj); } /** * Safe way of detecting whether or not the given thing is a primitive and * whether it has the given property */ function primitiveHasOwnProperty (primitive, propName) { return ( primitive != null && typeof primitive !== 'object' && primitive.hasOwnProperty && primitive.hasOwnProperty(propName) ); } // Workaround for https://issues.apache.org/jira/browse/COUCHDB-577 // See https://github.com/janl/mustache.js/issues/189 var regExpTest = RegExp.prototype.test; function testRegExp (re, string) { return regExpTest.call(re, string); } var nonSpaceRe = /\S/; function isWhitespace (string) { return !testRegExp(nonSpaceRe, string); } var entityMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', '`': '`', '=': '=' }; function escapeHtml (string) { return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) { return entityMap[s]; }); } var whiteRe = /\s*/; var spaceRe = /\s+/; var equalsRe = /\s*=/; var curlyRe = /\s*\}/; var tagRe = /#|\^|\/|>|\{|&|=|!|\$|</; /** * Breaks up the given `template` string into a tree of tokens. If the `tags` * argument is given here it must be an array with two string values: the * opening and closing tags used in the template (e.g. [ "<%", "%>" ]). Of * course, the default is to use mustaches (i.e. mustache.tags). * * A token is an array with at least 4 elements. The first element is the * mustache symbol that was used inside the tag, e.g. "#" or "&". If the tag * did not contain a symbol (i.e. {{myValue}}) this element is "name". For * all text that appears outside a symbol this element is "text". * * The second element of a token is its "value". For mustache tags this is * whatever else was inside the tag besides the opening symbol. For text tokens * this is the text itself. * * The third and fourth elements of the token are the start and end indices, * respectively, of the token in the original template. * * Tokens that are the root node of a subtree contain two more elements: 1) an * array of tokens in the subtree and 2) the index in the original template at * which the closing tag for that section begins. * * Tokens for partials also contain two more elements: 1) a string value of * indendation prior to that tag and 2) the index of that tag on that line - * eg a value of 2 indicates the partial is the third tag on this line. */ function parseTemplate (template, tags) { if (!template) return []; var lineHasNonSpace = false; var sections = []; // Stack to hold section tokens var tokens = []; // Buffer to hold the tokens var spaces = []; // Indices of whitespace tokens on the current line var hasTag = false; // Is there a {{tag}} on the current line? var nonSpace = false; // Is there a non-space char on the current line? var indentation = ''; // Tracks indentation for tags that use it var tagIndex = 0; // Stores a count of number of tags encountered on a line // Strips all whitespace tokens array for the current line // if there was a {{#tag}} on it and otherwise only space. function stripSpace () { if (hasTag && !nonSpace) { while (spaces.length) delete tokens[spaces.pop()]; } else { spaces = []; } hasTag = false; nonSpace = false; } var openingTagRe, closingTagRe, closingCurlyRe; function compileTags (tagsToCompile) { if (typeof tagsToCompile === 'string') tagsToCompile = tagsToCompile.split(spaceRe, 2); if (!isArray(tagsToCompile) || tagsToCompile.length !== 2) throw new Error('Invalid tags: ' + tagsToCompile); openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + '\\s*'); closingTagRe = new RegExp('\\s*' + escapeRegExp(tagsToCompile[1])); closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tagsToCompile[1])); } compileTags(tags || mustache.tags); var scanner = new Scanner(template); var start, type, value, chr, token, openSection, tagName, endTagName; while (!scanner.eos()) { start = scanner.pos; // Match any text between tags. value = scanner.scanUntil(openingTagRe); if (value) { for (var i = 0, valueLength = value.length; i < valueLength; ++i) { chr = value.charAt(i); if (isWhitespace(chr)) { spaces.push(tokens.length); indentation += chr; } else { nonSpace = true; lineHasNonSpace = true; indentation += ' '; } tokens.push([ 'text', chr, start, start + 1 ]); start += 1; // Check for whitespace on the current line. if (chr === '\n') { stripSpace(); indentation = ''; tagIndex = 0; lineHasNonSpace = false; } } } // Match the opening tag. if (!scanner.scan(openingTagRe)) break; hasTag = true; // Get the tag type. type = scanner.scan(tagRe) || 'name'; scanner.scan(whiteRe); // Get the tag value. if (type === '=') { value = scanner.scanUntil(equalsRe); scanner.scan(equalsRe); scanner.scanUntil(closingTagRe); } else if (type === '{') { value = scanner.scanUntil(closingCurlyRe); scanner.scan(curlyRe); scanner.scanUntil(closingTagRe); type = '&'; } else { value = scanner.scanUntil(closingTagRe); } // Match the closing tag. if (!scanner.scan(closingTagRe)) throw new Error('Unclosed tag at ' + scanner.pos); if (type == '>') { token = [ type, value, start, scanner.pos, indentation, tagIndex, lineHasNonSpace ]; } else { token = [ type, value, start, scanner.pos ]; } tagIndex++; tokens.push(token); if (type === '#' || type === '^' || type === '$' || type === '<') { sections.push(token); } else if (type === '/') { // Check section nesting. openSection = sections.pop(); if (!openSection) throw new Error('Unopened section "' + value + '" at ' + start); tagName = openSection[1].split(' ', 1)[0]; endTagName = value.split(' ', 1)[0]; if (tagName !== endTagName) throw new Error('Unclosed section "' + tagName + '" at ' + start); } else if (type === 'name' || type === '{' || type === '&') { nonSpace = true; } else if (type === '=') { // Set the tags for the next time around. compileTags(value); } } stripSpace(); // Make sure there are no open sections when we're done. openSection = sections.pop(); if (openSection) throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos); return nestTokens(squashTokens(tokens)); } /** * Combines the values of consecutive text tokens in the given `tokens` array * to a single token. */ function squashTokens (tokens) { var squashedTokens = []; var token, lastToken; for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { token = tokens[i]; if (token) { if (token[0] === 'text' && lastToken && lastToken[0] === 'text') { lastToken[1] += token[1]; lastToken[3] = token[3]; } else { squashedTokens.push(token); lastToken = token; } } } return squashedTokens; } /** * Forms the given array of `tokens` into a nested tree structure where * tokens that represent a section have two additional items: 1) an array of * all tokens that appear in that section and 2) the index in the original * template that represents the end of that section. */ function nestTokens (tokens) { var nestedTokens = []; var collector = nestedTokens; var sections = []; var token, section; for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { token = tokens[i]; switch (token[0]) { case '$': case '<': case '#': case '^': collector.push(token); sections.push(token); collector = token[4] = []; break; case '/': section = sections.pop(); section[5] = token[2]; collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens; break; default: collector.push(token); } } return nestedTokens; } /** * A simple string scanner that is used by the template parser to find * tokens in template strings. */ function Scanner (string) { this.string = string; this.tail = string; this.pos = 0; } /** * Returns `true` if the tail is empty (end of string). */ Scanner.prototype.eos = function eos () { return this.tail === ''; }; /** * Tries to match the given regular expression at the current position. * Returns the matched text if it can match, the empty string otherwise. */ Scanner.prototype.scan = function scan (re) { var match = this.tail.match(re); if (!match || match.index !== 0) return ''; var string = match[0]; this.tail = this.tail.substring(string.length); this.pos += string.length; return string; }; /** * Skips all text until the given regular expression can be matched. Returns * the skipped string, which is the entire tail if no match can be made. */ Scanner.prototype.scanUntil = function scanUntil (re) { var index = this.tail.search(re), match; switch (index) { case -1: match = this.tail; this.tail = ''; break; case 0: match = ''; break; default: match = this.tail.substring(0, index); this.tail = this.tail.substring(index); } this.pos += match.length; return match; }; /** * Represents a rendering context by wrapping a view object and * maintaining a reference to the parent context. */ function Context (view, parentContext) { this.view = view; this.blocks = {}; this.cache = { '.': this.view }; this.parent = parentContext; } /** * Creates a new context using the given view with this context * as the parent. */ Context.prototype.push = function push (view) { return new Context(view, this); }; /** * Set a value in the current block context. */ Context.prototype.setBlockVar = function set (name, value) { var blocks = this.blocks; blocks[name] = value; return value; }; /** * Clear all current block vars. */ Context.prototype.clearBlockVars = function clearBlockVars () { this.blocks = {}; }; /** * Get a value only from the current block context. */ Context.prototype.getBlockVar = function getBlockVar (name) { var blocks = this.blocks; var value; if (blocks.hasOwnProperty(name)) { value = blocks[name]; } else { if (this.parent) { value = this.parent.getBlockVar(name); } } // Can return undefined. return value; }; /** * Parse a tag name into an array of name and arguments (space separated, quoted strings allowed). */ Context.prototype.parseNameAndArgs = function parseNameAndArgs (name) { var parts = name.split(' '); var inString = false; var first = true; var i = 0; var arg; var unescapedArg; var argbuffer; var finalArgs = []; for (i = 0; i < parts.length; i++) { arg = parts[i]; argbuffer = ''; if (inString) { unescapedArg = arg.replace('\\\\', ''); if (unescapedArg.search(/^"$|[^\\]"$/) !== -1) { finalArgs[finalArgs.length] = argbuffer + ' ' + arg.substr(0, arg.length - 1); argbuffer = ''; inString = false; } else { argbuffer += ' ' + arg; } } else { if (arg.search(/^"/) !== -1 && !first) { unescapedArg = arg.replace('\\\\', ''); if (unescapedArg.search(/^".*[^\\]"$/) !== -1) { finalArgs[finalArgs.length] = arg.substr(1, arg.length - 2); } else { inString = true; argbuffer = arg.substr(1); } } else { if (arg.search(/^\d+(\.\d*)?$/) !== -1) { finalArgs[finalArgs.length] = parseFloat(arg); } else if (arg === 'true') { finalArgs[finalArgs.length] = 1; } else if (arg === 'false') { finalArgs[finalArgs.length] = 0; } else if (first) { finalArgs[finalArgs.length] = arg; } else { finalArgs[finalArgs.length] = this.lookup(arg); } first = false; } } } return finalArgs; }; /** * Returns the value of the given name in this context, traversing * up the context hierarchy if the value is absent in this context's view. */ Context.prototype.lookup = function lookup (name) { var cache = this.cache; var lambdaArgs = this.parseNameAndArgs(name); name= lambdaArgs.shift(); var value; if (cache.hasOwnProperty(name)) { value = cache[name]; } else { var context = this, intermediateValue, names, index, lookupHit = false; while (context) { if (name.indexOf('.') > 0) { intermediateValue = context.view; names = name.split('.'); index = 0; /** * Using the dot notion path in `name`, we descend through the * nested objects. * * To be certain that the lookup has been successful, we have to * check if the last object in the path actually has the property * we are looking for. We store the result in `lookupHit`. * * This is specially necessary for when the value has been set to * `undefined` and we want to avoid looking up parent contexts. * * In the case where dot notation is used, we consider the lookup * to be successful even if the last "object" in the path is * not actually an object but a primitive (e.g., a string, or an * integer), because it is sometimes useful to access a property * of an autoboxed primitive, such as the length of a string. **/ while (intermediateValue != null && index < names.length) { if (index === names.length - 1) lookupHit = ( hasProperty(intermediateValue, names[index]) || primitiveHasOwnProperty(intermediateValue, names[index]) ); intermediateValue = intermediateValue[names[index++]]; } } else { intermediateValue = context.view[name]; /** * Only checking against `hasProperty`, which always returns `false` if * `context.view` is not an object. Deliberately omitting the check * against `primitiveHasOwnProperty` if dot notation is not used. * * Consider this example: * ``` * Mustache.render("The length of a football field is {{#length}}{{length}}{{/length}}.", {length: "100 yards"}) * ``` * * If we were to check also against `primitiveHasOwnProperty`, as we do * in the dot notation case, then render call would return: * * "The length of a football field is 9." * * rather than the expected: * * "The length of a football field is 100 yards." **/ lookupHit = hasProperty(context.view, name); } if (lookupHit) { value = intermediateValue; break; } context = context.parent; } cache[name] = value; } if (isFunction(value)) value = value.call(this.view, lambdaArgs); return value; }; /** * A Writer knows how to take a stream of tokens and render them to a * string, given a context. It also maintains a cache of templates to * avoid the need to parse the same template twice. */ function Writer () { this.templateCache = { _cache: {}, set: function set (key, value) { this._cache[key] = value; }, get: function get (key) { return this._cache[key]; }, clear: function clear () { this._cache = {}; } }; } /** * Clears all cached templates in this writer. */ Writer.prototype.clearCache = function clearCache () { if (typeof this.templateCache !== 'undefined') { this.templateCache.clear(); } }; /** * Parses and caches the given `template` according to the given `tags` or * `mustache.tags` if `tags` is omitted, and returns the array of tokens * that is generated from the parse. */ Writer.prototype.parse = function parse (template, tags) { var cache = this.templateCache; var cacheKey = template + ':' + (tags || mustache.tags).join(':'); var isCacheEnabled = typeof cache !== 'undefined'; var tokens = isCacheEnabled ? cache.get(cacheKey) : undefined; if (tokens == undefined) { tokens = parseTemplate(template, tags); isCacheEnabled && cache.set(cacheKey, tokens); } return tokens; }; /** * High-level method that is used to render the given `template` with * the given `view`. * * The optional `partials` argument may be an object that contains the * names and templates of partials that are used in the template. It may * also be a function that is used to load partial templates on the fly * that takes a single argument: the name of the partial. * * If the optional `config` argument is given here, then it should be an * object with a `tags` attribute or an `escape` attribute or both. * If an array is passed, then it will be interpreted the same way as * a `tags` attribute on a `config` object. * * The `tags` attribute of a `config` object must be an array with two * string values: the opening and closing tags used in the template (e.g. * [ "<%", "%>" ]). The default is to mustache.tags. * * The `escape` attribute of a `config` object must be a function which * accepts a string as input and outputs a safely escaped string. * If an `escape` function is not provided, then an HTML-safe string * escaping function is used as the default. */ Writer.prototype.render = function render (template, view, partials, config) { var tags = this.getConfigTags(config); var tokens = this.parse(template, tags); var context = (view instanceof Context) ? view : new Context(view, undefined); return this.renderTokens(tokens, context, partials, template, config); }; /** * Low-level method that renders the given array of `tokens` using * the given `context` and `partials`. * * Note: The `originalTemplate` is only ever used to extract the portion * of the original template that was contained in a higher-order section. * If the template doesn't use higher-order sections, this argument may * be omitted. */ Writer.prototype.renderTokens = function renderTokens (tokens, context, partials, originalTemplate, config) { var buffer = ''; var token, symbol, value; for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { value = undefined; token = tokens[i]; symbol = token[0]; if (symbol === '#') value = this.renderSection(token, context, partials, originalTemplate, config); else if (symbol === '^') value = this.renderInverted(token, context, partials, originalTemplate, config); else if (symbol === '>') value = this.renderPartial(token, context, partials, config); else if (symbol === '<') value = this.renderBlock(token, context, partials, originalTemplate, config); else if (symbol === '$') value = this.renderBlockVariable(token, context, partials, originalTemplate, config); else if (symbol === '&') value = this.unescapedValue(token, context); else if (symbol === 'name') value = this.escapedValue(token, context, config); else if (symbol === 'text') value = this.rawValue(token); if (value !== undefined) buffer += value; } return buffer; }; Writer.prototype.renderSection = function renderSection (token, context, partials, originalTemplate, config) { var self = this; var buffer = ''; var lambdaArgs = context.parseNameAndArgs(token[1]); var name = lambdaArgs.shift(); var value = context.lookup(name); // This function is used to render an arbitrary template // in the current context by higher-order sections. function subRender (template) { return self.render(template, context, partials, config); } if (!value) return; if (isArray(value)) { for (var j = 0, valueLength = value.length; j < valueLength; ++j) { buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate, config); } } else if (typeof value === 'object' || typeof value === 'string' || typeof value === 'number') { buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate, config); } else if (isFunction(value)) { if (typeof originalTemplate !== 'string') throw new Error('Cannot use higher-order sections without the original template'); // Extract the portion of the original template that the section contains. value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender, lambdaArgs); if (value != null) buffer += value; } else { buffer += this.renderTokens(token[4], context, partials, originalTemplate, config); } return buffer; }; Writer.prototype.renderInverted = function renderInverted (token, context, partials, originalTemplate, config) { var value = context.lookup(token[1]); // Use JavaScript's definition of falsy. Include empty arrays. // See https://github.com/janl/mustache.js/issues/186 if (!value || (isArray(value) && value.length === 0)) return this.renderTokens(token[4], context, partials, originalTemplate, config); }; Writer.prototype.indentPartial = function indentPartial (partial, indentation, lineHasNonSpace) { var filteredIndentation = indentation.replace(/[^ \t]/g, ''); var partialByNl = partial.split('\n'); for (var i = 0; i < partialByNl.length; i++) { if (partialByNl[i].length && (i > 0 || !lineHasNonSpace)) { partialByNl[i] = filteredIndentation + partialByNl[i]; } } return partialByNl.join('\n'); }; Writer.prototype.renderPartial = function renderPartial (token, context, partials, config) { if (!partials) return; var tags = this.getConfigTags(config); var value = isFunction(partials) ? partials(token[1]) : partials[token[1]]; if (value != null) { var lineHasNonSpace = token[6]; var tagIndex = token[5]; var indentation = token[4]; var indentedValue = value; if (tagIndex == 0 && indentation) { indentedValue = this.indentPartial(value, indentation, lineHasNonSpace); } var tokens = this.parse(indentedValue, tags); return this.renderTokens(tokens, context, partials, indentedValue, config); } }; Writer.prototype.renderBlock = function renderBlock (token, context, partials, originalTemplate, config) { if (!partials) return; var value = isFunction(partials) ? partials(token[1]) : partials[token[1]]; if (value != null) // Ignore any wrongly set block vars before we started. context.clearBlockVars(); // We are only rendering to record the default block variables. this.renderTokens(token[4], context, partials, originalTemplate, config); // Now we render and return the result. var result = this.renderTokens(this.parse(value), context, partials, value, config); // Don't leak the block variables outside this include. context.clearBlockVars(); return result; }; Writer.prototype.renderBlockVariable = function renderBlockVariable (token, context, partials, originalTemplate, config) { var value = token[1]; var exists = context.getBlockVar(value); if (!exists) { context.setBlockVar(value, originalTemplate.slice(token[3], token[5])); return this.renderTokens(token[4], context, partials, originalTemplate, config); } else { return this.renderTokens(this.parse(exists), context, partials, exists, config); } }; Writer.prototype.unescapedValue = function unescapedValue (token, context) { var value = context.lookup(token[1]); if (value != null) return value; }; Writer.prototype.escapedValue = function escapedValue (token, context, config) { var escape = this.getConfigEscape(config) || mustache.escape; var value = context.lookup(token[1]); if (value != null) return (typeof value === 'number' && escape === mustache.escape) ? String(value) : escape(value); }; Writer.prototype.rawValue = function rawValue (token) { return token[1]; }; Writer.prototype.getConfigTags = function getConfigTags (config) { if (isArray(config)) { return config; } else if (config && typeof config === 'object') { return config.tags; } else { return undefined; } }; Writer.prototype.getConfigEscape = function getConfigEscape (config) { if (config && typeof config === 'object' && !isArray(config)) { return config.escape; } else { return undefined; } }; var mustache = { name: 'mustache.js', version: '4.2.0', tags: [ '{{', '}}' ], clearCache: undefined, escape: undefined, parse: undefined, render: undefined, Scanner: undefined, Context: undefined, Writer: undefined, /** * Allows a user to override the default caching strategy, by providing an * object with set, get and clear methods. This can also be used to disable * the cache by setting it to the literal `undefined`. */ set templateCache (cache) { defaultWriter.templateCache = cache; }, /** * Gets the default or overridden caching object from the default writer. */ get templateCache () { return defaultWriter.templateCache; } }; // All high-level mustache.* functions use this writer. var defaultWriter = new Writer(); /** * Clears all cached templates in the default writer. */ mustache.clearCache = function clearCache () { return defaultWriter.clearCache(); }; /** * Parses and caches the given template in the default writer and returns the * array of tokens it contains. Doing this ahead of time avoids the need to * parse templates on the fly as they are rendered. */ mustache.parse = function parse (template, tags) { return defaultWriter.parse(template, tags); }; /** * Renders the `template` with the given `view`, `partials`, and `config` * using the default writer. */ mustache.render = function render (template, view, partials, config) { if (typeof template !== 'string') { throw new TypeError('Invalid template! Template should be a "string" ' + 'but "' + typeStr(template) + '" was given as the first ' + 'argument for mustache#render(template, view, partials)'); } return defaultWriter.render(template, view, partials, config); }; // Export the escaping function so that the user may override it. // See https://github.com/janl/mustache.js/issues/244 mustache.escape = escapeHtml; // Export these mainly for testing, but also for advanced usage. mustache.Scanner = Scanner; mustache.Context = Context; mustache.Writer = Writer; export default mustache; url.js 0000644 00000007235 15152050146 0005712 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * URL utility functions. * * @module core/url * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 2.9 */ define(['jquery', 'core/config'], function($, config) { return /** @alias module:core/url */ { // Public variables and functions. /** * Construct a file url * * @method fileUrl * @param {string} relativeScript * @param {string} slashArg * @return {string} */ fileUrl: function(relativeScript, slashArg) { var url = config.wwwroot + relativeScript; // Force a / if (slashArg.charAt(0) != '/') { slashArg = '/' + slashArg; } if (config.slasharguments) { url += slashArg; } else { url += '?file=' + encodeURIComponent(slashArg); } return url; }, /** * Take a path relative to the moodle basedir and do some fixing (see class moodle_url in php). * * @method relativeUrl * @param {string} relativePath The path relative to the moodle basedir. * @param {object} params The query parameters for the URL. * @param {bool} includeSessKey Add the session key to the query params. * @return {string} */ relativeUrl: function(relativePath, params, includeSessKey) { if (relativePath.indexOf('http:') === 0 || relativePath.indexOf('https:') === 0 || relativePath.indexOf('://') >= 0) { throw new Error('relativeUrl function does not accept absolute urls'); } // Fix non-relative paths; if (relativePath.charAt(0) != '/') { relativePath = '/' + relativePath; } // Fix admin urls. if (config.admin !== 'admin') { relativePath = relativePath.replace(/^\/admin\//, '/' + config.admin + '/'); } params = params || {}; if (includeSessKey) { params.sesskey = config.sesskey; } var queryString = ''; if (Object.keys(params).length) { queryString = $.map(params, function(value, param) { return param + '=' + value; }).join('&'); } if (queryString !== '') { return config.wwwroot + relativePath + '?' + queryString; } else { return config.wwwroot + relativePath; } }, /** * Wrapper for image_url function. * * @method imageUrl * @param {string} imagename The image name (e.g. t/edit). * @param {string} component The component (e.g. mod_feedback). * @return {string} */ imageUrl: function(imagename, component) { return M.util.image_url(imagename, component); } }; }); userfeedback.js 0000644 00000005644 15152050146 0007535 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Handle clicking on action links of the feedback alert. * * @module core/cta_feedback * @copyright 2020 Shamim Rezaie <shamim@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Ajax from 'core/ajax'; import Notification from 'core/notification'; const Selectors = { regions: { root: '[data-region="core/userfeedback"]', }, actions: {}, }; Selectors.actions.give = `${Selectors.regions.root} [data-action="give"]`; Selectors.actions.remind = `${Selectors.regions.root} [data-action="remind"]`; /** * Attach the necessary event handlers to the action links */ export const registerEventListeners = () => { document.addEventListener('click', e => { const giveAction = e.target.closest(Selectors.actions.give); if (giveAction) { e.preventDefault(); if (!window.open(giveAction.href)) { throw new Error('Unable to open popup'); } Promise.resolve(giveAction) .then(hideRoot) .then(recordAction) .catch(Notification.exception); } const remindAction = e.target.closest(Selectors.actions.remind); if (remindAction) { e.preventDefault(); Promise.resolve(remindAction) .then(hideRoot) .then(recordAction) .catch(Notification.exception); } }); }; /** * Record the action that the user took. * * @param {HTMLElement} clickedItem The action element that the user chose. * @returns {Promise} */ const recordAction = clickedItem => { if (clickedItem.dataset.record) { return Ajax.call([{ methodname: 'core_create_userfeedback_action_record', args: { action: clickedItem.dataset.action, contextid: M.cfg.contextid, } }])[0]; } return Promise.resolve(); }; /** * Hide the root node of the CTA notification. * * @param {HTMLElement} clickedItem The action element that the user chose. * @returns {HTMLElement} */ const hideRoot = clickedItem => { if (clickedItem.dataset.hide) { clickedItem.closest(Selectors.regions.root).remove(); } return clickedItem; }; icon_system_fontawesome.js 0000644 00000010010 15152050146 0012034 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Competency rule points module. * * @module core/icon_system_fontawesome * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['core/icon_system', 'jquery', 'core/ajax', 'core/mustache', 'core/localstorage', 'core/url'], function(IconSystem, $, Ajax, Mustache, LocalStorage, Url) { var staticMap = null; var fetchMap = null; /** * IconSystemFontawesome * @class core/icon_system_fontawesome */ var IconSystemFontawesome = function() { IconSystem.apply(this, arguments); }; IconSystemFontawesome.prototype = Object.create(IconSystem.prototype); /** * Prefetch resources so later calls to renderIcon can be resolved synchronously. * * @method init * @return {Promise} */ IconSystemFontawesome.prototype.init = function() { var currTheme = M.cfg.theme; if (staticMap) { return $.when(this); } var map = LocalStorage.get('core_iconsystem/theme/' + currTheme + '/core/iconmap-fontawesome'); if (map) { map = JSON.parse(map); } if (map) { staticMap = map; return $.when(this); } if (fetchMap === null) { fetchMap = Ajax.call([{ methodname: 'core_output_load_fontawesome_icon_system_map', args: { themename: M.cfg.theme, }, }], true, false, false, 0, M.cfg.themerev)[0]; } return fetchMap.then(function(map) { staticMap = {}; $.each(map, function(index, value) { staticMap[value.component + '/' + value.pix] = value.to; }); LocalStorage.set('core_iconsystem/theme/' + currTheme + '/core/iconmap-fontawesome', JSON.stringify(staticMap)); return this; }.bind(this)); }; /** * Render an icon. * * @param {String} key * @param {String} component * @param {String} title * @param {String} template * @return {String} * @method renderIcon */ IconSystemFontawesome.prototype.renderIcon = function(key, component, title, template) { var mappedIcon = staticMap[component + '/' + key]; var unmappedIcon = false; if (typeof mappedIcon === "undefined") { var url = Url.imageUrl(key, component); unmappedIcon = { attributes: [ {name: 'src', value: url}, {name: 'alt', value: title}, {name: 'title', value: title} ] }; } var context = { key: mappedIcon, title: title, alt: title, unmappedIcon: unmappedIcon }; if (typeof title === "undefined" || title === '') { context['aria-hidden'] = true; } var result = Mustache.render(template, context); return result.trim(); }; /** * Get the name of the template to pre-cache for this icon system. * * @return {String} * @method getTemplateName */ IconSystemFontawesome.prototype.getTemplateName = function() { return 'core/pix_icon_fontawesome'; }; return /** @alias module:core/icon_system_fontawesome */ IconSystemFontawesome; }); drawer_events.js 0000644 00000001707 15152050146 0007756 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Events for the drawer. * * @module core/drawer_events * @copyright 2019 Jun Pataleta <jun@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ export default { DRAWER_SHOWN: 'drawer-shown', DRAWER_HIDDEN: 'drawer-hidden', }; storagewrapper.js 0000644 00000012323 15152050146 0010147 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Wrap an instance of the browser's local or session storage to handle * cache expiry, key namespacing and other helpful things. * * @module core/storagewrapper * @copyright 2017 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['core/config'], function(config) { /** * Constructor. * * @param {object} storage window.localStorage or window.sessionStorage */ var Wrapper = function(storage) { this.storage = storage; this.supported = this.detectSupport(); this.hashSource = config.wwwroot + '/' + config.jsrev; this.hash = this.hashString(this.hashSource); this.prefix = this.hash + '/'; this.jsrevPrefix = this.hashString(config.wwwroot) + '/jsrev'; this.validateCache(); }; /** * Check if the browser supports the type of storage. * * @method detectSupport * @return {boolean} True if the browser supports storage. */ Wrapper.prototype.detectSupport = function() { if (config.jsrev == -1) { // Disable cache if debugging. return false; } if (typeof (this.storage) === "undefined") { return false; } var testKey = 'test'; try { if (this.storage === null) { return false; } // MDL-51461 - Some browsers misreport availability of the storage // so check it is actually usable. this.storage.setItem(testKey, '1'); this.storage.removeItem(testKey); return true; } catch (ex) { return false; } }; /** * Add a unique prefix to all keys so multiple moodle sites do not share caches. * * @method prefixKey * @param {string} key The cache key to prefix. * @return {string} The new key */ Wrapper.prototype.prefixKey = function(key) { return this.prefix + key; }; /** * Check the current jsrev version and clear the cache if it has been bumped. * * @method validateCache */ Wrapper.prototype.validateCache = function() { if (!this.supported) { return; } var cacheVersion = this.storage.getItem(this.jsrevPrefix); if (cacheVersion === null) { this.storage.setItem(this.jsrevPrefix, config.jsrev); return; } var moodleVersion = config.jsrev; if (moodleVersion != cacheVersion) { this.storage.clear(); this.storage.setItem(this.jsrevPrefix, config.jsrev); } }; /** * Hash a string, used to make shorter key prefixes. * * @method hashString * @param {String} source The string to hash * @return {Number} */ Wrapper.prototype.hashString = function(source) { // From http://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript-jquery. /* jshint bitwise: false */ /* eslint no-bitwise: "off" */ var hash = 0; var i, chr, len; if (source.length === 0) { return hash; } for (i = 0, len = source.length; i < len; i++) { chr = source.charCodeAt(i); hash = ((hash << 5) - hash) + chr; hash |= 0; // Convert to 32bit integer } return hash; }; /** * Get a value from local storage. Remember - all values must be strings. * * @method get * @param {string} key The cache key to check. * @return {boolean|string} False if the value is not in the cache, or some other error - a string otherwise. */ Wrapper.prototype.get = function(key) { if (!this.supported) { return false; } key = this.prefixKey(key); return this.storage.getItem(key); }; /** * Set a value to local storage. Remember - all values must be strings. * * @method set * @param {string} key The cache key to set. * @param {string} value The value to set. * @return {boolean} False if the value can't be saved in the cache, or some other error - true otherwise. */ Wrapper.prototype.set = function(key, value) { if (!this.supported) { return false; } key = this.prefixKey(key); // This can throw exceptions when the storage limit is reached. try { this.storage.setItem(key, value); } catch (e) { return false; } return true; }; return Wrapper; }); network.js 0000644 00000023244 15152050146 0006577 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Poll the server to keep the session alive. * * @module core/network * @copyright 2019 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'], function($, Ajax, Config, Notification, Str) { var started = false; var warningDisplayed = false; var keepAliveFrequency = 0; var requestTimeout = 0; var keepAliveMessage = false; var sessionTimeout = false; // 1/10 of session timeout, max of 10 minutes. var checkFrequency = Math.min((Config.sessiontimeout / 10), 600) * 1000; // Check if sessiontimeoutwarning is set or double the checkFrequency. var warningLimit = (Config.sessiontimeoutwarning > 0) ? (Config.sessiontimeoutwarning * 1000) : (checkFrequency * 2); // First wait is minimum of remaining time or half of the session timeout. var firstWait = (Config.sessiontimeoutwarning > 0) ? Math.min((Config.sessiontimeout - Config.sessiontimeoutwarning) * 1000, checkFrequency * 5) : checkFrequency * 5; /** * The session time has expired - we can't extend it now. * @param {Modal} modal */ var timeoutSessionExpired = function(modal) { sessionTimeout = true; warningDisplayed = false; closeModal(modal); displaySessionExpired(); }; /** * Close modal - this relies on modal object passed from Notification.confirm. * * @param {Modal} modal */ var closeModal = function(modal) { modal.destroy(); }; /** * The session time has expired - we can't extend it now. * @return {Promise} */ var displaySessionExpired = function() { // Check again if its already extended before displaying session expired popup in case multiple tabs are open. var request = { methodname: 'core_session_time_remaining', args: { } }; return Ajax.call([request], true, true, true)[0].then(function(args) { if (args.timeremaining * 1000 > warningLimit) { return false; } else { return Str.get_strings([ {key: 'sessionexpired', component: 'error'}, {key: 'sessionerroruser', component: 'error'}, {key: 'loginagain', component: 'moodle'}, {key: 'cancel', component: 'moodle'} ]).then(function(strings) { Notification.confirm( strings[0], // Title. strings[1], // Message. strings[2], // Login Again. strings[3], // Cancel. function() { location.reload(); return true; } ); return true; }).catch(Notification.exception); } }); }; /** * Ping the server to keep the session alive. * * @return {Promise} */ var touchSession = function() { var request = { methodname: 'core_session_touch', args: { } }; if (sessionTimeout) { // We timed out before we extended the session. return displaySessionExpired(); } else { return Ajax.call([request], true, true, false, requestTimeout)[0].then(function() { if (keepAliveFrequency > 0) { setTimeout(touchSession, keepAliveFrequency); } return true; }).catch(function() { Notification.alert('', keepAliveMessage); }); } }; /** * Ask the server how much time is remaining in this session and * show confirm/cancel notifications if the session is about to run out. * * @return {Promise} */ var checkSession = function() { var request = { methodname: 'core_session_time_remaining', args: { } }; sessionTimeout = false; return Ajax.call([request], true, true, true)[0].then(function(args) { if (args.userid <= 0) { return false; } if (args.timeremaining <= 0) { return displaySessionExpired(); } else if (args.timeremaining * 1000 <= warningLimit && !warningDisplayed) { warningDisplayed = true; Str.get_strings([ {key: 'norecentactivity', component: 'moodle'}, {key: 'sessiontimeoutsoon', component: 'moodle'}, {key: 'extendsession', component: 'moodle'}, {key: 'cancel', component: 'moodle'} ]).then(function(strings) { return Notification.confirm( strings[0], // Title. strings[1], // Message. strings[2], // Extend session. strings[3], // Cancel. function() { touchSession(); warningDisplayed = false; // First wait is minimum of remaining time or half of the session timeout. setTimeout(checkSession, firstWait); return true; }, function() { // User has cancelled notification. setTimeout(checkSession, checkFrequency); } ); }).then(modal => { // If we don't extend the session before the timeout - warn. setTimeout(timeoutSessionExpired, args.timeremaining * 1000, modal); return; }).catch(Notification.exception); } else { setTimeout(checkSession, checkFrequency); } return true; }); // We do not catch the fails from the above ajax call because they will fail when // we are not logged in - we don't need to take any action then. }; /** * Start calling a function to check if the session is still alive. */ var start = function() { if (keepAliveFrequency > 0) { setTimeout(touchSession, keepAliveFrequency); } else { // First wait is minimum of remaining time or half of the session timeout. setTimeout(checkSession, firstWait); } }; /** * Are we in an iframe and the parent page is from the same Moodle site? * * @return {boolean} true if we are in an iframe in a page from this Moodle site. */ const isMoodleIframe = function() { if (window.parent === window) { // Not in an iframe. return false; } // We are in an iframe. Is the parent from the same Moodle site? let parentUrl; try { parentUrl = window.parent.location.href; } catch (e) { // If we cannot access the URL of the parent page, it must be another site. return false; } return parentUrl.startsWith(M.cfg.wwwroot); }; /** * Don't allow more than one of these polling loops in a single page. */ var init = function() { // We only allow one concurrent instance of this checker. if (started) { return; } started = true; if (isMoodleIframe()) { window.console.log('Not starting Moodle session timeout warning in this iframe.'); return; } window.console.log('Starting Moodle session timeout warning.'); start(); }; /** * Start polling with more specific values for the frequency, timeout and message. * * @param {number} freq How ofter to poll the server. * @param {number} timeout The time to wait for each request to the server. * @param {string} identifier The string identifier for the message to show if session is going to time out. * @param {string} component The string component for the message to show if session is going to time out. */ var keepalive = async function(freq, timeout, identifier, component) { // We only allow one concurrent instance of this checker. if (started) { window.console.warn('Ignoring session keep-alive. The core/network module was already initialised.'); return; } started = true; if (isMoodleIframe()) { window.console.warn('Ignoring session keep-alive in this iframe inside another Moodle page.'); return; } window.console.log('Starting Moodle session keep-alive.'); keepAliveFrequency = freq * 1000; keepAliveMessage = await Str.get_string(identifier, component); requestTimeout = timeout * 1000; start(); }; return { keepalive: keepalive, init: init }; }); chart_output.js 0000644 00000002124 15152050146 0007621 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart output. * * Proxy to the default output module. * * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['core/chart_output_chartjs'], function(Output) { /** * @exports module:core/chart_output * @extends {module:core/chart_output_chartjs} */ var defaultModule = Output; return defaultModule; }); emoji/auto_complete.js 0000644 00000026436 15152050146 0011057 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Emoji auto complete. * * @module core/emoji/auto_complete * @copyright 2019 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import * as EmojiData from 'core/emoji/data'; import {render as renderTemplate} from 'core/templates'; import {debounce} from 'core/utils'; import LocalStorage from 'core/localstorage'; import KeyCodes from 'core/key_codes'; const INPUT_DEBOUNCE_TIMER = 200; const SUGGESTION_LIMIT = 50; const MAX_RECENT_COUNT = 27; const RECENT_EMOJIS_STORAGE_KEY = 'moodle-recent-emojis'; const SELECTORS = { EMOJI_BUTTON: '[data-region="emoji-button"]', ACTIVE_EMOJI_BUTTON: '[data-region="emoji-button"].active', }; /** * Get the list of recent emojis data from local storage. * * @return {Array} */ const getRecentEmojis = () => { const storedData = LocalStorage.get(RECENT_EMOJIS_STORAGE_KEY); return storedData ? JSON.parse(storedData) : []; }; /** * Add an emoji data to the set of recent emojis. The new set of recent emojis are * saved in local storage. * * @param {String} unified The char chodes for the emoji * @param {String} shortName The emoji short name */ const addRecentEmoji = (unified, shortName) => { const newEmoji = { unified, shortnames: [shortName] }; const recentEmojis = getRecentEmojis(); // Add the new emoji to the start of the list of recent emojis. let newRecentEmojis = [newEmoji, ...recentEmojis.filter(emoji => emoji.unified != newEmoji.unified)]; // Limit the number of recent emojis. newRecentEmojis = newRecentEmojis.slice(0, MAX_RECENT_COUNT); LocalStorage.set(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(newRecentEmojis)); }; /** * Get the actual emoji string from the short name. * * @param {String} shortName Emoji short name * @return {String|null} */ const getEmojiTextFromShortName = (shortName) => { const unified = EmojiData.byShortName[shortName]; if (unified) { const charCodes = unified.split('-').map(code => `0x${code}`); return String.fromCodePoint.apply(null, charCodes); } else { return null; } }; /** * Render the auto complete list for the given short names. * * @param {Element} root The root container for the emoji auto complete * @param {Array} shortNames The list of short names for emoji suggestions to show */ const render = async(root, shortNames) => { const renderContext = { emojis: shortNames.map((shortName, index) => { return { active: index === 0, emojitext: getEmojiTextFromShortName(shortName), displayshortname: `:${shortName}:`, shortname: shortName, unified: EmojiData.byShortName[shortName] }; }) }; const html = await renderTemplate('core/emoji/auto_complete', renderContext); root.innerHTML = html; }; /** * Get the list of emoji short names that include the given search term. If * the search term is an empty string then the list of recently used emojis * will be returned. * * @param {String} searchTerm Text to match on * @param {Number} limit Maximum number of results to return * @return {Array} */ const searchEmojis = (searchTerm, limit) => { if (searchTerm === '') { return getRecentEmojis().map(data => data.shortnames[0]).slice(0, limit); } else { searchTerm = searchTerm.toLowerCase(); return Object.keys(EmojiData.byShortName) .filter(shortName => shortName.includes(searchTerm)) .slice(0, limit); } }; /** * Get the current word at the given position (index) within the text. * * @param {String} text The text to process * @param {Number} position The position (index) within the text to match the word * @return {String} */ const getWordFromPosition = (text, position) => { const startMatches = text.slice(0, position).match(/(\S*)$/); const endMatches = text.slice(position).match(/^(\S*)/); let startText = ''; let endText = ''; if (startMatches) { startText = startMatches[startMatches.length - 1]; } if (endMatches) { endText = endMatches[endMatches.length - 1]; } return `${startText}${endText}`; }; /** * Check if the given text is a full short name, i.e. has leading and trialing colon * characters. * * @param {String} text The text to process * @return {Bool} */ const isCompleteShortName = text => /^:[^:\s]+:$/.test(text); /** * Check if the given text is a partial short name, i.e. has a leading colon but no * trailing colon. * * @param {String} text The text to process * @return {Bool} */ const isPartialShortName = text => /^:[^:\s]*$/.test(text); /** * Remove the colon characters from the given text. * * @param {String} text The text to process * @return {String} */ const getShortNameFromText = text => text.replace(/:/g, ''); /** * Get the currently active emoji button element in the list of suggestions. * * @param {Element} root The emoji auto complete container element * @return {Element|null} */ const getActiveEmojiSuggestion = (root) => { return root.querySelector(SELECTORS.ACTIVE_EMOJI_BUTTON); }; /** * Make the previous sibling of the current active emoji active. * * @param {Element} root The emoji auto complete container element */ const selectPreviousEmojiSuggestion = (root) => { const activeEmojiSuggestion = getActiveEmojiSuggestion(root); const previousSuggestion = activeEmojiSuggestion.previousElementSibling; if (previousSuggestion) { activeEmojiSuggestion.classList.remove('active'); previousSuggestion.classList.add('active'); previousSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'}); } }; /** * Make the next sibling to the current active emoji active. * * @param {Element} root The emoji auto complete container element */ const selectNextEmojiSuggestion = (root) => { const activeEmojiSuggestion = getActiveEmojiSuggestion(root); const nextSuggestion = activeEmojiSuggestion.nextElementSibling; if (nextSuggestion) { activeEmojiSuggestion.classList.remove('active'); nextSuggestion.classList.add('active'); nextSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'}); } }; /** * Trigger the select callback for the given emoji button element. * * @param {Element} element The emoji button element * @param {Function} selectCallback The callback for when the user selects an emoji */ const selectEmojiElement = (element, selectCallback) => { const shortName = element.getAttribute('data-short-name'); const unified = element.getAttribute('data-unified'); addRecentEmoji(unified, shortName); selectCallback(element.innerHTML.trim()); }; /** * Initialise the emoji auto complete. * * @method * @param {Element} root The root container element for the auto complete * @param {Element} textArea The text area element to monitor for auto complete * @param {Function} hasSuggestionCallback Callback for when there are auto-complete suggestions * @param {Function} selectCallback Callback for when the user selects an emoji */ export default (root, textArea, hasSuggestionCallback, selectCallback) => { let hasSuggestions = false; let previousSearchText = ''; // Debounce the listener so that each keypress delays the execution of the handler. The // handler should only run 200 milliseconds after the last keypress. textArea.addEventListener('keyup', debounce(() => { // This is a "keyup" listener so that it only executes after the text area value // has been updated. const text = textArea.value; const cursorPos = textArea.selectionStart; const searchText = getWordFromPosition(text, cursorPos); if (searchText === previousSearchText) { // Nothing has changed so no need to take any action. return; } else { previousSearchText = searchText; } if (isCompleteShortName(searchText)) { // If the user has entered a full short name (with leading and trialing colons) // then see if we can find a match for it and auto complete it. const shortName = getShortNameFromText(searchText); const emojiText = getEmojiTextFromShortName(shortName); hasSuggestions = false; if (emojiText) { addRecentEmoji(EmojiData.byShortName[shortName], shortName); selectCallback(emojiText); } } else if (isPartialShortName(searchText)) { // If the user has entered a partial short name (leading colon but no trailing) then // search on the text to see if we can find some suggestions for them. const suggestions = searchEmojis(getShortNameFromText(searchText), SUGGESTION_LIMIT); if (suggestions.length) { render(root, suggestions); hasSuggestions = true; } else { hasSuggestions = false; } } else { hasSuggestions = false; } hasSuggestionCallback(hasSuggestions); }, INPUT_DEBOUNCE_TIMER)); textArea.addEventListener('keydown', (e) => { if (hasSuggestions) { const isModifierPressed = (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey); if (!isModifierPressed) { switch (e.which) { case KeyCodes.escape: // Escape key closes the auto complete. hasSuggestions = false; hasSuggestionCallback(false); break; case KeyCodes.arrowLeft: // Arrow keys navigate through the list of suggetions. selectPreviousEmojiSuggestion(root); e.preventDefault(); break; case KeyCodes.arrowRight: // Arrow keys navigate through the list of suggetions. selectNextEmojiSuggestion(root); e.preventDefault(); break; case KeyCodes.enter: // Enter key selects the current suggestion. selectEmojiElement(getActiveEmojiSuggestion(root), selectCallback); e.preventDefault(); e.stopPropagation(); break; } } } }); root.addEventListener('click', (e) => { const target = e.target; if (target.matches(SELECTORS.EMOJI_BUTTON)) { selectEmojiElement(target, selectCallback); } }); }; emoji/picker.js 0000644 00000100472 15152050146 0007465 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Emoji picker. * * @module core/emoji/picker * @copyright 2019 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import LocalStorage from 'core/localstorage'; import * as EmojiData from 'core/emoji/data'; import {throttle, debounce} from 'core/utils'; import {get_string as getString} from 'core/str'; import {render as renderTemplate} from 'core/templates'; const VISIBLE_ROW_COUNT = 10; const ROW_RENDER_BUFFER_COUNT = 5; const RECENT_EMOJIS_STORAGE_KEY = 'moodle-recent-emojis'; const ROW_HEIGHT_RAW = 40; const EMOJIS_PER_ROW = 7; const MAX_RECENT_COUNT = EMOJIS_PER_ROW * 3; const ROW_TYPE = { EMOJI: 0, HEADER: 1 }; const SELECTORS = { CATEGORY_SELECTOR: '[data-action="show-category"]', EMOJIS_CONTAINER: '[data-region="emojis-container"]', EMOJI_PREVIEW: '[data-region="emoji-preview"]', EMOJI_SHORT_NAME: '[data-region="emoji-short-name"]', ROW_CONTAINER: '[data-region="row-container"]', SEARCH_INPUT: '[data-region="search-input"]', SEARCH_RESULTS_CONTAINER: '[data-region="search-results-container"]' }; /** * Create the row data for a category. * * @method * @param {String} categoryName The category name * @param {String} categoryDisplayName The category display name * @param {Array} emojis The emoji data * @param {Number} totalRowCount The total number of rows generated so far * @return {Array} */ const createRowDataForCategory = (categoryName, categoryDisplayName, emojis, totalRowCount) => { const rowData = []; rowData.push({ index: totalRowCount + rowData.length, type: ROW_TYPE.HEADER, data: { name: categoryName, displayName: categoryDisplayName } }); for (let i = 0; i < emojis.length; i += EMOJIS_PER_ROW) { const rowEmojis = emojis.slice(i, i + EMOJIS_PER_ROW); rowData.push({ index: totalRowCount + rowData.length, type: ROW_TYPE.EMOJI, data: rowEmojis }); } return rowData; }; /** * Add each row's index to it's value in the row data. * * @method * @param {Array} rowData List of emoji row data * @return {Array} */ const addIndexesToRowData = (rowData) => { return rowData.map((data, index) => { return {...data, index}; }); }; /** * Calculate the scroll position for the beginning of each category from * the row data. * * @method * @param {Array} rowData List of emoji row data * @return {Object} */ const getCategoryScrollPositionsFromRowData = (rowData) => { return rowData.reduce((carry, row, index) => { if (row.type === ROW_TYPE.HEADER) { carry[row.data.name] = index * ROW_HEIGHT_RAW; } return carry; }, {}); }; /** * Create a header row element for the category name. * * @method * @param {Number} rowIndex Index of the row in the row data * @param {String} name The category display name * @return {Element} */ const createHeaderRow = async(rowIndex, name) => { const context = { index: rowIndex, text: name }; const html = await renderTemplate('core/emoji/header_row', context); const temp = document.createElement('div'); temp.innerHTML = html; return temp.firstChild; }; /** * Create an emoji row element. * * @method * @param {Number} rowIndex Index of the row in the row data * @param {Array} emojis The list of emoji data for the row * @return {Element} */ const createEmojiRow = async(rowIndex, emojis) => { const context = { index: rowIndex, emojis: emojis.map(emojiData => { const charCodes = emojiData.unified.split('-').map(code => `0x${code}`); const emojiText = String.fromCodePoint.apply(null, charCodes); return { shortnames: `:${emojiData.shortnames.join(': :')}:`, unified: emojiData.unified, text: emojiText, spacer: false }; }), spacers: Array(EMOJIS_PER_ROW - emojis.length).fill(true) }; const html = await renderTemplate('core/emoji/emoji_row', context); const temp = document.createElement('div'); temp.innerHTML = html; return temp.firstChild; }; /** * Check if the element is an emoji element. * * @method * @param {Element} element Element to check * @return {Bool} */ const isEmojiElement = element => element.getAttribute('data-short-names') !== null; /** * Search from an element and up through it's ancestors to fine the category * selector element and return it. * * @method * @param {Element} element Element to begin searching from * @return {Element|null} */ const findCategorySelectorFromElement = element => { if (!element) { return null; } if (element.getAttribute('data-action') === 'show-category') { return element; } else { return findCategorySelectorFromElement(element.parentElement); } }; const getCategorySelectorByCategoryName = (root, name) => { return root.querySelector(`[data-category="${name}"]`); }; /** * Sets the given category selector element as active. * * @method * @param {Element} root The root picker element * @param {Element} element The category selector element to make active */ const setCategorySelectorActive = (root, element) => { const allCategorySelectors = root.querySelectorAll(SELECTORS.CATEGORY_SELECTOR); for (let i = 0; i < allCategorySelectors.length; i++) { const selector = allCategorySelectors[i]; selector.classList.remove('selected'); } element.classList.add('selected'); }; /** * Get the category selector element and the scroll positions for the previous and * next categories for the given scroll position. * * @method * @param {Element} root The picker root element * @param {Number} position The position to get the category for * @param {Object} categoryScrollPositions Set of scroll positions for all categories * @return {Array} */ const getCategoryByScrollPosition = (root, position, categoryScrollPositions) => { let positions = []; if (position < 0) { position = 0; } // Get all of the category positions. for (const categoryName in categoryScrollPositions) { const categoryPosition = categoryScrollPositions[categoryName]; positions.push([categoryPosition, categoryName]); } // Sort the positions in ascending order. positions.sort(([a], [b]) => { if (a < b) { return -1; } else if (a > b) { return 1; } else { return 0; } }); // Get the current category name as well as the previous and next category // positions from the sorted list of positions. const {categoryName, previousPosition, nextPosition} = positions.reduce( (carry, candidate) => { const [categoryPosition, categoryName] = candidate; if (categoryPosition <= position) { carry.categoryName = categoryName; carry.previousPosition = carry.currentPosition; carry.currentPosition = position; } else if (carry.nextPosition === null) { carry.nextPosition = categoryPosition; } return carry; }, { categoryName: null, currentPosition: null, previousPosition: null, nextPosition: null } ); return [getCategorySelectorByCategoryName(root, categoryName), previousPosition, nextPosition]; }; /** * Get the list of recent emojis data from local storage. * * @method * @return {Array} */ const getRecentEmojis = () => { const storedData = LocalStorage.get(RECENT_EMOJIS_STORAGE_KEY); return storedData ? JSON.parse(storedData) : []; }; /** * Save the list of recent emojis in local storage. * * @method * @param {Array} recentEmojis List of emoji data to save */ const saveRecentEmoji = (recentEmojis) => { LocalStorage.set(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(recentEmojis)); }; /** * Add an emoji data to the set of recent emojis. This function will update the row * data to ensure that the recent emoji rows are correct and all of the rows are * re-indexed. * * The new set of recent emojis are saved in local storage and the full set of updated * row data and new emoji row count are returned. * * @method * @param {Array} rowData The emoji rows data * @param {Number} recentEmojiRowCount Count of the recent emoji rows * @param {Object} newEmoji The emoji data for the emoji to add to the recent emoji list * @return {Array} */ const addRecentEmoji = (rowData, recentEmojiRowCount, newEmoji) => { // The first set of rows is always the recent emojis. const categoryName = rowData[0].data.name; const categoryDisplayName = rowData[0].data.displayName; const recentEmojis = getRecentEmojis(); // Add the new emoji to the start of the list of recent emojis. let newRecentEmojis = [newEmoji, ...recentEmojis.filter(emoji => emoji.unified != newEmoji.unified)]; // Limit the number of recent emojis. newRecentEmojis = newRecentEmojis.slice(0, MAX_RECENT_COUNT); const newRecentEmojiRowData = createRowDataForCategory(categoryName, categoryDisplayName, newRecentEmojis); // Save the new list in local storage. saveRecentEmoji(newRecentEmojis); return [ // Return the new rowData and re-index it to make sure it's all correct. addIndexesToRowData(newRecentEmojiRowData.concat(rowData.slice(recentEmojiRowCount))), newRecentEmojiRowData.length ]; }; /** * Calculate which rows should be visible based on the given scroll position. Adds a * buffer to amount to either side of the total number of requested rows so that * scrolling the emoji rows container is smooth. * * @method * @param {Number} scrollPosition Scroll position within the emoji container * @param {Number} visibleRowCount How many rows should be visible * @param {Array} rowData The emoji rows data * @return {Array} */ const getRowsToRender = (scrollPosition, visibleRowCount, rowData) => { const minVisibleRow = scrollPosition > ROW_HEIGHT_RAW ? Math.floor(scrollPosition / ROW_HEIGHT_RAW) : 0; const start = minVisibleRow >= ROW_RENDER_BUFFER_COUNT ? minVisibleRow - ROW_RENDER_BUFFER_COUNT : minVisibleRow; const end = minVisibleRow + visibleRowCount + ROW_RENDER_BUFFER_COUNT; const rows = rowData.slice(start, end); return rows; }; /** * Create a row element from the row data. * * @method * @param {Object} rowData The emoji row data * @return {Element} */ const createRowElement = async(rowData) => { let row = null; if (rowData.type === ROW_TYPE.HEADER) { row = await createHeaderRow(rowData.index, rowData.data.displayName); } else { row = await createEmojiRow(rowData.index, rowData.data); } row.style.position = 'absolute'; row.style.left = 0; row.style.right = 0; row.style.top = `${rowData.index * ROW_HEIGHT_RAW}px`; return row; }; /** * Check if the given rows match. * * @method * @param {Object} a The first row * @param {Object} b The second row * @return {Bool} */ const doRowsMatch = (a, b) => { if (a.index !== b.index) { return false; } if (a.type !== b.type) { return false; } if (typeof a.data != typeof b.data) { return false; } if (a.type === ROW_TYPE.HEADER) { return a.data.name === b.data.name; } else { if (a.data.length !== b.data.length) { return false; } for (let i = 0; i < a.data.length; i++) { if (a.data[i].unified != b.data[i].unified) { return false; } } } return true; }; /** * Update the visible rows. Deletes any row elements that should no longer * be visible and creates the newly visible row elements. Any rows that haven't * changed visibility will be left untouched. * * @method * @param {Element} rowContainer The container element for the emoji rows * @param {Array} currentRows List of row data that matches the currently visible rows * @param {Array} nextRows List of row data containing the new list of rows to be made visible */ const renderRows = async(rowContainer, currentRows, nextRows) => { // We need to add any rows that are in nextRows but not in currentRows. const toAdd = nextRows.filter(nextRow => !currentRows.some(currentRow => doRowsMatch(currentRow, nextRow))); // Remember which rows will still be visible so that we can insert our element in the correct place in the DOM. let toKeep = currentRows.filter(currentRow => nextRows.some(nextRow => doRowsMatch(currentRow, nextRow))); // We need to remove any rows that are in currentRows but not in nextRows. const toRemove = currentRows.filter(currentRow => !nextRows.some(nextRow => doRowsMatch(currentRow, nextRow))); const toRemoveElements = toRemove.map(rowData => rowContainer.querySelectorAll(`[data-row="${rowData.index}"]`)); // Render all of the templates first. const rows = await Promise.all(toAdd.map(rowData => createRowElement(rowData))); rows.forEach((row, index) => { const rowData = toAdd[index]; let nextRowIndex = null; for (let i = 0; i < toKeep.length; i++) { const candidate = toKeep[i]; if (candidate.index > rowData.index) { nextRowIndex = i; break; } } // Make sure the elements get added to the DOM in the correct order (ascending by row data index) // so that they appear naturally in the tab order. if (nextRowIndex !== null) { const nextRowData = toKeep[nextRowIndex]; const nextRowNode = rowContainer.querySelector(`[data-row="${nextRowData.index}"]`); rowContainer.insertBefore(row, nextRowNode); toKeep.splice(nextRowIndex, 0, toKeep); } else { toKeep.push(rowData); rowContainer.appendChild(row); } }); toRemoveElements.forEach(rows => { for (let i = 0; i < rows.length; i++) { const row = rows[i]; rowContainer.removeChild(row); } }); }; /** * Build a function to render the visible emoji rows for a given scroll * position. * * @method * @param {Element} rowContainer The container element for the emoji rows * @return {Function} */ const generateRenderRowsAtPositionFunction = (rowContainer) => { let currentRows = []; let nextRows = []; let rowCount = 0; let isRendering = false; const renderNextRows = async() => { if (!nextRows.length) { return; } if (isRendering) { return; } isRendering = true; const nextRowsToRender = nextRows.slice(); nextRows = []; await renderRows(rowContainer, currentRows, nextRowsToRender); currentRows = nextRowsToRender; isRendering = false; renderNextRows(); }; return (scrollPosition, rowData, rowLimit = VISIBLE_ROW_COUNT) => { nextRows = getRowsToRender(scrollPosition, rowLimit, rowData); renderNextRows(); if (rowCount !== rowData.length) { // Adjust the height of the container to match the number of rows. rowContainer.style.height = `${rowData.length * ROW_HEIGHT_RAW}px`; } rowCount = rowData.length; }; }; /** * Show the search results container and hide the emoji container. * * @method * @param {Element} emojiContainer The emojis container * @param {Element} searchResultsContainer The search results container */ const showSearchResults = (emojiContainer, searchResultsContainer) => { searchResultsContainer.classList.remove('hidden'); emojiContainer.classList.add('hidden'); }; /** * Hide the search result container and show the emojis container. * * @method * @param {Element} emojiContainer The emojis container * @param {Element} searchResultsContainer The search results container * @param {Element} searchInput The search input */ const clearSearch = (emojiContainer, searchResultsContainer, searchInput) => { searchResultsContainer.classList.add('hidden'); emojiContainer.classList.remove('hidden'); searchInput.value = ''; }; /** * Build function to handle mouse hovering an emoji. Shows the preview. * * @method * @param {Element} emojiPreview The emoji preview element * @param {Element} emojiShortName The emoji short name element * @return {Function} */ const getHandleMouseEnter = (emojiPreview, emojiShortName) => { return (e) => { const target = e.target; if (isEmojiElement(target)) { emojiShortName.textContent = target.getAttribute('data-short-names'); emojiPreview.textContent = target.textContent; } }; }; /** * Build function to handle mouse leaving an emoji. Removes the preview. * * @method * @param {Element} emojiPreview The emoji preview element * @param {Element} emojiShortName The emoji short name element * @return {Function} */ const getHandleMouseLeave = (emojiPreview, emojiShortName) => { return (e) => { const target = e.target; if (isEmojiElement(target)) { emojiShortName.textContent = ''; emojiPreview.textContent = ''; } }; }; /** * Build the function to handle a user clicking something in the picker. * * The function currently handles clicking on the category selector or selecting * a specific emoji. * * @method * @param {Number} recentEmojiRowCount Number of rows of recent emojis * @param {Element} emojiContainer Container element for the visible of emojis * @param {Element} searchResultsContainer Contaienr element for the search results * @param {Element} searchInput Search input element * @param {Function} selectCallback Callback function to execute when a user selects an emoji * @param {Function} renderAtPosition Render function to display current visible emojis * @return {Function} */ const getHandleClick = ( recentEmojiRowCount, emojiContainer, searchResultsContainer, searchInput, selectCallback, renderAtPosition ) => { return (e, rowData, categoryScrollPositions) => { const target = e.target; let newRowData = rowData; let newCategoryScrollPositions = categoryScrollPositions; // Hide the search results if they are visible. clearSearch(emojiContainer, searchResultsContainer, searchInput); if (isEmojiElement(target)) { // Emoji selected. const unified = target.getAttribute('data-unified'); const shortnames = target.getAttribute('data-short-names').replace(/:/g, '').split(' '); // Build the emoji data from the selected element. const emojiData = {unified, shortnames}; const currentScrollTop = emojiContainer.scrollTop; const isRecentEmojiRowVisible = emojiContainer.querySelector(`[data-row="${recentEmojiRowCount - 1}"]`) !== null; // Save the selected emoji in the recent emojis list. [newRowData, recentEmojiRowCount] = addRecentEmoji(rowData, recentEmojiRowCount, emojiData); // Re-index the category scroll positions because the additional recent emoji may have // changed their positions. newCategoryScrollPositions = getCategoryScrollPositionsFromRowData(newRowData); if (isRecentEmojiRowVisible) { // If the list of recent emojis is currently visible then we need to re-render the emojis // to update the display and show the newly selected recent emoji. renderAtPosition(currentScrollTop, newRowData); } // Call the client's callback function with the selected emoji. selectCallback(target.textContent); // Return the newly calculated row data and scroll positions. return [newRowData, newCategoryScrollPositions]; } const categorySelector = findCategorySelectorFromElement(target); if (categorySelector) { // Category selector. const selectedCategory = categorySelector.getAttribute('data-category'); const position = categoryScrollPositions[selectedCategory]; // Scroll the container to the selected category. This will trigger the // on scroll handler to re-render the visibile emojis. emojiContainer.scrollTop = position; } return [newRowData, newCategoryScrollPositions]; }; }; /** * Build the function that handles scrolling of the emoji container to display the * correct emojis. * * We render the emoji rows as they are needed rather than all up front so that we * can avoid adding tends of thousands of elements to the DOM unnecessarily which * would bog down performance. * * @method * @param {Element} root The picker root element * @param {Number} currentVisibleRowScrollPosition The current scroll position of the container * @param {Element} emojiContainer The emojis container element * @param {Object} initialCategoryScrollPositions Scroll positions for each category * @param {Function} renderAtPosition Function to render the appropriate emojis for a scroll position * @return {Function} */ const getHandleScroll = ( root, currentVisibleRowScrollPosition, emojiContainer, initialCategoryScrollPositions, renderAtPosition ) => { // Scope some local variables to track the scroll positions of the categories. We need to // recalculate these because adding recent emojis can change those positions by adding // additional rows. let [ currentCategoryElement, previousCategoryPosition, nextCategoryPosition ] = getCategoryByScrollPosition(root, emojiContainer.scrollTop, initialCategoryScrollPositions); return (categoryScrollPositions, rowData) => { const newScrollPosition = emojiContainer.scrollTop; const upperScrollBound = currentVisibleRowScrollPosition + ROW_HEIGHT_RAW; const lowerScrollBound = currentVisibleRowScrollPosition - ROW_HEIGHT_RAW; // We only need to update the active category indicator if the user has scrolled into a // new category scroll position. const updateActiveCategory = (newScrollPosition >= nextCategoryPosition) || (newScrollPosition < previousCategoryPosition); // We only need to render new emoji rows if the user has scrolled far enough that a new row // would be visible (i.e. they've scrolled up or down more than 40px - the height of a row). const updateRenderRows = (newScrollPosition < lowerScrollBound) || (newScrollPosition > upperScrollBound); if (updateActiveCategory) { // New category is visible so update the active category selector and re-index the // positions incase anything has changed. [ currentCategoryElement, previousCategoryPosition, nextCategoryPosition ] = getCategoryByScrollPosition(root, newScrollPosition, categoryScrollPositions); setCategorySelectorActive(root, currentCategoryElement); } if (updateRenderRows) { // A new row should be visible so re-render the visible emojis at this new position. // We request an animation frame from the browser so that we're not blocking anything. // The animation only needs to occur as soon as the browser is ready not immediately. requestAnimationFrame(() => { renderAtPosition(newScrollPosition, rowData); // Remember the updated position. currentVisibleRowScrollPosition = newScrollPosition; }); } }; }; /** * Build the function that handles search input from the user. * * @method * @param {Element} searchInput The search input element * @param {Element} searchResultsContainer Container element to display the search results * @param {Element} emojiContainer Container element for the emoji rows * @return {Function} */ const getHandleSearch = (searchInput, searchResultsContainer, emojiContainer) => { const rowContainer = searchResultsContainer.querySelector(SELECTORS.ROW_CONTAINER); // Build a render function for the search results. const renderSearchResultsAtPosition = generateRenderRowsAtPositionFunction(rowContainer); searchResultsContainer.appendChild(rowContainer); return async() => { const searchTerm = searchInput.value.toLowerCase(); if (searchTerm) { // Display the search results container and hide the emojis container. showSearchResults(emojiContainer, searchResultsContainer); // Find which emojis match the user's search input. const matchingEmojis = Object.keys(EmojiData.byShortName).reduce((carry, shortName) => { if (shortName.includes(searchTerm)) { carry.push({ shortnames: [shortName], unified: EmojiData.byShortName[shortName] }); } return carry; }, []); const searchResultsString = await getString('searchresults', 'core'); const rowData = createRowDataForCategory(searchResultsString, searchResultsString, matchingEmojis, 0); // Show the emoji rows for the search results. renderSearchResultsAtPosition(0, rowData, rowData.length); } else { // Hide the search container and show the emojis container. clearSearch(emojiContainer, searchResultsContainer, searchInput); } }; }; /** * Register the emoji picker event listeners. * * @method * @param {Element} root The picker root element * @param {Element} emojiContainer Root element containing the list of visible emojis * @param {Function} renderAtPosition Function to render the visible emojis at a given scroll position * @param {Number} currentVisibleRowScrollPosition What is the current scroll position * @param {Function} selectCallback Function to execute when the user picks an emoji * @param {Object} categoryScrollPositions Scroll positions for where each of the emoji categories begin * @param {Array} rowData Data representing each of the display rows for hte emoji container * @param {Number} recentEmojiRowCount Number of rows of recent emojis */ const registerEventListeners = ( root, emojiContainer, renderAtPosition, currentVisibleRowScrollPosition, selectCallback, categoryScrollPositions, rowData, recentEmojiRowCount ) => { const searchInput = root.querySelector(SELECTORS.SEARCH_INPUT); const searchResultsContainer = root.querySelector(SELECTORS.SEARCH_RESULTS_CONTAINER); const emojiPreview = root.querySelector(SELECTORS.EMOJI_PREVIEW); const emojiShortName = root.querySelector(SELECTORS.EMOJI_SHORT_NAME); // Build the click handler function. const clickHandler = getHandleClick( recentEmojiRowCount, emojiContainer, searchResultsContainer, searchInput, selectCallback, renderAtPosition ); // Build the scroll handler function. const scrollHandler = getHandleScroll( root, currentVisibleRowScrollPosition, emojiContainer, categoryScrollPositions, renderAtPosition ); const searchHandler = getHandleSearch(searchInput, searchResultsContainer, emojiContainer); // Mouse enter/leave events to show the emoji preview on hover or focus. root.addEventListener('focus', getHandleMouseEnter(emojiPreview, emojiShortName), true); root.addEventListener('blur', getHandleMouseLeave(emojiPreview, emojiShortName), true); root.addEventListener('mouseenter', getHandleMouseEnter(emojiPreview, emojiShortName), true); root.addEventListener('mouseleave', getHandleMouseLeave(emojiPreview, emojiShortName), true); // User selects an emoji or clicks on one of the emoji category selectors. root.addEventListener('click', e => { // Update the row data and category scroll positions because they may have changes if the // user selects an emoji which updates the recent emojis list. [rowData, categoryScrollPositions] = clickHandler(e, rowData, categoryScrollPositions); }); // Throttle the scroll event to only execute once every 50 milliseconds to prevent performance issues // in the browser when re-rendering the picker emojis. The scroll event fires a lot otherwise. emojiContainer.addEventListener('scroll', throttle(() => scrollHandler(categoryScrollPositions, rowData), 50)); // Debounce the search input so that it only executes 200 milliseconds after the user has finished typing. searchInput.addEventListener('input', debounce(searchHandler, 200)); }; /** * Initialise the emoji picker. * * @method * @param {Element} root The root element for the picker * @param {Function} selectCallback Callback for when the user selects an emoji */ export default (root, selectCallback) => { const emojiContainer = root.querySelector(SELECTORS.EMOJIS_CONTAINER); const rowContainer = emojiContainer.querySelector(SELECTORS.ROW_CONTAINER); const recentEmojis = getRecentEmojis(); // Add the recent emojis category to the list of standard categories. const allData = [{ name: 'Recent', emojis: recentEmojis }, ...EmojiData.byCategory]; let rowData = []; let recentEmojiRowCount = 0; /** * Split categories data into rows which represent how they will be displayed in the * picker. Each category will add a row containing the display name for the category * and a row for every 9 emojis in the category. The row data will be used to calculate * which emojis should be visible in the picker at any given time. * * E.g. * input = [ * {name: 'example1', emojis: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]}, * {name: 'example2', emojis: [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]}, * ] * output = [ * {type: 'categoryName': data: 'Example 1'}, * {type: 'emojiRow': data: [1, 2, 3, 4, 5, 6, 7, 8, 9]}, * {type: 'emojiRow': data: [10, 11, 12]}, * {type: 'categoryName': data: 'Example 2'}, * {type: 'emojiRow': data: [13, 14, 15, 16, 17, 18, 19, 20, 21]}, * {type: 'emojiRow': data: [22, 23]}, * ] */ allData.forEach(category => { const categorySelector = getCategorySelectorByCategoryName(root, category.name); // Get the display name from the category selector button so that we don't need to // send an ajax request for the string. const categoryDisplayName = categorySelector.title; const categoryRowData = createRowDataForCategory(category.name, categoryDisplayName, category.emojis, rowData.length); if (category.name === 'Recent') { // Remember how many recent emoji rows there are because it needs to be used to // re-index the row data later when we're adding more recent emojis. recentEmojiRowCount = categoryRowData.length; } rowData = rowData.concat(categoryRowData); }); // Index the row data so that we can calculate which rows should be visible. rowData = addIndexesToRowData(rowData); // Calculate the scroll positions for each of the categories within the emoji container. // These are used to know where to jump to when the user selects a specific category. const categoryScrollPositions = getCategoryScrollPositionsFromRowData(rowData); const renderAtPosition = generateRenderRowsAtPositionFunction(rowContainer); // Display the initial set of emojis. renderAtPosition(0, rowData); registerEventListeners( root, emojiContainer, renderAtPosition, 0, selectCallback, categoryScrollPositions, rowData, recentEmojiRowCount ); }; emoji/data.js 0000644 00001216036 15152050146 0007126 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Emoji data based on the data available from https://github.com/iamcal/emoji-data. * * @copyright 2019 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ export const byCategory = [ { "name": "Smileys & Emotion", "emojis": [ { "unified": "1F600", "shortnames": [ "grinning" ] }, { "unified": "1F603", "shortnames": [ "smiley" ] }, { "unified": "1F604", "shortnames": [ "smile" ] }, { "unified": "1F601", "shortnames": [ "grin" ] }, { "unified": "1F606", "shortnames": [ "laughing" ] }, { "unified": "1F605", "shortnames": [ "sweat_smile" ] }, { "unified": "1F923", "shortnames": [ "rolling_on_the_floor_laughing" ] }, { "unified": "1F602", "shortnames": [ "joy" ] }, { "unified": "1F642", "shortnames": [ "slightly_smiling_face" ] }, { "unified": "1F643", "shortnames": [ "upside_down_face" ] }, { "unified": "1FAE0", "shortnames": [ "melting_face" ] }, { "unified": "1F609", "shortnames": [ "wink" ] }, { "unified": "1F60A", "shortnames": [ "blush" ] }, { "unified": "1F607", "shortnames": [ "innocent" ] }, { "unified": "1F970", "shortnames": [ "smiling_face_with_3_hearts" ] }, { "unified": "1F60D", "shortnames": [ "heart_eyes" ] }, { "unified": "1F929", "shortnames": [ "star-struck" ] }, { "unified": "1F618", "shortnames": [ "kissing_heart" ] }, { "unified": "1F617", "shortnames": [ "kissing" ] }, { "unified": "263A-FE0F", "shortnames": [ "relaxed" ] }, { "unified": "1F61A", "shortnames": [ "kissing_closed_eyes" ] }, { "unified": "1F619", "shortnames": [ "kissing_smiling_eyes" ] }, { "unified": "1F972", "shortnames": [ "smiling_face_with_tear" ] }, { "unified": "1F60B", "shortnames": [ "yum" ] }, { "unified": "1F61B", "shortnames": [ "stuck_out_tongue" ] }, { "unified": "1F61C", "shortnames": [ "stuck_out_tongue_winking_eye" ] }, { "unified": "1F92A", "shortnames": [ "zany_face" ] }, { "unified": "1F61D", "shortnames": [ "stuck_out_tongue_closed_eyes" ] }, { "unified": "1F911", "shortnames": [ "money_mouth_face" ] }, { "unified": "1F917", "shortnames": [ "hugging_face" ] }, { "unified": "1F92D", "shortnames": [ "face_with_hand_over_mouth" ] }, { "unified": "1FAE2", "shortnames": [ "face_with_open_eyes_and_hand_over_mouth" ] }, { "unified": "1FAE3", "shortnames": [ "face_with_peeking_eye" ] }, { "unified": "1F92B", "shortnames": [ "shushing_face" ] }, { "unified": "1F914", "shortnames": [ "thinking_face" ] }, { "unified": "1FAE1", "shortnames": [ "saluting_face" ] }, { "unified": "1F910", "shortnames": [ "zipper_mouth_face" ] }, { "unified": "1F928", "shortnames": [ "face_with_raised_eyebrow" ] }, { "unified": "1F610", "shortnames": [ "neutral_face" ] }, { "unified": "1F611", "shortnames": [ "expressionless" ] }, { "unified": "1F636", "shortnames": [ "no_mouth" ] }, { "unified": "1FAE5", "shortnames": [ "dotted_line_face" ] }, { "unified": "1F636-200D-1F32B-FE0F", "shortnames": [ "face_in_clouds" ] }, { "unified": "1F60F", "shortnames": [ "smirk" ] }, { "unified": "1F612", "shortnames": [ "unamused" ] }, { "unified": "1F644", "shortnames": [ "face_with_rolling_eyes" ] }, { "unified": "1F62C", "shortnames": [ "grimacing" ] }, { "unified": "1F62E-200D-1F4A8", "shortnames": [ "face_exhaling" ] }, { "unified": "1F925", "shortnames": [ "lying_face" ] }, { "unified": "1F60C", "shortnames": [ "relieved" ] }, { "unified": "1F614", "shortnames": [ "pensive" ] }, { "unified": "1F62A", "shortnames": [ "sleepy" ] }, { "unified": "1F924", "shortnames": [ "drooling_face" ] }, { "unified": "1F634", "shortnames": [ "sleeping" ] }, { "unified": "1F637", "shortnames": [ "mask" ] }, { "unified": "1F912", "shortnames": [ "face_with_thermometer" ] }, { "unified": "1F915", "shortnames": [ "face_with_head_bandage" ] }, { "unified": "1F922", "shortnames": [ "nauseated_face" ] }, { "unified": "1F92E", "shortnames": [ "face_vomiting" ] }, { "unified": "1F927", "shortnames": [ "sneezing_face" ] }, { "unified": "1F975", "shortnames": [ "hot_face" ] }, { "unified": "1F976", "shortnames": [ "cold_face" ] }, { "unified": "1F974", "shortnames": [ "woozy_face" ] }, { "unified": "1F635", "shortnames": [ "dizzy_face" ] }, { "unified": "1F635-200D-1F4AB", "shortnames": [ "face_with_spiral_eyes" ] }, { "unified": "1F92F", "shortnames": [ "exploding_head" ] }, { "unified": "1F920", "shortnames": [ "face_with_cowboy_hat" ] }, { "unified": "1F973", "shortnames": [ "partying_face" ] }, { "unified": "1F978", "shortnames": [ "disguised_face" ] }, { "unified": "1F60E", "shortnames": [ "sunglasses" ] }, { "unified": "1F913", "shortnames": [ "nerd_face" ] }, { "unified": "1F9D0", "shortnames": [ "face_with_monocle" ] }, { "unified": "1F615", "shortnames": [ "confused" ] }, { "unified": "1FAE4", "shortnames": [ "face_with_diagonal_mouth" ] }, { "unified": "1F61F", "shortnames": [ "worried" ] }, { "unified": "1F641", "shortnames": [ "slightly_frowning_face" ] }, { "unified": "2639-FE0F", "shortnames": [ "white_frowning_face" ] }, { "unified": "1F62E", "shortnames": [ "open_mouth" ] }, { "unified": "1F62F", "shortnames": [ "hushed" ] }, { "unified": "1F632", "shortnames": [ "astonished" ] }, { "unified": "1F633", "shortnames": [ "flushed" ] }, { "unified": "1F97A", "shortnames": [ "pleading_face" ] }, { "unified": "1F979", "shortnames": [ "face_holding_back_tears" ] }, { "unified": "1F626", "shortnames": [ "frowning" ] }, { "unified": "1F627", "shortnames": [ "anguished" ] }, { "unified": "1F628", "shortnames": [ "fearful" ] }, { "unified": "1F630", "shortnames": [ "cold_sweat" ] }, { "unified": "1F625", "shortnames": [ "disappointed_relieved" ] }, { "unified": "1F622", "shortnames": [ "cry" ] }, { "unified": "1F62D", "shortnames": [ "sob" ] }, { "unified": "1F631", "shortnames": [ "scream" ] }, { "unified": "1F616", "shortnames": [ "confounded" ] }, { "unified": "1F623", "shortnames": [ "persevere" ] }, { "unified": "1F61E", "shortnames": [ "disappointed" ] }, { "unified": "1F613", "shortnames": [ "sweat" ] }, { "unified": "1F629", "shortnames": [ "weary" ] }, { "unified": "1F62B", "shortnames": [ "tired_face" ] }, { "unified": "1F971", "shortnames": [ "yawning_face" ] }, { "unified": "1F624", "shortnames": [ "triumph" ] }, { "unified": "1F621", "shortnames": [ "rage" ] }, { "unified": "1F620", "shortnames": [ "angry" ] }, { "unified": "1F92C", "shortnames": [ "face_with_symbols_on_mouth" ] }, { "unified": "1F608", "shortnames": [ "smiling_imp" ] }, { "unified": "1F47F", "shortnames": [ "imp" ] }, { "unified": "1F480", "shortnames": [ "skull" ] }, { "unified": "2620-FE0F", "shortnames": [ "skull_and_crossbones" ] }, { "unified": "1F4A9", "shortnames": [ "hankey" ] }, { "unified": "1F921", "shortnames": [ "clown_face" ] }, { "unified": "1F479", "shortnames": [ "japanese_ogre" ] }, { "unified": "1F47A", "shortnames": [ "japanese_goblin" ] }, { "unified": "1F47B", "shortnames": [ "ghost" ] }, { "unified": "1F47D", "shortnames": [ "alien" ] }, { "unified": "1F47E", "shortnames": [ "space_invader" ] }, { "unified": "1F916", "shortnames": [ "robot_face" ] }, { "unified": "1F63A", "shortnames": [ "smiley_cat" ] }, { "unified": "1F638", "shortnames": [ "smile_cat" ] }, { "unified": "1F639", "shortnames": [ "joy_cat" ] }, { "unified": "1F63B", "shortnames": [ "heart_eyes_cat" ] }, { "unified": "1F63C", "shortnames": [ "smirk_cat" ] }, { "unified": "1F63D", "shortnames": [ "kissing_cat" ] }, { "unified": "1F640", "shortnames": [ "scream_cat" ] }, { "unified": "1F63F", "shortnames": [ "crying_cat_face" ] }, { "unified": "1F63E", "shortnames": [ "pouting_cat" ] }, { "unified": "1F648", "shortnames": [ "see_no_evil" ] }, { "unified": "1F649", "shortnames": [ "hear_no_evil" ] }, { "unified": "1F64A", "shortnames": [ "speak_no_evil" ] }, { "unified": "1F48B", "shortnames": [ "kiss" ] }, { "unified": "1F48C", "shortnames": [ "love_letter" ] }, { "unified": "1F498", "shortnames": [ "cupid" ] }, { "unified": "1F49D", "shortnames": [ "gift_heart" ] }, { "unified": "1F496", "shortnames": [ "sparkling_heart" ] }, { "unified": "1F497", "shortnames": [ "heartpulse" ] }, { "unified": "1F493", "shortnames": [ "heartbeat" ] }, { "unified": "1F49E", "shortnames": [ "revolving_hearts" ] }, { "unified": "1F495", "shortnames": [ "two_hearts" ] }, { "unified": "1F49F", "shortnames": [ "heart_decoration" ] }, { "unified": "2763-FE0F", "shortnames": [ "heavy_heart_exclamation_mark_ornament" ] }, { "unified": "1F494", "shortnames": [ "broken_heart" ] }, { "unified": "2764-FE0F-200D-1F525", "shortnames": [ "heart_on_fire" ] }, { "unified": "2764-FE0F-200D-1FA79", "shortnames": [ "mending_heart" ] }, { "unified": "2764-FE0F", "shortnames": [ "heart" ] }, { "unified": "1F9E1", "shortnames": [ "orange_heart" ] }, { "unified": "1F49B", "shortnames": [ "yellow_heart" ] }, { "unified": "1F49A", "shortnames": [ "green_heart" ] }, { "unified": "1F499", "shortnames": [ "blue_heart" ] }, { "unified": "1F49C", "shortnames": [ "purple_heart" ] }, { "unified": "1F90E", "shortnames": [ "brown_heart" ] }, { "unified": "1F5A4", "shortnames": [ "black_heart" ] }, { "unified": "1F90D", "shortnames": [ "white_heart" ] }, { "unified": "1F4AF", "shortnames": [ "100" ] }, { "unified": "1F4A2", "shortnames": [ "anger" ] }, { "unified": "1F4A5", "shortnames": [ "boom" ] }, { "unified": "1F4AB", "shortnames": [ "dizzy" ] }, { "unified": "1F4A6", "shortnames": [ "sweat_drops" ] }, { "unified": "1F4A8", "shortnames": [ "dash" ] }, { "unified": "1F573-FE0F", "shortnames": [ "hole" ] }, { "unified": "1F4A3", "shortnames": [ "bomb" ] }, { "unified": "1F4AC", "shortnames": [ "speech_balloon" ] }, { "unified": "1F441-FE0F-200D-1F5E8-FE0F", "shortnames": [ "eye-in-speech-bubble" ] }, { "unified": "1F5E8-FE0F", "shortnames": [ "left_speech_bubble" ] }, { "unified": "1F5EF-FE0F", "shortnames": [ "right_anger_bubble" ] }, { "unified": "1F4AD", "shortnames": [ "thought_balloon" ] }, { "unified": "1F4A4", "shortnames": [ "zzz" ] } ] }, { "name": "People & Body", "emojis": [ { "unified": "1F44B", "shortnames": [ "wave" ] }, { "unified": "1F91A", "shortnames": [ "raised_back_of_hand" ] }, { "unified": "1F590-FE0F", "shortnames": [ "raised_hand_with_fingers_splayed" ] }, { "unified": "270B", "shortnames": [ "hand" ] }, { "unified": "1F596", "shortnames": [ "spock-hand" ] }, { "unified": "1FAF1", "shortnames": [ "rightwards_hand" ] }, { "unified": "1FAF2", "shortnames": [ "leftwards_hand" ] }, { "unified": "1FAF3", "shortnames": [ "palm_down_hand" ] }, { "unified": "1FAF4", "shortnames": [ "palm_up_hand" ] }, { "unified": "1F44C", "shortnames": [ "ok_hand" ] }, { "unified": "1F90C", "shortnames": [ "pinched_fingers" ] }, { "unified": "1F90F", "shortnames": [ "pinching_hand" ] }, { "unified": "270C-FE0F", "shortnames": [ "v" ] }, { "unified": "1F91E", "shortnames": [ "crossed_fingers" ] }, { "unified": "1FAF0", "shortnames": [ "hand_with_index_finger_and_thumb_crossed" ] }, { "unified": "1F91F", "shortnames": [ "i_love_you_hand_sign" ] }, { "unified": "1F918", "shortnames": [ "the_horns" ] }, { "unified": "1F919", "shortnames": [ "call_me_hand" ] }, { "unified": "1F448", "shortnames": [ "point_left" ] }, { "unified": "1F449", "shortnames": [ "point_right" ] }, { "unified": "1F446", "shortnames": [ "point_up_2" ] }, { "unified": "1F595", "shortnames": [ "middle_finger" ] }, { "unified": "1F447", "shortnames": [ "point_down" ] }, { "unified": "261D-FE0F", "shortnames": [ "point_up" ] }, { "unified": "1FAF5", "shortnames": [ "index_pointing_at_the_viewer" ] }, { "unified": "1F44D", "shortnames": [ "+1" ] }, { "unified": "1F44E", "shortnames": [ "-1" ] }, { "unified": "270A", "shortnames": [ "fist" ] }, { "unified": "1F44A", "shortnames": [ "facepunch" ] }, { "unified": "1F91B", "shortnames": [ "left-facing_fist" ] }, { "unified": "1F91C", "shortnames": [ "right-facing_fist" ] }, { "unified": "1F44F", "shortnames": [ "clap" ] }, { "unified": "1F64C", "shortnames": [ "raised_hands" ] }, { "unified": "1FAF6", "shortnames": [ "heart_hands" ] }, { "unified": "1F450", "shortnames": [ "open_hands" ] }, { "unified": "1F932", "shortnames": [ "palms_up_together" ] }, { "unified": "1F91D", "shortnames": [ "handshake" ] }, { "unified": "1F64F", "shortnames": [ "pray" ] }, { "unified": "270D-FE0F", "shortnames": [ "writing_hand" ] }, { "unified": "1F485", "shortnames": [ "nail_care" ] }, { "unified": "1F933", "shortnames": [ "selfie" ] }, { "unified": "1F4AA", "shortnames": [ "muscle" ] }, { "unified": "1F9BE", "shortnames": [ "mechanical_arm" ] }, { "unified": "1F9BF", "shortnames": [ "mechanical_leg" ] }, { "unified": "1F9B5", "shortnames": [ "leg" ] }, { "unified": "1F9B6", "shortnames": [ "foot" ] }, { "unified": "1F442", "shortnames": [ "ear" ] }, { "unified": "1F9BB", "shortnames": [ "ear_with_hearing_aid" ] }, { "unified": "1F443", "shortnames": [ "nose" ] }, { "unified": "1F9E0", "shortnames": [ "brain" ] }, { "unified": "1FAC0", "shortnames": [ "anatomical_heart" ] }, { "unified": "1FAC1", "shortnames": [ "lungs" ] }, { "unified": "1F9B7", "shortnames": [ "tooth" ] }, { "unified": "1F9B4", "shortnames": [ "bone" ] }, { "unified": "1F440", "shortnames": [ "eyes" ] }, { "unified": "1F441-FE0F", "shortnames": [ "eye" ] }, { "unified": "1F445", "shortnames": [ "tongue" ] }, { "unified": "1F444", "shortnames": [ "lips" ] }, { "unified": "1FAE6", "shortnames": [ "biting_lip" ] }, { "unified": "1F476", "shortnames": [ "baby" ] }, { "unified": "1F9D2", "shortnames": [ "child" ] }, { "unified": "1F466", "shortnames": [ "boy" ] }, { "unified": "1F467", "shortnames": [ "girl" ] }, { "unified": "1F9D1", "shortnames": [ "adult" ] }, { "unified": "1F468", "shortnames": [ "man" ] }, { "unified": "1F9D4", "shortnames": [ "bearded_person" ] }, { "unified": "1F9D4-200D-2642-FE0F", "shortnames": [ "man_with_beard" ] }, { "unified": "1F9D4-200D-2640-FE0F", "shortnames": [ "woman_with_beard" ] }, { "unified": "1F468-200D-1F9B0", "shortnames": [ "red_haired_man" ] }, { "unified": "1F468-200D-1F9B1", "shortnames": [ "curly_haired_man" ] }, { "unified": "1F468-200D-1F9B3", "shortnames": [ "white_haired_man" ] }, { "unified": "1F468-200D-1F9B2", "shortnames": [ "bald_man" ] }, { "unified": "1F469", "shortnames": [ "woman" ] }, { "unified": "1F469-200D-1F9B0", "shortnames": [ "red_haired_woman" ] }, { "unified": "1F9D1-200D-1F9B0", "shortnames": [ "red_haired_person" ] }, { "unified": "1F469-200D-1F9B1", "shortnames": [ "curly_haired_woman" ] }, { "unified": "1F9D1-200D-1F9B1", "shortnames": [ "curly_haired_person" ] }, { "unified": "1F469-200D-1F9B3", "shortnames": [ "white_haired_woman" ] }, { "unified": "1F9D1-200D-1F9B3", "shortnames": [ "white_haired_person" ] }, { "unified": "1F469-200D-1F9B2", "shortnames": [ "bald_woman" ] }, { "unified": "1F9D1-200D-1F9B2", "shortnames": [ "bald_person" ] }, { "unified": "1F471-200D-2640-FE0F", "shortnames": [ "blond-haired-woman" ] }, { "unified": "1F471-200D-2642-FE0F", "shortnames": [ "blond-haired-man", "person_with_blond_hair" ] }, { "unified": "1F9D3", "shortnames": [ "older_adult" ] }, { "unified": "1F474", "shortnames": [ "older_man" ] }, { "unified": "1F475", "shortnames": [ "older_woman" ] }, { "unified": "1F64D-200D-2642-FE0F", "shortnames": [ "man-frowning" ] }, { "unified": "1F64D-200D-2640-FE0F", "shortnames": [ "woman-frowning", "person_frowning" ] }, { "unified": "1F64E-200D-2642-FE0F", "shortnames": [ "man-pouting" ] }, { "unified": "1F64E-200D-2640-FE0F", "shortnames": [ "woman-pouting", "person_with_pouting_face" ] }, { "unified": "1F645-200D-2642-FE0F", "shortnames": [ "man-gesturing-no" ] }, { "unified": "1F645-200D-2640-FE0F", "shortnames": [ "woman-gesturing-no", "no_good" ] }, { "unified": "1F646-200D-2642-FE0F", "shortnames": [ "man-gesturing-ok" ] }, { "unified": "1F646-200D-2640-FE0F", "shortnames": [ "woman-gesturing-ok", "ok_woman" ] }, { "unified": "1F481-200D-2642-FE0F", "shortnames": [ "man-tipping-hand" ] }, { "unified": "1F481-200D-2640-FE0F", "shortnames": [ "woman-tipping-hand", "information_desk_person" ] }, { "unified": "1F64B-200D-2642-FE0F", "shortnames": [ "man-raising-hand" ] }, { "unified": "1F64B-200D-2640-FE0F", "shortnames": [ "woman-raising-hand", "raising_hand" ] }, { "unified": "1F9CF", "shortnames": [ "deaf_person" ] }, { "unified": "1F9CF-200D-2642-FE0F", "shortnames": [ "deaf_man" ] }, { "unified": "1F9CF-200D-2640-FE0F", "shortnames": [ "deaf_woman" ] }, { "unified": "1F647", "shortnames": [ "bow" ] }, { "unified": "1F647-200D-2642-FE0F", "shortnames": [ "man-bowing" ] }, { "unified": "1F647-200D-2640-FE0F", "shortnames": [ "woman-bowing" ] }, { "unified": "1F926", "shortnames": [ "face_palm" ] }, { "unified": "1F926-200D-2642-FE0F", "shortnames": [ "man-facepalming" ] }, { "unified": "1F926-200D-2640-FE0F", "shortnames": [ "woman-facepalming" ] }, { "unified": "1F937", "shortnames": [ "shrug" ] }, { "unified": "1F937-200D-2642-FE0F", "shortnames": [ "man-shrugging" ] }, { "unified": "1F937-200D-2640-FE0F", "shortnames": [ "woman-shrugging" ] }, { "unified": "1F9D1-200D-2695-FE0F", "shortnames": [ "health_worker" ] }, { "unified": "1F468-200D-2695-FE0F", "shortnames": [ "male-doctor" ] }, { "unified": "1F469-200D-2695-FE0F", "shortnames": [ "female-doctor" ] }, { "unified": "1F9D1-200D-1F393", "shortnames": [ "student" ] }, { "unified": "1F468-200D-1F393", "shortnames": [ "male-student" ] }, { "unified": "1F469-200D-1F393", "shortnames": [ "female-student" ] }, { "unified": "1F9D1-200D-1F3EB", "shortnames": [ "teacher" ] }, { "unified": "1F468-200D-1F3EB", "shortnames": [ "male-teacher" ] }, { "unified": "1F469-200D-1F3EB", "shortnames": [ "female-teacher" ] }, { "unified": "1F9D1-200D-2696-FE0F", "shortnames": [ "judge" ] }, { "unified": "1F468-200D-2696-FE0F", "shortnames": [ "male-judge" ] }, { "unified": "1F469-200D-2696-FE0F", "shortnames": [ "female-judge" ] }, { "unified": "1F9D1-200D-1F33E", "shortnames": [ "farmer" ] }, { "unified": "1F468-200D-1F33E", "shortnames": [ "male-farmer" ] }, { "unified": "1F469-200D-1F33E", "shortnames": [ "female-farmer" ] }, { "unified": "1F9D1-200D-1F373", "shortnames": [ "cook" ] }, { "unified": "1F468-200D-1F373", "shortnames": [ "male-cook" ] }, { "unified": "1F469-200D-1F373", "shortnames": [ "female-cook" ] }, { "unified": "1F9D1-200D-1F527", "shortnames": [ "mechanic" ] }, { "unified": "1F468-200D-1F527", "shortnames": [ "male-mechanic" ] }, { "unified": "1F469-200D-1F527", "shortnames": [ "female-mechanic" ] }, { "unified": "1F9D1-200D-1F3ED", "shortnames": [ "factory_worker" ] }, { "unified": "1F468-200D-1F3ED", "shortnames": [ "male-factory-worker" ] }, { "unified": "1F469-200D-1F3ED", "shortnames": [ "female-factory-worker" ] }, { "unified": "1F9D1-200D-1F4BC", "shortnames": [ "office_worker" ] }, { "unified": "1F468-200D-1F4BC", "shortnames": [ "male-office-worker" ] }, { "unified": "1F469-200D-1F4BC", "shortnames": [ "female-office-worker" ] }, { "unified": "1F9D1-200D-1F52C", "shortnames": [ "scientist" ] }, { "unified": "1F468-200D-1F52C", "shortnames": [ "male-scientist" ] }, { "unified": "1F469-200D-1F52C", "shortnames": [ "female-scientist" ] }, { "unified": "1F9D1-200D-1F4BB", "shortnames": [ "technologist" ] }, { "unified": "1F468-200D-1F4BB", "shortnames": [ "male-technologist" ] }, { "unified": "1F469-200D-1F4BB", "shortnames": [ "female-technologist" ] }, { "unified": "1F9D1-200D-1F3A4", "shortnames": [ "singer" ] }, { "unified": "1F468-200D-1F3A4", "shortnames": [ "male-singer" ] }, { "unified": "1F469-200D-1F3A4", "shortnames": [ "female-singer" ] }, { "unified": "1F9D1-200D-1F3A8", "shortnames": [ "artist" ] }, { "unified": "1F468-200D-1F3A8", "shortnames": [ "male-artist" ] }, { "unified": "1F469-200D-1F3A8", "shortnames": [ "female-artist" ] }, { "unified": "1F9D1-200D-2708-FE0F", "shortnames": [ "pilot" ] }, { "unified": "1F468-200D-2708-FE0F", "shortnames": [ "male-pilot" ] }, { "unified": "1F469-200D-2708-FE0F", "shortnames": [ "female-pilot" ] }, { "unified": "1F9D1-200D-1F680", "shortnames": [ "astronaut" ] }, { "unified": "1F468-200D-1F680", "shortnames": [ "male-astronaut" ] }, { "unified": "1F469-200D-1F680", "shortnames": [ "female-astronaut" ] }, { "unified": "1F9D1-200D-1F692", "shortnames": [ "firefighter" ] }, { "unified": "1F468-200D-1F692", "shortnames": [ "male-firefighter" ] }, { "unified": "1F469-200D-1F692", "shortnames": [ "female-firefighter" ] }, { "unified": "1F46E-200D-2642-FE0F", "shortnames": [ "male-police-officer", "cop" ] }, { "unified": "1F46E-200D-2640-FE0F", "shortnames": [ "female-police-officer" ] }, { "unified": "1F575-FE0F-200D-2642-FE0F", "shortnames": [ "male-detective", "sleuth_or_spy" ] }, { "unified": "1F575-FE0F-200D-2640-FE0F", "shortnames": [ "female-detective" ] }, { "unified": "1F482-200D-2642-FE0F", "shortnames": [ "male-guard", "guardsman" ] }, { "unified": "1F482-200D-2640-FE0F", "shortnames": [ "female-guard" ] }, { "unified": "1F977", "shortnames": [ "ninja" ] }, { "unified": "1F477-200D-2642-FE0F", "shortnames": [ "male-construction-worker", "construction_worker" ] }, { "unified": "1F477-200D-2640-FE0F", "shortnames": [ "female-construction-worker" ] }, { "unified": "1FAC5", "shortnames": [ "person_with_crown" ] }, { "unified": "1F934", "shortnames": [ "prince" ] }, { "unified": "1F478", "shortnames": [ "princess" ] }, { "unified": "1F473-200D-2642-FE0F", "shortnames": [ "man-wearing-turban", "man_with_turban" ] }, { "unified": "1F473-200D-2640-FE0F", "shortnames": [ "woman-wearing-turban" ] }, { "unified": "1F472", "shortnames": [ "man_with_gua_pi_mao" ] }, { "unified": "1F9D5", "shortnames": [ "person_with_headscarf" ] }, { "unified": "1F935", "shortnames": [ "person_in_tuxedo" ] }, { "unified": "1F935-200D-2642-FE0F", "shortnames": [ "man_in_tuxedo" ] }, { "unified": "1F935-200D-2640-FE0F", "shortnames": [ "woman_in_tuxedo" ] }, { "unified": "1F470", "shortnames": [ "bride_with_veil" ] }, { "unified": "1F470-200D-2642-FE0F", "shortnames": [ "man_with_veil" ] }, { "unified": "1F470-200D-2640-FE0F", "shortnames": [ "woman_with_veil" ] }, { "unified": "1F930", "shortnames": [ "pregnant_woman" ] }, { "unified": "1FAC3", "shortnames": [ "pregnant_man" ] }, { "unified": "1FAC4", "shortnames": [ "pregnant_person" ] }, { "unified": "1F931", "shortnames": [ "breast-feeding" ] }, { "unified": "1F469-200D-1F37C", "shortnames": [ "woman_feeding_baby" ] }, { "unified": "1F468-200D-1F37C", "shortnames": [ "man_feeding_baby" ] }, { "unified": "1F9D1-200D-1F37C", "shortnames": [ "person_feeding_baby" ] }, { "unified": "1F47C", "shortnames": [ "angel" ] }, { "unified": "1F385", "shortnames": [ "santa" ] }, { "unified": "1F936", "shortnames": [ "mrs_claus" ] }, { "unified": "1F9D1-200D-1F384", "shortnames": [ "mx_claus" ] }, { "unified": "1F9B8", "shortnames": [ "superhero" ] }, { "unified": "1F9B8-200D-2642-FE0F", "shortnames": [ "male_superhero" ] }, { "unified": "1F9B8-200D-2640-FE0F", "shortnames": [ "female_superhero" ] }, { "unified": "1F9B9", "shortnames": [ "supervillain" ] }, { "unified": "1F9B9-200D-2642-FE0F", "shortnames": [ "male_supervillain" ] }, { "unified": "1F9B9-200D-2640-FE0F", "shortnames": [ "female_supervillain" ] }, { "unified": "1F9D9-200D-2642-FE0F", "shortnames": [ "male_mage" ] }, { "unified": "1F9D9-200D-2640-FE0F", "shortnames": [ "female_mage", "mage" ] }, { "unified": "1F9DA-200D-2642-FE0F", "shortnames": [ "male_fairy" ] }, { "unified": "1F9DA-200D-2640-FE0F", "shortnames": [ "female_fairy", "fairy" ] }, { "unified": "1F9DB-200D-2642-FE0F", "shortnames": [ "male_vampire" ] }, { "unified": "1F9DB-200D-2640-FE0F", "shortnames": [ "female_vampire", "vampire" ] }, { "unified": "1F9DC-200D-2642-FE0F", "shortnames": [ "merman", "merperson" ] }, { "unified": "1F9DC-200D-2640-FE0F", "shortnames": [ "mermaid" ] }, { "unified": "1F9DD-200D-2642-FE0F", "shortnames": [ "male_elf", "elf" ] }, { "unified": "1F9DD-200D-2640-FE0F", "shortnames": [ "female_elf" ] }, { "unified": "1F9DE-200D-2642-FE0F", "shortnames": [ "male_genie", "genie" ] }, { "unified": "1F9DE-200D-2640-FE0F", "shortnames": [ "female_genie" ] }, { "unified": "1F9DF-200D-2642-FE0F", "shortnames": [ "male_zombie", "zombie" ] }, { "unified": "1F9DF-200D-2640-FE0F", "shortnames": [ "female_zombie" ] }, { "unified": "1F9CC", "shortnames": [ "troll" ] }, { "unified": "1F486-200D-2642-FE0F", "shortnames": [ "man-getting-massage" ] }, { "unified": "1F486-200D-2640-FE0F", "shortnames": [ "woman-getting-massage", "massage" ] }, { "unified": "1F487-200D-2642-FE0F", "shortnames": [ "man-getting-haircut" ] }, { "unified": "1F487-200D-2640-FE0F", "shortnames": [ "woman-getting-haircut", "haircut" ] }, { "unified": "1F6B6-200D-2642-FE0F", "shortnames": [ "man-walking", "walking" ] }, { "unified": "1F6B6-200D-2640-FE0F", "shortnames": [ "woman-walking" ] }, { "unified": "1F9CD", "shortnames": [ "standing_person" ] }, { "unified": "1F9CD-200D-2642-FE0F", "shortnames": [ "man_standing" ] }, { "unified": "1F9CD-200D-2640-FE0F", "shortnames": [ "woman_standing" ] }, { "unified": "1F9CE", "shortnames": [ "kneeling_person" ] }, { "unified": "1F9CE-200D-2642-FE0F", "shortnames": [ "man_kneeling" ] }, { "unified": "1F9CE-200D-2640-FE0F", "shortnames": [ "woman_kneeling" ] }, { "unified": "1F9D1-200D-1F9AF", "shortnames": [ "person_with_probing_cane" ] }, { "unified": "1F468-200D-1F9AF", "shortnames": [ "man_with_probing_cane" ] }, { "unified": "1F469-200D-1F9AF", "shortnames": [ "woman_with_probing_cane" ] }, { "unified": "1F9D1-200D-1F9BC", "shortnames": [ "person_in_motorized_wheelchair" ] }, { "unified": "1F468-200D-1F9BC", "shortnames": [ "man_in_motorized_wheelchair" ] }, { "unified": "1F469-200D-1F9BC", "shortnames": [ "woman_in_motorized_wheelchair" ] }, { "unified": "1F9D1-200D-1F9BD", "shortnames": [ "person_in_manual_wheelchair" ] }, { "unified": "1F468-200D-1F9BD", "shortnames": [ "man_in_manual_wheelchair" ] }, { "unified": "1F469-200D-1F9BD", "shortnames": [ "woman_in_manual_wheelchair" ] }, { "unified": "1F3C3-200D-2642-FE0F", "shortnames": [ "man-running", "runner" ] }, { "unified": "1F3C3-200D-2640-FE0F", "shortnames": [ "woman-running" ] }, { "unified": "1F483", "shortnames": [ "dancer" ] }, { "unified": "1F57A", "shortnames": [ "man_dancing" ] }, { "unified": "1F574-FE0F", "shortnames": [ "man_in_business_suit_levitating" ] }, { "unified": "1F46F-200D-2642-FE0F", "shortnames": [ "men-with-bunny-ears-partying" ] }, { "unified": "1F46F-200D-2640-FE0F", "shortnames": [ "women-with-bunny-ears-partying", "dancers" ] }, { "unified": "1F9D6-200D-2642-FE0F", "shortnames": [ "man_in_steamy_room", "person_in_steamy_room" ] }, { "unified": "1F9D6-200D-2640-FE0F", "shortnames": [ "woman_in_steamy_room" ] }, { "unified": "1F9D7-200D-2642-FE0F", "shortnames": [ "man_climbing" ] }, { "unified": "1F9D7-200D-2640-FE0F", "shortnames": [ "woman_climbing", "person_climbing" ] }, { "unified": "1F93A", "shortnames": [ "fencer" ] }, { "unified": "1F3C7", "shortnames": [ "horse_racing" ] }, { "unified": "26F7-FE0F", "shortnames": [ "skier" ] }, { "unified": "1F3C2", "shortnames": [ "snowboarder" ] }, { "unified": "1F3CC-FE0F-200D-2642-FE0F", "shortnames": [ "man-golfing", "golfer" ] }, { "unified": "1F3CC-FE0F-200D-2640-FE0F", "shortnames": [ "woman-golfing" ] }, { "unified": "1F3C4-200D-2642-FE0F", "shortnames": [ "man-surfing", "surfer" ] }, { "unified": "1F3C4-200D-2640-FE0F", "shortnames": [ "woman-surfing" ] }, { "unified": "1F6A3-200D-2642-FE0F", "shortnames": [ "man-rowing-boat", "rowboat" ] }, { "unified": "1F6A3-200D-2640-FE0F", "shortnames": [ "woman-rowing-boat" ] }, { "unified": "1F3CA-200D-2642-FE0F", "shortnames": [ "man-swimming", "swimmer" ] }, { "unified": "1F3CA-200D-2640-FE0F", "shortnames": [ "woman-swimming" ] }, { "unified": "26F9-FE0F-200D-2642-FE0F", "shortnames": [ "man-bouncing-ball", "person_with_ball" ] }, { "unified": "26F9-FE0F-200D-2640-FE0F", "shortnames": [ "woman-bouncing-ball" ] }, { "unified": "1F3CB-FE0F-200D-2642-FE0F", "shortnames": [ "man-lifting-weights", "weight_lifter" ] }, { "unified": "1F3CB-FE0F-200D-2640-FE0F", "shortnames": [ "woman-lifting-weights" ] }, { "unified": "1F6B4-200D-2642-FE0F", "shortnames": [ "man-biking", "bicyclist" ] }, { "unified": "1F6B4-200D-2640-FE0F", "shortnames": [ "woman-biking" ] }, { "unified": "1F6B5-200D-2642-FE0F", "shortnames": [ "man-mountain-biking", "mountain_bicyclist" ] }, { "unified": "1F6B5-200D-2640-FE0F", "shortnames": [ "woman-mountain-biking" ] }, { "unified": "1F938", "shortnames": [ "person_doing_cartwheel" ] }, { "unified": "1F938-200D-2642-FE0F", "shortnames": [ "man-cartwheeling" ] }, { "unified": "1F938-200D-2640-FE0F", "shortnames": [ "woman-cartwheeling" ] }, { "unified": "1F93C", "shortnames": [ "wrestlers" ] }, { "unified": "1F93C-200D-2642-FE0F", "shortnames": [ "man-wrestling" ] }, { "unified": "1F93C-200D-2640-FE0F", "shortnames": [ "woman-wrestling" ] }, { "unified": "1F93D", "shortnames": [ "water_polo" ] }, { "unified": "1F93D-200D-2642-FE0F", "shortnames": [ "man-playing-water-polo" ] }, { "unified": "1F93D-200D-2640-FE0F", "shortnames": [ "woman-playing-water-polo" ] }, { "unified": "1F93E", "shortnames": [ "handball" ] }, { "unified": "1F93E-200D-2642-FE0F", "shortnames": [ "man-playing-handball" ] }, { "unified": "1F93E-200D-2640-FE0F", "shortnames": [ "woman-playing-handball" ] }, { "unified": "1F939", "shortnames": [ "juggling" ] }, { "unified": "1F939-200D-2642-FE0F", "shortnames": [ "man-juggling" ] }, { "unified": "1F939-200D-2640-FE0F", "shortnames": [ "woman-juggling" ] }, { "unified": "1F9D8-200D-2642-FE0F", "shortnames": [ "man_in_lotus_position" ] }, { "unified": "1F9D8-200D-2640-FE0F", "shortnames": [ "woman_in_lotus_position", "person_in_lotus_position" ] }, { "unified": "1F6C0", "shortnames": [ "bath" ] }, { "unified": "1F6CC", "shortnames": [ "sleeping_accommodation" ] }, { "unified": "1F9D1-200D-1F91D-200D-1F9D1", "shortnames": [ "people_holding_hands" ] }, { "unified": "1F46D", "shortnames": [ "two_women_holding_hands" ] }, { "unified": "1F46B", "shortnames": [ "man_and_woman_holding_hands" ] }, { "unified": "1F46C", "shortnames": [ "two_men_holding_hands" ] }, { "unified": "1F48F", "shortnames": [ "couplekiss" ] }, { "unified": "1F469-200D-2764-FE0F-200D-1F48B-200D-1F468", "shortnames": [ "woman-kiss-man" ] }, { "unified": "1F468-200D-2764-FE0F-200D-1F48B-200D-1F468", "shortnames": [ "man-kiss-man" ] }, { "unified": "1F469-200D-2764-FE0F-200D-1F48B-200D-1F469", "shortnames": [ "woman-kiss-woman" ] }, { "unified": "1F491", "shortnames": [ "couple_with_heart" ] }, { "unified": "1F469-200D-2764-FE0F-200D-1F468", "shortnames": [ "woman-heart-man" ] }, { "unified": "1F468-200D-2764-FE0F-200D-1F468", "shortnames": [ "man-heart-man" ] }, { "unified": "1F469-200D-2764-FE0F-200D-1F469", "shortnames": [ "woman-heart-woman" ] }, { "unified": "1F468-200D-1F469-200D-1F466", "shortnames": [ "man-woman-boy", "family" ] }, { "unified": "1F468-200D-1F469-200D-1F467", "shortnames": [ "man-woman-girl" ] }, { "unified": "1F468-200D-1F469-200D-1F467-200D-1F466", "shortnames": [ "man-woman-girl-boy" ] }, { "unified": "1F468-200D-1F469-200D-1F466-200D-1F466", "shortnames": [ "man-woman-boy-boy" ] }, { "unified": "1F468-200D-1F469-200D-1F467-200D-1F467", "shortnames": [ "man-woman-girl-girl" ] }, { "unified": "1F468-200D-1F468-200D-1F466", "shortnames": [ "man-man-boy" ] }, { "unified": "1F468-200D-1F468-200D-1F467", "shortnames": [ "man-man-girl" ] }, { "unified": "1F468-200D-1F468-200D-1F467-200D-1F466", "shortnames": [ "man-man-girl-boy" ] }, { "unified": "1F468-200D-1F468-200D-1F466-200D-1F466", "shortnames": [ "man-man-boy-boy" ] }, { "unified": "1F468-200D-1F468-200D-1F467-200D-1F467", "shortnames": [ "man-man-girl-girl" ] }, { "unified": "1F469-200D-1F469-200D-1F466", "shortnames": [ "woman-woman-boy" ] }, { "unified": "1F469-200D-1F469-200D-1F467", "shortnames": [ "woman-woman-girl" ] }, { "unified": "1F469-200D-1F469-200D-1F467-200D-1F466", "shortnames": [ "woman-woman-girl-boy" ] }, { "unified": "1F469-200D-1F469-200D-1F466-200D-1F466", "shortnames": [ "woman-woman-boy-boy" ] }, { "unified": "1F469-200D-1F469-200D-1F467-200D-1F467", "shortnames": [ "woman-woman-girl-girl" ] }, { "unified": "1F468-200D-1F466", "shortnames": [ "man-boy" ] }, { "unified": "1F468-200D-1F466-200D-1F466", "shortnames": [ "man-boy-boy" ] }, { "unified": "1F468-200D-1F467", "shortnames": [ "man-girl" ] }, { "unified": "1F468-200D-1F467-200D-1F466", "shortnames": [ "man-girl-boy" ] }, { "unified": "1F468-200D-1F467-200D-1F467", "shortnames": [ "man-girl-girl" ] }, { "unified": "1F469-200D-1F466", "shortnames": [ "woman-boy" ] }, { "unified": "1F469-200D-1F466-200D-1F466", "shortnames": [ "woman-boy-boy" ] }, { "unified": "1F469-200D-1F467", "shortnames": [ "woman-girl" ] }, { "unified": "1F469-200D-1F467-200D-1F466", "shortnames": [ "woman-girl-boy" ] }, { "unified": "1F469-200D-1F467-200D-1F467", "shortnames": [ "woman-girl-girl" ] }, { "unified": "1F5E3-FE0F", "shortnames": [ "speaking_head_in_silhouette" ] }, { "unified": "1F464", "shortnames": [ "bust_in_silhouette" ] }, { "unified": "1F465", "shortnames": [ "busts_in_silhouette" ] }, { "unified": "1FAC2", "shortnames": [ "people_hugging" ] }, { "unified": "1F463", "shortnames": [ "footprints" ] } ] }, { "name": "Animals & Nature", "emojis": [ { "unified": "1F435", "shortnames": [ "monkey_face" ] }, { "unified": "1F412", "shortnames": [ "monkey" ] }, { "unified": "1F98D", "shortnames": [ "gorilla" ] }, { "unified": "1F9A7", "shortnames": [ "orangutan" ] }, { "unified": "1F436", "shortnames": [ "dog" ] }, { "unified": "1F415", "shortnames": [ "dog2" ] }, { "unified": "1F9AE", "shortnames": [ "guide_dog" ] }, { "unified": "1F415-200D-1F9BA", "shortnames": [ "service_dog" ] }, { "unified": "1F429", "shortnames": [ "poodle" ] }, { "unified": "1F43A", "shortnames": [ "wolf" ] }, { "unified": "1F98A", "shortnames": [ "fox_face" ] }, { "unified": "1F99D", "shortnames": [ "raccoon" ] }, { "unified": "1F431", "shortnames": [ "cat" ] }, { "unified": "1F408", "shortnames": [ "cat2" ] }, { "unified": "1F408-200D-2B1B", "shortnames": [ "black_cat" ] }, { "unified": "1F981", "shortnames": [ "lion_face" ] }, { "unified": "1F42F", "shortnames": [ "tiger" ] }, { "unified": "1F405", "shortnames": [ "tiger2" ] }, { "unified": "1F406", "shortnames": [ "leopard" ] }, { "unified": "1F434", "shortnames": [ "horse" ] }, { "unified": "1F40E", "shortnames": [ "racehorse" ] }, { "unified": "1F984", "shortnames": [ "unicorn_face" ] }, { "unified": "1F993", "shortnames": [ "zebra_face" ] }, { "unified": "1F98C", "shortnames": [ "deer" ] }, { "unified": "1F9AC", "shortnames": [ "bison" ] }, { "unified": "1F42E", "shortnames": [ "cow" ] }, { "unified": "1F402", "shortnames": [ "ox" ] }, { "unified": "1F403", "shortnames": [ "water_buffalo" ] }, { "unified": "1F404", "shortnames": [ "cow2" ] }, { "unified": "1F437", "shortnames": [ "pig" ] }, { "unified": "1F416", "shortnames": [ "pig2" ] }, { "unified": "1F417", "shortnames": [ "boar" ] }, { "unified": "1F43D", "shortnames": [ "pig_nose" ] }, { "unified": "1F40F", "shortnames": [ "ram" ] }, { "unified": "1F411", "shortnames": [ "sheep" ] }, { "unified": "1F410", "shortnames": [ "goat" ] }, { "unified": "1F42A", "shortnames": [ "dromedary_camel" ] }, { "unified": "1F42B", "shortnames": [ "camel" ] }, { "unified": "1F999", "shortnames": [ "llama" ] }, { "unified": "1F992", "shortnames": [ "giraffe_face" ] }, { "unified": "1F418", "shortnames": [ "elephant" ] }, { "unified": "1F9A3", "shortnames": [ "mammoth" ] }, { "unified": "1F98F", "shortnames": [ "rhinoceros" ] }, { "unified": "1F99B", "shortnames": [ "hippopotamus" ] }, { "unified": "1F42D", "shortnames": [ "mouse" ] }, { "unified": "1F401", "shortnames": [ "mouse2" ] }, { "unified": "1F400", "shortnames": [ "rat" ] }, { "unified": "1F439", "shortnames": [ "hamster" ] }, { "unified": "1F430", "shortnames": [ "rabbit" ] }, { "unified": "1F407", "shortnames": [ "rabbit2" ] }, { "unified": "1F43F-FE0F", "shortnames": [ "chipmunk" ] }, { "unified": "1F9AB", "shortnames": [ "beaver" ] }, { "unified": "1F994", "shortnames": [ "hedgehog" ] }, { "unified": "1F987", "shortnames": [ "bat" ] }, { "unified": "1F43B", "shortnames": [ "bear" ] }, { "unified": "1F43B-200D-2744-FE0F", "shortnames": [ "polar_bear" ] }, { "unified": "1F428", "shortnames": [ "koala" ] }, { "unified": "1F43C", "shortnames": [ "panda_face" ] }, { "unified": "1F9A5", "shortnames": [ "sloth" ] }, { "unified": "1F9A6", "shortnames": [ "otter" ] }, { "unified": "1F9A8", "shortnames": [ "skunk" ] }, { "unified": "1F998", "shortnames": [ "kangaroo" ] }, { "unified": "1F9A1", "shortnames": [ "badger" ] }, { "unified": "1F43E", "shortnames": [ "feet" ] }, { "unified": "1F983", "shortnames": [ "turkey" ] }, { "unified": "1F414", "shortnames": [ "chicken" ] }, { "unified": "1F413", "shortnames": [ "rooster" ] }, { "unified": "1F423", "shortnames": [ "hatching_chick" ] }, { "unified": "1F424", "shortnames": [ "baby_chick" ] }, { "unified": "1F425", "shortnames": [ "hatched_chick" ] }, { "unified": "1F426", "shortnames": [ "bird" ] }, { "unified": "1F427", "shortnames": [ "penguin" ] }, { "unified": "1F54A-FE0F", "shortnames": [ "dove_of_peace" ] }, { "unified": "1F985", "shortnames": [ "eagle" ] }, { "unified": "1F986", "shortnames": [ "duck" ] }, { "unified": "1F9A2", "shortnames": [ "swan" ] }, { "unified": "1F989", "shortnames": [ "owl" ] }, { "unified": "1F9A4", "shortnames": [ "dodo" ] }, { "unified": "1FAB6", "shortnames": [ "feather" ] }, { "unified": "1F9A9", "shortnames": [ "flamingo" ] }, { "unified": "1F99A", "shortnames": [ "peacock" ] }, { "unified": "1F99C", "shortnames": [ "parrot" ] }, { "unified": "1F438", "shortnames": [ "frog" ] }, { "unified": "1F40A", "shortnames": [ "crocodile" ] }, { "unified": "1F422", "shortnames": [ "turtle" ] }, { "unified": "1F98E", "shortnames": [ "lizard" ] }, { "unified": "1F40D", "shortnames": [ "snake" ] }, { "unified": "1F432", "shortnames": [ "dragon_face" ] }, { "unified": "1F409", "shortnames": [ "dragon" ] }, { "unified": "1F995", "shortnames": [ "sauropod" ] }, { "unified": "1F996", "shortnames": [ "t-rex" ] }, { "unified": "1F433", "shortnames": [ "whale" ] }, { "unified": "1F40B", "shortnames": [ "whale2" ] }, { "unified": "1F42C", "shortnames": [ "dolphin" ] }, { "unified": "1F9AD", "shortnames": [ "seal" ] }, { "unified": "1F41F", "shortnames": [ "fish" ] }, { "unified": "1F420", "shortnames": [ "tropical_fish" ] }, { "unified": "1F421", "shortnames": [ "blowfish" ] }, { "unified": "1F988", "shortnames": [ "shark" ] }, { "unified": "1F419", "shortnames": [ "octopus" ] }, { "unified": "1F41A", "shortnames": [ "shell" ] }, { "unified": "1FAB8", "shortnames": [ "coral" ] }, { "unified": "1F40C", "shortnames": [ "snail" ] }, { "unified": "1F98B", "shortnames": [ "butterfly" ] }, { "unified": "1F41B", "shortnames": [ "bug" ] }, { "unified": "1F41C", "shortnames": [ "ant" ] }, { "unified": "1F41D", "shortnames": [ "bee" ] }, { "unified": "1FAB2", "shortnames": [ "beetle" ] }, { "unified": "1F41E", "shortnames": [ "ladybug" ] }, { "unified": "1F997", "shortnames": [ "cricket" ] }, { "unified": "1FAB3", "shortnames": [ "cockroach" ] }, { "unified": "1F577-FE0F", "shortnames": [ "spider" ] }, { "unified": "1F578-FE0F", "shortnames": [ "spider_web" ] }, { "unified": "1F982", "shortnames": [ "scorpion" ] }, { "unified": "1F99F", "shortnames": [ "mosquito" ] }, { "unified": "1FAB0", "shortnames": [ "fly" ] }, { "unified": "1FAB1", "shortnames": [ "worm" ] }, { "unified": "1F9A0", "shortnames": [ "microbe" ] }, { "unified": "1F490", "shortnames": [ "bouquet" ] }, { "unified": "1F338", "shortnames": [ "cherry_blossom" ] }, { "unified": "1F4AE", "shortnames": [ "white_flower" ] }, { "unified": "1FAB7", "shortnames": [ "lotus" ] }, { "unified": "1F3F5-FE0F", "shortnames": [ "rosette" ] }, { "unified": "1F339", "shortnames": [ "rose" ] }, { "unified": "1F940", "shortnames": [ "wilted_flower" ] }, { "unified": "1F33A", "shortnames": [ "hibiscus" ] }, { "unified": "1F33B", "shortnames": [ "sunflower" ] }, { "unified": "1F33C", "shortnames": [ "blossom" ] }, { "unified": "1F337", "shortnames": [ "tulip" ] }, { "unified": "1F331", "shortnames": [ "seedling" ] }, { "unified": "1FAB4", "shortnames": [ "potted_plant" ] }, { "unified": "1F332", "shortnames": [ "evergreen_tree" ] }, { "unified": "1F333", "shortnames": [ "deciduous_tree" ] }, { "unified": "1F334", "shortnames": [ "palm_tree" ] }, { "unified": "1F335", "shortnames": [ "cactus" ] }, { "unified": "1F33E", "shortnames": [ "ear_of_rice" ] }, { "unified": "1F33F", "shortnames": [ "herb" ] }, { "unified": "2618-FE0F", "shortnames": [ "shamrock" ] }, { "unified": "1F340", "shortnames": [ "four_leaf_clover" ] }, { "unified": "1F341", "shortnames": [ "maple_leaf" ] }, { "unified": "1F342", "shortnames": [ "fallen_leaf" ] }, { "unified": "1F343", "shortnames": [ "leaves" ] }, { "unified": "1FAB9", "shortnames": [ "empty_nest" ] }, { "unified": "1FABA", "shortnames": [ "nest_with_eggs" ] } ] }, { "name": "Food & Drink", "emojis": [ { "unified": "1F347", "shortnames": [ "grapes" ] }, { "unified": "1F348", "shortnames": [ "melon" ] }, { "unified": "1F349", "shortnames": [ "watermelon" ] }, { "unified": "1F34A", "shortnames": [ "tangerine" ] }, { "unified": "1F34B", "shortnames": [ "lemon" ] }, { "unified": "1F34C", "shortnames": [ "banana" ] }, { "unified": "1F34D", "shortnames": [ "pineapple" ] }, { "unified": "1F96D", "shortnames": [ "mango" ] }, { "unified": "1F34E", "shortnames": [ "apple" ] }, { "unified": "1F34F", "shortnames": [ "green_apple" ] }, { "unified": "1F350", "shortnames": [ "pear" ] }, { "unified": "1F351", "shortnames": [ "peach" ] }, { "unified": "1F352", "shortnames": [ "cherries" ] }, { "unified": "1F353", "shortnames": [ "strawberry" ] }, { "unified": "1FAD0", "shortnames": [ "blueberries" ] }, { "unified": "1F95D", "shortnames": [ "kiwifruit" ] }, { "unified": "1F345", "shortnames": [ "tomato" ] }, { "unified": "1FAD2", "shortnames": [ "olive" ] }, { "unified": "1F965", "shortnames": [ "coconut" ] }, { "unified": "1F951", "shortnames": [ "avocado" ] }, { "unified": "1F346", "shortnames": [ "eggplant" ] }, { "unified": "1F954", "shortnames": [ "potato" ] }, { "unified": "1F955", "shortnames": [ "carrot" ] }, { "unified": "1F33D", "shortnames": [ "corn" ] }, { "unified": "1F336-FE0F", "shortnames": [ "hot_pepper" ] }, { "unified": "1FAD1", "shortnames": [ "bell_pepper" ] }, { "unified": "1F952", "shortnames": [ "cucumber" ] }, { "unified": "1F96C", "shortnames": [ "leafy_green" ] }, { "unified": "1F966", "shortnames": [ "broccoli" ] }, { "unified": "1F9C4", "shortnames": [ "garlic" ] }, { "unified": "1F9C5", "shortnames": [ "onion" ] }, { "unified": "1F344", "shortnames": [ "mushroom" ] }, { "unified": "1F95C", "shortnames": [ "peanuts" ] }, { "unified": "1FAD8", "shortnames": [ "beans" ] }, { "unified": "1F330", "shortnames": [ "chestnut" ] }, { "unified": "1F35E", "shortnames": [ "bread" ] }, { "unified": "1F950", "shortnames": [ "croissant" ] }, { "unified": "1F956", "shortnames": [ "baguette_bread" ] }, { "unified": "1FAD3", "shortnames": [ "flatbread" ] }, { "unified": "1F968", "shortnames": [ "pretzel" ] }, { "unified": "1F96F", "shortnames": [ "bagel" ] }, { "unified": "1F95E", "shortnames": [ "pancakes" ] }, { "unified": "1F9C7", "shortnames": [ "waffle" ] }, { "unified": "1F9C0", "shortnames": [ "cheese_wedge" ] }, { "unified": "1F356", "shortnames": [ "meat_on_bone" ] }, { "unified": "1F357", "shortnames": [ "poultry_leg" ] }, { "unified": "1F969", "shortnames": [ "cut_of_meat" ] }, { "unified": "1F953", "shortnames": [ "bacon" ] }, { "unified": "1F354", "shortnames": [ "hamburger" ] }, { "unified": "1F35F", "shortnames": [ "fries" ] }, { "unified": "1F355", "shortnames": [ "pizza" ] }, { "unified": "1F32D", "shortnames": [ "hotdog" ] }, { "unified": "1F96A", "shortnames": [ "sandwich" ] }, { "unified": "1F32E", "shortnames": [ "taco" ] }, { "unified": "1F32F", "shortnames": [ "burrito" ] }, { "unified": "1FAD4", "shortnames": [ "tamale" ] }, { "unified": "1F959", "shortnames": [ "stuffed_flatbread" ] }, { "unified": "1F9C6", "shortnames": [ "falafel" ] }, { "unified": "1F95A", "shortnames": [ "egg" ] }, { "unified": "1F373", "shortnames": [ "fried_egg" ] }, { "unified": "1F958", "shortnames": [ "shallow_pan_of_food" ] }, { "unified": "1F372", "shortnames": [ "stew" ] }, { "unified": "1FAD5", "shortnames": [ "fondue" ] }, { "unified": "1F963", "shortnames": [ "bowl_with_spoon" ] }, { "unified": "1F957", "shortnames": [ "green_salad" ] }, { "unified": "1F37F", "shortnames": [ "popcorn" ] }, { "unified": "1F9C8", "shortnames": [ "butter" ] }, { "unified": "1F9C2", "shortnames": [ "salt" ] }, { "unified": "1F96B", "shortnames": [ "canned_food" ] }, { "unified": "1F371", "shortnames": [ "bento" ] }, { "unified": "1F358", "shortnames": [ "rice_cracker" ] }, { "unified": "1F359", "shortnames": [ "rice_ball" ] }, { "unified": "1F35A", "shortnames": [ "rice" ] }, { "unified": "1F35B", "shortnames": [ "curry" ] }, { "unified": "1F35C", "shortnames": [ "ramen" ] }, { "unified": "1F35D", "shortnames": [ "spaghetti" ] }, { "unified": "1F360", "shortnames": [ "sweet_potato" ] }, { "unified": "1F362", "shortnames": [ "oden" ] }, { "unified": "1F363", "shortnames": [ "sushi" ] }, { "unified": "1F364", "shortnames": [ "fried_shrimp" ] }, { "unified": "1F365", "shortnames": [ "fish_cake" ] }, { "unified": "1F96E", "shortnames": [ "moon_cake" ] }, { "unified": "1F361", "shortnames": [ "dango" ] }, { "unified": "1F95F", "shortnames": [ "dumpling" ] }, { "unified": "1F960", "shortnames": [ "fortune_cookie" ] }, { "unified": "1F961", "shortnames": [ "takeout_box" ] }, { "unified": "1F980", "shortnames": [ "crab" ] }, { "unified": "1F99E", "shortnames": [ "lobster" ] }, { "unified": "1F990", "shortnames": [ "shrimp" ] }, { "unified": "1F991", "shortnames": [ "squid" ] }, { "unified": "1F9AA", "shortnames": [ "oyster" ] }, { "unified": "1F366", "shortnames": [ "icecream" ] }, { "unified": "1F367", "shortnames": [ "shaved_ice" ] }, { "unified": "1F368", "shortnames": [ "ice_cream" ] }, { "unified": "1F369", "shortnames": [ "doughnut" ] }, { "unified": "1F36A", "shortnames": [ "cookie" ] }, { "unified": "1F382", "shortnames": [ "birthday" ] }, { "unified": "1F370", "shortnames": [ "cake" ] }, { "unified": "1F9C1", "shortnames": [ "cupcake" ] }, { "unified": "1F967", "shortnames": [ "pie" ] }, { "unified": "1F36B", "shortnames": [ "chocolate_bar" ] }, { "unified": "1F36C", "shortnames": [ "candy" ] }, { "unified": "1F36D", "shortnames": [ "lollipop" ] }, { "unified": "1F36E", "shortnames": [ "custard" ] }, { "unified": "1F36F", "shortnames": [ "honey_pot" ] }, { "unified": "1F37C", "shortnames": [ "baby_bottle" ] }, { "unified": "1F95B", "shortnames": [ "glass_of_milk" ] }, { "unified": "2615", "shortnames": [ "coffee" ] }, { "unified": "1FAD6", "shortnames": [ "teapot" ] }, { "unified": "1F375", "shortnames": [ "tea" ] }, { "unified": "1F376", "shortnames": [ "sake" ] }, { "unified": "1F37E", "shortnames": [ "champagne" ] }, { "unified": "1F377", "shortnames": [ "wine_glass" ] }, { "unified": "1F378", "shortnames": [ "cocktail" ] }, { "unified": "1F379", "shortnames": [ "tropical_drink" ] }, { "unified": "1F37A", "shortnames": [ "beer" ] }, { "unified": "1F37B", "shortnames": [ "beers" ] }, { "unified": "1F942", "shortnames": [ "clinking_glasses" ] }, { "unified": "1F943", "shortnames": [ "tumbler_glass" ] }, { "unified": "1FAD7", "shortnames": [ "pouring_liquid" ] }, { "unified": "1F964", "shortnames": [ "cup_with_straw" ] }, { "unified": "1F9CB", "shortnames": [ "bubble_tea" ] }, { "unified": "1F9C3", "shortnames": [ "beverage_box" ] }, { "unified": "1F9C9", "shortnames": [ "mate_drink" ] }, { "unified": "1F9CA", "shortnames": [ "ice_cube" ] }, { "unified": "1F962", "shortnames": [ "chopsticks" ] }, { "unified": "1F37D-FE0F", "shortnames": [ "knife_fork_plate" ] }, { "unified": "1F374", "shortnames": [ "fork_and_knife" ] }, { "unified": "1F944", "shortnames": [ "spoon" ] }, { "unified": "1F52A", "shortnames": [ "hocho" ] }, { "unified": "1FAD9", "shortnames": [ "jar" ] }, { "unified": "1F3FA", "shortnames": [ "amphora" ] } ] }, { "name": "Travel & Places", "emojis": [ { "unified": "1F30D", "shortnames": [ "earth_africa" ] }, { "unified": "1F30E", "shortnames": [ "earth_americas" ] }, { "unified": "1F30F", "shortnames": [ "earth_asia" ] }, { "unified": "1F310", "shortnames": [ "globe_with_meridians" ] }, { "unified": "1F5FA-FE0F", "shortnames": [ "world_map" ] }, { "unified": "1F5FE", "shortnames": [ "japan" ] }, { "unified": "1F9ED", "shortnames": [ "compass" ] }, { "unified": "1F3D4-FE0F", "shortnames": [ "snow_capped_mountain" ] }, { "unified": "26F0-FE0F", "shortnames": [ "mountain" ] }, { "unified": "1F30B", "shortnames": [ "volcano" ] }, { "unified": "1F5FB", "shortnames": [ "mount_fuji" ] }, { "unified": "1F3D5-FE0F", "shortnames": [ "camping" ] }, { "unified": "1F3D6-FE0F", "shortnames": [ "beach_with_umbrella" ] }, { "unified": "1F3DC-FE0F", "shortnames": [ "desert" ] }, { "unified": "1F3DD-FE0F", "shortnames": [ "desert_island" ] }, { "unified": "1F3DE-FE0F", "shortnames": [ "national_park" ] }, { "unified": "1F3DF-FE0F", "shortnames": [ "stadium" ] }, { "unified": "1F3DB-FE0F", "shortnames": [ "classical_building" ] }, { "unified": "1F3D7-FE0F", "shortnames": [ "building_construction" ] }, { "unified": "1F9F1", "shortnames": [ "bricks" ] }, { "unified": "1FAA8", "shortnames": [ "rock" ] }, { "unified": "1FAB5", "shortnames": [ "wood" ] }, { "unified": "1F6D6", "shortnames": [ "hut" ] }, { "unified": "1F3D8-FE0F", "shortnames": [ "house_buildings" ] }, { "unified": "1F3DA-FE0F", "shortnames": [ "derelict_house_building" ] }, { "unified": "1F3E0", "shortnames": [ "house" ] }, { "unified": "1F3E1", "shortnames": [ "house_with_garden" ] }, { "unified": "1F3E2", "shortnames": [ "office" ] }, { "unified": "1F3E3", "shortnames": [ "post_office" ] }, { "unified": "1F3E4", "shortnames": [ "european_post_office" ] }, { "unified": "1F3E5", "shortnames": [ "hospital" ] }, { "unified": "1F3E6", "shortnames": [ "bank" ] }, { "unified": "1F3E8", "shortnames": [ "hotel" ] }, { "unified": "1F3E9", "shortnames": [ "love_hotel" ] }, { "unified": "1F3EA", "shortnames": [ "convenience_store" ] }, { "unified": "1F3EB", "shortnames": [ "school" ] }, { "unified": "1F3EC", "shortnames": [ "department_store" ] }, { "unified": "1F3ED", "shortnames": [ "factory" ] }, { "unified": "1F3EF", "shortnames": [ "japanese_castle" ] }, { "unified": "1F3F0", "shortnames": [ "european_castle" ] }, { "unified": "1F492", "shortnames": [ "wedding" ] }, { "unified": "1F5FC", "shortnames": [ "tokyo_tower" ] }, { "unified": "1F5FD", "shortnames": [ "statue_of_liberty" ] }, { "unified": "26EA", "shortnames": [ "church" ] }, { "unified": "1F54C", "shortnames": [ "mosque" ] }, { "unified": "1F6D5", "shortnames": [ "hindu_temple" ] }, { "unified": "1F54D", "shortnames": [ "synagogue" ] }, { "unified": "26E9-FE0F", "shortnames": [ "shinto_shrine" ] }, { "unified": "1F54B", "shortnames": [ "kaaba" ] }, { "unified": "26F2", "shortnames": [ "fountain" ] }, { "unified": "26FA", "shortnames": [ "tent" ] }, { "unified": "1F301", "shortnames": [ "foggy" ] }, { "unified": "1F303", "shortnames": [ "night_with_stars" ] }, { "unified": "1F3D9-FE0F", "shortnames": [ "cityscape" ] }, { "unified": "1F304", "shortnames": [ "sunrise_over_mountains" ] }, { "unified": "1F305", "shortnames": [ "sunrise" ] }, { "unified": "1F306", "shortnames": [ "city_sunset" ] }, { "unified": "1F307", "shortnames": [ "city_sunrise" ] }, { "unified": "1F309", "shortnames": [ "bridge_at_night" ] }, { "unified": "2668-FE0F", "shortnames": [ "hotsprings" ] }, { "unified": "1F3A0", "shortnames": [ "carousel_horse" ] }, { "unified": "1F6DD", "shortnames": [ "playground_slide" ] }, { "unified": "1F3A1", "shortnames": [ "ferris_wheel" ] }, { "unified": "1F3A2", "shortnames": [ "roller_coaster" ] }, { "unified": "1F488", "shortnames": [ "barber" ] }, { "unified": "1F3AA", "shortnames": [ "circus_tent" ] }, { "unified": "1F682", "shortnames": [ "steam_locomotive" ] }, { "unified": "1F683", "shortnames": [ "railway_car" ] }, { "unified": "1F684", "shortnames": [ "bullettrain_side" ] }, { "unified": "1F685", "shortnames": [ "bullettrain_front" ] }, { "unified": "1F686", "shortnames": [ "train2" ] }, { "unified": "1F687", "shortnames": [ "metro" ] }, { "unified": "1F688", "shortnames": [ "light_rail" ] }, { "unified": "1F689", "shortnames": [ "station" ] }, { "unified": "1F68A", "shortnames": [ "tram" ] }, { "unified": "1F69D", "shortnames": [ "monorail" ] }, { "unified": "1F69E", "shortnames": [ "mountain_railway" ] }, { "unified": "1F68B", "shortnames": [ "train" ] }, { "unified": "1F68C", "shortnames": [ "bus" ] }, { "unified": "1F68D", "shortnames": [ "oncoming_bus" ] }, { "unified": "1F68E", "shortnames": [ "trolleybus" ] }, { "unified": "1F690", "shortnames": [ "minibus" ] }, { "unified": "1F691", "shortnames": [ "ambulance" ] }, { "unified": "1F692", "shortnames": [ "fire_engine" ] }, { "unified": "1F693", "shortnames": [ "police_car" ] }, { "unified": "1F694", "shortnames": [ "oncoming_police_car" ] }, { "unified": "1F695", "shortnames": [ "taxi" ] }, { "unified": "1F696", "shortnames": [ "oncoming_taxi" ] }, { "unified": "1F697", "shortnames": [ "car" ] }, { "unified": "1F698", "shortnames": [ "oncoming_automobile" ] }, { "unified": "1F699", "shortnames": [ "blue_car" ] }, { "unified": "1F6FB", "shortnames": [ "pickup_truck" ] }, { "unified": "1F69A", "shortnames": [ "truck" ] }, { "unified": "1F69B", "shortnames": [ "articulated_lorry" ] }, { "unified": "1F69C", "shortnames": [ "tractor" ] }, { "unified": "1F3CE-FE0F", "shortnames": [ "racing_car" ] }, { "unified": "1F3CD-FE0F", "shortnames": [ "racing_motorcycle" ] }, { "unified": "1F6F5", "shortnames": [ "motor_scooter" ] }, { "unified": "1F9BD", "shortnames": [ "manual_wheelchair" ] }, { "unified": "1F9BC", "shortnames": [ "motorized_wheelchair" ] }, { "unified": "1F6FA", "shortnames": [ "auto_rickshaw" ] }, { "unified": "1F6B2", "shortnames": [ "bike" ] }, { "unified": "1F6F4", "shortnames": [ "scooter" ] }, { "unified": "1F6F9", "shortnames": [ "skateboard" ] }, { "unified": "1F6FC", "shortnames": [ "roller_skate" ] }, { "unified": "1F68F", "shortnames": [ "busstop" ] }, { "unified": "1F6E3-FE0F", "shortnames": [ "motorway" ] }, { "unified": "1F6E4-FE0F", "shortnames": [ "railway_track" ] }, { "unified": "1F6E2-FE0F", "shortnames": [ "oil_drum" ] }, { "unified": "26FD", "shortnames": [ "fuelpump" ] }, { "unified": "1F6DE", "shortnames": [ "wheel" ] }, { "unified": "1F6A8", "shortnames": [ "rotating_light" ] }, { "unified": "1F6A5", "shortnames": [ "traffic_light" ] }, { "unified": "1F6A6", "shortnames": [ "vertical_traffic_light" ] }, { "unified": "1F6D1", "shortnames": [ "octagonal_sign" ] }, { "unified": "1F6A7", "shortnames": [ "construction" ] }, { "unified": "2693", "shortnames": [ "anchor" ] }, { "unified": "1F6DF", "shortnames": [ "ring_buoy" ] }, { "unified": "26F5", "shortnames": [ "boat" ] }, { "unified": "1F6F6", "shortnames": [ "canoe" ] }, { "unified": "1F6A4", "shortnames": [ "speedboat" ] }, { "unified": "1F6F3-FE0F", "shortnames": [ "passenger_ship" ] }, { "unified": "26F4-FE0F", "shortnames": [ "ferry" ] }, { "unified": "1F6E5-FE0F", "shortnames": [ "motor_boat" ] }, { "unified": "1F6A2", "shortnames": [ "ship" ] }, { "unified": "2708-FE0F", "shortnames": [ "airplane" ] }, { "unified": "1F6E9-FE0F", "shortnames": [ "small_airplane" ] }, { "unified": "1F6EB", "shortnames": [ "airplane_departure" ] }, { "unified": "1F6EC", "shortnames": [ "airplane_arriving" ] }, { "unified": "1FA82", "shortnames": [ "parachute" ] }, { "unified": "1F4BA", "shortnames": [ "seat" ] }, { "unified": "1F681", "shortnames": [ "helicopter" ] }, { "unified": "1F69F", "shortnames": [ "suspension_railway" ] }, { "unified": "1F6A0", "shortnames": [ "mountain_cableway" ] }, { "unified": "1F6A1", "shortnames": [ "aerial_tramway" ] }, { "unified": "1F6F0-FE0F", "shortnames": [ "satellite" ] }, { "unified": "1F680", "shortnames": [ "rocket" ] }, { "unified": "1F6F8", "shortnames": [ "flying_saucer" ] }, { "unified": "1F6CE-FE0F", "shortnames": [ "bellhop_bell" ] }, { "unified": "1F9F3", "shortnames": [ "luggage" ] }, { "unified": "231B", "shortnames": [ "hourglass" ] }, { "unified": "23F3", "shortnames": [ "hourglass_flowing_sand" ] }, { "unified": "231A", "shortnames": [ "watch" ] }, { "unified": "23F0", "shortnames": [ "alarm_clock" ] }, { "unified": "23F1-FE0F", "shortnames": [ "stopwatch" ] }, { "unified": "23F2-FE0F", "shortnames": [ "timer_clock" ] }, { "unified": "1F570-FE0F", "shortnames": [ "mantelpiece_clock" ] }, { "unified": "1F55B", "shortnames": [ "clock12" ] }, { "unified": "1F567", "shortnames": [ "clock1230" ] }, { "unified": "1F550", "shortnames": [ "clock1" ] }, { "unified": "1F55C", "shortnames": [ "clock130" ] }, { "unified": "1F551", "shortnames": [ "clock2" ] }, { "unified": "1F55D", "shortnames": [ "clock230" ] }, { "unified": "1F552", "shortnames": [ "clock3" ] }, { "unified": "1F55E", "shortnames": [ "clock330" ] }, { "unified": "1F553", "shortnames": [ "clock4" ] }, { "unified": "1F55F", "shortnames": [ "clock430" ] }, { "unified": "1F554", "shortnames": [ "clock5" ] }, { "unified": "1F560", "shortnames": [ "clock530" ] }, { "unified": "1F555", "shortnames": [ "clock6" ] }, { "unified": "1F561", "shortnames": [ "clock630" ] }, { "unified": "1F556", "shortnames": [ "clock7" ] }, { "unified": "1F562", "shortnames": [ "clock730" ] }, { "unified": "1F557", "shortnames": [ "clock8" ] }, { "unified": "1F563", "shortnames": [ "clock830" ] }, { "unified": "1F558", "shortnames": [ "clock9" ] }, { "unified": "1F564", "shortnames": [ "clock930" ] }, { "unified": "1F559", "shortnames": [ "clock10" ] }, { "unified": "1F565", "shortnames": [ "clock1030" ] }, { "unified": "1F55A", "shortnames": [ "clock11" ] }, { "unified": "1F566", "shortnames": [ "clock1130" ] }, { "unified": "1F311", "shortnames": [ "new_moon" ] }, { "unified": "1F312", "shortnames": [ "waxing_crescent_moon" ] }, { "unified": "1F313", "shortnames": [ "first_quarter_moon" ] }, { "unified": "1F314", "shortnames": [ "moon" ] }, { "unified": "1F315", "shortnames": [ "full_moon" ] }, { "unified": "1F316", "shortnames": [ "waning_gibbous_moon" ] }, { "unified": "1F317", "shortnames": [ "last_quarter_moon" ] }, { "unified": "1F318", "shortnames": [ "waning_crescent_moon" ] }, { "unified": "1F319", "shortnames": [ "crescent_moon" ] }, { "unified": "1F31A", "shortnames": [ "new_moon_with_face" ] }, { "unified": "1F31B", "shortnames": [ "first_quarter_moon_with_face" ] }, { "unified": "1F31C", "shortnames": [ "last_quarter_moon_with_face" ] }, { "unified": "1F321-FE0F", "shortnames": [ "thermometer" ] }, { "unified": "2600-FE0F", "shortnames": [ "sunny" ] }, { "unified": "1F31D", "shortnames": [ "full_moon_with_face" ] }, { "unified": "1F31E", "shortnames": [ "sun_with_face" ] }, { "unified": "1FA90", "shortnames": [ "ringed_planet" ] }, { "unified": "2B50", "shortnames": [ "star" ] }, { "unified": "1F31F", "shortnames": [ "star2" ] }, { "unified": "1F320", "shortnames": [ "stars" ] }, { "unified": "1F30C", "shortnames": [ "milky_way" ] }, { "unified": "2601-FE0F", "shortnames": [ "cloud" ] }, { "unified": "26C5", "shortnames": [ "partly_sunny" ] }, { "unified": "26C8-FE0F", "shortnames": [ "thunder_cloud_and_rain" ] }, { "unified": "1F324-FE0F", "shortnames": [ "mostly_sunny" ] }, { "unified": "1F325-FE0F", "shortnames": [ "barely_sunny" ] }, { "unified": "1F326-FE0F", "shortnames": [ "partly_sunny_rain" ] }, { "unified": "1F327-FE0F", "shortnames": [ "rain_cloud" ] }, { "unified": "1F328-FE0F", "shortnames": [ "snow_cloud" ] }, { "unified": "1F329-FE0F", "shortnames": [ "lightning" ] }, { "unified": "1F32A-FE0F", "shortnames": [ "tornado" ] }, { "unified": "1F32B-FE0F", "shortnames": [ "fog" ] }, { "unified": "1F32C-FE0F", "shortnames": [ "wind_blowing_face" ] }, { "unified": "1F300", "shortnames": [ "cyclone" ] }, { "unified": "1F308", "shortnames": [ "rainbow" ] }, { "unified": "1F302", "shortnames": [ "closed_umbrella" ] }, { "unified": "2602-FE0F", "shortnames": [ "umbrella" ] }, { "unified": "2614", "shortnames": [ "umbrella_with_rain_drops" ] }, { "unified": "26F1-FE0F", "shortnames": [ "umbrella_on_ground" ] }, { "unified": "26A1", "shortnames": [ "zap" ] }, { "unified": "2744-FE0F", "shortnames": [ "snowflake" ] }, { "unified": "2603-FE0F", "shortnames": [ "snowman" ] }, { "unified": "26C4", "shortnames": [ "snowman_without_snow" ] }, { "unified": "2604-FE0F", "shortnames": [ "comet" ] }, { "unified": "1F525", "shortnames": [ "fire" ] }, { "unified": "1F4A7", "shortnames": [ "droplet" ] }, { "unified": "1F30A", "shortnames": [ "ocean" ] } ] }, { "name": "Activities", "emojis": [ { "unified": "1F383", "shortnames": [ "jack_o_lantern" ] }, { "unified": "1F384", "shortnames": [ "christmas_tree" ] }, { "unified": "1F386", "shortnames": [ "fireworks" ] }, { "unified": "1F387", "shortnames": [ "sparkler" ] }, { "unified": "1F9E8", "shortnames": [ "firecracker" ] }, { "unified": "2728", "shortnames": [ "sparkles" ] }, { "unified": "1F388", "shortnames": [ "balloon" ] }, { "unified": "1F389", "shortnames": [ "tada" ] }, { "unified": "1F38A", "shortnames": [ "confetti_ball" ] }, { "unified": "1F38B", "shortnames": [ "tanabata_tree" ] }, { "unified": "1F38D", "shortnames": [ "bamboo" ] }, { "unified": "1F38E", "shortnames": [ "dolls" ] }, { "unified": "1F38F", "shortnames": [ "flags" ] }, { "unified": "1F390", "shortnames": [ "wind_chime" ] }, { "unified": "1F391", "shortnames": [ "rice_scene" ] }, { "unified": "1F9E7", "shortnames": [ "red_envelope" ] }, { "unified": "1F380", "shortnames": [ "ribbon" ] }, { "unified": "1F381", "shortnames": [ "gift" ] }, { "unified": "1F397-FE0F", "shortnames": [ "reminder_ribbon" ] }, { "unified": "1F39F-FE0F", "shortnames": [ "admission_tickets" ] }, { "unified": "1F3AB", "shortnames": [ "ticket" ] }, { "unified": "1F396-FE0F", "shortnames": [ "medal" ] }, { "unified": "1F3C6", "shortnames": [ "trophy" ] }, { "unified": "1F3C5", "shortnames": [ "sports_medal" ] }, { "unified": "1F947", "shortnames": [ "first_place_medal" ] }, { "unified": "1F948", "shortnames": [ "second_place_medal" ] }, { "unified": "1F949", "shortnames": [ "third_place_medal" ] }, { "unified": "26BD", "shortnames": [ "soccer" ] }, { "unified": "26BE", "shortnames": [ "baseball" ] }, { "unified": "1F94E", "shortnames": [ "softball" ] }, { "unified": "1F3C0", "shortnames": [ "basketball" ] }, { "unified": "1F3D0", "shortnames": [ "volleyball" ] }, { "unified": "1F3C8", "shortnames": [ "football" ] }, { "unified": "1F3C9", "shortnames": [ "rugby_football" ] }, { "unified": "1F3BE", "shortnames": [ "tennis" ] }, { "unified": "1F94F", "shortnames": [ "flying_disc" ] }, { "unified": "1F3B3", "shortnames": [ "bowling" ] }, { "unified": "1F3CF", "shortnames": [ "cricket_bat_and_ball" ] }, { "unified": "1F3D1", "shortnames": [ "field_hockey_stick_and_ball" ] }, { "unified": "1F3D2", "shortnames": [ "ice_hockey_stick_and_puck" ] }, { "unified": "1F94D", "shortnames": [ "lacrosse" ] }, { "unified": "1F3D3", "shortnames": [ "table_tennis_paddle_and_ball" ] }, { "unified": "1F3F8", "shortnames": [ "badminton_racquet_and_shuttlecock" ] }, { "unified": "1F94A", "shortnames": [ "boxing_glove" ] }, { "unified": "1F94B", "shortnames": [ "martial_arts_uniform" ] }, { "unified": "1F945", "shortnames": [ "goal_net" ] }, { "unified": "26F3", "shortnames": [ "golf" ] }, { "unified": "26F8-FE0F", "shortnames": [ "ice_skate" ] }, { "unified": "1F3A3", "shortnames": [ "fishing_pole_and_fish" ] }, { "unified": "1F93F", "shortnames": [ "diving_mask" ] }, { "unified": "1F3BD", "shortnames": [ "running_shirt_with_sash" ] }, { "unified": "1F3BF", "shortnames": [ "ski" ] }, { "unified": "1F6F7", "shortnames": [ "sled" ] }, { "unified": "1F94C", "shortnames": [ "curling_stone" ] }, { "unified": "1F3AF", "shortnames": [ "dart" ] }, { "unified": "1FA80", "shortnames": [ "yo-yo" ] }, { "unified": "1FA81", "shortnames": [ "kite" ] }, { "unified": "1F3B1", "shortnames": [ "8ball" ] }, { "unified": "1F52E", "shortnames": [ "crystal_ball" ] }, { "unified": "1FA84", "shortnames": [ "magic_wand" ] }, { "unified": "1F9FF", "shortnames": [ "nazar_amulet" ] }, { "unified": "1FAAC", "shortnames": [ "hamsa" ] }, { "unified": "1F3AE", "shortnames": [ "video_game" ] }, { "unified": "1F579-FE0F", "shortnames": [ "joystick" ] }, { "unified": "1F3B0", "shortnames": [ "slot_machine" ] }, { "unified": "1F3B2", "shortnames": [ "game_die" ] }, { "unified": "1F9E9", "shortnames": [ "jigsaw" ] }, { "unified": "1F9F8", "shortnames": [ "teddy_bear" ] }, { "unified": "1FA85", "shortnames": [ "pinata" ] }, { "unified": "1FAA9", "shortnames": [ "mirror_ball" ] }, { "unified": "1FA86", "shortnames": [ "nesting_dolls" ] }, { "unified": "2660-FE0F", "shortnames": [ "spades" ] }, { "unified": "2665-FE0F", "shortnames": [ "hearts" ] }, { "unified": "2666-FE0F", "shortnames": [ "diamonds" ] }, { "unified": "2663-FE0F", "shortnames": [ "clubs" ] }, { "unified": "265F-FE0F", "shortnames": [ "chess_pawn" ] }, { "unified": "1F0CF", "shortnames": [ "black_joker" ] }, { "unified": "1F004", "shortnames": [ "mahjong" ] }, { "unified": "1F3B4", "shortnames": [ "flower_playing_cards" ] }, { "unified": "1F3AD", "shortnames": [ "performing_arts" ] }, { "unified": "1F5BC-FE0F", "shortnames": [ "frame_with_picture" ] }, { "unified": "1F3A8", "shortnames": [ "art" ] }, { "unified": "1F9F5", "shortnames": [ "thread" ] }, { "unified": "1FAA1", "shortnames": [ "sewing_needle" ] }, { "unified": "1F9F6", "shortnames": [ "yarn" ] }, { "unified": "1FAA2", "shortnames": [ "knot" ] } ] }, { "name": "Objects", "emojis": [ { "unified": "1F453", "shortnames": [ "eyeglasses" ] }, { "unified": "1F576-FE0F", "shortnames": [ "dark_sunglasses" ] }, { "unified": "1F97D", "shortnames": [ "goggles" ] }, { "unified": "1F97C", "shortnames": [ "lab_coat" ] }, { "unified": "1F9BA", "shortnames": [ "safety_vest" ] }, { "unified": "1F454", "shortnames": [ "necktie" ] }, { "unified": "1F455", "shortnames": [ "shirt" ] }, { "unified": "1F456", "shortnames": [ "jeans" ] }, { "unified": "1F9E3", "shortnames": [ "scarf" ] }, { "unified": "1F9E4", "shortnames": [ "gloves" ] }, { "unified": "1F9E5", "shortnames": [ "coat" ] }, { "unified": "1F9E6", "shortnames": [ "socks" ] }, { "unified": "1F457", "shortnames": [ "dress" ] }, { "unified": "1F458", "shortnames": [ "kimono" ] }, { "unified": "1F97B", "shortnames": [ "sari" ] }, { "unified": "1FA71", "shortnames": [ "one-piece_swimsuit" ] }, { "unified": "1FA72", "shortnames": [ "briefs" ] }, { "unified": "1FA73", "shortnames": [ "shorts" ] }, { "unified": "1F459", "shortnames": [ "bikini" ] }, { "unified": "1F45A", "shortnames": [ "womans_clothes" ] }, { "unified": "1F45B", "shortnames": [ "purse" ] }, { "unified": "1F45C", "shortnames": [ "handbag" ] }, { "unified": "1F45D", "shortnames": [ "pouch" ] }, { "unified": "1F6CD-FE0F", "shortnames": [ "shopping_bags" ] }, { "unified": "1F392", "shortnames": [ "school_satchel" ] }, { "unified": "1FA74", "shortnames": [ "thong_sandal" ] }, { "unified": "1F45E", "shortnames": [ "mans_shoe" ] }, { "unified": "1F45F", "shortnames": [ "athletic_shoe" ] }, { "unified": "1F97E", "shortnames": [ "hiking_boot" ] }, { "unified": "1F97F", "shortnames": [ "womans_flat_shoe" ] }, { "unified": "1F460", "shortnames": [ "high_heel" ] }, { "unified": "1F461", "shortnames": [ "sandal" ] }, { "unified": "1FA70", "shortnames": [ "ballet_shoes" ] }, { "unified": "1F462", "shortnames": [ "boot" ] }, { "unified": "1F451", "shortnames": [ "crown" ] }, { "unified": "1F452", "shortnames": [ "womans_hat" ] }, { "unified": "1F3A9", "shortnames": [ "tophat" ] }, { "unified": "1F393", "shortnames": [ "mortar_board" ] }, { "unified": "1F9E2", "shortnames": [ "billed_cap" ] }, { "unified": "1FA96", "shortnames": [ "military_helmet" ] }, { "unified": "26D1-FE0F", "shortnames": [ "helmet_with_white_cross" ] }, { "unified": "1F4FF", "shortnames": [ "prayer_beads" ] }, { "unified": "1F484", "shortnames": [ "lipstick" ] }, { "unified": "1F48D", "shortnames": [ "ring" ] }, { "unified": "1F48E", "shortnames": [ "gem" ] }, { "unified": "1F507", "shortnames": [ "mute" ] }, { "unified": "1F508", "shortnames": [ "speaker" ] }, { "unified": "1F509", "shortnames": [ "sound" ] }, { "unified": "1F50A", "shortnames": [ "loud_sound" ] }, { "unified": "1F4E2", "shortnames": [ "loudspeaker" ] }, { "unified": "1F4E3", "shortnames": [ "mega" ] }, { "unified": "1F4EF", "shortnames": [ "postal_horn" ] }, { "unified": "1F514", "shortnames": [ "bell" ] }, { "unified": "1F515", "shortnames": [ "no_bell" ] }, { "unified": "1F3BC", "shortnames": [ "musical_score" ] }, { "unified": "1F3B5", "shortnames": [ "musical_note" ] }, { "unified": "1F3B6", "shortnames": [ "notes" ] }, { "unified": "1F399-FE0F", "shortnames": [ "studio_microphone" ] }, { "unified": "1F39A-FE0F", "shortnames": [ "level_slider" ] }, { "unified": "1F39B-FE0F", "shortnames": [ "control_knobs" ] }, { "unified": "1F3A4", "shortnames": [ "microphone" ] }, { "unified": "1F3A7", "shortnames": [ "headphones" ] }, { "unified": "1F4FB", "shortnames": [ "radio" ] }, { "unified": "1F3B7", "shortnames": [ "saxophone" ] }, { "unified": "1FA97", "shortnames": [ "accordion" ] }, { "unified": "1F3B8", "shortnames": [ "guitar" ] }, { "unified": "1F3B9", "shortnames": [ "musical_keyboard" ] }, { "unified": "1F3BA", "shortnames": [ "trumpet" ] }, { "unified": "1F3BB", "shortnames": [ "violin" ] }, { "unified": "1FA95", "shortnames": [ "banjo" ] }, { "unified": "1F941", "shortnames": [ "drum_with_drumsticks" ] }, { "unified": "1FA98", "shortnames": [ "long_drum" ] }, { "unified": "1F4F1", "shortnames": [ "iphone" ] }, { "unified": "1F4F2", "shortnames": [ "calling" ] }, { "unified": "260E-FE0F", "shortnames": [ "phone" ] }, { "unified": "1F4DE", "shortnames": [ "telephone_receiver" ] }, { "unified": "1F4DF", "shortnames": [ "pager" ] }, { "unified": "1F4E0", "shortnames": [ "fax" ] }, { "unified": "1F50B", "shortnames": [ "battery" ] }, { "unified": "1FAAB", "shortnames": [ "low_battery" ] }, { "unified": "1F50C", "shortnames": [ "electric_plug" ] }, { "unified": "1F4BB", "shortnames": [ "computer" ] }, { "unified": "1F5A5-FE0F", "shortnames": [ "desktop_computer" ] }, { "unified": "1F5A8-FE0F", "shortnames": [ "printer" ] }, { "unified": "2328-FE0F", "shortnames": [ "keyboard" ] }, { "unified": "1F5B1-FE0F", "shortnames": [ "three_button_mouse" ] }, { "unified": "1F5B2-FE0F", "shortnames": [ "trackball" ] }, { "unified": "1F4BD", "shortnames": [ "minidisc" ] }, { "unified": "1F4BE", "shortnames": [ "floppy_disk" ] }, { "unified": "1F4BF", "shortnames": [ "cd" ] }, { "unified": "1F4C0", "shortnames": [ "dvd" ] }, { "unified": "1F9EE", "shortnames": [ "abacus" ] }, { "unified": "1F3A5", "shortnames": [ "movie_camera" ] }, { "unified": "1F39E-FE0F", "shortnames": [ "film_frames" ] }, { "unified": "1F4FD-FE0F", "shortnames": [ "film_projector" ] }, { "unified": "1F3AC", "shortnames": [ "clapper" ] }, { "unified": "1F4FA", "shortnames": [ "tv" ] }, { "unified": "1F4F7", "shortnames": [ "camera" ] }, { "unified": "1F4F8", "shortnames": [ "camera_with_flash" ] }, { "unified": "1F4F9", "shortnames": [ "video_camera" ] }, { "unified": "1F4FC", "shortnames": [ "vhs" ] }, { "unified": "1F50D", "shortnames": [ "mag" ] }, { "unified": "1F50E", "shortnames": [ "mag_right" ] }, { "unified": "1F56F-FE0F", "shortnames": [ "candle" ] }, { "unified": "1F4A1", "shortnames": [ "bulb" ] }, { "unified": "1F526", "shortnames": [ "flashlight" ] }, { "unified": "1F3EE", "shortnames": [ "izakaya_lantern" ] }, { "unified": "1FA94", "shortnames": [ "diya_lamp" ] }, { "unified": "1F4D4", "shortnames": [ "notebook_with_decorative_cover" ] }, { "unified": "1F4D5", "shortnames": [ "closed_book" ] }, { "unified": "1F4D6", "shortnames": [ "book" ] }, { "unified": "1F4D7", "shortnames": [ "green_book" ] }, { "unified": "1F4D8", "shortnames": [ "blue_book" ] }, { "unified": "1F4D9", "shortnames": [ "orange_book" ] }, { "unified": "1F4DA", "shortnames": [ "books" ] }, { "unified": "1F4D3", "shortnames": [ "notebook" ] }, { "unified": "1F4D2", "shortnames": [ "ledger" ] }, { "unified": "1F4C3", "shortnames": [ "page_with_curl" ] }, { "unified": "1F4DC", "shortnames": [ "scroll" ] }, { "unified": "1F4C4", "shortnames": [ "page_facing_up" ] }, { "unified": "1F4F0", "shortnames": [ "newspaper" ] }, { "unified": "1F5DE-FE0F", "shortnames": [ "rolled_up_newspaper" ] }, { "unified": "1F4D1", "shortnames": [ "bookmark_tabs" ] }, { "unified": "1F516", "shortnames": [ "bookmark" ] }, { "unified": "1F3F7-FE0F", "shortnames": [ "label" ] }, { "unified": "1F4B0", "shortnames": [ "moneybag" ] }, { "unified": "1FA99", "shortnames": [ "coin" ] }, { "unified": "1F4B4", "shortnames": [ "yen" ] }, { "unified": "1F4B5", "shortnames": [ "dollar" ] }, { "unified": "1F4B6", "shortnames": [ "euro" ] }, { "unified": "1F4B7", "shortnames": [ "pound" ] }, { "unified": "1F4B8", "shortnames": [ "money_with_wings" ] }, { "unified": "1F4B3", "shortnames": [ "credit_card" ] }, { "unified": "1F9FE", "shortnames": [ "receipt" ] }, { "unified": "1F4B9", "shortnames": [ "chart" ] }, { "unified": "2709-FE0F", "shortnames": [ "email" ] }, { "unified": "1F4E7", "shortnames": [ "e-mail" ] }, { "unified": "1F4E8", "shortnames": [ "incoming_envelope" ] }, { "unified": "1F4E9", "shortnames": [ "envelope_with_arrow" ] }, { "unified": "1F4E4", "shortnames": [ "outbox_tray" ] }, { "unified": "1F4E5", "shortnames": [ "inbox_tray" ] }, { "unified": "1F4E6", "shortnames": [ "package" ] }, { "unified": "1F4EB", "shortnames": [ "mailbox" ] }, { "unified": "1F4EA", "shortnames": [ "mailbox_closed" ] }, { "unified": "1F4EC", "shortnames": [ "mailbox_with_mail" ] }, { "unified": "1F4ED", "shortnames": [ "mailbox_with_no_mail" ] }, { "unified": "1F4EE", "shortnames": [ "postbox" ] }, { "unified": "1F5F3-FE0F", "shortnames": [ "ballot_box_with_ballot" ] }, { "unified": "270F-FE0F", "shortnames": [ "pencil2" ] }, { "unified": "2712-FE0F", "shortnames": [ "black_nib" ] }, { "unified": "1F58B-FE0F", "shortnames": [ "lower_left_fountain_pen" ] }, { "unified": "1F58A-FE0F", "shortnames": [ "lower_left_ballpoint_pen" ] }, { "unified": "1F58C-FE0F", "shortnames": [ "lower_left_paintbrush" ] }, { "unified": "1F58D-FE0F", "shortnames": [ "lower_left_crayon" ] }, { "unified": "1F4DD", "shortnames": [ "memo" ] }, { "unified": "1F4BC", "shortnames": [ "briefcase" ] }, { "unified": "1F4C1", "shortnames": [ "file_folder" ] }, { "unified": "1F4C2", "shortnames": [ "open_file_folder" ] }, { "unified": "1F5C2-FE0F", "shortnames": [ "card_index_dividers" ] }, { "unified": "1F4C5", "shortnames": [ "date" ] }, { "unified": "1F4C6", "shortnames": [ "calendar" ] }, { "unified": "1F5D2-FE0F", "shortnames": [ "spiral_note_pad" ] }, { "unified": "1F5D3-FE0F", "shortnames": [ "spiral_calendar_pad" ] }, { "unified": "1F4C7", "shortnames": [ "card_index" ] }, { "unified": "1F4C8", "shortnames": [ "chart_with_upwards_trend" ] }, { "unified": "1F4C9", "shortnames": [ "chart_with_downwards_trend" ] }, { "unified": "1F4CA", "shortnames": [ "bar_chart" ] }, { "unified": "1F4CB", "shortnames": [ "clipboard" ] }, { "unified": "1F4CC", "shortnames": [ "pushpin" ] }, { "unified": "1F4CD", "shortnames": [ "round_pushpin" ] }, { "unified": "1F4CE", "shortnames": [ "paperclip" ] }, { "unified": "1F587-FE0F", "shortnames": [ "linked_paperclips" ] }, { "unified": "1F4CF", "shortnames": [ "straight_ruler" ] }, { "unified": "1F4D0", "shortnames": [ "triangular_ruler" ] }, { "unified": "2702-FE0F", "shortnames": [ "scissors" ] }, { "unified": "1F5C3-FE0F", "shortnames": [ "card_file_box" ] }, { "unified": "1F5C4-FE0F", "shortnames": [ "file_cabinet" ] }, { "unified": "1F5D1-FE0F", "shortnames": [ "wastebasket" ] }, { "unified": "1F512", "shortnames": [ "lock" ] }, { "unified": "1F513", "shortnames": [ "unlock" ] }, { "unified": "1F50F", "shortnames": [ "lock_with_ink_pen" ] }, { "unified": "1F510", "shortnames": [ "closed_lock_with_key" ] }, { "unified": "1F511", "shortnames": [ "key" ] }, { "unified": "1F5DD-FE0F", "shortnames": [ "old_key" ] }, { "unified": "1F528", "shortnames": [ "hammer" ] }, { "unified": "1FA93", "shortnames": [ "axe" ] }, { "unified": "26CF-FE0F", "shortnames": [ "pick" ] }, { "unified": "2692-FE0F", "shortnames": [ "hammer_and_pick" ] }, { "unified": "1F6E0-FE0F", "shortnames": [ "hammer_and_wrench" ] }, { "unified": "1F5E1-FE0F", "shortnames": [ "dagger_knife" ] }, { "unified": "2694-FE0F", "shortnames": [ "crossed_swords" ] }, { "unified": "1F52B", "shortnames": [ "gun" ] }, { "unified": "1FA83", "shortnames": [ "boomerang" ] }, { "unified": "1F3F9", "shortnames": [ "bow_and_arrow" ] }, { "unified": "1F6E1-FE0F", "shortnames": [ "shield" ] }, { "unified": "1FA9A", "shortnames": [ "carpentry_saw" ] }, { "unified": "1F527", "shortnames": [ "wrench" ] }, { "unified": "1FA9B", "shortnames": [ "screwdriver" ] }, { "unified": "1F529", "shortnames": [ "nut_and_bolt" ] }, { "unified": "2699-FE0F", "shortnames": [ "gear" ] }, { "unified": "1F5DC-FE0F", "shortnames": [ "compression" ] }, { "unified": "2696-FE0F", "shortnames": [ "scales" ] }, { "unified": "1F9AF", "shortnames": [ "probing_cane" ] }, { "unified": "1F517", "shortnames": [ "link" ] }, { "unified": "26D3-FE0F", "shortnames": [ "chains" ] }, { "unified": "1FA9D", "shortnames": [ "hook" ] }, { "unified": "1F9F0", "shortnames": [ "toolbox" ] }, { "unified": "1F9F2", "shortnames": [ "magnet" ] }, { "unified": "1FA9C", "shortnames": [ "ladder" ] }, { "unified": "2697-FE0F", "shortnames": [ "alembic" ] }, { "unified": "1F9EA", "shortnames": [ "test_tube" ] }, { "unified": "1F9EB", "shortnames": [ "petri_dish" ] }, { "unified": "1F9EC", "shortnames": [ "dna" ] }, { "unified": "1F52C", "shortnames": [ "microscope" ] }, { "unified": "1F52D", "shortnames": [ "telescope" ] }, { "unified": "1F4E1", "shortnames": [ "satellite_antenna" ] }, { "unified": "1F489", "shortnames": [ "syringe" ] }, { "unified": "1FA78", "shortnames": [ "drop_of_blood" ] }, { "unified": "1F48A", "shortnames": [ "pill" ] }, { "unified": "1FA79", "shortnames": [ "adhesive_bandage" ] }, { "unified": "1FA7C", "shortnames": [ "crutch" ] }, { "unified": "1FA7A", "shortnames": [ "stethoscope" ] }, { "unified": "1FA7B", "shortnames": [ "x-ray" ] }, { "unified": "1F6AA", "shortnames": [ "door" ] }, { "unified": "1F6D7", "shortnames": [ "elevator" ] }, { "unified": "1FA9E", "shortnames": [ "mirror" ] }, { "unified": "1FA9F", "shortnames": [ "window" ] }, { "unified": "1F6CF-FE0F", "shortnames": [ "bed" ] }, { "unified": "1F6CB-FE0F", "shortnames": [ "couch_and_lamp" ] }, { "unified": "1FA91", "shortnames": [ "chair" ] }, { "unified": "1F6BD", "shortnames": [ "toilet" ] }, { "unified": "1FAA0", "shortnames": [ "plunger" ] }, { "unified": "1F6BF", "shortnames": [ "shower" ] }, { "unified": "1F6C1", "shortnames": [ "bathtub" ] }, { "unified": "1FAA4", "shortnames": [ "mouse_trap" ] }, { "unified": "1FA92", "shortnames": [ "razor" ] }, { "unified": "1F9F4", "shortnames": [ "lotion_bottle" ] }, { "unified": "1F9F7", "shortnames": [ "safety_pin" ] }, { "unified": "1F9F9", "shortnames": [ "broom" ] }, { "unified": "1F9FA", "shortnames": [ "basket" ] }, { "unified": "1F9FB", "shortnames": [ "roll_of_paper" ] }, { "unified": "1FAA3", "shortnames": [ "bucket" ] }, { "unified": "1F9FC", "shortnames": [ "soap" ] }, { "unified": "1FAE7", "shortnames": [ "bubbles" ] }, { "unified": "1FAA5", "shortnames": [ "toothbrush" ] }, { "unified": "1F9FD", "shortnames": [ "sponge" ] }, { "unified": "1F9EF", "shortnames": [ "fire_extinguisher" ] }, { "unified": "1F6D2", "shortnames": [ "shopping_trolley" ] }, { "unified": "1F6AC", "shortnames": [ "smoking" ] }, { "unified": "26B0-FE0F", "shortnames": [ "coffin" ] }, { "unified": "1FAA6", "shortnames": [ "headstone" ] }, { "unified": "26B1-FE0F", "shortnames": [ "funeral_urn" ] }, { "unified": "1F5FF", "shortnames": [ "moyai" ] }, { "unified": "1FAA7", "shortnames": [ "placard" ] }, { "unified": "1FAAA", "shortnames": [ "identification_card" ] } ] }, { "name": "Symbols", "emojis": [ { "unified": "1F3E7", "shortnames": [ "atm" ] }, { "unified": "1F6AE", "shortnames": [ "put_litter_in_its_place" ] }, { "unified": "1F6B0", "shortnames": [ "potable_water" ] }, { "unified": "267F", "shortnames": [ "wheelchair" ] }, { "unified": "1F6B9", "shortnames": [ "mens" ] }, { "unified": "1F6BA", "shortnames": [ "womens" ] }, { "unified": "1F6BB", "shortnames": [ "restroom" ] }, { "unified": "1F6BC", "shortnames": [ "baby_symbol" ] }, { "unified": "1F6BE", "shortnames": [ "wc" ] }, { "unified": "1F6C2", "shortnames": [ "passport_control" ] }, { "unified": "1F6C3", "shortnames": [ "customs" ] }, { "unified": "1F6C4", "shortnames": [ "baggage_claim" ] }, { "unified": "1F6C5", "shortnames": [ "left_luggage" ] }, { "unified": "26A0-FE0F", "shortnames": [ "warning" ] }, { "unified": "1F6B8", "shortnames": [ "children_crossing" ] }, { "unified": "26D4", "shortnames": [ "no_entry" ] }, { "unified": "1F6AB", "shortnames": [ "no_entry_sign" ] }, { "unified": "1F6B3", "shortnames": [ "no_bicycles" ] }, { "unified": "1F6AD", "shortnames": [ "no_smoking" ] }, { "unified": "1F6AF", "shortnames": [ "do_not_litter" ] }, { "unified": "1F6B1", "shortnames": [ "non-potable_water" ] }, { "unified": "1F6B7", "shortnames": [ "no_pedestrians" ] }, { "unified": "1F4F5", "shortnames": [ "no_mobile_phones" ] }, { "unified": "1F51E", "shortnames": [ "underage" ] }, { "unified": "2622-FE0F", "shortnames": [ "radioactive_sign" ] }, { "unified": "2623-FE0F", "shortnames": [ "biohazard_sign" ] }, { "unified": "2B06-FE0F", "shortnames": [ "arrow_up" ] }, { "unified": "2197-FE0F", "shortnames": [ "arrow_upper_right" ] }, { "unified": "27A1-FE0F", "shortnames": [ "arrow_right" ] }, { "unified": "2198-FE0F", "shortnames": [ "arrow_lower_right" ] }, { "unified": "2B07-FE0F", "shortnames": [ "arrow_down" ] }, { "unified": "2199-FE0F", "shortnames": [ "arrow_lower_left" ] }, { "unified": "2B05-FE0F", "shortnames": [ "arrow_left" ] }, { "unified": "2196-FE0F", "shortnames": [ "arrow_upper_left" ] }, { "unified": "2195-FE0F", "shortnames": [ "arrow_up_down" ] }, { "unified": "2194-FE0F", "shortnames": [ "left_right_arrow" ] }, { "unified": "21A9-FE0F", "shortnames": [ "leftwards_arrow_with_hook" ] }, { "unified": "21AA-FE0F", "shortnames": [ "arrow_right_hook" ] }, { "unified": "2934-FE0F", "shortnames": [ "arrow_heading_up" ] }, { "unified": "2935-FE0F", "shortnames": [ "arrow_heading_down" ] }, { "unified": "1F503", "shortnames": [ "arrows_clockwise" ] }, { "unified": "1F504", "shortnames": [ "arrows_counterclockwise" ] }, { "unified": "1F519", "shortnames": [ "back" ] }, { "unified": "1F51A", "shortnames": [ "end" ] }, { "unified": "1F51B", "shortnames": [ "on" ] }, { "unified": "1F51C", "shortnames": [ "soon" ] }, { "unified": "1F51D", "shortnames": [ "top" ] }, { "unified": "1F6D0", "shortnames": [ "place_of_worship" ] }, { "unified": "269B-FE0F", "shortnames": [ "atom_symbol" ] }, { "unified": "1F549-FE0F", "shortnames": [ "om_symbol" ] }, { "unified": "2721-FE0F", "shortnames": [ "star_of_david" ] }, { "unified": "2638-FE0F", "shortnames": [ "wheel_of_dharma" ] }, { "unified": "262F-FE0F", "shortnames": [ "yin_yang" ] }, { "unified": "271D-FE0F", "shortnames": [ "latin_cross" ] }, { "unified": "2626-FE0F", "shortnames": [ "orthodox_cross" ] }, { "unified": "262A-FE0F", "shortnames": [ "star_and_crescent" ] }, { "unified": "262E-FE0F", "shortnames": [ "peace_symbol" ] }, { "unified": "1F54E", "shortnames": [ "menorah_with_nine_branches" ] }, { "unified": "1F52F", "shortnames": [ "six_pointed_star" ] }, { "unified": "2648", "shortnames": [ "aries" ] }, { "unified": "2649", "shortnames": [ "taurus" ] }, { "unified": "264A", "shortnames": [ "gemini" ] }, { "unified": "264B", "shortnames": [ "cancer" ] }, { "unified": "264C", "shortnames": [ "leo" ] }, { "unified": "264D", "shortnames": [ "virgo" ] }, { "unified": "264E", "shortnames": [ "libra" ] }, { "unified": "264F", "shortnames": [ "scorpius" ] }, { "unified": "2650", "shortnames": [ "sagittarius" ] }, { "unified": "2651", "shortnames": [ "capricorn" ] }, { "unified": "2652", "shortnames": [ "aquarius" ] }, { "unified": "2653", "shortnames": [ "pisces" ] }, { "unified": "26CE", "shortnames": [ "ophiuchus" ] }, { "unified": "1F500", "shortnames": [ "twisted_rightwards_arrows" ] }, { "unified": "1F501", "shortnames": [ "repeat" ] }, { "unified": "1F502", "shortnames": [ "repeat_one" ] }, { "unified": "25B6-FE0F", "shortnames": [ "arrow_forward" ] }, { "unified": "23E9", "shortnames": [ "fast_forward" ] }, { "unified": "23ED-FE0F", "shortnames": [ "black_right_pointing_double_triangle_with_vertical_bar" ] }, { "unified": "23EF-FE0F", "shortnames": [ "black_right_pointing_triangle_with_double_vertical_bar" ] }, { "unified": "25C0-FE0F", "shortnames": [ "arrow_backward" ] }, { "unified": "23EA", "shortnames": [ "rewind" ] }, { "unified": "23EE-FE0F", "shortnames": [ "black_left_pointing_double_triangle_with_vertical_bar" ] }, { "unified": "1F53C", "shortnames": [ "arrow_up_small" ] }, { "unified": "23EB", "shortnames": [ "arrow_double_up" ] }, { "unified": "1F53D", "shortnames": [ "arrow_down_small" ] }, { "unified": "23EC", "shortnames": [ "arrow_double_down" ] }, { "unified": "23F8-FE0F", "shortnames": [ "double_vertical_bar" ] }, { "unified": "23F9-FE0F", "shortnames": [ "black_square_for_stop" ] }, { "unified": "23FA-FE0F", "shortnames": [ "black_circle_for_record" ] }, { "unified": "23CF-FE0F", "shortnames": [ "eject" ] }, { "unified": "1F3A6", "shortnames": [ "cinema" ] }, { "unified": "1F505", "shortnames": [ "low_brightness" ] }, { "unified": "1F506", "shortnames": [ "high_brightness" ] }, { "unified": "1F4F6", "shortnames": [ "signal_strength" ] }, { "unified": "1F4F3", "shortnames": [ "vibration_mode" ] }, { "unified": "1F4F4", "shortnames": [ "mobile_phone_off" ] }, { "unified": "2640-FE0F", "shortnames": [ "female_sign" ] }, { "unified": "2642-FE0F", "shortnames": [ "male_sign" ] }, { "unified": "26A7-FE0F", "shortnames": [ "transgender_symbol" ] }, { "unified": "2716-FE0F", "shortnames": [ "heavy_multiplication_x" ] }, { "unified": "2795", "shortnames": [ "heavy_plus_sign" ] }, { "unified": "2796", "shortnames": [ "heavy_minus_sign" ] }, { "unified": "2797", "shortnames": [ "heavy_division_sign" ] }, { "unified": "1F7F0", "shortnames": [ "heavy_equals_sign" ] }, { "unified": "267E-FE0F", "shortnames": [ "infinity" ] }, { "unified": "203C-FE0F", "shortnames": [ "bangbang" ] }, { "unified": "2049-FE0F", "shortnames": [ "interrobang" ] }, { "unified": "2753", "shortnames": [ "question" ] }, { "unified": "2754", "shortnames": [ "grey_question" ] }, { "unified": "2755", "shortnames": [ "grey_exclamation" ] }, { "unified": "2757", "shortnames": [ "exclamation" ] }, { "unified": "3030-FE0F", "shortnames": [ "wavy_dash" ] }, { "unified": "1F4B1", "shortnames": [ "currency_exchange" ] }, { "unified": "1F4B2", "shortnames": [ "heavy_dollar_sign" ] }, { "unified": "2695-FE0F", "shortnames": [ "medical_symbol" ] }, { "unified": "267B-FE0F", "shortnames": [ "recycle" ] }, { "unified": "269C-FE0F", "shortnames": [ "fleur_de_lis" ] }, { "unified": "1F531", "shortnames": [ "trident" ] }, { "unified": "1F4DB", "shortnames": [ "name_badge" ] }, { "unified": "1F530", "shortnames": [ "beginner" ] }, { "unified": "2B55", "shortnames": [ "o" ] }, { "unified": "2705", "shortnames": [ "white_check_mark" ] }, { "unified": "2611-FE0F", "shortnames": [ "ballot_box_with_check" ] }, { "unified": "2714-FE0F", "shortnames": [ "heavy_check_mark" ] }, { "unified": "274C", "shortnames": [ "x" ] }, { "unified": "274E", "shortnames": [ "negative_squared_cross_mark" ] }, { "unified": "27B0", "shortnames": [ "curly_loop" ] }, { "unified": "27BF", "shortnames": [ "loop" ] }, { "unified": "303D-FE0F", "shortnames": [ "part_alternation_mark" ] }, { "unified": "2733-FE0F", "shortnames": [ "eight_spoked_asterisk" ] }, { "unified": "2734-FE0F", "shortnames": [ "eight_pointed_black_star" ] }, { "unified": "2747-FE0F", "shortnames": [ "sparkle" ] }, { "unified": "00A9-FE0F", "shortnames": [ "copyright" ] }, { "unified": "00AE-FE0F", "shortnames": [ "registered" ] }, { "unified": "2122-FE0F", "shortnames": [ "tm" ] }, { "unified": "0023-FE0F-20E3", "shortnames": [ "hash" ] }, { "unified": "002A-FE0F-20E3", "shortnames": [ "keycap_star" ] }, { "unified": "0030-FE0F-20E3", "shortnames": [ "zero" ] }, { "unified": "0031-FE0F-20E3", "shortnames": [ "one" ] }, { "unified": "0032-FE0F-20E3", "shortnames": [ "two" ] }, { "unified": "0033-FE0F-20E3", "shortnames": [ "three" ] }, { "unified": "0034-FE0F-20E3", "shortnames": [ "four" ] }, { "unified": "0035-FE0F-20E3", "shortnames": [ "five" ] }, { "unified": "0036-FE0F-20E3", "shortnames": [ "six" ] }, { "unified": "0037-FE0F-20E3", "shortnames": [ "seven" ] }, { "unified": "0038-FE0F-20E3", "shortnames": [ "eight" ] }, { "unified": "0039-FE0F-20E3", "shortnames": [ "nine" ] }, { "unified": "1F51F", "shortnames": [ "keycap_ten" ] }, { "unified": "1F520", "shortnames": [ "capital_abcd" ] }, { "unified": "1F521", "shortnames": [ "abcd" ] }, { "unified": "1F522", "shortnames": [ "1234" ] }, { "unified": "1F523", "shortnames": [ "symbols" ] }, { "unified": "1F524", "shortnames": [ "abc" ] }, { "unified": "1F170-FE0F", "shortnames": [ "a" ] }, { "unified": "1F18E", "shortnames": [ "ab" ] }, { "unified": "1F171-FE0F", "shortnames": [ "b" ] }, { "unified": "1F191", "shortnames": [ "cl" ] }, { "unified": "1F192", "shortnames": [ "cool" ] }, { "unified": "1F193", "shortnames": [ "free" ] }, { "unified": "2139-FE0F", "shortnames": [ "information_source" ] }, { "unified": "1F194", "shortnames": [ "id" ] }, { "unified": "24C2-FE0F", "shortnames": [ "m" ] }, { "unified": "1F195", "shortnames": [ "new" ] }, { "unified": "1F196", "shortnames": [ "ng" ] }, { "unified": "1F17E-FE0F", "shortnames": [ "o2" ] }, { "unified": "1F197", "shortnames": [ "ok" ] }, { "unified": "1F17F-FE0F", "shortnames": [ "parking" ] }, { "unified": "1F198", "shortnames": [ "sos" ] }, { "unified": "1F199", "shortnames": [ "up" ] }, { "unified": "1F19A", "shortnames": [ "vs" ] }, { "unified": "1F201", "shortnames": [ "koko" ] }, { "unified": "1F202-FE0F", "shortnames": [ "sa" ] }, { "unified": "1F237-FE0F", "shortnames": [ "u6708" ] }, { "unified": "1F236", "shortnames": [ "u6709" ] }, { "unified": "1F22F", "shortnames": [ "u6307" ] }, { "unified": "1F250", "shortnames": [ "ideograph_advantage" ] }, { "unified": "1F239", "shortnames": [ "u5272" ] }, { "unified": "1F21A", "shortnames": [ "u7121" ] }, { "unified": "1F232", "shortnames": [ "u7981" ] }, { "unified": "1F251", "shortnames": [ "accept" ] }, { "unified": "1F238", "shortnames": [ "u7533" ] }, { "unified": "1F234", "shortnames": [ "u5408" ] }, { "unified": "1F233", "shortnames": [ "u7a7a" ] }, { "unified": "3297-FE0F", "shortnames": [ "congratulations" ] }, { "unified": "3299-FE0F", "shortnames": [ "secret" ] }, { "unified": "1F23A", "shortnames": [ "u55b6" ] }, { "unified": "1F235", "shortnames": [ "u6e80" ] }, { "unified": "1F534", "shortnames": [ "red_circle" ] }, { "unified": "1F7E0", "shortnames": [ "large_orange_circle" ] }, { "unified": "1F7E1", "shortnames": [ "large_yellow_circle" ] }, { "unified": "1F7E2", "shortnames": [ "large_green_circle" ] }, { "unified": "1F535", "shortnames": [ "large_blue_circle" ] }, { "unified": "1F7E3", "shortnames": [ "large_purple_circle" ] }, { "unified": "1F7E4", "shortnames": [ "large_brown_circle" ] }, { "unified": "26AB", "shortnames": [ "black_circle" ] }, { "unified": "26AA", "shortnames": [ "white_circle" ] }, { "unified": "1F7E5", "shortnames": [ "large_red_square" ] }, { "unified": "1F7E7", "shortnames": [ "large_orange_square" ] }, { "unified": "1F7E8", "shortnames": [ "large_yellow_square" ] }, { "unified": "1F7E9", "shortnames": [ "large_green_square" ] }, { "unified": "1F7E6", "shortnames": [ "large_blue_square" ] }, { "unified": "1F7EA", "shortnames": [ "large_purple_square" ] }, { "unified": "1F7EB", "shortnames": [ "large_brown_square" ] }, { "unified": "2B1B", "shortnames": [ "black_large_square" ] }, { "unified": "2B1C", "shortnames": [ "white_large_square" ] }, { "unified": "25FC-FE0F", "shortnames": [ "black_medium_square" ] }, { "unified": "25FB-FE0F", "shortnames": [ "white_medium_square" ] }, { "unified": "25FE", "shortnames": [ "black_medium_small_square" ] }, { "unified": "25FD", "shortnames": [ "white_medium_small_square" ] }, { "unified": "25AA-FE0F", "shortnames": [ "black_small_square" ] }, { "unified": "25AB-FE0F", "shortnames": [ "white_small_square" ] }, { "unified": "1F536", "shortnames": [ "large_orange_diamond" ] }, { "unified": "1F537", "shortnames": [ "large_blue_diamond" ] }, { "unified": "1F538", "shortnames": [ "small_orange_diamond" ] }, { "unified": "1F539", "shortnames": [ "small_blue_diamond" ] }, { "unified": "1F53A", "shortnames": [ "small_red_triangle" ] }, { "unified": "1F53B", "shortnames": [ "small_red_triangle_down" ] }, { "unified": "1F4A0", "shortnames": [ "diamond_shape_with_a_dot_inside" ] }, { "unified": "1F518", "shortnames": [ "radio_button" ] }, { "unified": "1F533", "shortnames": [ "white_square_button" ] }, { "unified": "1F532", "shortnames": [ "black_square_button" ] } ] }, { "name": "Flags", "emojis": [ { "unified": "1F3C1", "shortnames": [ "checkered_flag" ] }, { "unified": "1F6A9", "shortnames": [ "triangular_flag_on_post" ] }, { "unified": "1F38C", "shortnames": [ "crossed_flags" ] }, { "unified": "1F3F4", "shortnames": [ "waving_black_flag" ] }, { "unified": "1F3F3-FE0F", "shortnames": [ "waving_white_flag" ] }, { "unified": "1F3F3-FE0F-200D-1F308", "shortnames": [ "rainbow-flag" ] }, { "unified": "1F3F3-FE0F-200D-26A7-FE0F", "shortnames": [ "transgender_flag" ] }, { "unified": "1F3F4-200D-2620-FE0F", "shortnames": [ "pirate_flag" ] }, { "unified": "1F1E6-1F1E8", "shortnames": [ "flag-ac" ] }, { "unified": "1F1E6-1F1E9", "shortnames": [ "flag-ad" ] }, { "unified": "1F1E6-1F1EA", "shortnames": [ "flag-ae" ] }, { "unified": "1F1E6-1F1EB", "shortnames": [ "flag-af" ] }, { "unified": "1F1E6-1F1EC", "shortnames": [ "flag-ag" ] }, { "unified": "1F1E6-1F1EE", "shortnames": [ "flag-ai" ] }, { "unified": "1F1E6-1F1F1", "shortnames": [ "flag-al" ] }, { "unified": "1F1E6-1F1F2", "shortnames": [ "flag-am" ] }, { "unified": "1F1E6-1F1F4", "shortnames": [ "flag-ao" ] }, { "unified": "1F1E6-1F1F6", "shortnames": [ "flag-aq" ] }, { "unified": "1F1E6-1F1F7", "shortnames": [ "flag-ar" ] }, { "unified": "1F1E6-1F1F8", "shortnames": [ "flag-as" ] }, { "unified": "1F1E6-1F1F9", "shortnames": [ "flag-at" ] }, { "unified": "1F1E6-1F1FA", "shortnames": [ "flag-au" ] }, { "unified": "1F1E6-1F1FC", "shortnames": [ "flag-aw" ] }, { "unified": "1F1E6-1F1FD", "shortnames": [ "flag-ax" ] }, { "unified": "1F1E6-1F1FF", "shortnames": [ "flag-az" ] }, { "unified": "1F1E7-1F1E6", "shortnames": [ "flag-ba" ] }, { "unified": "1F1E7-1F1E7", "shortnames": [ "flag-bb" ] }, { "unified": "1F1E7-1F1E9", "shortnames": [ "flag-bd" ] }, { "unified": "1F1E7-1F1EA", "shortnames": [ "flag-be" ] }, { "unified": "1F1E7-1F1EB", "shortnames": [ "flag-bf" ] }, { "unified": "1F1E7-1F1EC", "shortnames": [ "flag-bg" ] }, { "unified": "1F1E7-1F1ED", "shortnames": [ "flag-bh" ] }, { "unified": "1F1E7-1F1EE", "shortnames": [ "flag-bi" ] }, { "unified": "1F1E7-1F1EF", "shortnames": [ "flag-bj" ] }, { "unified": "1F1E7-1F1F1", "shortnames": [ "flag-bl" ] }, { "unified": "1F1E7-1F1F2", "shortnames": [ "flag-bm" ] }, { "unified": "1F1E7-1F1F3", "shortnames": [ "flag-bn" ] }, { "unified": "1F1E7-1F1F4", "shortnames": [ "flag-bo" ] }, { "unified": "1F1E7-1F1F6", "shortnames": [ "flag-bq" ] }, { "unified": "1F1E7-1F1F7", "shortnames": [ "flag-br" ] }, { "unified": "1F1E7-1F1F8", "shortnames": [ "flag-bs" ] }, { "unified": "1F1E7-1F1F9", "shortnames": [ "flag-bt" ] }, { "unified": "1F1E7-1F1FB", "shortnames": [ "flag-bv" ] }, { "unified": "1F1E7-1F1FC", "shortnames": [ "flag-bw" ] }, { "unified": "1F1E7-1F1FE", "shortnames": [ "flag-by" ] }, { "unified": "1F1E7-1F1FF", "shortnames": [ "flag-bz" ] }, { "unified": "1F1E8-1F1E6", "shortnames": [ "flag-ca" ] }, { "unified": "1F1E8-1F1E8", "shortnames": [ "flag-cc" ] }, { "unified": "1F1E8-1F1E9", "shortnames": [ "flag-cd" ] }, { "unified": "1F1E8-1F1EB", "shortnames": [ "flag-cf" ] }, { "unified": "1F1E8-1F1EC", "shortnames": [ "flag-cg" ] }, { "unified": "1F1E8-1F1ED", "shortnames": [ "flag-ch" ] }, { "unified": "1F1E8-1F1EE", "shortnames": [ "flag-ci" ] }, { "unified": "1F1E8-1F1F0", "shortnames": [ "flag-ck" ] }, { "unified": "1F1E8-1F1F1", "shortnames": [ "flag-cl" ] }, { "unified": "1F1E8-1F1F2", "shortnames": [ "flag-cm" ] }, { "unified": "1F1E8-1F1F3", "shortnames": [ "cn" ] }, { "unified": "1F1E8-1F1F4", "shortnames": [ "flag-co" ] }, { "unified": "1F1E8-1F1F5", "shortnames": [ "flag-cp" ] }, { "unified": "1F1E8-1F1F7", "shortnames": [ "flag-cr" ] }, { "unified": "1F1E8-1F1FA", "shortnames": [ "flag-cu" ] }, { "unified": "1F1E8-1F1FB", "shortnames": [ "flag-cv" ] }, { "unified": "1F1E8-1F1FC", "shortnames": [ "flag-cw" ] }, { "unified": "1F1E8-1F1FD", "shortnames": [ "flag-cx" ] }, { "unified": "1F1E8-1F1FE", "shortnames": [ "flag-cy" ] }, { "unified": "1F1E8-1F1FF", "shortnames": [ "flag-cz" ] }, { "unified": "1F1E9-1F1EA", "shortnames": [ "de" ] }, { "unified": "1F1E9-1F1EC", "shortnames": [ "flag-dg" ] }, { "unified": "1F1E9-1F1EF", "shortnames": [ "flag-dj" ] }, { "unified": "1F1E9-1F1F0", "shortnames": [ "flag-dk" ] }, { "unified": "1F1E9-1F1F2", "shortnames": [ "flag-dm" ] }, { "unified": "1F1E9-1F1F4", "shortnames": [ "flag-do" ] }, { "unified": "1F1E9-1F1FF", "shortnames": [ "flag-dz" ] }, { "unified": "1F1EA-1F1E6", "shortnames": [ "flag-ea" ] }, { "unified": "1F1EA-1F1E8", "shortnames": [ "flag-ec" ] }, { "unified": "1F1EA-1F1EA", "shortnames": [ "flag-ee" ] }, { "unified": "1F1EA-1F1EC", "shortnames": [ "flag-eg" ] }, { "unified": "1F1EA-1F1ED", "shortnames": [ "flag-eh" ] }, { "unified": "1F1EA-1F1F7", "shortnames": [ "flag-er" ] }, { "unified": "1F1EA-1F1F8", "shortnames": [ "es" ] }, { "unified": "1F1EA-1F1F9", "shortnames": [ "flag-et" ] }, { "unified": "1F1EA-1F1FA", "shortnames": [ "flag-eu" ] }, { "unified": "1F1EB-1F1EE", "shortnames": [ "flag-fi" ] }, { "unified": "1F1EB-1F1EF", "shortnames": [ "flag-fj" ] }, { "unified": "1F1EB-1F1F0", "shortnames": [ "flag-fk" ] }, { "unified": "1F1EB-1F1F2", "shortnames": [ "flag-fm" ] }, { "unified": "1F1EB-1F1F4", "shortnames": [ "flag-fo" ] }, { "unified": "1F1EB-1F1F7", "shortnames": [ "fr" ] }, { "unified": "1F1EC-1F1E6", "shortnames": [ "flag-ga" ] }, { "unified": "1F1EC-1F1E7", "shortnames": [ "gb" ] }, { "unified": "1F1EC-1F1E9", "shortnames": [ "flag-gd" ] }, { "unified": "1F1EC-1F1EA", "shortnames": [ "flag-ge" ] }, { "unified": "1F1EC-1F1EB", "shortnames": [ "flag-gf" ] }, { "unified": "1F1EC-1F1EC", "shortnames": [ "flag-gg" ] }, { "unified": "1F1EC-1F1ED", "shortnames": [ "flag-gh" ] }, { "unified": "1F1EC-1F1EE", "shortnames": [ "flag-gi" ] }, { "unified": "1F1EC-1F1F1", "shortnames": [ "flag-gl" ] }, { "unified": "1F1EC-1F1F2", "shortnames": [ "flag-gm" ] }, { "unified": "1F1EC-1F1F3", "shortnames": [ "flag-gn" ] }, { "unified": "1F1EC-1F1F5", "shortnames": [ "flag-gp" ] }, { "unified": "1F1EC-1F1F6", "shortnames": [ "flag-gq" ] }, { "unified": "1F1EC-1F1F7", "shortnames": [ "flag-gr" ] }, { "unified": "1F1EC-1F1F8", "shortnames": [ "flag-gs" ] }, { "unified": "1F1EC-1F1F9", "shortnames": [ "flag-gt" ] }, { "unified": "1F1EC-1F1FA", "shortnames": [ "flag-gu" ] }, { "unified": "1F1EC-1F1FC", "shortnames": [ "flag-gw" ] }, { "unified": "1F1EC-1F1FE", "shortnames": [ "flag-gy" ] }, { "unified": "1F1ED-1F1F0", "shortnames": [ "flag-hk" ] }, { "unified": "1F1ED-1F1F2", "shortnames": [ "flag-hm" ] }, { "unified": "1F1ED-1F1F3", "shortnames": [ "flag-hn" ] }, { "unified": "1F1ED-1F1F7", "shortnames": [ "flag-hr" ] }, { "unified": "1F1ED-1F1F9", "shortnames": [ "flag-ht" ] }, { "unified": "1F1ED-1F1FA", "shortnames": [ "flag-hu" ] }, { "unified": "1F1EE-1F1E8", "shortnames": [ "flag-ic" ] }, { "unified": "1F1EE-1F1E9", "shortnames": [ "flag-id" ] }, { "unified": "1F1EE-1F1EA", "shortnames": [ "flag-ie" ] }, { "unified": "1F1EE-1F1F1", "shortnames": [ "flag-il" ] }, { "unified": "1F1EE-1F1F2", "shortnames": [ "flag-im" ] }, { "unified": "1F1EE-1F1F3", "shortnames": [ "flag-in" ] }, { "unified": "1F1EE-1F1F4", "shortnames": [ "flag-io" ] }, { "unified": "1F1EE-1F1F6", "shortnames": [ "flag-iq" ] }, { "unified": "1F1EE-1F1F7", "shortnames": [ "flag-ir" ] }, { "unified": "1F1EE-1F1F8", "shortnames": [ "flag-is" ] }, { "unified": "1F1EE-1F1F9", "shortnames": [ "it" ] }, { "unified": "1F1EF-1F1EA", "shortnames": [ "flag-je" ] }, { "unified": "1F1EF-1F1F2", "shortnames": [ "flag-jm" ] }, { "unified": "1F1EF-1F1F4", "shortnames": [ "flag-jo" ] }, { "unified": "1F1EF-1F1F5", "shortnames": [ "jp" ] }, { "unified": "1F1F0-1F1EA", "shortnames": [ "flag-ke" ] }, { "unified": "1F1F0-1F1EC", "shortnames": [ "flag-kg" ] }, { "unified": "1F1F0-1F1ED", "shortnames": [ "flag-kh" ] }, { "unified": "1F1F0-1F1EE", "shortnames": [ "flag-ki" ] }, { "unified": "1F1F0-1F1F2", "shortnames": [ "flag-km" ] }, { "unified": "1F1F0-1F1F3", "shortnames": [ "flag-kn" ] }, { "unified": "1F1F0-1F1F5", "shortnames": [ "flag-kp" ] }, { "unified": "1F1F0-1F1F7", "shortnames": [ "kr" ] }, { "unified": "1F1F0-1F1FC", "shortnames": [ "flag-kw" ] }, { "unified": "1F1F0-1F1FE", "shortnames": [ "flag-ky" ] }, { "unified": "1F1F0-1F1FF", "shortnames": [ "flag-kz" ] }, { "unified": "1F1F1-1F1E6", "shortnames": [ "flag-la" ] }, { "unified": "1F1F1-1F1E7", "shortnames": [ "flag-lb" ] }, { "unified": "1F1F1-1F1E8", "shortnames": [ "flag-lc" ] }, { "unified": "1F1F1-1F1EE", "shortnames": [ "flag-li" ] }, { "unified": "1F1F1-1F1F0", "shortnames": [ "flag-lk" ] }, { "unified": "1F1F1-1F1F7", "shortnames": [ "flag-lr" ] }, { "unified": "1F1F1-1F1F8", "shortnames": [ "flag-ls" ] }, { "unified": "1F1F1-1F1F9", "shortnames": [ "flag-lt" ] }, { "unified": "1F1F1-1F1FA", "shortnames": [ "flag-lu" ] }, { "unified": "1F1F1-1F1FB", "shortnames": [ "flag-lv" ] }, { "unified": "1F1F1-1F1FE", "shortnames": [ "flag-ly" ] }, { "unified": "1F1F2-1F1E6", "shortnames": [ "flag-ma" ] }, { "unified": "1F1F2-1F1E8", "shortnames": [ "flag-mc" ] }, { "unified": "1F1F2-1F1E9", "shortnames": [ "flag-md" ] }, { "unified": "1F1F2-1F1EA", "shortnames": [ "flag-me" ] }, { "unified": "1F1F2-1F1EB", "shortnames": [ "flag-mf" ] }, { "unified": "1F1F2-1F1EC", "shortnames": [ "flag-mg" ] }, { "unified": "1F1F2-1F1ED", "shortnames": [ "flag-mh" ] }, { "unified": "1F1F2-1F1F0", "shortnames": [ "flag-mk" ] }, { "unified": "1F1F2-1F1F1", "shortnames": [ "flag-ml" ] }, { "unified": "1F1F2-1F1F2", "shortnames": [ "flag-mm" ] }, { "unified": "1F1F2-1F1F3", "shortnames": [ "flag-mn" ] }, { "unified": "1F1F2-1F1F4", "shortnames": [ "flag-mo" ] }, { "unified": "1F1F2-1F1F5", "shortnames": [ "flag-mp" ] }, { "unified": "1F1F2-1F1F6", "shortnames": [ "flag-mq" ] }, { "unified": "1F1F2-1F1F7", "shortnames": [ "flag-mr" ] }, { "unified": "1F1F2-1F1F8", "shortnames": [ "flag-ms" ] }, { "unified": "1F1F2-1F1F9", "shortnames": [ "flag-mt" ] }, { "unified": "1F1F2-1F1FA", "shortnames": [ "flag-mu" ] }, { "unified": "1F1F2-1F1FB", "shortnames": [ "flag-mv" ] }, { "unified": "1F1F2-1F1FC", "shortnames": [ "flag-mw" ] }, { "unified": "1F1F2-1F1FD", "shortnames": [ "flag-mx" ] }, { "unified": "1F1F2-1F1FE", "shortnames": [ "flag-my" ] }, { "unified": "1F1F2-1F1FF", "shortnames": [ "flag-mz" ] }, { "unified": "1F1F3-1F1E6", "shortnames": [ "flag-na" ] }, { "unified": "1F1F3-1F1E8", "shortnames": [ "flag-nc" ] }, { "unified": "1F1F3-1F1EA", "shortnames": [ "flag-ne" ] }, { "unified": "1F1F3-1F1EB", "shortnames": [ "flag-nf" ] }, { "unified": "1F1F3-1F1EC", "shortnames": [ "flag-ng" ] }, { "unified": "1F1F3-1F1EE", "shortnames": [ "flag-ni" ] }, { "unified": "1F1F3-1F1F1", "shortnames": [ "flag-nl" ] }, { "unified": "1F1F3-1F1F4", "shortnames": [ "flag-no" ] }, { "unified": "1F1F3-1F1F5", "shortnames": [ "flag-np" ] }, { "unified": "1F1F3-1F1F7", "shortnames": [ "flag-nr" ] }, { "unified": "1F1F3-1F1FA", "shortnames": [ "flag-nu" ] }, { "unified": "1F1F3-1F1FF", "shortnames": [ "flag-nz" ] }, { "unified": "1F1F4-1F1F2", "shortnames": [ "flag-om" ] }, { "unified": "1F1F5-1F1E6", "shortnames": [ "flag-pa" ] }, { "unified": "1F1F5-1F1EA", "shortnames": [ "flag-pe" ] }, { "unified": "1F1F5-1F1EB", "shortnames": [ "flag-pf" ] }, { "unified": "1F1F5-1F1EC", "shortnames": [ "flag-pg" ] }, { "unified": "1F1F5-1F1ED", "shortnames": [ "flag-ph" ] }, { "unified": "1F1F5-1F1F0", "shortnames": [ "flag-pk" ] }, { "unified": "1F1F5-1F1F1", "shortnames": [ "flag-pl" ] }, { "unified": "1F1F5-1F1F2", "shortnames": [ "flag-pm" ] }, { "unified": "1F1F5-1F1F3", "shortnames": [ "flag-pn" ] }, { "unified": "1F1F5-1F1F7", "shortnames": [ "flag-pr" ] }, { "unified": "1F1F5-1F1F8", "shortnames": [ "flag-ps" ] }, { "unified": "1F1F5-1F1F9", "shortnames": [ "flag-pt" ] }, { "unified": "1F1F5-1F1FC", "shortnames": [ "flag-pw" ] }, { "unified": "1F1F5-1F1FE", "shortnames": [ "flag-py" ] }, { "unified": "1F1F6-1F1E6", "shortnames": [ "flag-qa" ] }, { "unified": "1F1F7-1F1EA", "shortnames": [ "flag-re" ] }, { "unified": "1F1F7-1F1F4", "shortnames": [ "flag-ro" ] }, { "unified": "1F1F7-1F1F8", "shortnames": [ "flag-rs" ] }, { "unified": "1F1F7-1F1FA", "shortnames": [ "ru" ] }, { "unified": "1F1F7-1F1FC", "shortnames": [ "flag-rw" ] }, { "unified": "1F1F8-1F1E6", "shortnames": [ "flag-sa" ] }, { "unified": "1F1F8-1F1E7", "shortnames": [ "flag-sb" ] }, { "unified": "1F1F8-1F1E8", "shortnames": [ "flag-sc" ] }, { "unified": "1F1F8-1F1E9", "shortnames": [ "flag-sd" ] }, { "unified": "1F1F8-1F1EA", "shortnames": [ "flag-se" ] }, { "unified": "1F1F8-1F1EC", "shortnames": [ "flag-sg" ] }, { "unified": "1F1F8-1F1ED", "shortnames": [ "flag-sh" ] }, { "unified": "1F1F8-1F1EE", "shortnames": [ "flag-si" ] }, { "unified": "1F1F8-1F1EF", "shortnames": [ "flag-sj" ] }, { "unified": "1F1F8-1F1F0", "shortnames": [ "flag-sk" ] }, { "unified": "1F1F8-1F1F1", "shortnames": [ "flag-sl" ] }, { "unified": "1F1F8-1F1F2", "shortnames": [ "flag-sm" ] }, { "unified": "1F1F8-1F1F3", "shortnames": [ "flag-sn" ] }, { "unified": "1F1F8-1F1F4", "shortnames": [ "flag-so" ] }, { "unified": "1F1F8-1F1F7", "shortnames": [ "flag-sr" ] }, { "unified": "1F1F8-1F1F8", "shortnames": [ "flag-ss" ] }, { "unified": "1F1F8-1F1F9", "shortnames": [ "flag-st" ] }, { "unified": "1F1F8-1F1FB", "shortnames": [ "flag-sv" ] }, { "unified": "1F1F8-1F1FD", "shortnames": [ "flag-sx" ] }, { "unified": "1F1F8-1F1FE", "shortnames": [ "flag-sy" ] }, { "unified": "1F1F8-1F1FF", "shortnames": [ "flag-sz" ] }, { "unified": "1F1F9-1F1E6", "shortnames": [ "flag-ta" ] }, { "unified": "1F1F9-1F1E8", "shortnames": [ "flag-tc" ] }, { "unified": "1F1F9-1F1E9", "shortnames": [ "flag-td" ] }, { "unified": "1F1F9-1F1EB", "shortnames": [ "flag-tf" ] }, { "unified": "1F1F9-1F1EC", "shortnames": [ "flag-tg" ] }, { "unified": "1F1F9-1F1ED", "shortnames": [ "flag-th" ] }, { "unified": "1F1F9-1F1EF", "shortnames": [ "flag-tj" ] }, { "unified": "1F1F9-1F1F0", "shortnames": [ "flag-tk" ] }, { "unified": "1F1F9-1F1F1", "shortnames": [ "flag-tl" ] }, { "unified": "1F1F9-1F1F2", "shortnames": [ "flag-tm" ] }, { "unified": "1F1F9-1F1F3", "shortnames": [ "flag-tn" ] }, { "unified": "1F1F9-1F1F4", "shortnames": [ "flag-to" ] }, { "unified": "1F1F9-1F1F7", "shortnames": [ "flag-tr" ] }, { "unified": "1F1F9-1F1F9", "shortnames": [ "flag-tt" ] }, { "unified": "1F1F9-1F1FB", "shortnames": [ "flag-tv" ] }, { "unified": "1F1F9-1F1FC", "shortnames": [ "flag-tw" ] }, { "unified": "1F1F9-1F1FF", "shortnames": [ "flag-tz" ] }, { "unified": "1F1FA-1F1E6", "shortnames": [ "flag-ua" ] }, { "unified": "1F1FA-1F1EC", "shortnames": [ "flag-ug" ] }, { "unified": "1F1FA-1F1F2", "shortnames": [ "flag-um" ] }, { "unified": "1F1FA-1F1F3", "shortnames": [ "flag-un" ] }, { "unified": "1F1FA-1F1F8", "shortnames": [ "us" ] }, { "unified": "1F1FA-1F1FE", "shortnames": [ "flag-uy" ] }, { "unified": "1F1FA-1F1FF", "shortnames": [ "flag-uz" ] }, { "unified": "1F1FB-1F1E6", "shortnames": [ "flag-va" ] }, { "unified": "1F1FB-1F1E8", "shortnames": [ "flag-vc" ] }, { "unified": "1F1FB-1F1EA", "shortnames": [ "flag-ve" ] }, { "unified": "1F1FB-1F1EC", "shortnames": [ "flag-vg" ] }, { "unified": "1F1FB-1F1EE", "shortnames": [ "flag-vi" ] }, { "unified": "1F1FB-1F1F3", "shortnames": [ "flag-vn" ] }, { "unified": "1F1FB-1F1FA", "shortnames": [ "flag-vu" ] }, { "unified": "1F1FC-1F1EB", "shortnames": [ "flag-wf" ] }, { "unified": "1F1FC-1F1F8", "shortnames": [ "flag-ws" ] }, { "unified": "1F1FD-1F1F0", "shortnames": [ "flag-xk" ] }, { "unified": "1F1FE-1F1EA", "shortnames": [ "flag-ye" ] }, { "unified": "1F1FE-1F1F9", "shortnames": [ "flag-yt" ] }, { "unified": "1F1FF-1F1E6", "shortnames": [ "flag-za" ] }, { "unified": "1F1FF-1F1F2", "shortnames": [ "flag-zm" ] }, { "unified": "1F1FF-1F1FC", "shortnames": [ "flag-zw" ] }, { "unified": "1F3F4-E0067-E0062-E0065-E006E-E0067-E007F", "shortnames": [ "flag-england" ] }, { "unified": "1F3F4-E0067-E0062-E0073-E0063-E0074-E007F", "shortnames": [ "flag-scotland" ] }, { "unified": "1F3F4-E0067-E0062-E0077-E006C-E0073-E007F", "shortnames": [ "flag-wales" ] } ] } ]; export const byShortName = { "hash": "0023-FE0F-20E3", "keycap_star": "002A-FE0F-20E3", "zero": "0030-FE0F-20E3", "one": "0031-FE0F-20E3", "two": "0032-FE0F-20E3", "three": "0033-FE0F-20E3", "four": "0034-FE0F-20E3", "five": "0035-FE0F-20E3", "six": "0036-FE0F-20E3", "seven": "0037-FE0F-20E3", "eight": "0038-FE0F-20E3", "nine": "0039-FE0F-20E3", "copyright": "00A9-FE0F", "registered": "00AE-FE0F", "mahjong": "1F004", "black_joker": "1F0CF", "a": "1F170-FE0F", "b": "1F171-FE0F", "o2": "1F17E-FE0F", "parking": "1F17F-FE0F", "ab": "1F18E", "cl": "1F191", "cool": "1F192", "free": "1F193", "id": "1F194", "new": "1F195", "ng": "1F196", "ok": "1F197", "sos": "1F198", "up": "1F199", "vs": "1F19A", "flag-ac": "1F1E6-1F1E8", "flag-ad": "1F1E6-1F1E9", "flag-ae": "1F1E6-1F1EA", "flag-af": "1F1E6-1F1EB", "flag-ag": "1F1E6-1F1EC", "flag-ai": "1F1E6-1F1EE", "flag-al": "1F1E6-1F1F1", "flag-am": "1F1E6-1F1F2", "flag-ao": "1F1E6-1F1F4", "flag-aq": "1F1E6-1F1F6", "flag-ar": "1F1E6-1F1F7", "flag-as": "1F1E6-1F1F8", "flag-at": "1F1E6-1F1F9", "flag-au": "1F1E6-1F1FA", "flag-aw": "1F1E6-1F1FC", "flag-ax": "1F1E6-1F1FD", "flag-az": "1F1E6-1F1FF", "flag-ba": "1F1E7-1F1E6", "flag-bb": "1F1E7-1F1E7", "flag-bd": "1F1E7-1F1E9", "flag-be": "1F1E7-1F1EA", "flag-bf": "1F1E7-1F1EB", "flag-bg": "1F1E7-1F1EC", "flag-bh": "1F1E7-1F1ED", "flag-bi": "1F1E7-1F1EE", "flag-bj": "1F1E7-1F1EF", "flag-bl": "1F1E7-1F1F1", "flag-bm": "1F1E7-1F1F2", "flag-bn": "1F1E7-1F1F3", "flag-bo": "1F1E7-1F1F4", "flag-bq": "1F1E7-1F1F6", "flag-br": "1F1E7-1F1F7", "flag-bs": "1F1E7-1F1F8", "flag-bt": "1F1E7-1F1F9", "flag-bv": "1F1E7-1F1FB", "flag-bw": "1F1E7-1F1FC", "flag-by": "1F1E7-1F1FE", "flag-bz": "1F1E7-1F1FF", "flag-ca": "1F1E8-1F1E6", "flag-cc": "1F1E8-1F1E8", "flag-cd": "1F1E8-1F1E9", "flag-cf": "1F1E8-1F1EB", "flag-cg": "1F1E8-1F1EC", "flag-ch": "1F1E8-1F1ED", "flag-ci": "1F1E8-1F1EE", "flag-ck": "1F1E8-1F1F0", "flag-cl": "1F1E8-1F1F1", "flag-cm": "1F1E8-1F1F2", "cn": "1F1E8-1F1F3", "flag-co": "1F1E8-1F1F4", "flag-cp": "1F1E8-1F1F5", "flag-cr": "1F1E8-1F1F7", "flag-cu": "1F1E8-1F1FA", "flag-cv": "1F1E8-1F1FB", "flag-cw": "1F1E8-1F1FC", "flag-cx": "1F1E8-1F1FD", "flag-cy": "1F1E8-1F1FE", "flag-cz": "1F1E8-1F1FF", "de": "1F1E9-1F1EA", "flag-dg": "1F1E9-1F1EC", "flag-dj": "1F1E9-1F1EF", "flag-dk": "1F1E9-1F1F0", "flag-dm": "1F1E9-1F1F2", "flag-do": "1F1E9-1F1F4", "flag-dz": "1F1E9-1F1FF", "flag-ea": "1F1EA-1F1E6", "flag-ec": "1F1EA-1F1E8", "flag-ee": "1F1EA-1F1EA", "flag-eg": "1F1EA-1F1EC", "flag-eh": "1F1EA-1F1ED", "flag-er": "1F1EA-1F1F7", "es": "1F1EA-1F1F8", "flag-et": "1F1EA-1F1F9", "flag-eu": "1F1EA-1F1FA", "flag-fi": "1F1EB-1F1EE", "flag-fj": "1F1EB-1F1EF", "flag-fk": "1F1EB-1F1F0", "flag-fm": "1F1EB-1F1F2", "flag-fo": "1F1EB-1F1F4", "fr": "1F1EB-1F1F7", "flag-ga": "1F1EC-1F1E6", "gb": "1F1EC-1F1E7", "flag-gd": "1F1EC-1F1E9", "flag-ge": "1F1EC-1F1EA", "flag-gf": "1F1EC-1F1EB", "flag-gg": "1F1EC-1F1EC", "flag-gh": "1F1EC-1F1ED", "flag-gi": "1F1EC-1F1EE", "flag-gl": "1F1EC-1F1F1", "flag-gm": "1F1EC-1F1F2", "flag-gn": "1F1EC-1F1F3", "flag-gp": "1F1EC-1F1F5", "flag-gq": "1F1EC-1F1F6", "flag-gr": "1F1EC-1F1F7", "flag-gs": "1F1EC-1F1F8", "flag-gt": "1F1EC-1F1F9", "flag-gu": "1F1EC-1F1FA", "flag-gw": "1F1EC-1F1FC", "flag-gy": "1F1EC-1F1FE", "flag-hk": "1F1ED-1F1F0", "flag-hm": "1F1ED-1F1F2", "flag-hn": "1F1ED-1F1F3", "flag-hr": "1F1ED-1F1F7", "flag-ht": "1F1ED-1F1F9", "flag-hu": "1F1ED-1F1FA", "flag-ic": "1F1EE-1F1E8", "flag-id": "1F1EE-1F1E9", "flag-ie": "1F1EE-1F1EA", "flag-il": "1F1EE-1F1F1", "flag-im": "1F1EE-1F1F2", "flag-in": "1F1EE-1F1F3", "flag-io": "1F1EE-1F1F4", "flag-iq": "1F1EE-1F1F6", "flag-ir": "1F1EE-1F1F7", "flag-is": "1F1EE-1F1F8", "it": "1F1EE-1F1F9", "flag-je": "1F1EF-1F1EA", "flag-jm": "1F1EF-1F1F2", "flag-jo": "1F1EF-1F1F4", "jp": "1F1EF-1F1F5", "flag-ke": "1F1F0-1F1EA", "flag-kg": "1F1F0-1F1EC", "flag-kh": "1F1F0-1F1ED", "flag-ki": "1F1F0-1F1EE", "flag-km": "1F1F0-1F1F2", "flag-kn": "1F1F0-1F1F3", "flag-kp": "1F1F0-1F1F5", "kr": "1F1F0-1F1F7", "flag-kw": "1F1F0-1F1FC", "flag-ky": "1F1F0-1F1FE", "flag-kz": "1F1F0-1F1FF", "flag-la": "1F1F1-1F1E6", "flag-lb": "1F1F1-1F1E7", "flag-lc": "1F1F1-1F1E8", "flag-li": "1F1F1-1F1EE", "flag-lk": "1F1F1-1F1F0", "flag-lr": "1F1F1-1F1F7", "flag-ls": "1F1F1-1F1F8", "flag-lt": "1F1F1-1F1F9", "flag-lu": "1F1F1-1F1FA", "flag-lv": "1F1F1-1F1FB", "flag-ly": "1F1F1-1F1FE", "flag-ma": "1F1F2-1F1E6", "flag-mc": "1F1F2-1F1E8", "flag-md": "1F1F2-1F1E9", "flag-me": "1F1F2-1F1EA", "flag-mf": "1F1F2-1F1EB", "flag-mg": "1F1F2-1F1EC", "flag-mh": "1F1F2-1F1ED", "flag-mk": "1F1F2-1F1F0", "flag-ml": "1F1F2-1F1F1", "flag-mm": "1F1F2-1F1F2", "flag-mn": "1F1F2-1F1F3", "flag-mo": "1F1F2-1F1F4", "flag-mp": "1F1F2-1F1F5", "flag-mq": "1F1F2-1F1F6", "flag-mr": "1F1F2-1F1F7", "flag-ms": "1F1F2-1F1F8", "flag-mt": "1F1F2-1F1F9", "flag-mu": "1F1F2-1F1FA", "flag-mv": "1F1F2-1F1FB", "flag-mw": "1F1F2-1F1FC", "flag-mx": "1F1F2-1F1FD", "flag-my": "1F1F2-1F1FE", "flag-mz": "1F1F2-1F1FF", "flag-na": "1F1F3-1F1E6", "flag-nc": "1F1F3-1F1E8", "flag-ne": "1F1F3-1F1EA", "flag-nf": "1F1F3-1F1EB", "flag-ng": "1F1F3-1F1EC", "flag-ni": "1F1F3-1F1EE", "flag-nl": "1F1F3-1F1F1", "flag-no": "1F1F3-1F1F4", "flag-np": "1F1F3-1F1F5", "flag-nr": "1F1F3-1F1F7", "flag-nu": "1F1F3-1F1FA", "flag-nz": "1F1F3-1F1FF", "flag-om": "1F1F4-1F1F2", "flag-pa": "1F1F5-1F1E6", "flag-pe": "1F1F5-1F1EA", "flag-pf": "1F1F5-1F1EB", "flag-pg": "1F1F5-1F1EC", "flag-ph": "1F1F5-1F1ED", "flag-pk": "1F1F5-1F1F0", "flag-pl": "1F1F5-1F1F1", "flag-pm": "1F1F5-1F1F2", "flag-pn": "1F1F5-1F1F3", "flag-pr": "1F1F5-1F1F7", "flag-ps": "1F1F5-1F1F8", "flag-pt": "1F1F5-1F1F9", "flag-pw": "1F1F5-1F1FC", "flag-py": "1F1F5-1F1FE", "flag-qa": "1F1F6-1F1E6", "flag-re": "1F1F7-1F1EA", "flag-ro": "1F1F7-1F1F4", "flag-rs": "1F1F7-1F1F8", "ru": "1F1F7-1F1FA", "flag-rw": "1F1F7-1F1FC", "flag-sa": "1F1F8-1F1E6", "flag-sb": "1F1F8-1F1E7", "flag-sc": "1F1F8-1F1E8", "flag-sd": "1F1F8-1F1E9", "flag-se": "1F1F8-1F1EA", "flag-sg": "1F1F8-1F1EC", "flag-sh": "1F1F8-1F1ED", "flag-si": "1F1F8-1F1EE", "flag-sj": "1F1F8-1F1EF", "flag-sk": "1F1F8-1F1F0", "flag-sl": "1F1F8-1F1F1", "flag-sm": "1F1F8-1F1F2", "flag-sn": "1F1F8-1F1F3", "flag-so": "1F1F8-1F1F4", "flag-sr": "1F1F8-1F1F7", "flag-ss": "1F1F8-1F1F8", "flag-st": "1F1F8-1F1F9", "flag-sv": "1F1F8-1F1FB", "flag-sx": "1F1F8-1F1FD", "flag-sy": "1F1F8-1F1FE", "flag-sz": "1F1F8-1F1FF", "flag-ta": "1F1F9-1F1E6", "flag-tc": "1F1F9-1F1E8", "flag-td": "1F1F9-1F1E9", "flag-tf": "1F1F9-1F1EB", "flag-tg": "1F1F9-1F1EC", "flag-th": "1F1F9-1F1ED", "flag-tj": "1F1F9-1F1EF", "flag-tk": "1F1F9-1F1F0", "flag-tl": "1F1F9-1F1F1", "flag-tm": "1F1F9-1F1F2", "flag-tn": "1F1F9-1F1F3", "flag-to": "1F1F9-1F1F4", "flag-tr": "1F1F9-1F1F7", "flag-tt": "1F1F9-1F1F9", "flag-tv": "1F1F9-1F1FB", "flag-tw": "1F1F9-1F1FC", "flag-tz": "1F1F9-1F1FF", "flag-ua": "1F1FA-1F1E6", "flag-ug": "1F1FA-1F1EC", "flag-um": "1F1FA-1F1F2", "flag-un": "1F1FA-1F1F3", "us": "1F1FA-1F1F8", "flag-uy": "1F1FA-1F1FE", "flag-uz": "1F1FA-1F1FF", "flag-va": "1F1FB-1F1E6", "flag-vc": "1F1FB-1F1E8", "flag-ve": "1F1FB-1F1EA", "flag-vg": "1F1FB-1F1EC", "flag-vi": "1F1FB-1F1EE", "flag-vn": "1F1FB-1F1F3", "flag-vu": "1F1FB-1F1FA", "flag-wf": "1F1FC-1F1EB", "flag-ws": "1F1FC-1F1F8", "flag-xk": "1F1FD-1F1F0", "flag-ye": "1F1FE-1F1EA", "flag-yt": "1F1FE-1F1F9", "flag-za": "1F1FF-1F1E6", "flag-zm": "1F1FF-1F1F2", "flag-zw": "1F1FF-1F1FC", "koko": "1F201", "sa": "1F202-FE0F", "u7121": "1F21A", "u6307": "1F22F", "u7981": "1F232", "u7a7a": "1F233", "u5408": "1F234", "u6e80": "1F235", "u6709": "1F236", "u6708": "1F237-FE0F", "u7533": "1F238", "u5272": "1F239", "u55b6": "1F23A", "ideograph_advantage": "1F250", "accept": "1F251", "cyclone": "1F300", "foggy": "1F301", "closed_umbrella": "1F302", "night_with_stars": "1F303", "sunrise_over_mountains": "1F304", "sunrise": "1F305", "city_sunset": "1F306", "city_sunrise": "1F307", "rainbow": "1F308", "bridge_at_night": "1F309", "ocean": "1F30A", "volcano": "1F30B", "milky_way": "1F30C", "earth_africa": "1F30D", "earth_americas": "1F30E", "earth_asia": "1F30F", "globe_with_meridians": "1F310", "new_moon": "1F311", "waxing_crescent_moon": "1F312", "first_quarter_moon": "1F313", "moon": "1F314", "full_moon": "1F315", "waning_gibbous_moon": "1F316", "last_quarter_moon": "1F317", "waning_crescent_moon": "1F318", "crescent_moon": "1F319", "new_moon_with_face": "1F31A", "first_quarter_moon_with_face": "1F31B", "last_quarter_moon_with_face": "1F31C", "full_moon_with_face": "1F31D", "sun_with_face": "1F31E", "star2": "1F31F", "stars": "1F320", "thermometer": "1F321-FE0F", "mostly_sunny": "1F324-FE0F", "barely_sunny": "1F325-FE0F", "partly_sunny_rain": "1F326-FE0F", "rain_cloud": "1F327-FE0F", "snow_cloud": "1F328-FE0F", "lightning": "1F329-FE0F", "tornado": "1F32A-FE0F", "fog": "1F32B-FE0F", "wind_blowing_face": "1F32C-FE0F", "hotdog": "1F32D", "taco": "1F32E", "burrito": "1F32F", "chestnut": "1F330", "seedling": "1F331", "evergreen_tree": "1F332", "deciduous_tree": "1F333", "palm_tree": "1F334", "cactus": "1F335", "hot_pepper": "1F336-FE0F", "tulip": "1F337", "cherry_blossom": "1F338", "rose": "1F339", "hibiscus": "1F33A", "sunflower": "1F33B", "blossom": "1F33C", "corn": "1F33D", "ear_of_rice": "1F33E", "herb": "1F33F", "four_leaf_clover": "1F340", "maple_leaf": "1F341", "fallen_leaf": "1F342", "leaves": "1F343", "mushroom": "1F344", "tomato": "1F345", "eggplant": "1F346", "grapes": "1F347", "melon": "1F348", "watermelon": "1F349", "tangerine": "1F34A", "lemon": "1F34B", "banana": "1F34C", "pineapple": "1F34D", "apple": "1F34E", "green_apple": "1F34F", "pear": "1F350", "peach": "1F351", "cherries": "1F352", "strawberry": "1F353", "hamburger": "1F354", "pizza": "1F355", "meat_on_bone": "1F356", "poultry_leg": "1F357", "rice_cracker": "1F358", "rice_ball": "1F359", "rice": "1F35A", "curry": "1F35B", "ramen": "1F35C", "spaghetti": "1F35D", "bread": "1F35E", "fries": "1F35F", "sweet_potato": "1F360", "dango": "1F361", "oden": "1F362", "sushi": "1F363", "fried_shrimp": "1F364", "fish_cake": "1F365", "icecream": "1F366", "shaved_ice": "1F367", "ice_cream": "1F368", "doughnut": "1F369", "cookie": "1F36A", "chocolate_bar": "1F36B", "candy": "1F36C", "lollipop": "1F36D", "custard": "1F36E", "honey_pot": "1F36F", "cake": "1F370", "bento": "1F371", "stew": "1F372", "fried_egg": "1F373", "fork_and_knife": "1F374", "tea": "1F375", "sake": "1F376", "wine_glass": "1F377", "cocktail": "1F378", "tropical_drink": "1F379", "beer": "1F37A", "beers": "1F37B", "baby_bottle": "1F37C", "knife_fork_plate": "1F37D-FE0F", "champagne": "1F37E", "popcorn": "1F37F", "ribbon": "1F380", "gift": "1F381", "birthday": "1F382", "jack_o_lantern": "1F383", "christmas_tree": "1F384", "santa": "1F385", "fireworks": "1F386", "sparkler": "1F387", "balloon": "1F388", "tada": "1F389", "confetti_ball": "1F38A", "tanabata_tree": "1F38B", "crossed_flags": "1F38C", "bamboo": "1F38D", "dolls": "1F38E", "flags": "1F38F", "wind_chime": "1F390", "rice_scene": "1F391", "school_satchel": "1F392", "mortar_board": "1F393", "medal": "1F396-FE0F", "reminder_ribbon": "1F397-FE0F", "studio_microphone": "1F399-FE0F", "level_slider": "1F39A-FE0F", "control_knobs": "1F39B-FE0F", "film_frames": "1F39E-FE0F", "admission_tickets": "1F39F-FE0F", "carousel_horse": "1F3A0", "ferris_wheel": "1F3A1", "roller_coaster": "1F3A2", "fishing_pole_and_fish": "1F3A3", "microphone": "1F3A4", "movie_camera": "1F3A5", "cinema": "1F3A6", "headphones": "1F3A7", "art": "1F3A8", "tophat": "1F3A9", "circus_tent": "1F3AA", "ticket": "1F3AB", "clapper": "1F3AC", "performing_arts": "1F3AD", "video_game": "1F3AE", "dart": "1F3AF", "slot_machine": "1F3B0", "8ball": "1F3B1", "game_die": "1F3B2", "bowling": "1F3B3", "flower_playing_cards": "1F3B4", "musical_note": "1F3B5", "notes": "1F3B6", "saxophone": "1F3B7", "guitar": "1F3B8", "musical_keyboard": "1F3B9", "trumpet": "1F3BA", "violin": "1F3BB", "musical_score": "1F3BC", "running_shirt_with_sash": "1F3BD", "tennis": "1F3BE", "ski": "1F3BF", "basketball": "1F3C0", "checkered_flag": "1F3C1", "snowboarder": "1F3C2", "woman-running": "1F3C3-200D-2640-FE0F", "man-running": "1F3C3-200D-2642-FE0F", "runner": "1F3C3-200D-2642-FE0F", "woman-surfing": "1F3C4-200D-2640-FE0F", "man-surfing": "1F3C4-200D-2642-FE0F", "surfer": "1F3C4-200D-2642-FE0F", "sports_medal": "1F3C5", "trophy": "1F3C6", "horse_racing": "1F3C7", "football": "1F3C8", "rugby_football": "1F3C9", "woman-swimming": "1F3CA-200D-2640-FE0F", "man-swimming": "1F3CA-200D-2642-FE0F", "swimmer": "1F3CA-200D-2642-FE0F", "woman-lifting-weights": "1F3CB-FE0F-200D-2640-FE0F", "man-lifting-weights": "1F3CB-FE0F-200D-2642-FE0F", "weight_lifter": "1F3CB-FE0F-200D-2642-FE0F", "woman-golfing": "1F3CC-FE0F-200D-2640-FE0F", "man-golfing": "1F3CC-FE0F-200D-2642-FE0F", "golfer": "1F3CC-FE0F-200D-2642-FE0F", "racing_motorcycle": "1F3CD-FE0F", "racing_car": "1F3CE-FE0F", "cricket_bat_and_ball": "1F3CF", "volleyball": "1F3D0", "field_hockey_stick_and_ball": "1F3D1", "ice_hockey_stick_and_puck": "1F3D2", "table_tennis_paddle_and_ball": "1F3D3", "snow_capped_mountain": "1F3D4-FE0F", "camping": "1F3D5-FE0F", "beach_with_umbrella": "1F3D6-FE0F", "building_construction": "1F3D7-FE0F", "house_buildings": "1F3D8-FE0F", "cityscape": "1F3D9-FE0F", "derelict_house_building": "1F3DA-FE0F", "classical_building": "1F3DB-FE0F", "desert": "1F3DC-FE0F", "desert_island": "1F3DD-FE0F", "national_park": "1F3DE-FE0F", "stadium": "1F3DF-FE0F", "house": "1F3E0", "house_with_garden": "1F3E1", "office": "1F3E2", "post_office": "1F3E3", "european_post_office": "1F3E4", "hospital": "1F3E5", "bank": "1F3E6", "atm": "1F3E7", "hotel": "1F3E8", "love_hotel": "1F3E9", "convenience_store": "1F3EA", "school": "1F3EB", "department_store": "1F3EC", "factory": "1F3ED", "izakaya_lantern": "1F3EE", "japanese_castle": "1F3EF", "european_castle": "1F3F0", "rainbow-flag": "1F3F3-FE0F-200D-1F308", "transgender_flag": "1F3F3-FE0F-200D-26A7-FE0F", "waving_white_flag": "1F3F3-FE0F", "pirate_flag": "1F3F4-200D-2620-FE0F", "flag-england": "1F3F4-E0067-E0062-E0065-E006E-E0067-E007F", "flag-scotland": "1F3F4-E0067-E0062-E0073-E0063-E0074-E007F", "flag-wales": "1F3F4-E0067-E0062-E0077-E006C-E0073-E007F", "waving_black_flag": "1F3F4", "rosette": "1F3F5-FE0F", "label": "1F3F7-FE0F", "badminton_racquet_and_shuttlecock": "1F3F8", "bow_and_arrow": "1F3F9", "amphora": "1F3FA", "skin-tone-2": "1F3FB", "skin-tone-3": "1F3FC", "skin-tone-4": "1F3FD", "skin-tone-5": "1F3FE", "skin-tone-6": "1F3FF", "rat": "1F400", "mouse2": "1F401", "ox": "1F402", "water_buffalo": "1F403", "cow2": "1F404", "tiger2": "1F405", "leopard": "1F406", "rabbit2": "1F407", "black_cat": "1F408-200D-2B1B", "cat2": "1F408", "dragon": "1F409", "crocodile": "1F40A", "whale2": "1F40B", "snail": "1F40C", "snake": "1F40D", "racehorse": "1F40E", "ram": "1F40F", "goat": "1F410", "sheep": "1F411", "monkey": "1F412", "rooster": "1F413", "chicken": "1F414", "service_dog": "1F415-200D-1F9BA", "dog2": "1F415", "pig2": "1F416", "boar": "1F417", "elephant": "1F418", "octopus": "1F419", "shell": "1F41A", "bug": "1F41B", "ant": "1F41C", "bee": "1F41D", "ladybug": "1F41E", "fish": "1F41F", "tropical_fish": "1F420", "blowfish": "1F421", "turtle": "1F422", "hatching_chick": "1F423", "baby_chick": "1F424", "hatched_chick": "1F425", "bird": "1F426", "penguin": "1F427", "koala": "1F428", "poodle": "1F429", "dromedary_camel": "1F42A", "camel": "1F42B", "dolphin": "1F42C", "mouse": "1F42D", "cow": "1F42E", "tiger": "1F42F", "rabbit": "1F430", "cat": "1F431", "dragon_face": "1F432", "whale": "1F433", "horse": "1F434", "monkey_face": "1F435", "dog": "1F436", "pig": "1F437", "frog": "1F438", "hamster": "1F439", "wolf": "1F43A", "polar_bear": "1F43B-200D-2744-FE0F", "bear": "1F43B", "panda_face": "1F43C", "pig_nose": "1F43D", "feet": "1F43E", "chipmunk": "1F43F-FE0F", "eyes": "1F440", "eye-in-speech-bubble": "1F441-FE0F-200D-1F5E8-FE0F", "eye": "1F441-FE0F", "ear": "1F442", "nose": "1F443", "lips": "1F444", "tongue": "1F445", "point_up_2": "1F446", "point_down": "1F447", "point_left": "1F448", "point_right": "1F449", "facepunch": "1F44A", "wave": "1F44B", "ok_hand": "1F44C", "+1": "1F44D", "-1": "1F44E", "clap": "1F44F", "open_hands": "1F450", "crown": "1F451", "womans_hat": "1F452", "eyeglasses": "1F453", "necktie": "1F454", "shirt": "1F455", "jeans": "1F456", "dress": "1F457", "kimono": "1F458", "bikini": "1F459", "womans_clothes": "1F45A", "purse": "1F45B", "handbag": "1F45C", "pouch": "1F45D", "mans_shoe": "1F45E", "athletic_shoe": "1F45F", "high_heel": "1F460", "sandal": "1F461", "boot": "1F462", "footprints": "1F463", "bust_in_silhouette": "1F464", "busts_in_silhouette": "1F465", "boy": "1F466", "girl": "1F467", "male-farmer": "1F468-200D-1F33E", "male-cook": "1F468-200D-1F373", "man_feeding_baby": "1F468-200D-1F37C", "male-student": "1F468-200D-1F393", "male-singer": "1F468-200D-1F3A4", "male-artist": "1F468-200D-1F3A8", "male-teacher": "1F468-200D-1F3EB", "male-factory-worker": "1F468-200D-1F3ED", "man-boy-boy": "1F468-200D-1F466-200D-1F466", "man-boy": "1F468-200D-1F466", "man-girl-boy": "1F468-200D-1F467-200D-1F466", "man-girl-girl": "1F468-200D-1F467-200D-1F467", "man-girl": "1F468-200D-1F467", "man-man-boy": "1F468-200D-1F468-200D-1F466", "man-man-boy-boy": "1F468-200D-1F468-200D-1F466-200D-1F466", "man-man-girl": "1F468-200D-1F468-200D-1F467", "man-man-girl-boy": "1F468-200D-1F468-200D-1F467-200D-1F466", "man-man-girl-girl": "1F468-200D-1F468-200D-1F467-200D-1F467", "man-woman-boy": "1F468-200D-1F469-200D-1F466", "man-woman-boy-boy": "1F468-200D-1F469-200D-1F466-200D-1F466", "man-woman-girl": "1F468-200D-1F469-200D-1F467", "man-woman-girl-boy": "1F468-200D-1F469-200D-1F467-200D-1F466", "man-woman-girl-girl": "1F468-200D-1F469-200D-1F467-200D-1F467", "male-technologist": "1F468-200D-1F4BB", "male-office-worker": "1F468-200D-1F4BC", "male-mechanic": "1F468-200D-1F527", "male-scientist": "1F468-200D-1F52C", "male-astronaut": "1F468-200D-1F680", "male-firefighter": "1F468-200D-1F692", "man_with_probing_cane": "1F468-200D-1F9AF", "red_haired_man": "1F468-200D-1F9B0", "curly_haired_man": "1F468-200D-1F9B1", "bald_man": "1F468-200D-1F9B2", "white_haired_man": "1F468-200D-1F9B3", "man_in_motorized_wheelchair": "1F468-200D-1F9BC", "man_in_manual_wheelchair": "1F468-200D-1F9BD", "male-doctor": "1F468-200D-2695-FE0F", "male-judge": "1F468-200D-2696-FE0F", "male-pilot": "1F468-200D-2708-FE0F", "man-heart-man": "1F468-200D-2764-FE0F-200D-1F468", "man-kiss-man": "1F468-200D-2764-FE0F-200D-1F48B-200D-1F468", "man": "1F468", "female-farmer": "1F469-200D-1F33E", "female-cook": "1F469-200D-1F373", "woman_feeding_baby": "1F469-200D-1F37C", "female-student": "1F469-200D-1F393", "female-singer": "1F469-200D-1F3A4", "female-artist": "1F469-200D-1F3A8", "female-teacher": "1F469-200D-1F3EB", "female-factory-worker": "1F469-200D-1F3ED", "woman-boy-boy": "1F469-200D-1F466-200D-1F466", "woman-boy": "1F469-200D-1F466", "woman-girl-boy": "1F469-200D-1F467-200D-1F466", "woman-girl-girl": "1F469-200D-1F467-200D-1F467", "woman-girl": "1F469-200D-1F467", "woman-woman-boy": "1F469-200D-1F469-200D-1F466", "woman-woman-boy-boy": "1F469-200D-1F469-200D-1F466-200D-1F466", "woman-woman-girl": "1F469-200D-1F469-200D-1F467", "woman-woman-girl-boy": "1F469-200D-1F469-200D-1F467-200D-1F466", "woman-woman-girl-girl": "1F469-200D-1F469-200D-1F467-200D-1F467", "female-technologist": "1F469-200D-1F4BB", "female-office-worker": "1F469-200D-1F4BC", "female-mechanic": "1F469-200D-1F527", "female-scientist": "1F469-200D-1F52C", "female-astronaut": "1F469-200D-1F680", "female-firefighter": "1F469-200D-1F692", "woman_with_probing_cane": "1F469-200D-1F9AF", "red_haired_woman": "1F469-200D-1F9B0", "curly_haired_woman": "1F469-200D-1F9B1", "bald_woman": "1F469-200D-1F9B2", "white_haired_woman": "1F469-200D-1F9B3", "woman_in_motorized_wheelchair": "1F469-200D-1F9BC", "woman_in_manual_wheelchair": "1F469-200D-1F9BD", "female-doctor": "1F469-200D-2695-FE0F", "female-judge": "1F469-200D-2696-FE0F", "female-pilot": "1F469-200D-2708-FE0F", "woman-heart-man": "1F469-200D-2764-FE0F-200D-1F468", "woman-heart-woman": "1F469-200D-2764-FE0F-200D-1F469", "woman-kiss-man": "1F469-200D-2764-FE0F-200D-1F48B-200D-1F468", "woman-kiss-woman": "1F469-200D-2764-FE0F-200D-1F48B-200D-1F469", "woman": "1F469", "family": "1F468-200D-1F469-200D-1F466", "man_and_woman_holding_hands": "1F46B", "two_men_holding_hands": "1F46C", "two_women_holding_hands": "1F46D", "female-police-officer": "1F46E-200D-2640-FE0F", "male-police-officer": "1F46E-200D-2642-FE0F", "cop": "1F46E-200D-2642-FE0F", "women-with-bunny-ears-partying": "1F46F-200D-2640-FE0F", "men-with-bunny-ears-partying": "1F46F-200D-2642-FE0F", "dancers": "1F46F-200D-2640-FE0F", "woman_with_veil": "1F470-200D-2640-FE0F", "man_with_veil": "1F470-200D-2642-FE0F", "bride_with_veil": "1F470", "blond-haired-woman": "1F471-200D-2640-FE0F", "blond-haired-man": "1F471-200D-2642-FE0F", "person_with_blond_hair": "1F471-200D-2642-FE0F", "man_with_gua_pi_mao": "1F472", "woman-wearing-turban": "1F473-200D-2640-FE0F", "man-wearing-turban": "1F473-200D-2642-FE0F", "man_with_turban": "1F473-200D-2642-FE0F", "older_man": "1F474", "older_woman": "1F475", "baby": "1F476", "female-construction-worker": "1F477-200D-2640-FE0F", "male-construction-worker": "1F477-200D-2642-FE0F", "construction_worker": "1F477-200D-2642-FE0F", "princess": "1F478", "japanese_ogre": "1F479", "japanese_goblin": "1F47A", "ghost": "1F47B", "angel": "1F47C", "alien": "1F47D", "space_invader": "1F47E", "imp": "1F47F", "skull": "1F480", "woman-tipping-hand": "1F481-200D-2640-FE0F", "man-tipping-hand": "1F481-200D-2642-FE0F", "information_desk_person": "1F481-200D-2640-FE0F", "female-guard": "1F482-200D-2640-FE0F", "male-guard": "1F482-200D-2642-FE0F", "guardsman": "1F482-200D-2642-FE0F", "dancer": "1F483", "lipstick": "1F484", "nail_care": "1F485", "woman-getting-massage": "1F486-200D-2640-FE0F", "man-getting-massage": "1F486-200D-2642-FE0F", "massage": "1F486-200D-2640-FE0F", "woman-getting-haircut": "1F487-200D-2640-FE0F", "man-getting-haircut": "1F487-200D-2642-FE0F", "haircut": "1F487-200D-2640-FE0F", "barber": "1F488", "syringe": "1F489", "pill": "1F48A", "kiss": "1F48B", "love_letter": "1F48C", "ring": "1F48D", "gem": "1F48E", "couplekiss": "1F48F", "bouquet": "1F490", "couple_with_heart": "1F491", "wedding": "1F492", "heartbeat": "1F493", "broken_heart": "1F494", "two_hearts": "1F495", "sparkling_heart": "1F496", "heartpulse": "1F497", "cupid": "1F498", "blue_heart": "1F499", "green_heart": "1F49A", "yellow_heart": "1F49B", "purple_heart": "1F49C", "gift_heart": "1F49D", "revolving_hearts": "1F49E", "heart_decoration": "1F49F", "diamond_shape_with_a_dot_inside": "1F4A0", "bulb": "1F4A1", "anger": "1F4A2", "bomb": "1F4A3", "zzz": "1F4A4", "boom": "1F4A5", "sweat_drops": "1F4A6", "droplet": "1F4A7", "dash": "1F4A8", "hankey": "1F4A9", "muscle": "1F4AA", "dizzy": "1F4AB", "speech_balloon": "1F4AC", "thought_balloon": "1F4AD", "white_flower": "1F4AE", "100": "1F4AF", "moneybag": "1F4B0", "currency_exchange": "1F4B1", "heavy_dollar_sign": "1F4B2", "credit_card": "1F4B3", "yen": "1F4B4", "dollar": "1F4B5", "euro": "1F4B6", "pound": "1F4B7", "money_with_wings": "1F4B8", "chart": "1F4B9", "seat": "1F4BA", "computer": "1F4BB", "briefcase": "1F4BC", "minidisc": "1F4BD", "floppy_disk": "1F4BE", "cd": "1F4BF", "dvd": "1F4C0", "file_folder": "1F4C1", "open_file_folder": "1F4C2", "page_with_curl": "1F4C3", "page_facing_up": "1F4C4", "date": "1F4C5", "calendar": "1F4C6", "card_index": "1F4C7", "chart_with_upwards_trend": "1F4C8", "chart_with_downwards_trend": "1F4C9", "bar_chart": "1F4CA", "clipboard": "1F4CB", "pushpin": "1F4CC", "round_pushpin": "1F4CD", "paperclip": "1F4CE", "straight_ruler": "1F4CF", "triangular_ruler": "1F4D0", "bookmark_tabs": "1F4D1", "ledger": "1F4D2", "notebook": "1F4D3", "notebook_with_decorative_cover": "1F4D4", "closed_book": "1F4D5", "book": "1F4D6", "green_book": "1F4D7", "blue_book": "1F4D8", "orange_book": "1F4D9", "books": "1F4DA", "name_badge": "1F4DB", "scroll": "1F4DC", "memo": "1F4DD", "telephone_receiver": "1F4DE", "pager": "1F4DF", "fax": "1F4E0", "satellite_antenna": "1F4E1", "loudspeaker": "1F4E2", "mega": "1F4E3", "outbox_tray": "1F4E4", "inbox_tray": "1F4E5", "package": "1F4E6", "e-mail": "1F4E7", "incoming_envelope": "1F4E8", "envelope_with_arrow": "1F4E9", "mailbox_closed": "1F4EA", "mailbox": "1F4EB", "mailbox_with_mail": "1F4EC", "mailbox_with_no_mail": "1F4ED", "postbox": "1F4EE", "postal_horn": "1F4EF", "newspaper": "1F4F0", "iphone": "1F4F1", "calling": "1F4F2", "vibration_mode": "1F4F3", "mobile_phone_off": "1F4F4", "no_mobile_phones": "1F4F5", "signal_strength": "1F4F6", "camera": "1F4F7", "camera_with_flash": "1F4F8", "video_camera": "1F4F9", "tv": "1F4FA", "radio": "1F4FB", "vhs": "1F4FC", "film_projector": "1F4FD-FE0F", "prayer_beads": "1F4FF", "twisted_rightwards_arrows": "1F500", "repeat": "1F501", "repeat_one": "1F502", "arrows_clockwise": "1F503", "arrows_counterclockwise": "1F504", "low_brightness": "1F505", "high_brightness": "1F506", "mute": "1F507", "speaker": "1F508", "sound": "1F509", "loud_sound": "1F50A", "battery": "1F50B", "electric_plug": "1F50C", "mag": "1F50D", "mag_right": "1F50E", "lock_with_ink_pen": "1F50F", "closed_lock_with_key": "1F510", "key": "1F511", "lock": "1F512", "unlock": "1F513", "bell": "1F514", "no_bell": "1F515", "bookmark": "1F516", "link": "1F517", "radio_button": "1F518", "back": "1F519", "end": "1F51A", "on": "1F51B", "soon": "1F51C", "top": "1F51D", "underage": "1F51E", "keycap_ten": "1F51F", "capital_abcd": "1F520", "abcd": "1F521", "1234": "1F522", "symbols": "1F523", "abc": "1F524", "fire": "1F525", "flashlight": "1F526", "wrench": "1F527", "hammer": "1F528", "nut_and_bolt": "1F529", "hocho": "1F52A", "gun": "1F52B", "microscope": "1F52C", "telescope": "1F52D", "crystal_ball": "1F52E", "six_pointed_star": "1F52F", "beginner": "1F530", "trident": "1F531", "black_square_button": "1F532", "white_square_button": "1F533", "red_circle": "1F534", "large_blue_circle": "1F535", "large_orange_diamond": "1F536", "large_blue_diamond": "1F537", "small_orange_diamond": "1F538", "small_blue_diamond": "1F539", "small_red_triangle": "1F53A", "small_red_triangle_down": "1F53B", "arrow_up_small": "1F53C", "arrow_down_small": "1F53D", "om_symbol": "1F549-FE0F", "dove_of_peace": "1F54A-FE0F", "kaaba": "1F54B", "mosque": "1F54C", "synagogue": "1F54D", "menorah_with_nine_branches": "1F54E", "clock1": "1F550", "clock2": "1F551", "clock3": "1F552", "clock4": "1F553", "clock5": "1F554", "clock6": "1F555", "clock7": "1F556", "clock8": "1F557", "clock9": "1F558", "clock10": "1F559", "clock11": "1F55A", "clock12": "1F55B", "clock130": "1F55C", "clock230": "1F55D", "clock330": "1F55E", "clock430": "1F55F", "clock530": "1F560", "clock630": "1F561", "clock730": "1F562", "clock830": "1F563", "clock930": "1F564", "clock1030": "1F565", "clock1130": "1F566", "clock1230": "1F567", "candle": "1F56F-FE0F", "mantelpiece_clock": "1F570-FE0F", "hole": "1F573-FE0F", "man_in_business_suit_levitating": "1F574-FE0F", "female-detective": "1F575-FE0F-200D-2640-FE0F", "male-detective": "1F575-FE0F-200D-2642-FE0F", "sleuth_or_spy": "1F575-FE0F-200D-2642-FE0F", "dark_sunglasses": "1F576-FE0F", "spider": "1F577-FE0F", "spider_web": "1F578-FE0F", "joystick": "1F579-FE0F", "man_dancing": "1F57A", "linked_paperclips": "1F587-FE0F", "lower_left_ballpoint_pen": "1F58A-FE0F", "lower_left_fountain_pen": "1F58B-FE0F", "lower_left_paintbrush": "1F58C-FE0F", "lower_left_crayon": "1F58D-FE0F", "raised_hand_with_fingers_splayed": "1F590-FE0F", "middle_finger": "1F595", "spock-hand": "1F596", "black_heart": "1F5A4", "desktop_computer": "1F5A5-FE0F", "printer": "1F5A8-FE0F", "three_button_mouse": "1F5B1-FE0F", "trackball": "1F5B2-FE0F", "frame_with_picture": "1F5BC-FE0F", "card_index_dividers": "1F5C2-FE0F", "card_file_box": "1F5C3-FE0F", "file_cabinet": "1F5C4-FE0F", "wastebasket": "1F5D1-FE0F", "spiral_note_pad": "1F5D2-FE0F", "spiral_calendar_pad": "1F5D3-FE0F", "compression": "1F5DC-FE0F", "old_key": "1F5DD-FE0F", "rolled_up_newspaper": "1F5DE-FE0F", "dagger_knife": "1F5E1-FE0F", "speaking_head_in_silhouette": "1F5E3-FE0F", "left_speech_bubble": "1F5E8-FE0F", "right_anger_bubble": "1F5EF-FE0F", "ballot_box_with_ballot": "1F5F3-FE0F", "world_map": "1F5FA-FE0F", "mount_fuji": "1F5FB", "tokyo_tower": "1F5FC", "statue_of_liberty": "1F5FD", "japan": "1F5FE", "moyai": "1F5FF", "grinning": "1F600", "grin": "1F601", "joy": "1F602", "smiley": "1F603", "smile": "1F604", "sweat_smile": "1F605", "laughing": "1F606", "innocent": "1F607", "smiling_imp": "1F608", "wink": "1F609", "blush": "1F60A", "yum": "1F60B", "relieved": "1F60C", "heart_eyes": "1F60D", "sunglasses": "1F60E", "smirk": "1F60F", "neutral_face": "1F610", "expressionless": "1F611", "unamused": "1F612", "sweat": "1F613", "pensive": "1F614", "confused": "1F615", "confounded": "1F616", "kissing": "1F617", "kissing_heart": "1F618", "kissing_smiling_eyes": "1F619", "kissing_closed_eyes": "1F61A", "stuck_out_tongue": "1F61B", "stuck_out_tongue_winking_eye": "1F61C", "stuck_out_tongue_closed_eyes": "1F61D", "disappointed": "1F61E", "worried": "1F61F", "angry": "1F620", "rage": "1F621", "cry": "1F622", "persevere": "1F623", "triumph": "1F624", "disappointed_relieved": "1F625", "frowning": "1F626", "anguished": "1F627", "fearful": "1F628", "weary": "1F629", "sleepy": "1F62A", "tired_face": "1F62B", "grimacing": "1F62C", "sob": "1F62D", "face_exhaling": "1F62E-200D-1F4A8", "open_mouth": "1F62E", "hushed": "1F62F", "cold_sweat": "1F630", "scream": "1F631", "astonished": "1F632", "flushed": "1F633", "sleeping": "1F634", "face_with_spiral_eyes": "1F635-200D-1F4AB", "dizzy_face": "1F635", "face_in_clouds": "1F636-200D-1F32B-FE0F", "no_mouth": "1F636", "mask": "1F637", "smile_cat": "1F638", "joy_cat": "1F639", "smiley_cat": "1F63A", "heart_eyes_cat": "1F63B", "smirk_cat": "1F63C", "kissing_cat": "1F63D", "pouting_cat": "1F63E", "crying_cat_face": "1F63F", "scream_cat": "1F640", "slightly_frowning_face": "1F641", "slightly_smiling_face": "1F642", "upside_down_face": "1F643", "face_with_rolling_eyes": "1F644", "woman-gesturing-no": "1F645-200D-2640-FE0F", "man-gesturing-no": "1F645-200D-2642-FE0F", "no_good": "1F645-200D-2640-FE0F", "woman-gesturing-ok": "1F646-200D-2640-FE0F", "man-gesturing-ok": "1F646-200D-2642-FE0F", "ok_woman": "1F646-200D-2640-FE0F", "woman-bowing": "1F647-200D-2640-FE0F", "man-bowing": "1F647-200D-2642-FE0F", "bow": "1F647", "see_no_evil": "1F648", "hear_no_evil": "1F649", "speak_no_evil": "1F64A", "woman-raising-hand": "1F64B-200D-2640-FE0F", "man-raising-hand": "1F64B-200D-2642-FE0F", "raising_hand": "1F64B-200D-2640-FE0F", "raised_hands": "1F64C", "woman-frowning": "1F64D-200D-2640-FE0F", "man-frowning": "1F64D-200D-2642-FE0F", "person_frowning": "1F64D-200D-2640-FE0F", "woman-pouting": "1F64E-200D-2640-FE0F", "man-pouting": "1F64E-200D-2642-FE0F", "person_with_pouting_face": "1F64E-200D-2640-FE0F", "pray": "1F64F", "rocket": "1F680", "helicopter": "1F681", "steam_locomotive": "1F682", "railway_car": "1F683", "bullettrain_side": "1F684", "bullettrain_front": "1F685", "train2": "1F686", "metro": "1F687", "light_rail": "1F688", "station": "1F689", "tram": "1F68A", "train": "1F68B", "bus": "1F68C", "oncoming_bus": "1F68D", "trolleybus": "1F68E", "busstop": "1F68F", "minibus": "1F690", "ambulance": "1F691", "fire_engine": "1F692", "police_car": "1F693", "oncoming_police_car": "1F694", "taxi": "1F695", "oncoming_taxi": "1F696", "car": "1F697", "oncoming_automobile": "1F698", "blue_car": "1F699", "truck": "1F69A", "articulated_lorry": "1F69B", "tractor": "1F69C", "monorail": "1F69D", "mountain_railway": "1F69E", "suspension_railway": "1F69F", "mountain_cableway": "1F6A0", "aerial_tramway": "1F6A1", "ship": "1F6A2", "woman-rowing-boat": "1F6A3-200D-2640-FE0F", "man-rowing-boat": "1F6A3-200D-2642-FE0F", "rowboat": "1F6A3-200D-2642-FE0F", "speedboat": "1F6A4", "traffic_light": "1F6A5", "vertical_traffic_light": "1F6A6", "construction": "1F6A7", "rotating_light": "1F6A8", "triangular_flag_on_post": "1F6A9", "door": "1F6AA", "no_entry_sign": "1F6AB", "smoking": "1F6AC", "no_smoking": "1F6AD", "put_litter_in_its_place": "1F6AE", "do_not_litter": "1F6AF", "potable_water": "1F6B0", "non-potable_water": "1F6B1", "bike": "1F6B2", "no_bicycles": "1F6B3", "woman-biking": "1F6B4-200D-2640-FE0F", "man-biking": "1F6B4-200D-2642-FE0F", "bicyclist": "1F6B4-200D-2642-FE0F", "woman-mountain-biking": "1F6B5-200D-2640-FE0F", "man-mountain-biking": "1F6B5-200D-2642-FE0F", "mountain_bicyclist": "1F6B5-200D-2642-FE0F", "woman-walking": "1F6B6-200D-2640-FE0F", "man-walking": "1F6B6-200D-2642-FE0F", "walking": "1F6B6-200D-2642-FE0F", "no_pedestrians": "1F6B7", "children_crossing": "1F6B8", "mens": "1F6B9", "womens": "1F6BA", "restroom": "1F6BB", "baby_symbol": "1F6BC", "toilet": "1F6BD", "wc": "1F6BE", "shower": "1F6BF", "bath": "1F6C0", "bathtub": "1F6C1", "passport_control": "1F6C2", "customs": "1F6C3", "baggage_claim": "1F6C4", "left_luggage": "1F6C5", "couch_and_lamp": "1F6CB-FE0F", "sleeping_accommodation": "1F6CC", "shopping_bags": "1F6CD-FE0F", "bellhop_bell": "1F6CE-FE0F", "bed": "1F6CF-FE0F", "place_of_worship": "1F6D0", "octagonal_sign": "1F6D1", "shopping_trolley": "1F6D2", "hindu_temple": "1F6D5", "hut": "1F6D6", "elevator": "1F6D7", "playground_slide": "1F6DD", "wheel": "1F6DE", "ring_buoy": "1F6DF", "hammer_and_wrench": "1F6E0-FE0F", "shield": "1F6E1-FE0F", "oil_drum": "1F6E2-FE0F", "motorway": "1F6E3-FE0F", "railway_track": "1F6E4-FE0F", "motor_boat": "1F6E5-FE0F", "small_airplane": "1F6E9-FE0F", "airplane_departure": "1F6EB", "airplane_arriving": "1F6EC", "satellite": "1F6F0-FE0F", "passenger_ship": "1F6F3-FE0F", "scooter": "1F6F4", "motor_scooter": "1F6F5", "canoe": "1F6F6", "sled": "1F6F7", "flying_saucer": "1F6F8", "skateboard": "1F6F9", "auto_rickshaw": "1F6FA", "pickup_truck": "1F6FB", "roller_skate": "1F6FC", "large_orange_circle": "1F7E0", "large_yellow_circle": "1F7E1", "large_green_circle": "1F7E2", "large_purple_circle": "1F7E3", "large_brown_circle": "1F7E4", "large_red_square": "1F7E5", "large_blue_square": "1F7E6", "large_orange_square": "1F7E7", "large_yellow_square": "1F7E8", "large_green_square": "1F7E9", "large_purple_square": "1F7EA", "large_brown_square": "1F7EB", "heavy_equals_sign": "1F7F0", "pinched_fingers": "1F90C", "white_heart": "1F90D", "brown_heart": "1F90E", "pinching_hand": "1F90F", "zipper_mouth_face": "1F910", "money_mouth_face": "1F911", "face_with_thermometer": "1F912", "nerd_face": "1F913", "thinking_face": "1F914", "face_with_head_bandage": "1F915", "robot_face": "1F916", "hugging_face": "1F917", "the_horns": "1F918", "call_me_hand": "1F919", "raised_back_of_hand": "1F91A", "left-facing_fist": "1F91B", "right-facing_fist": "1F91C", "handshake": "1F91D", "crossed_fingers": "1F91E", "i_love_you_hand_sign": "1F91F", "face_with_cowboy_hat": "1F920", "clown_face": "1F921", "nauseated_face": "1F922", "rolling_on_the_floor_laughing": "1F923", "drooling_face": "1F924", "lying_face": "1F925", "woman-facepalming": "1F926-200D-2640-FE0F", "man-facepalming": "1F926-200D-2642-FE0F", "face_palm": "1F926", "sneezing_face": "1F927", "face_with_raised_eyebrow": "1F928", "star-struck": "1F929", "zany_face": "1F92A", "shushing_face": "1F92B", "face_with_symbols_on_mouth": "1F92C", "face_with_hand_over_mouth": "1F92D", "face_vomiting": "1F92E", "exploding_head": "1F92F", "pregnant_woman": "1F930", "breast-feeding": "1F931", "palms_up_together": "1F932", "selfie": "1F933", "prince": "1F934", "woman_in_tuxedo": "1F935-200D-2640-FE0F", "man_in_tuxedo": "1F935-200D-2642-FE0F", "person_in_tuxedo": "1F935", "mrs_claus": "1F936", "woman-shrugging": "1F937-200D-2640-FE0F", "man-shrugging": "1F937-200D-2642-FE0F", "shrug": "1F937", "woman-cartwheeling": "1F938-200D-2640-FE0F", "man-cartwheeling": "1F938-200D-2642-FE0F", "person_doing_cartwheel": "1F938", "woman-juggling": "1F939-200D-2640-FE0F", "man-juggling": "1F939-200D-2642-FE0F", "juggling": "1F939", "fencer": "1F93A", "woman-wrestling": "1F93C-200D-2640-FE0F", "man-wrestling": "1F93C-200D-2642-FE0F", "wrestlers": "1F93C", "woman-playing-water-polo": "1F93D-200D-2640-FE0F", "man-playing-water-polo": "1F93D-200D-2642-FE0F", "water_polo": "1F93D", "woman-playing-handball": "1F93E-200D-2640-FE0F", "man-playing-handball": "1F93E-200D-2642-FE0F", "handball": "1F93E", "diving_mask": "1F93F", "wilted_flower": "1F940", "drum_with_drumsticks": "1F941", "clinking_glasses": "1F942", "tumbler_glass": "1F943", "spoon": "1F944", "goal_net": "1F945", "first_place_medal": "1F947", "second_place_medal": "1F948", "third_place_medal": "1F949", "boxing_glove": "1F94A", "martial_arts_uniform": "1F94B", "curling_stone": "1F94C", "lacrosse": "1F94D", "softball": "1F94E", "flying_disc": "1F94F", "croissant": "1F950", "avocado": "1F951", "cucumber": "1F952", "bacon": "1F953", "potato": "1F954", "carrot": "1F955", "baguette_bread": "1F956", "green_salad": "1F957", "shallow_pan_of_food": "1F958", "stuffed_flatbread": "1F959", "egg": "1F95A", "glass_of_milk": "1F95B", "peanuts": "1F95C", "kiwifruit": "1F95D", "pancakes": "1F95E", "dumpling": "1F95F", "fortune_cookie": "1F960", "takeout_box": "1F961", "chopsticks": "1F962", "bowl_with_spoon": "1F963", "cup_with_straw": "1F964", "coconut": "1F965", "broccoli": "1F966", "pie": "1F967", "pretzel": "1F968", "cut_of_meat": "1F969", "sandwich": "1F96A", "canned_food": "1F96B", "leafy_green": "1F96C", "mango": "1F96D", "moon_cake": "1F96E", "bagel": "1F96F", "smiling_face_with_3_hearts": "1F970", "yawning_face": "1F971", "smiling_face_with_tear": "1F972", "partying_face": "1F973", "woozy_face": "1F974", "hot_face": "1F975", "cold_face": "1F976", "ninja": "1F977", "disguised_face": "1F978", "face_holding_back_tears": "1F979", "pleading_face": "1F97A", "sari": "1F97B", "lab_coat": "1F97C", "goggles": "1F97D", "hiking_boot": "1F97E", "womans_flat_shoe": "1F97F", "crab": "1F980", "lion_face": "1F981", "scorpion": "1F982", "turkey": "1F983", "unicorn_face": "1F984", "eagle": "1F985", "duck": "1F986", "bat": "1F987", "shark": "1F988", "owl": "1F989", "fox_face": "1F98A", "butterfly": "1F98B", "deer": "1F98C", "gorilla": "1F98D", "lizard": "1F98E", "rhinoceros": "1F98F", "shrimp": "1F990", "squid": "1F991", "giraffe_face": "1F992", "zebra_face": "1F993", "hedgehog": "1F994", "sauropod": "1F995", "t-rex": "1F996", "cricket": "1F997", "kangaroo": "1F998", "llama": "1F999", "peacock": "1F99A", "hippopotamus": "1F99B", "parrot": "1F99C", "raccoon": "1F99D", "lobster": "1F99E", "mosquito": "1F99F", "microbe": "1F9A0", "badger": "1F9A1", "swan": "1F9A2", "mammoth": "1F9A3", "dodo": "1F9A4", "sloth": "1F9A5", "otter": "1F9A6", "orangutan": "1F9A7", "skunk": "1F9A8", "flamingo": "1F9A9", "oyster": "1F9AA", "beaver": "1F9AB", "bison": "1F9AC", "seal": "1F9AD", "guide_dog": "1F9AE", "probing_cane": "1F9AF", "bone": "1F9B4", "leg": "1F9B5", "foot": "1F9B6", "tooth": "1F9B7", "female_superhero": "1F9B8-200D-2640-FE0F", "male_superhero": "1F9B8-200D-2642-FE0F", "superhero": "1F9B8", "female_supervillain": "1F9B9-200D-2640-FE0F", "male_supervillain": "1F9B9-200D-2642-FE0F", "supervillain": "1F9B9", "safety_vest": "1F9BA", "ear_with_hearing_aid": "1F9BB", "motorized_wheelchair": "1F9BC", "manual_wheelchair": "1F9BD", "mechanical_arm": "1F9BE", "mechanical_leg": "1F9BF", "cheese_wedge": "1F9C0", "cupcake": "1F9C1", "salt": "1F9C2", "beverage_box": "1F9C3", "garlic": "1F9C4", "onion": "1F9C5", "falafel": "1F9C6", "waffle": "1F9C7", "butter": "1F9C8", "mate_drink": "1F9C9", "ice_cube": "1F9CA", "bubble_tea": "1F9CB", "troll": "1F9CC", "woman_standing": "1F9CD-200D-2640-FE0F", "man_standing": "1F9CD-200D-2642-FE0F", "standing_person": "1F9CD", "woman_kneeling": "1F9CE-200D-2640-FE0F", "man_kneeling": "1F9CE-200D-2642-FE0F", "kneeling_person": "1F9CE", "deaf_woman": "1F9CF-200D-2640-FE0F", "deaf_man": "1F9CF-200D-2642-FE0F", "deaf_person": "1F9CF", "face_with_monocle": "1F9D0", "farmer": "1F9D1-200D-1F33E", "cook": "1F9D1-200D-1F373", "person_feeding_baby": "1F9D1-200D-1F37C", "mx_claus": "1F9D1-200D-1F384", "student": "1F9D1-200D-1F393", "singer": "1F9D1-200D-1F3A4", "artist": "1F9D1-200D-1F3A8", "teacher": "1F9D1-200D-1F3EB", "factory_worker": "1F9D1-200D-1F3ED", "technologist": "1F9D1-200D-1F4BB", "office_worker": "1F9D1-200D-1F4BC", "mechanic": "1F9D1-200D-1F527", "scientist": "1F9D1-200D-1F52C", "astronaut": "1F9D1-200D-1F680", "firefighter": "1F9D1-200D-1F692", "people_holding_hands": "1F9D1-200D-1F91D-200D-1F9D1", "person_with_probing_cane": "1F9D1-200D-1F9AF", "red_haired_person": "1F9D1-200D-1F9B0", "curly_haired_person": "1F9D1-200D-1F9B1", "bald_person": "1F9D1-200D-1F9B2", "white_haired_person": "1F9D1-200D-1F9B3", "person_in_motorized_wheelchair": "1F9D1-200D-1F9BC", "person_in_manual_wheelchair": "1F9D1-200D-1F9BD", "health_worker": "1F9D1-200D-2695-FE0F", "judge": "1F9D1-200D-2696-FE0F", "pilot": "1F9D1-200D-2708-FE0F", "adult": "1F9D1", "child": "1F9D2", "older_adult": "1F9D3", "woman_with_beard": "1F9D4-200D-2640-FE0F", "man_with_beard": "1F9D4-200D-2642-FE0F", "bearded_person": "1F9D4", "person_with_headscarf": "1F9D5", "woman_in_steamy_room": "1F9D6-200D-2640-FE0F", "man_in_steamy_room": "1F9D6-200D-2642-FE0F", "person_in_steamy_room": "1F9D6-200D-2642-FE0F", "woman_climbing": "1F9D7-200D-2640-FE0F", "man_climbing": "1F9D7-200D-2642-FE0F", "person_climbing": "1F9D7-200D-2640-FE0F", "woman_in_lotus_position": "1F9D8-200D-2640-FE0F", "man_in_lotus_position": "1F9D8-200D-2642-FE0F", "person_in_lotus_position": "1F9D8-200D-2640-FE0F", "female_mage": "1F9D9-200D-2640-FE0F", "male_mage": "1F9D9-200D-2642-FE0F", "mage": "1F9D9-200D-2640-FE0F", "female_fairy": "1F9DA-200D-2640-FE0F", "male_fairy": "1F9DA-200D-2642-FE0F", "fairy": "1F9DA-200D-2640-FE0F", "female_vampire": "1F9DB-200D-2640-FE0F", "male_vampire": "1F9DB-200D-2642-FE0F", "vampire": "1F9DB-200D-2640-FE0F", "mermaid": "1F9DC-200D-2640-FE0F", "merman": "1F9DC-200D-2642-FE0F", "merperson": "1F9DC-200D-2642-FE0F", "female_elf": "1F9DD-200D-2640-FE0F", "male_elf": "1F9DD-200D-2642-FE0F", "elf": "1F9DD-200D-2642-FE0F", "female_genie": "1F9DE-200D-2640-FE0F", "male_genie": "1F9DE-200D-2642-FE0F", "genie": "1F9DE-200D-2642-FE0F", "female_zombie": "1F9DF-200D-2640-FE0F", "male_zombie": "1F9DF-200D-2642-FE0F", "zombie": "1F9DF-200D-2642-FE0F", "brain": "1F9E0", "orange_heart": "1F9E1", "billed_cap": "1F9E2", "scarf": "1F9E3", "gloves": "1F9E4", "coat": "1F9E5", "socks": "1F9E6", "red_envelope": "1F9E7", "firecracker": "1F9E8", "jigsaw": "1F9E9", "test_tube": "1F9EA", "petri_dish": "1F9EB", "dna": "1F9EC", "compass": "1F9ED", "abacus": "1F9EE", "fire_extinguisher": "1F9EF", "toolbox": "1F9F0", "bricks": "1F9F1", "magnet": "1F9F2", "luggage": "1F9F3", "lotion_bottle": "1F9F4", "thread": "1F9F5", "yarn": "1F9F6", "safety_pin": "1F9F7", "teddy_bear": "1F9F8", "broom": "1F9F9", "basket": "1F9FA", "roll_of_paper": "1F9FB", "soap": "1F9FC", "sponge": "1F9FD", "receipt": "1F9FE", "nazar_amulet": "1F9FF", "ballet_shoes": "1FA70", "one-piece_swimsuit": "1FA71", "briefs": "1FA72", "shorts": "1FA73", "thong_sandal": "1FA74", "drop_of_blood": "1FA78", "adhesive_bandage": "1FA79", "stethoscope": "1FA7A", "x-ray": "1FA7B", "crutch": "1FA7C", "yo-yo": "1FA80", "kite": "1FA81", "parachute": "1FA82", "boomerang": "1FA83", "magic_wand": "1FA84", "pinata": "1FA85", "nesting_dolls": "1FA86", "ringed_planet": "1FA90", "chair": "1FA91", "razor": "1FA92", "axe": "1FA93", "diya_lamp": "1FA94", "banjo": "1FA95", "military_helmet": "1FA96", "accordion": "1FA97", "long_drum": "1FA98", "coin": "1FA99", "carpentry_saw": "1FA9A", "screwdriver": "1FA9B", "ladder": "1FA9C", "hook": "1FA9D", "mirror": "1FA9E", "window": "1FA9F", "plunger": "1FAA0", "sewing_needle": "1FAA1", "knot": "1FAA2", "bucket": "1FAA3", "mouse_trap": "1FAA4", "toothbrush": "1FAA5", "headstone": "1FAA6", "placard": "1FAA7", "rock": "1FAA8", "mirror_ball": "1FAA9", "identification_card": "1FAAA", "low_battery": "1FAAB", "hamsa": "1FAAC", "fly": "1FAB0", "worm": "1FAB1", "beetle": "1FAB2", "cockroach": "1FAB3", "potted_plant": "1FAB4", "wood": "1FAB5", "feather": "1FAB6", "lotus": "1FAB7", "coral": "1FAB8", "empty_nest": "1FAB9", "nest_with_eggs": "1FABA", "anatomical_heart": "1FAC0", "lungs": "1FAC1", "people_hugging": "1FAC2", "pregnant_man": "1FAC3", "pregnant_person": "1FAC4", "person_with_crown": "1FAC5", "blueberries": "1FAD0", "bell_pepper": "1FAD1", "olive": "1FAD2", "flatbread": "1FAD3", "tamale": "1FAD4", "fondue": "1FAD5", "teapot": "1FAD6", "pouring_liquid": "1FAD7", "beans": "1FAD8", "jar": "1FAD9", "melting_face": "1FAE0", "saluting_face": "1FAE1", "face_with_open_eyes_and_hand_over_mouth": "1FAE2", "face_with_peeking_eye": "1FAE3", "face_with_diagonal_mouth": "1FAE4", "dotted_line_face": "1FAE5", "biting_lip": "1FAE6", "bubbles": "1FAE7", "hand_with_index_finger_and_thumb_crossed": "1FAF0", "rightwards_hand": "1FAF1", "leftwards_hand": "1FAF2", "palm_down_hand": "1FAF3", "palm_up_hand": "1FAF4", "index_pointing_at_the_viewer": "1FAF5", "heart_hands": "1FAF6", "bangbang": "203C-FE0F", "interrobang": "2049-FE0F", "tm": "2122-FE0F", "information_source": "2139-FE0F", "left_right_arrow": "2194-FE0F", "arrow_up_down": "2195-FE0F", "arrow_upper_left": "2196-FE0F", "arrow_upper_right": "2197-FE0F", "arrow_lower_right": "2198-FE0F", "arrow_lower_left": "2199-FE0F", "leftwards_arrow_with_hook": "21A9-FE0F", "arrow_right_hook": "21AA-FE0F", "watch": "231A", "hourglass": "231B", "keyboard": "2328-FE0F", "eject": "23CF-FE0F", "fast_forward": "23E9", "rewind": "23EA", "arrow_double_up": "23EB", "arrow_double_down": "23EC", "black_right_pointing_double_triangle_with_vertical_bar": "23ED-FE0F", "black_left_pointing_double_triangle_with_vertical_bar": "23EE-FE0F", "black_right_pointing_triangle_with_double_vertical_bar": "23EF-FE0F", "alarm_clock": "23F0", "stopwatch": "23F1-FE0F", "timer_clock": "23F2-FE0F", "hourglass_flowing_sand": "23F3", "double_vertical_bar": "23F8-FE0F", "black_square_for_stop": "23F9-FE0F", "black_circle_for_record": "23FA-FE0F", "m": "24C2-FE0F", "black_small_square": "25AA-FE0F", "white_small_square": "25AB-FE0F", "arrow_forward": "25B6-FE0F", "arrow_backward": "25C0-FE0F", "white_medium_square": "25FB-FE0F", "black_medium_square": "25FC-FE0F", "white_medium_small_square": "25FD", "black_medium_small_square": "25FE", "sunny": "2600-FE0F", "cloud": "2601-FE0F", "umbrella": "2602-FE0F", "snowman": "2603-FE0F", "comet": "2604-FE0F", "phone": "260E-FE0F", "ballot_box_with_check": "2611-FE0F", "umbrella_with_rain_drops": "2614", "coffee": "2615", "shamrock": "2618-FE0F", "point_up": "261D-FE0F", "skull_and_crossbones": "2620-FE0F", "radioactive_sign": "2622-FE0F", "biohazard_sign": "2623-FE0F", "orthodox_cross": "2626-FE0F", "star_and_crescent": "262A-FE0F", "peace_symbol": "262E-FE0F", "yin_yang": "262F-FE0F", "wheel_of_dharma": "2638-FE0F", "white_frowning_face": "2639-FE0F", "relaxed": "263A-FE0F", "female_sign": "2640-FE0F", "male_sign": "2642-FE0F", "aries": "2648", "taurus": "2649", "gemini": "264A", "cancer": "264B", "leo": "264C", "virgo": "264D", "libra": "264E", "scorpius": "264F", "sagittarius": "2650", "capricorn": "2651", "aquarius": "2652", "pisces": "2653", "chess_pawn": "265F-FE0F", "spades": "2660-FE0F", "clubs": "2663-FE0F", "hearts": "2665-FE0F", "diamonds": "2666-FE0F", "hotsprings": "2668-FE0F", "recycle": "267B-FE0F", "infinity": "267E-FE0F", "wheelchair": "267F", "hammer_and_pick": "2692-FE0F", "anchor": "2693", "crossed_swords": "2694-FE0F", "medical_symbol": "2695-FE0F", "scales": "2696-FE0F", "alembic": "2697-FE0F", "gear": "2699-FE0F", "atom_symbol": "269B-FE0F", "fleur_de_lis": "269C-FE0F", "warning": "26A0-FE0F", "zap": "26A1", "transgender_symbol": "26A7-FE0F", "white_circle": "26AA", "black_circle": "26AB", "coffin": "26B0-FE0F", "funeral_urn": "26B1-FE0F", "soccer": "26BD", "baseball": "26BE", "snowman_without_snow": "26C4", "partly_sunny": "26C5", "thunder_cloud_and_rain": "26C8-FE0F", "ophiuchus": "26CE", "pick": "26CF-FE0F", "helmet_with_white_cross": "26D1-FE0F", "chains": "26D3-FE0F", "no_entry": "26D4", "shinto_shrine": "26E9-FE0F", "church": "26EA", "mountain": "26F0-FE0F", "umbrella_on_ground": "26F1-FE0F", "fountain": "26F2", "golf": "26F3", "ferry": "26F4-FE0F", "boat": "26F5", "skier": "26F7-FE0F", "ice_skate": "26F8-FE0F", "woman-bouncing-ball": "26F9-FE0F-200D-2640-FE0F", "man-bouncing-ball": "26F9-FE0F-200D-2642-FE0F", "person_with_ball": "26F9-FE0F-200D-2642-FE0F", "tent": "26FA", "fuelpump": "26FD", "scissors": "2702-FE0F", "white_check_mark": "2705", "airplane": "2708-FE0F", "email": "2709-FE0F", "fist": "270A", "hand": "270B", "v": "270C-FE0F", "writing_hand": "270D-FE0F", "pencil2": "270F-FE0F", "black_nib": "2712-FE0F", "heavy_check_mark": "2714-FE0F", "heavy_multiplication_x": "2716-FE0F", "latin_cross": "271D-FE0F", "star_of_david": "2721-FE0F", "sparkles": "2728", "eight_spoked_asterisk": "2733-FE0F", "eight_pointed_black_star": "2734-FE0F", "snowflake": "2744-FE0F", "sparkle": "2747-FE0F", "x": "274C", "negative_squared_cross_mark": "274E", "question": "2753", "grey_question": "2754", "grey_exclamation": "2755", "exclamation": "2757", "heavy_heart_exclamation_mark_ornament": "2763-FE0F", "heart_on_fire": "2764-FE0F-200D-1F525", "mending_heart": "2764-FE0F-200D-1FA79", "heart": "2764-FE0F", "heavy_plus_sign": "2795", "heavy_minus_sign": "2796", "heavy_division_sign": "2797", "arrow_right": "27A1-FE0F", "curly_loop": "27B0", "loop": "27BF", "arrow_heading_up": "2934-FE0F", "arrow_heading_down": "2935-FE0F", "arrow_left": "2B05-FE0F", "arrow_up": "2B06-FE0F", "arrow_down": "2B07-FE0F", "black_large_square": "2B1B", "white_large_square": "2B1C", "star": "2B50", "o": "2B55", "wavy_dash": "3030-FE0F", "part_alternation_mark": "303D-FE0F", "congratulations": "3297-FE0F", "secret": "3299-FE0F" }; loglevel.js 0000644 00000025212 15152050146 0006714 0 ustar 00 // Copyright (c) 2013 Tim Perry // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation // files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following // conditions: // // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR // OTHER DEALINGS IN THE SOFTWARE. // Description of import into Moodle: // Download from https://github.com/pimterry/loglevel/tree/master/dist // Copy loglevel.js into lib/amd/src/ in Moodle folder. // Add the license as a comment to the file and these instructions. /*! loglevel - v1.8.0 - https://github.com/pimterry/loglevel - (c) 2021 Tim Perry - licensed MIT */ (function (root, definition) { "use strict"; if (typeof define === 'function' && define.amd) { define(definition); } else if (typeof module === 'object' && module.exports) { module.exports = definition(); } else { root.log = definition(); } }(this, function () { "use strict"; // Slightly dubious tricks to cut down minimized file size var noop = function() {}; var undefinedType = "undefined"; var isIE = (typeof window !== undefinedType) && (typeof window.navigator !== undefinedType) && ( /Trident\/|MSIE /.test(window.navigator.userAgent) ); var logMethods = [ "trace", "debug", "info", "warn", "error" ]; // Cross-browser bind equivalent that works at least back to IE6 function bindMethod(obj, methodName) { var method = obj[methodName]; if (typeof method.bind === 'function') { return method.bind(obj); } else { try { return Function.prototype.bind.call(method, obj); } catch (e) { // Missing bind shim or IE8 + Modernizr, fallback to wrapping return function() { return Function.prototype.apply.apply(method, [obj, arguments]); }; } } } // Trace() doesn't print the message in IE, so for that case we need to wrap it function traceForIE() { if (console.log) { if (console.log.apply) { console.log.apply(console, arguments); } else { // In old IE, native console methods themselves don't have apply(). Function.prototype.apply.apply(console.log, [console, arguments]); } } if (console.trace) console.trace(); } // Build the best logging method possible for this env // Wherever possible we want to bind, not wrap, to preserve stack traces function realMethod(methodName) { if (methodName === 'debug') { methodName = 'log'; } if (typeof console === undefinedType) { return false; // No method possible, for now - fixed later by enableLoggingWhenConsoleArrives } else if (methodName === 'trace' && isIE) { return traceForIE; } else if (console[methodName] !== undefined) { return bindMethod(console, methodName); } else if (console.log !== undefined) { return bindMethod(console, 'log'); } else { return noop; } } // These private functions always need `this` to be set properly function replaceLoggingMethods(level, loggerName) { /*jshint validthis:true */ for (var i = 0; i < logMethods.length; i++) { var methodName = logMethods[i]; this[methodName] = (i < level) ? noop : this.methodFactory(methodName, level, loggerName); } // Define log.log as an alias for log.debug this.log = this.debug; } // In old IE versions, the console isn't present until you first open it. // We build realMethod() replacements here that regenerate logging methods function enableLoggingWhenConsoleArrives(methodName, level, loggerName) { return function () { if (typeof console !== undefinedType) { replaceLoggingMethods.call(this, level, loggerName); this[methodName].apply(this, arguments); } }; } // By default, we use closely bound real methods wherever possible, and // otherwise we wait for a console to appear, and then try again. function defaultMethodFactory(methodName, level, loggerName) { /*jshint validthis:true */ return realMethod(methodName) || enableLoggingWhenConsoleArrives.apply(this, arguments); } function Logger(name, defaultLevel, factory) { var self = this; var currentLevel; defaultLevel = defaultLevel == null ? "WARN" : defaultLevel; var storageKey = "loglevel"; if (typeof name === "string") { storageKey += ":" + name; } else if (typeof name === "symbol") { storageKey = undefined; } function persistLevelIfPossible(levelNum) { var levelName = (logMethods[levelNum] || 'silent').toUpperCase(); if (typeof window === undefinedType || !storageKey) return; // Use localStorage if available try { window.localStorage[storageKey] = levelName; return; } catch (ignore) {} // Use session cookie as fallback try { window.document.cookie = encodeURIComponent(storageKey) + "=" + levelName + ";"; } catch (ignore) {} } function getPersistedLevel() { var storedLevel; if (typeof window === undefinedType || !storageKey) return; try { storedLevel = window.localStorage[storageKey]; } catch (ignore) {} // Fallback to cookies if local storage gives us nothing if (typeof storedLevel === undefinedType) { try { var cookie = window.document.cookie; var location = cookie.indexOf( encodeURIComponent(storageKey) + "="); if (location !== -1) { storedLevel = /^([^;]+)/.exec(cookie.slice(location))[1]; } } catch (ignore) {} } // If the stored level is not valid, treat it as if nothing was stored. if (self.levels[storedLevel] === undefined) { storedLevel = undefined; } return storedLevel; } function clearPersistedLevel() { if (typeof window === undefinedType || !storageKey) return; // Use localStorage if available try { window.localStorage.removeItem(storageKey); return; } catch (ignore) {} // Use session cookie as fallback try { window.document.cookie = encodeURIComponent(storageKey) + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC"; } catch (ignore) {} } /* * * Public logger API - see https://github.com/pimterry/loglevel for details * */ self.name = name; self.levels = { "TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3, "ERROR": 4, "SILENT": 5}; self.methodFactory = factory || defaultMethodFactory; self.getLevel = function () { return currentLevel; }; self.setLevel = function (level, persist) { if (typeof level === "string" && self.levels[level.toUpperCase()] !== undefined) { level = self.levels[level.toUpperCase()]; } if (typeof level === "number" && level >= 0 && level <= self.levels.SILENT) { currentLevel = level; if (persist !== false) { // defaults to true persistLevelIfPossible(level); } replaceLoggingMethods.call(self, level, name); if (typeof console === undefinedType && level < self.levels.SILENT) { return "No console available for logging"; } } else { throw "log.setLevel() called with invalid level: " + level; } }; self.setDefaultLevel = function (level) { defaultLevel = level; if (!getPersistedLevel()) { self.setLevel(level, false); } }; self.resetLevel = function () { self.setLevel(defaultLevel, false); clearPersistedLevel(); }; self.enableAll = function(persist) { self.setLevel(self.levels.TRACE, persist); }; self.disableAll = function(persist) { self.setLevel(self.levels.SILENT, persist); }; // Initialize with the right level var initialLevel = getPersistedLevel(); if (initialLevel == null) { initialLevel = defaultLevel; } self.setLevel(initialLevel, false); } /* * * Top-level API * */ var defaultLogger = new Logger(); var _loggersByName = {}; defaultLogger.getLogger = function getLogger(name) { if ((typeof name !== "symbol" && typeof name !== "string") || name === "") { throw new TypeError("You must supply a name when creating a logger."); } var logger = _loggersByName[name]; if (!logger) { logger = _loggersByName[name] = new Logger( name, defaultLogger.getLevel(), defaultLogger.methodFactory); } return logger; }; // Grab the current global log variable in case of overwrite var _log = (typeof window !== undefinedType) ? window.log : undefined; defaultLogger.noConflict = function() { if (typeof window !== undefinedType && window.log === defaultLogger) { window.log = _log; } return defaultLogger; }; defaultLogger.getLoggers = function getLoggers() { return _loggersByName; }; // ES6 default export, for compatibility defaultLogger['default'] = defaultLogger; return defaultLogger; })); event_dispatcher.js 0000644 00000005445 15152050146 0010440 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * An Event dispatcher used to dispatch Native JS CustomEvent objects with custom default properties. * * @module core/event_dispatcher * @copyright 2021 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 4.0 */ /** * Dispatch an event as a CustomEvent on the specified container. * By default events are bubbled, and cancelable. * * The eventName should typically by sourced using a constant. See the supplied examples. * * Note: This function uses native events. Any additional details are passed to the function in event.detail. * * This function mimics the behaviour of EventTarget.dispatchEvent but bubbles by default. * * @method dispatchEvent * @param {String} eventName The name of the event * @param {Object} detail Any additional details to pass into the eveent * @param {HTMLElement} container The point at which to dispatch the event * @param {Object} options * @param {Boolean} options.bubbles Whether to bubble up the DOM * @param {Boolean} options.cancelable Whether preventDefault() can be called * @param {Boolean} options.composed Whether the event can bubble across the ShadowDOM bounadry * @returns {CustomEvent} * * @example <caption>Using a native CustomEvent to indicate that some example data was displayed.</caption> * // mod/example/amd/src/events.js * * import {dispatchEvent} from 'core/event_dispatcher'; * * export const eventTypes = { * exampleDataDisplayed: 'mod_example/exampleDataDisplayed', * }; * * export const notifyExampleDisplayed = someArgument => dispatchEvent(eventTypes.exampleDataDisplayed, { * someArgument, * }, document, { * cancelable: false, * }); */ export const dispatchEvent = ( eventName, detail = {}, container = document, { bubbles = true, cancelable = false, composed = false, } = {} ) => { const customEvent = new CustomEvent( eventName, { bubbles, cancelable, composed, detail, } ); container.dispatchEvent(customEvent); return customEvent; }; datafilter/selectors.js 0000644 00000004561 15152050146 0011231 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Module containing the selectors for user filters. * * @module core/datafilter/selectors * @copyright 2020 Michael Hawkins <michaelh@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const getFilterRegion = region => `[data-filterregion="${region}"]`; const getFilterAction = action => `[data-filteraction="${action}"]`; const getFilterField = field => `[data-filterfield="${field}"]`; export default { filter: { region: getFilterRegion('filter'), actions: { remove: getFilterAction('remove'), }, fields: { join: getFilterField('join'), type: getFilterField('type'), }, regions: { values: getFilterRegion('value'), }, byName: name => `${getFilterRegion('filter')}[data-filter-type="${name}"]`, }, filterset: { region: getFilterRegion('actions'), actions: { addRow: getFilterAction('add'), applyFilters: getFilterAction('apply'), resetFilters: getFilterAction('reset'), }, regions: { filtermatch: getFilterRegion('filtermatch'), filterlist: getFilterRegion('filters'), datasource: getFilterRegion('filtertypedata'), }, fields: { join: `${getFilterRegion('filtermatch')} ${getFilterField('join')}`, }, }, data: { fields: { byName: name => `[data-field-name="${name}"]`, all: `${getFilterRegion('filtertypedata')} [data-field-name]`, }, typeList: getFilterRegion('filtertypelist'), typeListSelect: `select${getFilterRegion('filtertypelist')}`, }, }; datafilter/filtertypes/courseid.js 0000644 00000002637 15152050146 0013417 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Course ID filter. * * @module core/datafilter/filtertypes/courseid * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Filter from 'core/datafilter/filtertype'; export default class extends Filter { constructor(filterType, filterSet) { super(filterType, filterSet); } async addValueSelector() { // eslint-disable-line no-empty-function } /** * Get the composed value for this filter. * * @returns {Object} */ get filterValue() { return { name: this.name, jointype: 1, values: [parseInt(this.rootNode.dataset.tableCourseId, 10)], }; } } datafilter/filtertypes/keyword.js 0000644 00000003060 15152050146 0013255 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Keyword filter. * * @module core/datafilter/filtertypes/keyword * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Filter from 'core/datafilter/filtertype'; import {get_string as getString} from 'core/str'; export default class extends Filter { /** * For keywords the final value is an Array of strings. * * @returns {Object} */ get values() { return this.rawValues; } /** * Get the placeholder to use when showing the value selector. * * @return {Promise} Resolving to a String */ get placeholder() { return getString('placeholdertype', 'core_user'); } /** * Whether to show suggestions in the autocomplete. * * @return {Boolean} */ get showSuggestions() { return false; } } datafilter/filtertypes/country.js 0000644 00000002204 15152050146 0013273 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Country filter * * @module core/datafilter/filtertypes/country * @copyright 2021 Paul Holden <paulh@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Filter from 'core/datafilter/filtertype'; export default class extends Filter { /** * For country the final value is an array of country code strings * * @return {Object} */ get values() { return this.rawValues; } } datafilter/filtertype.js 0000644 00000015625 15152050146 0011420 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Base Filter class for a filter type in the filter UI. * * @module core/datafilter/filtertype * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Autocomplete from 'core/form-autocomplete'; import Selectors from 'core/datafilter/selectors'; import {get_string as getString} from 'core/str'; /** * Fetch all checked options in the select. * * This is a poor-man's polyfill for select.selectedOptions, which is not available in IE11. * * @param {HTMLSelectElement} select * @returns {HTMLOptionElement[]} All selected options */ const getOptionsForSelect = select => { return select.querySelectorAll(':checked'); }; export default class { /** * Constructor for a new filter. * * @param {String} filterType The type of filter that this relates to * @param {HTMLElement} rootNode The root node for the participants filterset * @param {Array} initialValues The initial values for the selector */ constructor(filterType, rootNode, initialValues) { this.filterType = filterType; this.rootNode = rootNode; this.addValueSelector(initialValues); } /** * Perform any tear-down for this filter type. */ tearDown() { // eslint-disable-line no-empty-function } /** * Get the placeholder to use when showing the value selector. * * @return {Promise} Resolving to a String */ get placeholder() { return getString('placeholdertypeorselect', 'core'); } /** * Whether to show suggestions in the autocomplete. * * @return {Boolean} */ get showSuggestions() { return true; } /** * Add the value selector to the filter row. * * @param {Array} initialValues */ async addValueSelector(initialValues = []) { const filterValueNode = this.getFilterValueNode(); // Copy the data in place. const sourceDataNode = this.getSourceDataForFilter(); if (!sourceDataNode) { return; } filterValueNode.innerHTML = sourceDataNode.outerHTML; const dataSource = filterValueNode.querySelector('select'); // Set an ID for this filter value element. dataSource.id = 'filter-value-' + dataSource.getAttribute('data-field-name'); // Create a hidden label for the filter value. const filterValueLabel = document.createElement('label'); filterValueLabel.setAttribute('for', dataSource.id); filterValueLabel.classList.add('sr-only'); filterValueLabel.innerText = dataSource.getAttribute('data-field-title'); // Append this label to the filter value container. filterValueNode.appendChild(filterValueLabel); // If there are any initial values then attempt to apply them. initialValues.forEach(filterValue => { let selectedOption = dataSource.querySelector(`option[value="${filterValue}"]`); if (selectedOption) { selectedOption.selected = true; } else if (!this.showSuggestions) { selectedOption = document.createElement('option'); selectedOption.value = filterValue; selectedOption.innerHTML = filterValue; selectedOption.selected = true; dataSource.append(selectedOption); } }); Autocomplete.enhance( // The source select element. dataSource, // Whether to allow 'tags' (custom entries). dataSource.dataset.allowCustom == "1", // We do not require AJAX at all as standard. null, // The string to use as a placeholder. await this.placeholder, // Disable case sensitivity on searches. false, // Show suggestions. this.showSuggestions, // Do not override the 'no suggestions' string. null, // Close the suggestions if this is not a multi-select. !dataSource.multiple, // Template overrides. { items: 'core/datafilter/autocomplete_selection_items', layout: 'core/datafilter/autocomplete_layout', selection: 'core/datafilter/autocomplete_selection', } ); } /** * Get the root node for this filter. * * @returns {HTMLElement} */ get filterRoot() { return this.rootNode.querySelector(Selectors.filter.byName(this.filterType)); } /** * Get the possible data for this filter type. * * @returns {Array} */ getSourceDataForFilter() { const filterDataNode = this.rootNode.querySelector(Selectors.filterset.regions.datasource); return filterDataNode.querySelector(Selectors.data.fields.byName(this.filterType)); } /** * Get the HTMLElement which contains the value selector. * * @returns {HTMLElement} */ getFilterValueNode() { return this.filterRoot.querySelector(Selectors.filter.regions.values); } /** * Get the name of this filter. * * @returns {String} */ get name() { return this.filterType; } /** * Get the type of join specified. * * @returns {Number} */ get jointype() { return parseInt(this.filterRoot.querySelector(Selectors.filter.fields.join).value, 10); } /** * Get the list of raw values for this filter type. * * @returns {Array} */ get rawValues() { const filterValueNode = this.getFilterValueNode(); const filterValueSelect = filterValueNode.querySelector('select'); return Object.values(getOptionsForSelect(filterValueSelect)).map(option => option.value); } /** * Get the list of values for this filter type. * * @returns {Array} */ get values() { return this.rawValues.map(option => parseInt(option, 10)); } /** * Get the composed value for this filter. * * @returns {Object} */ get filterValue() { return { name: this.name, jointype: this.jointype, values: this.values, }; } } paged_content_pages.js 0000644 00000027405 15152050146 0011102 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Javascript for showing/hiding pages of content. * * @module core/paged_content_pages * @copyright 2018 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define( [ 'jquery', 'core/templates', 'core/notification', 'core/pubsub', 'core/paged_content_events', 'core/pending', ], function( $, Templates, Notification, PubSub, PagedContentEvents, Pending ) { var SELECTORS = { ROOT: '[data-region="page-container"]', PAGE_REGION: '[data-region="paged-content-page"]', ACTIVE_PAGE_REGION: '[data-region="paged-content-page"].active' }; var TEMPLATES = { PAGING_CONTENT_ITEM: 'core/paged_content_page', LOADING: 'core/overlay_loading' }; var PRELOADING_GRACE_PERIOD = 300; /** * Find a page by the number. * * @param {object} root The root element. * @param {Number} pageNumber The number of the page to be found. * @returns {jQuery} The page. */ var findPage = function(root, pageNumber) { return root.find('[data-page="' + pageNumber + '"]'); }; /** * Show the loading spinner until the returned deferred is resolved by the * calling code. * * The loading spinner is only rendered after a short grace period to avoid * having it flash up briefly in the interface. * * @param {object} root The root element. * @returns {promise} The page. */ var startLoading = function(root) { var deferred = $.Deferred(); root.attr('aria-busy', true); var pendingPromise = new Pending('core/paged_content_pages:startLoading'); Templates.render(TEMPLATES.LOADING, {visible: true}) .then(function(html) { var loadingSpinner = $(html); // Put this in a timer to give the calling code 300 milliseconds // to render the content before we show the loading spinner. This // helps prevent a loading icon flicker on close to instant // rendering. var timerId = setTimeout(function() { root.css('position', 'relative'); loadingSpinner.appendTo(root); }, PRELOADING_GRACE_PERIOD); deferred.always(function() { clearTimeout(timerId); // Remove the loading spinner when our deferred is resolved // by the calling code. loadingSpinner.remove(); root.css('position', ''); root.removeAttr('aria-busy'); pendingPromise.resolve(); return; }); return; }) .fail(Notification.exception); return deferred; }; /** * Render the result of the page promise in a paged content page. * * This function returns a promise that is resolved with the new paged content * page. * * @param {object} root The root element. * @param {promise} pagePromise The promise resolved with HTML and JS to render in the page. * @param {Number} pageNumber The page number. * @returns {promise} The page. */ var renderPagePromise = function(root, pagePromise, pageNumber) { var deferred = $.Deferred(); pagePromise.then(function(html, pageJS) { pageJS = pageJS || ''; // When we get the contents to be rendered we can pass it in as the // content for a new page. Templates.render(TEMPLATES.PAGING_CONTENT_ITEM, { page: pageNumber, content: html }) .then(function(html) { // Make sure the JS we got from the page promise is being added // to the page when we render the page. Templates.appendNodeContents(root, html, pageJS); var page = findPage(root, pageNumber); deferred.resolve(page); return; }) .fail(function(exception) { deferred.reject(exception); }) .fail(Notification.exception); return; }) .fail(function(exception) { deferred.reject(exception); return; }) .fail(Notification.exception); return deferred.promise(); }; /** * Make one or more pages visible based on the SHOW_PAGES event. The show * pages event provides data containing which pages should be shown as well * as the limit and offset values for loading the items for each of those pages. * * The renderPagesContentCallback is provided this list of data to know which * pages to load. E.g. the data to load 2 pages might look like: * [ * { * pageNumber: 1, * limit: 5, * offset: 0 * }, * { * pageNumber: 2, * limit: 5, * offset: 5 * } * ] * * The renderPagesContentCallback should return an array of promises, one for * each page in the pages data, that is resolved with the HTML and JS for that page. * * If the renderPagesContentCallback is not provided then it is assumed that * all pages have been rendered prior to initialising this module. * * This function triggers the PAGES_SHOWN event after the pages have been rendered. * * @param {object} root The root element. * @param {Number} pagesData The data for which pages need to be visible. * @param {string} id A unique id for this instance. * @param {function} renderPagesContentCallback Render pages content. */ var showPages = function(root, pagesData, id, renderPagesContentCallback) { var pendingPromise = new Pending('core/paged_content_pages:showPages'); var existingPages = []; var newPageData = []; var newPagesPromise = $.Deferred(); var shownewpage = true; // Check which of the pages being requests have previously been rendered // so that we only ask for new pages to be rendered by the callback. pagesData.forEach(function(pageData) { var pageNumber = pageData.pageNumber; var existingPage = findPage(root, pageNumber); if (existingPage.length) { existingPages.push(existingPage); } else { newPageData.push(pageData); } }); if (newPageData.length && typeof renderPagesContentCallback === 'function') { // If we have pages we haven't previously seen then ask the client code // to render them for us by calling the callback. var promises = renderPagesContentCallback(newPageData, { allItemsLoaded: function(lastPageNumber) { PubSub.publish(id + PagedContentEvents.ALL_ITEMS_LOADED, lastPageNumber); } }); // After the client has finished rendering each of the pages being asked // for then begin our rendering process to put that content into paged // content pages. var renderPagePromises = promises.map(function(promise, index) { // Create our promise for when our rendering will be completed. return renderPagePromise(root, promise, newPageData[index].pageNumber); }); // After each of our rendering promises have been completed then we can // give all of the new pages to the next bit of code for handling. $.when.apply($, renderPagePromises) .then(function() { var newPages = Array.prototype.slice.call(arguments); // Resolve the promise with the list of newly rendered pages. newPagesPromise.resolve(newPages); return; }) .fail(function(exception) { newPagesPromise.reject(exception); return; }) .fail(Notification.exception); } else { // If there aren't any pages to load then immediately resolve the promise. newPagesPromise.resolve([]); } var loadingPromise = startLoading(root); newPagesPromise.then(function(newPages) { // Once all of the new pages have been created then add them to any // existing pages we have. var pagesToShow = existingPages.concat(newPages); // Hide all existing pages. root.find(SELECTORS.PAGE_REGION).addClass('hidden'); // Show each of the pages that were requested.; pagesToShow.forEach(function(page) { if (shownewpage) { page.removeClass('hidden'); } }); return; }) .then(function() { // Let everything else know we've displayed the pages. PubSub.publish(id + PagedContentEvents.PAGES_SHOWN, pagesData); return; }) .fail(Notification.exception) .always(function() { loadingPromise.resolve(); pendingPromise.resolve(); }) .catch(); }; /** * Initialise the module to listen for SHOW_PAGES events and render the * appropriate pages using the provided renderPagesContentCallback function. * * The renderPagesContentCallback is provided a list of data to know which * pages to load. * E.g. the data to load 2 pages might look like: * [ * { * pageNumber: 1, * limit: 5, * offset: 0 * }, * { * pageNumber: 2, * limit: 5, * offset: 5 * } * ] * * The renderPagesContentCallback should return an array of promises, one for * each page in the pages data, that is resolved with the HTML and JS for that page. * * If the renderPagesContentCallback is not provided then it is assumed that * all pages have been rendered prior to initialising this module. * * The event element is the element to listen for the paged content events on. * * @param {object} root The root element. * @param {string} id A unique id for this instance. * @param {function} renderPagesContentCallback Render pages content. */ var init = function(root, id, renderPagesContentCallback) { root = $(root); PubSub.subscribe(id + PagedContentEvents.SHOW_PAGES, function(pagesData) { showPages(root, pagesData, id, renderPagesContentCallback); }); PubSub.subscribe(id + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT, function() { // If the items per page limit was changed then we need to clear our content // the load new values based on the new limit. root.empty(); }); }; return { init: init, rootSelector: SELECTORS.ROOT, }; }); modal_factory.js 0000644 00000021230 15152050146 0007722 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Create a modal. * * @module core/modal_factory * @copyright 2016 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery', 'core/modal_events', 'core/modal_registry', 'core/modal', 'core/modal_save_cancel', 'core/modal_cancel', 'core/local/modal/alert', 'core/templates', 'core/notification', 'core/custom_interaction_events', 'core/pending'], function($, ModalEvents, ModalRegistry, Modal, ModalSaveCancel, ModalCancel, ModalAlert, Templates, Notification, CustomEvents, Pending) { // The templates for each type of modal. var TEMPLATES = { DEFAULT: 'core/modal', SAVE_CANCEL: 'core/modal_save_cancel', CANCEL: 'core/modal_cancel', ALERT: 'core/local/modal/alert', }; /** * The available types of modals. * * @constant * @static * @public * @property {String} DEFAULT The default modal * @property {String} SAVE_CANCEL A modal which can be used to either save, or cancel. * @property {String} CANCEL A modal which displayed a cancel button * @property {String} ALERT An information modal which only displays information. */ var TYPES = { DEFAULT: 'DEFAULT', SAVE_CANCEL: 'SAVE_CANCEL', CANCEL: 'CANCEL', ALERT: 'ALERT', }; // Register the common set of modals. ModalRegistry.register(TYPES.DEFAULT, Modal, TEMPLATES.DEFAULT); ModalRegistry.register(TYPES.SAVE_CANCEL, ModalSaveCancel, TEMPLATES.SAVE_CANCEL); ModalRegistry.register(TYPES.CANCEL, ModalCancel, TEMPLATES.CANCEL); ModalRegistry.register(TYPES.ALERT, ModalAlert, TEMPLATES.ALERT); /** * Set up the events required to show the modal and return focus when the modal * is closed. * * @method setUpTrigger * @private * @param {Promise} modalPromise The modal instance * @param {object} triggerElement The jQuery element to open the modal * @param {object} modalConfig The modal configuration given to the factory */ var setUpTrigger = function(modalPromise, triggerElement, modalConfig) { // The element that actually shows the modal. var actualTriggerElement = null; // Check if the client has provided a callback function to be called // before the modal is displayed. var hasPreShowCallback = (typeof modalConfig.preShowCallback == 'function'); // Function to handle the trigger element being activated. var triggeredCallback = function(e, data) { var pendingPromise = new Pending('core/modal_factory:setUpTrigger:triggeredCallback'); actualTriggerElement = $(e.currentTarget); modalPromise.then(function(modal) { if (hasPreShowCallback) { // If the client provided a pre-show callback then execute // it now before showing the modal. modalConfig.preShowCallback(actualTriggerElement, modal); } modal.show(); return modal; }) .then(pendingPromise.resolve); data.originalEvent.preventDefault(); }; // The trigger element can either be a single element or it can be an // element + selector pair to create a delegated event handler to trigger // the modal. if (Array.isArray(triggerElement)) { var selector = triggerElement[1]; triggerElement = triggerElement[0]; CustomEvents.define(triggerElement, [CustomEvents.events.activate]); triggerElement.on(CustomEvents.events.activate, selector, triggeredCallback); } else { CustomEvents.define(triggerElement, [CustomEvents.events.activate]); triggerElement.on(CustomEvents.events.activate, triggeredCallback); } modalPromise.then(function(modal) { modal.getRoot().on(ModalEvents.hidden, function() { // Focus on the trigger element that actually launched the modal. if (actualTriggerElement !== null) { actualTriggerElement.focus(); } }); return modal; }); }; /** * Create the correct instance of a modal based on the givem type. Sets up * the trigger between the modal and the trigger element. * * @method createFromElement * @private * @param {object} registryConf A config from the ModalRegistry * @param {object} modalElement The modal HTML jQuery object * @return {object} Modal instance */ var createFromElement = function(registryConf, modalElement) { modalElement = $(modalElement); var Module = registryConf.module; var modal = new Module(modalElement); return modal; }; /** * Create the correct modal instance for the given type, including loading * the correct template. * * @method createFromType * @private * @param {object} registryConf A config from the ModalRegistry * @param {object} templateContext The context to render the template with * @returns {promise} Resolved with a Modal instance */ var createFromType = function(registryConf, templateContext) { var templateName = registryConf.template; var modalPromise = Templates.render(templateName, templateContext) .then(function(html) { var modalElement = $(html); return createFromElement(registryConf, modalElement); }) .fail(Notification.exception); return modalPromise; }; /** * Create a Modal instance. * * @method create * @param {object} modalConfig The configuration to create the modal instance * @param {object} triggerElement The trigger HTML jQuery object * @return {promise} Resolved with a Modal instance */ var create = function(modalConfig, triggerElement) { var type = modalConfig.type || TYPES.DEFAULT; var isLarge = modalConfig.large ? true : false; // If 'scrollable' is not configured, set the modal to be scrollable by default. var isScrollable = modalConfig.hasOwnProperty('scrollable') ? modalConfig.scrollable : true; var registryConf = null; var templateContext = {}; registryConf = ModalRegistry.get(type); if (!registryConf) { Notification.exception({message: 'Unable to find modal of type: ' + type}); } if (typeof modalConfig.templateContext != 'undefined') { templateContext = modalConfig.templateContext; } var modalPromise = createFromType(registryConf, templateContext) .then(function(modal) { if (typeof modalConfig.title != 'undefined') { modal.setTitle(modalConfig.title); } if (typeof modalConfig.body != 'undefined') { modal.setBody(modalConfig.body); } if (typeof modalConfig.footer != 'undefined') { modal.setFooter(modalConfig.footer); } if (modalConfig.buttons) { Object.entries(modalConfig.buttons).forEach(function([key, value]) { modal.setButtonText(key, value); }); } if (isLarge) { modal.setLarge(); } if (typeof modalConfig.removeOnClose !== 'undefined') { // If configured remove the modal when hiding it. modal.setRemoveOnClose(modalConfig.removeOnClose); } modal.setScrollable(isScrollable); return modal; }); if (typeof triggerElement != 'undefined') { setUpTrigger(modalPromise, triggerElement, modalConfig); } return modalPromise; }; return { create: create, types: TYPES, }; }); addblockmodal.js 0000644 00000010517 15152050146 0007665 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Show an add block modal instead of doing it on a separate page. * * @module core/addblockmodal * @copyright 2016 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import ModalFactory from 'core/modal_factory'; import Templates from 'core/templates'; import {get_string as getString} from 'core/str'; import Ajax from 'core/ajax'; const SELECTORS = { ADD_BLOCK: '[data-key="addblock"]' }; // Ensure we only add our listeners once. let listenerEventsRegistered = false; /** * Register related event listeners. * * @method registerListenerEvents * @param {String} pageType The type of the page * @param {String} pageLayout The layout of the page * @param {String|null} addBlockUrl The add block URL * @param {String} subPage The subpage identifier */ const registerListenerEvents = (pageType, pageLayout, addBlockUrl, subPage) => { document.addEventListener('click', e => { const addBlock = e.target.closest(SELECTORS.ADD_BLOCK); if (addBlock) { e.preventDefault(); let addBlockModal = null; let addBlockModalUrl = addBlockUrl ?? addBlock.dataset.url; buildAddBlockModal() .then(modal => { addBlockModal = modal; const modalBody = renderBlocks(addBlockModalUrl, pageType, pageLayout, subPage); modal.setBody(modalBody); modal.show(); return modalBody; }) .catch(() => { addBlockModal.destroy(); }); } }); }; /** * Method that creates the 'add block' modal. * * @method buildAddBlockModal * @returns {Promise} The modal promise (modal's body will be rendered later). */ const buildAddBlockModal = () => { return ModalFactory.create({ type: ModalFactory.types.CANCEL, title: getString('addblock') }); }; /** * Method that renders the list of available blocks. * * @method renderBlocks * @param {String} addBlockUrl The add block URL * @param {String} pageType The type of the page * @param {String} pageLayout The layout of the page * @param {String} subPage The subpage identifier * @return {Promise} */ const renderBlocks = async(addBlockUrl, pageType, pageLayout, subPage) => { // Fetch all addable blocks in the given page. const blocks = await getAddableBlocks(pageType, pageLayout, subPage); return Templates.render('core/add_block_body', { blocks: blocks, url: addBlockUrl }); }; /** * Method that fetches all addable blocks in a given page. * * @method getAddableBlocks * @param {String} pageType The type of the page * @param {String} pageLayout The layout of the page * @param {String} subPage The subpage identifier * @return {Promise} */ const getAddableBlocks = async(pageType, pageLayout, subPage) => { const request = { methodname: 'core_block_fetch_addable_blocks', args: { pagecontextid: M.cfg.contextid, pagetype: pageType, pagelayout: pageLayout, subpage: subPage, }, }; return Ajax.call([request])[0]; }; /** * Set up the actions. * * @method init * @param {String} pageType The type of the page * @param {String} pageLayout The layout of the page * @param {String|null} addBlockUrl The add block URL * @param {String} subPage The subpage identifier */ export const init = (pageType, pageLayout, addBlockUrl = null, subPage = '') => { if (!listenerEventsRegistered) { registerListenerEvents(pageType, pageLayout, addBlockUrl, subPage); listenerEventsRegistered = true; } }; icon_system.js 0000644 00000005101 15152050146 0007432 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Icon System base module. * * @module core/icon_system * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery'], function($) { /** * Icon System abstract class. * * Any icon system needs to define a module extending this one and return this module name from the php icon_system class. * * @class core/icon_system */ var IconSystem = function() { }; /** * Initialise the icon system. * * @return {Promise} * @method init */ IconSystem.prototype.init = function() { return $.when(this); }; /** * Render an icon. * * The key, component and title come from either the pix mustache helper tag, or the call to templates.renderIcon. * The template is the pre-loaded template string matching the template from getTemplateName() in this class. * This function must return a string (not a promise) because it is used during the internal rendering of the mustache * template (which is unfortunately synchronous). To render the mustache template in this function call * core/mustache.render() directly and do not use any partials, blocks or helper functions in the template. * * @param {String} key * @param {String} component * @param {String} title * @param {String} template * @return {String} * @method renderIcon */ IconSystem.prototype.renderIcon = function(key, component, title, template) { // eslint-disable-line no-unused-vars throw new Error('Abstract function not implemented.'); }; /** * getTemplateName * * @return {String} * @method getTemplateName */ IconSystem.prototype.getTemplateName = function() { throw new Error('Abstract function not implemented.'); }; return /** @alias module:core/icon_system */ IconSystem; }); form-autocomplete.js 0000644 00000146276 15152050146 0010563 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Autocomplete wrapper for select2 library. * * @module core/form-autocomplete * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.0 */ define([ 'jquery', 'core/log', 'core/str', 'core/templates', 'core/notification', 'core/loadingicon', 'core/aria', 'core_form/changechecker', ], function( $, log, str, templates, notification, LoadingIcon, Aria, FormChangeChecker ) { // Private functions and variables. /** @var {Object} KEYS - List of keycode constants. */ var KEYS = { DOWN: 40, ENTER: 13, SPACE: 32, ESCAPE: 27, COMMA: 44, UP: 38, LEFT: 37, RIGHT: 39 }; var uniqueId = Date.now(); /** * Make an item in the selection list "active". * * @method activateSelection * @private * @param {Number} index The index in the current (visible) list of selection. * @param {Object} state State variables for this autocomplete element. * @return {Promise} */ var activateSelection = function(index, state) { // Find the elements in the DOM. var selectionElement = $(document.getElementById(state.selectionId)); // Count the visible items. var length = selectionElement.children('[aria-selected=true]').length; // Limit the index to the upper/lower bounds of the list (wrap in both directions). index = index % length; while (index < 0) { index += length; } // Find the specified element. var element = $(selectionElement.children('[aria-selected=true]').get(index)); // Create an id we can assign to this element. var itemId = state.selectionId + '-' + index; // Deselect all the selections. selectionElement.children().attr('data-active-selection', null).attr('id', ''); // Select only this suggestion and assign it the id. element.attr('data-active-selection', true).attr('id', itemId); // Tell the input field it has a new active descendant so the item is announced. selectionElement.attr('aria-activedescendant', itemId); selectionElement.attr('data-active-value', element.attr('data-value')); return $.Deferred().resolve(); }; /** * Get the actively selected element from the state object. * * @param {Object} state * @returns {jQuery} */ var getActiveElementFromState = function(state) { var selectionRegion = $(document.getElementById(state.selectionId)); var activeId = selectionRegion.attr('aria-activedescendant'); if (activeId) { var activeElement = $(document.getElementById(activeId)); if (activeElement.length) { // The active descendent still exists. return activeElement; } } // Ensure we are creating a properly formed selector based on the active value. var activeValue = selectionRegion.attr('data-active-value')?.replace(/"/g, '\\"'); return selectionRegion.find('[data-value="' + activeValue + '"]'); }; /** * Update the active selection from the given state object. * * @param {Object} state */ var updateActiveSelectionFromState = function(state) { var activeElement = getActiveElementFromState(state); var activeValue = activeElement.attr('data-value'); var selectionRegion = $(document.getElementById(state.selectionId)); if (activeValue) { // Find the index of the currently selected index. var activeIndex = selectionRegion.find('[aria-selected=true]').index(activeElement); if (activeIndex !== -1) { activateSelection(activeIndex, state); return; } } // Either the active index was not set, or it could not be found. // Select the first value instead. activateSelection(0, state); }; /** * Update the element that shows the currently selected items. * * @method updateSelectionList * @private * @param {Object} options Original options for this autocomplete element. * @param {Object} state State variables for this autocomplete element. * @param {JQuery} originalSelect The JQuery object matching the hidden select list. * @return {Promise} */ var updateSelectionList = function(options, state, originalSelect) { var pendingKey = 'form-autocomplete-updateSelectionList-' + state.inputId; M.util.js_pending(pendingKey); // Build up a valid context to re-render the template. var items = []; var newSelection = $(document.getElementById(state.selectionId)); originalSelect.children('option').each(function(index, ele) { if ($(ele).prop('selected')) { var label; if ($(ele).data('html')) { label = $(ele).data('html'); } else { label = $(ele).html(); } if (label !== '') { items.push({label: label, value: $(ele).attr('value')}); } } }); if (!hasItemListChanged(state, items)) { M.util.js_complete(pendingKey); return Promise.resolve(); } state.items = items; var context = $.extend(options, state); // Render the template. return templates.render(options.templates.items, context) .then(function(html, js) { // Add it to the page. templates.replaceNodeContents(newSelection, html, js); updateActiveSelectionFromState(state); return; }) .then(function() { return M.util.js_complete(pendingKey); }) .catch(notification.exception); }; /** * Check whether the list of items stored in the state has changed. * * @param {Object} state * @param {Array} items * @returns {Boolean} */ var hasItemListChanged = function(state, items) { if (state.items.length !== items.length) { return true; } // Check for any items in the state items which are not present in the new items list. return state.items.filter(item => items.indexOf(item) === -1).length > 0; }; /** * Notify of a change in the selection. * * @param {jQuery} originalSelect The jQuery object matching the hidden select list. */ var notifyChange = function(originalSelect) { FormChangeChecker.markFormChangedFromNode(originalSelect[0]); // Note, jQuery .change() was not working here. Better to // use plain JavaScript anyway. originalSelect[0].dispatchEvent(new Event('change')); }; /** * Remove the given item from the list of selected things. * * @method deselectItem * @private * @param {Object} options Original options for this autocomplete element. * @param {Object} state State variables for this autocomplete element. * @param {Element} item The item to be deselected. * @param {Element} originalSelect The original select list. * @return {Promise} */ var deselectItem = function(options, state, item, originalSelect) { var selectedItemValue = $(item).attr('data-value'); // Look for a match, and toggle the selected property if there is a match. originalSelect.children('option').each(function(index, ele) { if ($(ele).attr('value') == selectedItemValue) { $(ele).prop('selected', false); // We remove newly created custom tags from the suggestions list when they are deselected. if ($(ele).attr('data-iscustom')) { $(ele).remove(); } } }); // Rerender the selection list. return updateSelectionList(options, state, originalSelect) .then(function() { // Notify that the selection changed. notifyChange(originalSelect); return; }); }; /** * Make an item in the suggestions "active" (about to be selected). * * @method activateItem * @private * @param {Number} index The index in the current (visible) list of suggestions. * @param {Object} state State variables for this instance of autocomplete. * @return {Promise} */ var activateItem = function(index, state) { // Find the elements in the DOM. var inputElement = $(document.getElementById(state.inputId)); var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Count the visible items. var length = suggestionsElement.children(':not([aria-hidden])').length; // Limit the index to the upper/lower bounds of the list (wrap in both directions). index = index % length; while (index < 0) { index += length; } // Find the specified element. var element = $(suggestionsElement.children(':not([aria-hidden])').get(index)); // Find the index of this item in the full list of suggestions (including hidden). var globalIndex = $(suggestionsElement.children('[role=option]')).index(element); // Create an id we can assign to this element. var itemId = state.suggestionsId + '-' + globalIndex; // Deselect all the suggestions. suggestionsElement.children().attr('aria-selected', false).attr('id', ''); // Select only this suggestion and assign it the id. element.attr('aria-selected', true).attr('id', itemId); // Tell the input field it has a new active descendant so the item is announced. inputElement.attr('aria-activedescendant', itemId); // Scroll it into view. var scrollPos = element.offset().top - suggestionsElement.offset().top + suggestionsElement.scrollTop() - (suggestionsElement.height() / 2); return suggestionsElement.animate({ scrollTop: scrollPos }, 100).promise(); }; /** * Find the index of the current active suggestion, and activate the next one. * * @method activateNextItem * @private * @param {Object} state State variable for this auto complete element. * @return {Promise} */ var activateNextItem = function(state) { // Find the list of suggestions. var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Find the active one. var element = suggestionsElement.children('[aria-selected=true]'); // Find it's index. var current = suggestionsElement.children(':not([aria-hidden])').index(element); // Activate the next one. return activateItem(current + 1, state); }; /** * Find the index of the current active selection, and activate the previous one. * * @method activatePreviousSelection * @private * @param {Object} state State variables for this instance of autocomplete. * @return {Promise} */ var activatePreviousSelection = function(state) { // Find the list of selections. var selectionsElement = $(document.getElementById(state.selectionId)); // Find the active one. var element = selectionsElement.children('[data-active-selection]'); if (!element) { return activateSelection(0, state); } // Find it's index. var current = selectionsElement.children('[aria-selected=true]').index(element); // Activate the next one. return activateSelection(current - 1, state); }; /** * Find the index of the current active selection, and activate the next one. * * @method activateNextSelection * @private * @param {Object} state State variables for this instance of autocomplete. * @return {Promise} */ var activateNextSelection = function(state) { // Find the list of selections. var selectionsElement = $(document.getElementById(state.selectionId)); // Find the active one. var element = selectionsElement.children('[data-active-selection]'); var current = 0; if (element) { // The element was found. Determine the index and move to the next one. current = selectionsElement.children('[aria-selected=true]').index(element); current = current + 1; } else { // No selected item found. Move to the first. current = 0; } return activateSelection(current, state); }; /** * Find the index of the current active suggestion, and activate the previous one. * * @method activatePreviousItem * @private * @param {Object} state State variables for this autocomplete element. * @return {Promise} */ var activatePreviousItem = function(state) { // Find the list of suggestions. var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Find the active one. var element = suggestionsElement.children('[aria-selected=true]'); // Find it's index. var current = suggestionsElement.children(':not([aria-hidden])').index(element); // Activate the previous one. return activateItem(current - 1, state); }; /** * Close the list of suggestions. * * @method closeSuggestions * @private * @param {Object} state State variables for this autocomplete element. * @return {Promise} */ var closeSuggestions = function(state) { // Find the elements in the DOM. var inputElement = $(document.getElementById(state.inputId)); var suggestionsElement = $(document.getElementById(state.suggestionsId)); if (inputElement.attr('aria-expanded') === "true") { // Announce the list of suggestions was closed. inputElement.attr('aria-expanded', false); } // Read the current list of selections. inputElement.attr('aria-activedescendant', state.selectionId); // Hide the suggestions list (from screen readers too). Aria.hide(suggestionsElement.get()); suggestionsElement.hide(); return $.Deferred().resolve(); }; /** * Rebuild the list of suggestions based on the current values in the select list, and the query. * * @method updateSuggestions * @private * @param {Object} options The original options for this autocomplete. * @param {Object} state The state variables for this autocomplete. * @param {String} query The current text for the search string. * @param {JQuery} originalSelect The JQuery object matching the hidden select list. * @return {Promise} */ var updateSuggestions = function(options, state, query, originalSelect) { var pendingKey = 'form-autocomplete-updateSuggestions-' + state.inputId; M.util.js_pending(pendingKey); // Find the elements in the DOM. var inputElement = $(document.getElementById(state.inputId)); var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Used to track if we found any visible suggestions. var matchingElements = false; // Options is used by the context when rendering the suggestions from a template. var suggestions = []; originalSelect.children('option').each(function(index, option) { if ($(option).prop('selected') !== true) { suggestions[suggestions.length] = {label: option.innerHTML, value: $(option).attr('value')}; } }); // Re-render the list of suggestions. var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase(); var context = $.extend({options: suggestions}, options, state); var returnVal = templates.render( 'core/form_autocomplete_suggestions', context ) .then(function(html, js) { // We have the new template, insert it in the page. templates.replaceNode(suggestionsElement, html, js); // Get the element again. suggestionsElement = $(document.getElementById(state.suggestionsId)); // Show it if it is hidden. Aria.unhide(suggestionsElement.get()); suggestionsElement.show(); // For each option in the list, hide it if it doesn't match the query. suggestionsElement.children().each(function(index, node) { node = $(node); if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) || (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) { Aria.unhide(node.get()); node.show(); matchingElements = true; } else { node.hide(); Aria.hide(node.get()); } }); // If we found any matches, show the list. inputElement.attr('aria-expanded', true); if (originalSelect.attr('data-notice')) { // Display a notice rather than actual suggestions. suggestionsElement.html(originalSelect.attr('data-notice')); } else if (matchingElements) { // We only activate the first item in the list if tags is false, // because otherwise "Enter" would select the first item, instead of // creating a new tag. if (!options.tags) { activateItem(0, state); } } else { // Nothing matches. Tell them that. str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) { suggestionsElement.html(nosuggestionsstr); }); } return suggestionsElement; }) .then(function() { return M.util.js_complete(pendingKey); }) .catch(notification.exception); return returnVal; }; /** * Create a new item for the list (a tag). * * @method createItem * @private * @param {Object} options The original options for the autocomplete. * @param {Object} state State variables for the autocomplete. * @param {JQuery} originalSelect The JQuery object matching the hidden select list. * @return {Promise} */ var createItem = function(options, state, originalSelect) { // Find the element in the DOM. var inputElement = $(document.getElementById(state.inputId)); // Get the current text in the input field. var query = inputElement.val(); var tags = query.split(','); var found = false; $.each(tags, function(tagindex, tag) { // If we can only select one at a time, deselect any current value. tag = tag.trim(); if (tag !== '') { if (!options.multiple) { originalSelect.children('option').prop('selected', false); } // Look for an existing option in the select list that matches this new tag. originalSelect.children('option').each(function(index, ele) { if ($(ele).attr('value') == tag) { found = true; $(ele).prop('selected', true); } }); // Only create the item if it's new. if (!found) { var option = $('<option>'); option.append(document.createTextNode(tag)); option.attr('value', tag); originalSelect.append(option); option.prop('selected', true); // We mark newly created custom options as we handle them differently if they are "deselected". option.attr('data-iscustom', true); } } }); return updateSelectionList(options, state, originalSelect) .then(function() { // Notify that the selection changed. notifyChange(originalSelect); return; }) .then(function() { // Clear the input field. inputElement.val(''); return; }) .then(function() { // Close the suggestions list. return closeSuggestions(state); }); }; /** * Select the currently active item from the suggestions list. * * @method selectCurrentItem * @private * @param {Object} options The original options for the autocomplete. * @param {Object} state State variables for the autocomplete. * @param {JQuery} originalSelect The JQuery object matching the hidden select list. * @return {Promise} */ var selectCurrentItem = function(options, state, originalSelect) { // Find the elements in the page. var inputElement = $(document.getElementById(state.inputId)); var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Here loop through suggestions and set val to join of all selected items. var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value'); // The select will either be a single or multi select, so the following will either // select one or more items correctly. // Take care to use 'prop' and not 'attr' for selected properties. // If only one can be selected at a time, start by deselecting everything. if (!options.multiple) { originalSelect.children('option').prop('selected', false); } // Look for a match, and toggle the selected property if there is a match. originalSelect.children('option').each(function(index, ele) { if ($(ele).attr('value') == selectedItemValue) { $(ele).prop('selected', true); } }); return updateSelectionList(options, state, originalSelect) .then(function() { // Notify that the selection changed. notifyChange(originalSelect); return; }) .then(function() { if (options.closeSuggestionsOnSelect) { // Clear the input element. inputElement.val(''); // Close the list of suggestions. return closeSuggestions(state); } else { // Focus on the input element so the suggestions does not auto-close. inputElement.focus(); // Remove the last selected item from the suggestions list. return updateSuggestions(options, state, inputElement.val(), originalSelect); } }); }; /** * Fetch a new list of options via ajax. * * @method updateAjax * @private * @param {Event} e The event that triggered this update. * @param {Object} options The original options for the autocomplete. * @param {Object} state The state variables for the autocomplete. * @param {JQuery} originalSelect The JQuery object matching the hidden select list. * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results. * @return {Promise} */ var updateAjax = function(e, options, state, originalSelect, ajaxHandler) { var pendingPromise = addPendingJSPromise('updateAjax'); // We need to show the indicator outside of the hidden select list. // So we get the parent id of the hidden select list. var parentElement = $(document.getElementById(state.selectId)).parent(); LoadingIcon.addIconToContainerRemoveOnCompletion(parentElement, pendingPromise); // Get the query to pass to the ajax function. var query = $(e.currentTarget).val(); // Call the transport function to do the ajax (name taken from Select2). ajaxHandler.transport(options.selector, query, function(results) { // We got a result - pass it through the translator before using it. var processedResults = ajaxHandler.processResults(options.selector, results); var existingValues = []; // Now destroy all options that are not currently selected. if (!options.multiple) { originalSelect.children('option').remove(); } originalSelect.children('option').each(function(optionIndex, option) { option = $(option); if (!option.prop('selected')) { option.remove(); } else { existingValues.push(String(option.attr('value'))); } }); if (!options.multiple && originalSelect.children('option').length === 0) { // If this is a single select - and there are no current options // the first option added will be selected by the browser. This causes a bug! // We need to insert an empty option so that none of the real options are selected. var option = $('<option>'); originalSelect.append(option); } if ($.isArray(processedResults)) { // Add all the new ones returned from ajax. $.each(processedResults, function(resultIndex, result) { if (existingValues.indexOf(String(result.value)) === -1) { var option = $('<option>'); option.append(result.label); option.attr('value', result.value); originalSelect.append(option); } }); originalSelect.attr('data-notice', ''); } else { // The AJAX handler returned a string instead of the array. originalSelect.attr('data-notice', processedResults); } // Update the list of suggestions now from the new values in the select list. pendingPromise.resolve(updateSuggestions(options, state, '', originalSelect)); }, function(error) { pendingPromise.reject(error); }); return pendingPromise; }; /** * Add all the event listeners required for keyboard nav, blur clicks etc. * * @method addNavigation * @private * @param {Object} options The options used to create this autocomplete element. * @param {Object} state State variables for this autocomplete element. * @param {JQuery} originalSelect The JQuery object matching the hidden select list. */ var addNavigation = function(options, state, originalSelect) { // Start with the input element. var inputElement = $(document.getElementById(state.inputId)); // Add keyboard nav with keydown. inputElement.on('keydown', function(e) { var pendingJsPromise = addPendingJSPromise('addNavigation-' + state.inputId + '-' + e.keyCode); switch (e.keyCode) { case KEYS.DOWN: // If the suggestion list is open, move to the next item. if (!options.showSuggestions) { // Do not consume this event. pendingJsPromise.resolve(); return true; } else if (inputElement.attr('aria-expanded') === "true") { pendingJsPromise.resolve(activateNextItem(state)); } else { // Handle ajax population of suggestions. if (!inputElement.val() && options.ajax) { require([options.ajax], function(ajaxHandler) { pendingJsPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler)); }); } else { // Open the suggestions list. pendingJsPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect)); } } // We handled this event, so prevent it. e.preventDefault(); return false; case KEYS.UP: // Choose the previous active item. pendingJsPromise.resolve(activatePreviousItem(state)); // We handled this event, so prevent it. e.preventDefault(); return false; case KEYS.ENTER: var suggestionsElement = $(document.getElementById(state.suggestionsId)); if ((inputElement.attr('aria-expanded') === "true") && (suggestionsElement.children('[aria-selected=true]').length > 0)) { // If the suggestion list has an active item, select it. pendingJsPromise.resolve(selectCurrentItem(options, state, originalSelect)); } else if (options.tags) { // If tags are enabled, create a tag. pendingJsPromise.resolve(createItem(options, state, originalSelect)); } else { pendingJsPromise.resolve(); } // We handled this event, so prevent it. e.preventDefault(); return false; case KEYS.ESCAPE: if (inputElement.attr('aria-expanded') === "true") { // If the suggestion list is open, close it. pendingJsPromise.resolve(closeSuggestions(state)); } else { pendingJsPromise.resolve(); } // We handled this event, so prevent it. e.preventDefault(); return false; } pendingJsPromise.resolve(); return true; }); // Support multi lingual COMMA keycode (44). inputElement.on('keypress', function(e) { if (e.keyCode === KEYS.COMMA) { if (options.tags) { // If we are allowing tags, comma should create a tag (or enter). addPendingJSPromise('keypress-' + e.keyCode) .resolve(createItem(options, state, originalSelect)); } // We handled this event, so prevent it. e.preventDefault(); return false; } return true; }); // Support submitting the form without leaving the autocomplete element, // or submitting too quick before the blur handler action is completed. inputElement.closest('form').on('submit', function() { if (options.tags) { // If tags are enabled, create a tag. addPendingJSPromise('form-autocomplete-submit') .resolve(createItem(options, state, originalSelect)); } return true; }); inputElement.on('blur', function() { var pendingPromise = addPendingJSPromise('form-autocomplete-blur'); window.setTimeout(function() { // Get the current element with focus. var focusElement = $(document.activeElement); var timeoutPromise = $.Deferred(); // Only close the menu if the input hasn't regained focus and if the element still exists, // and regain focus if the scrollbar is clicked. // Due to the half a second delay, it is possible that the input element no longer exist // by the time this code is being executed. if (focusElement.is(document.getElementById(state.suggestionsId))) { inputElement.focus(); // Probably the scrollbar is clicked. Regain focus. } else if (!focusElement.is(inputElement) && $(document.getElementById(state.inputId)).length) { if (options.tags) { timeoutPromise.then(function() { return createItem(options, state, originalSelect); }) .catch(); } timeoutPromise.then(function() { return closeSuggestions(state); }) .catch(); } timeoutPromise.then(function() { return pendingPromise.resolve(); }) .catch(); timeoutPromise.resolve(); }, 500); }); if (options.showSuggestions) { var arrowElement = $(document.getElementById(state.downArrowId)); arrowElement.on('click', function(e) { var pendingPromise = addPendingJSPromise('form-autocomplete-show-suggestions'); // Prevent the close timer, or we will open, then close the suggestions. inputElement.focus(); // Handle ajax population of suggestions. if (!inputElement.val() && options.ajax) { require([options.ajax], function(ajaxHandler) { pendingPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler)); }); } else { // Else - open the suggestions list. pendingPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect)); } }); } var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Remove any click handler first. suggestionsElement.parent().prop("onclick", null).off("click"); suggestionsElement.parent().on('click', `#${state.suggestionsId} [role=option]`, function(e) { var pendingPromise = addPendingJSPromise('form-autocomplete-parent'); // Handle clicks on suggestions. var element = $(e.currentTarget).closest('[role=option]'); var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Find the index of the clicked on suggestion. var current = suggestionsElement.children(':not([aria-hidden])').index(element); // Activate it. activateItem(current, state) .then(function() { // And select it. return selectCurrentItem(options, state, originalSelect); }) .then(function() { return pendingPromise.resolve(); }) .catch(); }); var selectionElement = $(document.getElementById(state.selectionId)); // Handle clicks on the selected items (will unselect an item). selectionElement.on('click', '[role=option]', function(e) { var pendingPromise = addPendingJSPromise('form-autocomplete-clicks'); // Remove it from the selection. pendingPromise.resolve(deselectItem(options, state, $(e.currentTarget), originalSelect)); }); // When listbox is focused, focus on the first option if there is no focused option. selectionElement.on('focus', function() { updateActiveSelectionFromState(state); }); // Keyboard navigation for the selection list. selectionElement.on('keydown', function(e) { var pendingPromise = addPendingJSPromise('form-autocomplete-keydown-' + e.keyCode); switch (e.keyCode) { case KEYS.RIGHT: case KEYS.DOWN: // We handled this event, so prevent it. e.preventDefault(); // Choose the next selection item. pendingPromise.resolve(activateNextSelection(state)); return; case KEYS.LEFT: case KEYS.UP: // We handled this event, so prevent it. e.preventDefault(); // Choose the previous selection item. pendingPromise.resolve(activatePreviousSelection(state)); return; case KEYS.SPACE: case KEYS.ENTER: // Get the item that is currently selected. var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection]'); if (selectedItem) { e.preventDefault(); // Unselect this item. pendingPromise.resolve(deselectItem(options, state, selectedItem, originalSelect)); } return; } // Not handled. Resolve the promise. pendingPromise.resolve(); }); // Whenever the input field changes, update the suggestion list. if (options.showSuggestions) { // Store the value of the field as its last value, when the field gains focus. inputElement.on('focus', function(e) { var query = $(e.currentTarget).val(); $(e.currentTarget).data('last-value', query); }); // If this field uses ajax, set it up. if (options.ajax) { require([options.ajax], function(ajaxHandler) { // Creating throttled handlers free of race conditions, and accurate. // This code keeps track of a throttleTimeout, which is periodically polled. // Once the throttled function is executed, the fact that it is running is noted. // If a subsequent request comes in whilst it is running, this request is re-applied. var throttleTimeout = null; var inProgress = false; var pendingKey = 'autocomplete-throttledhandler'; var handler = function(e) { // Empty the current timeout. throttleTimeout = null; // Mark this request as in-progress. inProgress = true; // Process the request. updateAjax(e, options, state, originalSelect, ajaxHandler) .then(function() { // Check if the throttleTimeout is still empty. // There's a potential condition whereby the JS request takes long enough to complete that // another task has been queued. // In this case another task will be kicked off and we must wait for that before marking htis as // complete. if (null === throttleTimeout) { // Mark this task as complete. M.util.js_complete(pendingKey); } inProgress = false; return arguments[0]; }) .catch(notification.exception); }; // For input events, we do not want to trigger many, many updates. var throttledHandler = function(e) { window.clearTimeout(throttleTimeout); if (inProgress) { // A request is currently ongoing. // Delay this request another 100ms. throttleTimeout = window.setTimeout(throttledHandler.bind(this, e), 100); return; } if (throttleTimeout === null) { // There is currently no existing timeout handler, and it has not been recently cleared, so // this is the start of a throttling check. M.util.js_pending(pendingKey); } // There is currently no existing timeout handler, and it has not been recently cleared, so this // is the start of a throttling check. // Queue a call to the handler. throttleTimeout = window.setTimeout(handler.bind(this, e), 300); }; // Trigger an ajax update after the text field value changes. inputElement.on('input', function(e) { var query = $(e.currentTarget).val(); var last = $(e.currentTarget).data('last-value'); // IE11 fires many more input events than required - even when the value has not changed. if (last !== query) { throttledHandler(e); } $(e.currentTarget).data('last-value', query); }); }); } else { inputElement.on('input', function(e) { var query = $(e.currentTarget).val(); var last = $(e.currentTarget).data('last-value'); // IE11 fires many more input events than required - even when the value has not changed. // We need to only do this for real value changed events or the suggestions will be // unclickable on IE11 (because they will be rebuilt before the click event fires). // Note - because of this we cannot close the list when the query is empty or it will break // on IE11. if (last !== query) { updateSuggestions(options, state, query, originalSelect); } $(e.currentTarget).data('last-value', query); }); } } }; /** * Create and return an unresolved Promise for some pending JS. * * @param {String} key The unique identifier for this promise * @return {Promise} */ var addPendingJSPromise = function(key) { var pendingKey = 'form-autocomplete:' + key; M.util.js_pending(pendingKey); var pendingPromise = $.Deferred(); pendingPromise .then(function() { M.util.js_complete(pendingKey); return arguments[0]; }) .catch(notification.exception); return pendingPromise; }; return { // Public variables and functions. /** * Turn a boring select box into an auto-complete beast. * * @method enhance * @param {string} selector The selector that identifies the select box. * @param {boolean} tags Whether to allow support for tags (can define new entries). * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD * module must expose 2 functions "transport" and "processResults". * These are modeled on Select2 see: https://select2.github.io/options.html#ajax * @param {String} placeholder - The text to display before a selection is made. * @param {Boolean} caseSensitive - If search has to be made case sensitive. * @param {Boolean} showSuggestions - If suggestions should be shown * @param {String} noSelectionString - Text to display when there is no selection * @param {Boolean} closeSuggestionsOnSelect - Whether to close the suggestions immediately after making a selection. * @param {Object} templateOverrides A set of templates to use instead of the standard templates * @return {Promise} */ enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString, closeSuggestionsOnSelect, templateOverrides) { // Set some default values. var options = { selector: selector, tags: false, ajax: false, placeholder: placeholder, caseSensitive: false, showSuggestions: true, noSelectionString: noSelectionString, templates: $.extend({ input: 'core/form_autocomplete_input', items: 'core/form_autocomplete_selection_items', layout: 'core/form_autocomplete_layout', selection: 'core/form_autocomplete_selection', suggestions: 'core/form_autocomplete_suggestions', }, templateOverrides), }; var pendingKey = 'autocomplete-setup-' + selector; M.util.js_pending(pendingKey); if (typeof tags !== "undefined") { options.tags = tags; } if (typeof ajax !== "undefined") { options.ajax = ajax; } if (typeof caseSensitive !== "undefined") { options.caseSensitive = caseSensitive; } if (typeof showSuggestions !== "undefined") { options.showSuggestions = showSuggestions; } if (typeof noSelectionString === "undefined") { str.get_string('noselection', 'form').done(function(result) { options.noSelectionString = result; }).fail(notification.exception); } // Look for the select element. var originalSelect = $(selector); if (!originalSelect) { log.debug('Selector not found: ' + selector); M.util.js_complete(pendingKey); return false; } // Ensure we enhance the element only once. if (originalSelect.data('enhanced') === 'enhanced') { M.util.js_complete(pendingKey); return false; } originalSelect.data('enhanced', 'enhanced'); // Hide the original select. Aria.hide(originalSelect.get()); originalSelect.css('visibility', 'hidden'); // Find or generate some ids. var state = { selectId: originalSelect.attr('id'), inputId: 'form_autocomplete_input-' + uniqueId, suggestionsId: 'form_autocomplete_suggestions-' + uniqueId, selectionId: 'form_autocomplete_selection-' + uniqueId, downArrowId: 'form_autocomplete_downarrow-' + uniqueId, items: [], }; // Increment the unique counter so we don't get duplicates ever. uniqueId++; options.multiple = originalSelect.attr('multiple'); if (!options.multiple) { // If this is a single select then there is no way to de-select the current value - // unless we add a bogus blank option to be selected when nothing else is. // This matches similar code in updateAjax above. originalSelect.prepend('<option>'); } if (typeof closeSuggestionsOnSelect !== "undefined") { options.closeSuggestionsOnSelect = closeSuggestionsOnSelect; } else { // If not specified, this will close suggestions by default for single-select elements only. options.closeSuggestionsOnSelect = !options.multiple; } var originalLabel = $('[for=' + state.selectId + ']'); // Create the new markup and insert it after the select. var suggestions = []; originalSelect.children('option').each(function(index, option) { suggestions[index] = {label: option.innerHTML, value: $(option).attr('value')}; }); // Render all the parts of our UI. var context = $.extend({}, options, state); context.options = suggestions; context.items = []; // Collect rendered inline JS to be executed once the HTML is shown. var collectedjs = ''; var renderLayout = templates.render(options.templates.layout, {}) .then(function(html) { return $(html); }); var renderInput = templates.render(options.templates.input, context).then(function(html, js) { collectedjs += js; return $(html); }); var renderDatalist = templates.render(options.templates.suggestions, context).then(function(html, js) { collectedjs += js; return $(html); }); var renderSelection = templates.render(options.templates.selection, context).then(function(html, js) { collectedjs += js; return $(html); }); return $.when(renderLayout, renderInput, renderDatalist, renderSelection) .then(function(layout, input, suggestions, selection) { originalSelect.hide(); var container = originalSelect.parent(); // Ensure that the data-fieldtype is set for behat. input.find('input').attr('data-fieldtype', 'autocomplete'); container.append(layout); container.find('[data-region="form_autocomplete-input"]').replaceWith(input); container.find('[data-region="form_autocomplete-suggestions"]').replaceWith(suggestions); container.find('[data-region="form_autocomplete-selection"]').replaceWith(selection); templates.runTemplateJS(collectedjs); // Update the form label to point to the text input. originalLabel.attr('for', state.inputId); // Add the event handlers. addNavigation(options, state, originalSelect); var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Hide the suggestions by default. suggestionsElement.hide(); Aria.hide(suggestionsElement.get()); return; }) .then(function() { // Show the current values in the selection list. return updateSelectionList(options, state, originalSelect); }) .then(function() { return M.util.js_complete(pendingKey); }) .catch(function(error) { M.util.js_complete(pendingKey); notification.exception(error); }); } }; }); notification.js 0000644 00000023430 15152050146 0007571 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Notification manager for in-page notifications in Moodle. * * @module core/notification * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 2.9 */ import Pending from 'core/pending'; import Log from 'core/log'; let currentContextId = M.cfg.contextid; const notificationTypes = { success: 'core/notification_success', info: 'core/notification_info', warning: 'core/notification_warning', error: 'core/notification_error', }; const notificationRegionId = 'user-notifications'; const Selectors = { notificationRegion: `#${notificationRegionId}`, fallbackRegionParents: [ '#region-main', '[role="main"]', 'body', ], }; const setupTargetRegion = () => { let targetRegion = getNotificationRegion(); if (targetRegion) { return false; } const newRegion = document.createElement('span'); newRegion.id = notificationRegionId; return Selectors.fallbackRegionParents.some(selector => { const targetRegion = document.querySelector(selector); if (targetRegion) { targetRegion.prepend(newRegion); return true; } return false; }); }; /** * A notification object displayed to a user. * * @typedef {Object} Notification * @property {string} message The body of the notification * @property {string} type The type of notification to add (error, warning, info, success). * @property {Boolean} closebutton Whether to show the close button. * @property {Boolean} announce Whether to announce to screen readers. */ /** * Poll the server for any new notifications. * * @method * @returns {Promise} */ export const fetchNotifications = async() => { const Ajax = await import('core/ajax'); return Ajax.call([{ methodname: 'core_fetch_notifications', args: { contextid: currentContextId } }])[0] .then(addNotifications); }; /** * Add all of the supplied notifications. * * @method * @param {Notification[]} notifications The list of notificaitons * @returns {Promise} */ const addNotifications = notifications => { if (!notifications.length) { return Promise.resolve(); } const pendingPromise = new Pending('core/notification:addNotifications'); notifications.forEach(notification => renderNotification(notification.template, notification.variables)); return pendingPromise.resolve(); }; /** * Add a notification to the page. * * Note: This does not cause the notification to be added to the session. * * @method * @param {Notification} notification The notification to add. * @returns {Promise} */ export const addNotification = notification => { const pendingPromise = new Pending('core/notification:addNotifications'); let template = notificationTypes.error; notification = { closebutton: true, announce: true, type: 'error', ...notification, }; if (notification.template) { template = notification.template; delete notification.template; } else if (notification.type) { if (typeof notificationTypes[notification.type] !== 'undefined') { template = notificationTypes[notification.type]; } delete notification.type; } return renderNotification(template, notification) .then(pendingPromise.resolve); }; const renderNotification = async(template, variables) => { if (typeof variables.message === 'undefined' || !variables.message) { Log.debug('Notification received without content. Skipping.'); return; } const pendingPromise = new Pending('core/notification:renderNotification'); const Templates = await import('core/templates'); Templates.renderForPromise(template, variables) .then(({html, js = ''}) => { Templates.prependNodeContents(getNotificationRegion(), html, js); return; }) .then(pendingPromise.resolve) .catch(exception); }; const getNotificationRegion = () => document.querySelector(Selectors.notificationRegion); /** * Alert dialogue. * * @method * @param {String|Promise} title * @param {String|Promise} message * @param {String|Promise} cancelText * @returns {Promise} */ export const alert = async(title, message, cancelText) => { var pendingPromise = new Pending('core/notification:alert'); const ModalFactory = await import('core/modal_factory'); return ModalFactory.create({ type: ModalFactory.types.ALERT, body: message, title: title, buttons: { cancel: cancelText, }, removeOnClose: true, }) .then(function(modal) { modal.show(); pendingPromise.resolve(); return modal; }); }; /** * The confirm has now been replaced with a save and cancel dialogue. * * @method * @param {String|Promise} title * @param {String|Promise} question * @param {String|Promise} saveLabel * @param {String|Promise} noLabel * @param {String|Promise} saveCallback * @param {String|Promise} cancelCallback * @returns {Promise} */ export const confirm = (title, question, saveLabel, noLabel, saveCallback, cancelCallback) => saveCancel(title, question, saveLabel, saveCallback, cancelCallback); /** * The Save and Cancel dialogue helper. * * @method * @param {String|Promise} title * @param {String|Promise} question * @param {String|Promise} saveLabel * @param {String|Promise} saveCallback * @param {String|Promise} cancelCallback * @param {Object} options * @param {HTMLElement} [options.triggerElement=null] The element that triggered the modal (will receive the focus after hidden) * @returns {Promise} */ export const saveCancel = async(title, question, saveLabel, saveCallback, cancelCallback, { triggerElement = null, } = {}) => { const pendingPromise = new Pending('core/notification:confirm'); const [ ModalFactory, ModalEvents, ] = await Promise.all([ import('core/modal_factory'), import('core/modal_events'), ]); return ModalFactory.create({ type: ModalFactory.types.SAVE_CANCEL, title: title, body: question, buttons: { // Note: The noLabel is no longer supported. save: saveLabel, }, removeOnClose: true, }) .then(function(modal) { modal.show(); modal.getRoot().on(ModalEvents.save, saveCallback); modal.getRoot().on(ModalEvents.cancel, cancelCallback); modal.getRoot().on(ModalEvents.hidden, () => triggerElement?.focus()); pendingPromise.resolve(); return modal; }); }; /** * Add all of the supplied notifications. * * @param {Promise|String} title The header of the modal * @param {Promise|String} question What do we want the user to confirm * @param {Promise|String} saveLabel The modal action link text * @param {Object} options * @param {HTMLElement} [options.triggerElement=null] The element that triggered the modal (will receive the focus after hidden) * @return {Promise} */ export const saveCancelPromise = (title, question, saveLabel, { triggerElement = null, } = {}) => new Promise((resolve, reject) => { saveCancel(title, question, saveLabel, resolve, reject, {triggerElement}); }); /** * Wrap M.core.exception. * * @method * @param {Error} ex */ export const exception = async ex => { const pendingPromise = new Pending('core/notification:displayException'); // Fudge some parameters. if (!ex.stack) { ex.stack = ''; } if (ex.debuginfo) { ex.stack += ex.debuginfo + '\n'; } if (!ex.backtrace && ex.stacktrace) { ex.backtrace = ex.stacktrace; } if (ex.backtrace) { ex.stack += ex.backtrace; const ln = ex.backtrace.match(/line ([^ ]*) of/); const fn = ex.backtrace.match(/ of ([^:]*): /); if (ln && ln[1]) { ex.lineNumber = ln[1]; } if (fn && fn[1]) { ex.fileName = fn[1]; if (ex.fileName.length > 30) { ex.fileName = '...' + ex.fileName.substr(ex.fileName.length - 27); } } } if (typeof ex.name === 'undefined' && ex.errorcode) { ex.name = ex.errorcode; } const Y = await import('core/yui'); Y.use('moodle-core-notification-exception', function() { var modal = new M.core.exception(ex); modal.show(); pendingPromise.resolve(); }); }; /** * Initialise the page for the suppled context, and displaying the supplied notifications. * * @method * @param {Number} contextId * @param {Notification[]} notificationList */ export const init = (contextId, notificationList) => { currentContextId = contextId; // Setup the message target region if it isn't setup already setupTargetRegion(); // Add provided notifications. addNotifications(notificationList); }; // To maintain backwards compatability we export default here. export default { init, fetchNotifications, addNotification, alert, confirm, saveCancel, saveCancelPromise, exception, }; paged_content_paging_bar.js 0000644 00000050542 15152050146 0012072 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Javascript to enhance the paged content paging bar. * * @module core/paging_bar * @copyright 2018 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define([ 'jquery', 'core/custom_interaction_events', 'core/paged_content_events', 'core/str', 'core/pubsub', 'core/pending', ], function( $, CustomEvents, PagedContentEvents, Str, PubSub, Pending ) { var SELECTORS = { ROOT: '[data-region="paging-bar"]', PAGE: '[data-page]', PAGE_ITEM: '[data-region="page-item"]', PAGE_LINK: '[data-region="page-link"]', FIRST_BUTTON: '[data-control="first"]', LAST_BUTTON: '[data-control="last"]', NEXT_BUTTON: '[data-control="next"]', PREVIOUS_BUTTON: '[data-control="previous"]', DOTS_BUTTONS: '[data-dots]', BEGINNING_DOTS_BUTTON: '[data-dots="beginning"]', ENDING_DOTS_BUTTON: '[data-dots="ending"]', }; /** * Get the page element by number. * * @param {object} root The root element. * @param {Number} pageNumber The page number. * @return {jQuery} */ var getPageByNumber = function(root, pageNumber) { return root.find(SELECTORS.PAGE_ITEM + '[data-page-number="' + pageNumber + '"]'); }; /** * Get the next button element. * * @param {object} root The root element. * @return {jQuery} */ var getNextButton = function(root) { return root.find(SELECTORS.NEXT_BUTTON); }; /** * Set the last page number after which no more pages * should be loaded. * * @param {object} root The root element. * @param {Number} number Page number. */ var setLastPageNumber = function(root, number) { root.attr('data-last-page-number', number); }; /** * Get the last page number. * * @param {object} root The root element. * @return {Number} */ var getLastPageNumber = function(root) { return parseInt(root.attr('data-last-page-number'), 10); }; /** * Get the active page number. * * @param {object} root The root element. * @returns {Number} The page number */ var getActivePageNumber = function(root) { return parseInt(root.attr('data-active-page-number'), 10); }; /** * Set the active page number. * * @param {object} root The root element. * @param {Number} number Page number. */ var setActivePageNumber = function(root, number) { root.attr('data-active-page-number', number); }; /** * Check if there is an active page number. * * @param {object} root The root element. * @returns {bool} */ var hasActivePageNumber = function(root) { var number = getActivePageNumber(root); return !isNaN(number) && number != 0; }; /** * Get the page number for a given page. * * @param {object} root The root element. * @param {object} page The page element. * @returns {Number} The page number */ var getPageNumber = function(root, page) { if (page.attr('data-page') != undefined) { // If it's an actual page then we can just use the page number // attribute. return parseInt(page.attr('data-page-number'), 10); } var pageNumber = 1; var activePageNumber = null; switch (page.attr('data-control')) { case 'first': pageNumber = 1; break; case 'last': pageNumber = getLastPageNumber(root); break; case 'next': activePageNumber = getActivePageNumber(root); var lastPage = getLastPageNumber(root); if (!lastPage) { pageNumber = activePageNumber + 1; } else if (activePageNumber && activePageNumber < lastPage) { pageNumber = activePageNumber + 1; } else { pageNumber = lastPage; } break; case 'previous': activePageNumber = getActivePageNumber(root); if (activePageNumber && activePageNumber > 1) { pageNumber = activePageNumber - 1; } else { pageNumber = 1; } break; default: pageNumber = 1; break; } // Make sure we return an int not a string. return parseInt(pageNumber, 10); }; /** * Get the limit of items for each page. * * @param {object} root The root element. * @returns {Number} */ var getLimit = function(root) { return parseInt(root.attr('data-items-per-page'), 10); }; /** * Set the limit of items for each page. * * @param {object} root The root element. * @param {Number} limit Items per page limit. */ var setLimit = function(root, limit) { root.attr('data-items-per-page', limit); }; /** * Show the paging bar. * * @param {object} root The root element. */ var show = function(root) { root.removeClass('hidden'); }; /** * Hide the paging bar. * * @param {object} root The root element. */ var hide = function(root) { root.addClass('hidden'); }; /** * Disable the next and last buttons in the paging bar. * * @param {object} root The root element. */ var disableNextControlButtons = function(root) { var nextButton = root.find(SELECTORS.NEXT_BUTTON); var lastButton = root.find(SELECTORS.LAST_BUTTON); nextButton.addClass('disabled'); nextButton.attr('aria-disabled', true); lastButton.addClass('disabled'); lastButton.attr('aria-disabled', true); }; /** * Enable the next and last buttons in the paging bar. * * @param {object} root The root element. */ var enableNextControlButtons = function(root) { var nextButton = root.find(SELECTORS.NEXT_BUTTON); var lastButton = root.find(SELECTORS.LAST_BUTTON); nextButton.removeClass('disabled'); nextButton.removeAttr('aria-disabled'); lastButton.removeClass('disabled'); lastButton.removeAttr('aria-disabled'); }; /** * Disable the previous and first buttons in the paging bar. * * @param {object} root The root element. */ var disablePreviousControlButtons = function(root) { var previousButton = root.find(SELECTORS.PREVIOUS_BUTTON); var firstButton = root.find(SELECTORS.FIRST_BUTTON); previousButton.addClass('disabled'); previousButton.attr('aria-disabled', true); firstButton.addClass('disabled'); firstButton.attr('aria-disabled', true); }; /** * Adjusts the size of the paging bar and hides unnecessary pages. * * @param {object} root The root element. */ var adjustPagingBarSize = function(root) { var activePageNumber = getActivePageNumber(root); var lastPageNumber = getLastPageNumber(root); var dotsButtons = root.find(SELECTORS.DOTS_BUTTONS); var beginningDotsButton = root.find(SELECTORS.BEGINNING_DOTS_BUTTON); var endingDotsButton = root.find(SELECTORS.ENDING_DOTS_BUTTON); var pages = root.find(SELECTORS.PAGE); var barSize = parseInt(root.attr('data-bar-size'), 10); if (barSize && lastPageNumber > barSize) { var minpage = Math.max(activePageNumber - Math.round(barSize / 2), 1); var maxpage = minpage + barSize - 1; if (maxpage >= lastPageNumber) { maxpage = lastPageNumber; minpage = maxpage - barSize + 1; } if (minpage > 1) { show(beginningDotsButton); minpage++; } else { hide(beginningDotsButton); } if (maxpage < lastPageNumber) { show(endingDotsButton); maxpage--; } else { hide(endingDotsButton); } dotsButtons.addClass('disabled'); dotsButtons.attr('aria-disabled', true); hide(pages); pages.each(function(index, page) { page = $(page); if ((index + 1) >= minpage && (index + 1) <= maxpage) { show(page); } }); } else { hide(dotsButtons); } }; /** * Enable the previous and first buttons in the paging bar. * * @param {object} root The root element. */ var enablePreviousControlButtons = function(root) { var previousButton = root.find(SELECTORS.PREVIOUS_BUTTON); var firstButton = root.find(SELECTORS.FIRST_BUTTON); previousButton.removeClass('disabled'); previousButton.removeAttr('aria-disabled'); firstButton.removeClass('disabled'); firstButton.removeAttr('aria-disabled'); }; /** * Get the components for a get_string request for the aria-label * on a page. The value is a comma separated string of key and * component. * * @param {object} root The root element. * @return {array} First element is the key, second is the component. */ var getPageAriaLabelComponents = function(root) { var componentString = root.attr('data-aria-label-components-pagination-item'); var components = componentString.split(',').map(function(component) { return component.trim(); }); return components; }; /** * Get the components for a get_string request for the aria-label * on an active page. The value is a comma separated string of key and * component. * * @param {object} root The root element. * @return {array} First element is the key, second is the component. */ var getActivePageAriaLabelComponents = function(root) { var componentString = root.attr('data-aria-label-components-pagination-active-item'); var components = componentString.split(',').map(function(component) { return component.trim(); }); return components; }; /** * Set page numbers on each of the given items. Page numbers are set * from 1..n (where n is the number of items). * * Sets the active page number to be the last page found with * an "active" class (if any). * * Sets the last page number. * * @param {object} root The root element. * @param {jQuery} items A jQuery list of items. */ var generatePageNumbers = function(root, items) { var lastPageNumber = 0; setActivePageNumber(root, 0); items.each(function(index, item) { var pageNumber = index + 1; item = $(item); item.attr('data-page-number', pageNumber); lastPageNumber++; if (item.hasClass('active')) { setActivePageNumber(root, pageNumber); } }); setLastPageNumber(root, lastPageNumber); }; /** * Set the aria-labels on each of the page items in the paging bar. * This includes the next, previous, first, and last items. * * @param {object} root The root element. */ var generateAriaLabels = function(root) { var pageAriaLabelComponents = getPageAriaLabelComponents(root); var activePageAriaLabelComponents = getActivePageAriaLabelComponents(root); var activePageNumber = getActivePageNumber(root); var pageItems = root.find(SELECTORS.PAGE_ITEM); // We want to request all of the strings at once rather than // one at a time. var stringRequests = pageItems.toArray().map(function(index, page) { page = $(page); var pageNumber = getPageNumber(root, page); if (pageNumber === activePageNumber) { return { key: activePageAriaLabelComponents[0], component: activePageAriaLabelComponents[1], param: pageNumber }; } else { return { key: pageAriaLabelComponents[0], component: pageAriaLabelComponents[1], param: pageNumber }; } }); Str.get_strings(stringRequests).then(function(strings) { pageItems.each(function(index, page) { page = $(page); var string = strings[index]; page.attr('aria-label', string); page.find(SELECTORS.PAGE_LINK).attr('aria-label', string); }); return strings; }) .catch(function() { // No need to interrupt the page if we can't load the aria lang strings. return; }); }; /** * Make the paging bar item for the given page number visible and fire * the SHOW_PAGES paged content event to tell any listening content to * update. * * @param {object} root The root element. * @param {Number} pageNumber The number for the page to show. * @param {string} id A uniqie id for this instance. */ var showPage = function(root, pageNumber, id) { var pendingPromise = new Pending('core/paged_content_paging_bar:showPage'); var lastPageNumber = getLastPageNumber(root); var isSamePage = pageNumber == getActivePageNumber(root); var limit = getLimit(root); var offset = (pageNumber - 1) * limit; if (!isSamePage) { // We only need to toggle the active class if the user didn't click // on the already active page. root.find(SELECTORS.PAGE_ITEM).removeClass('active').removeAttr('aria-current'); var page = getPageByNumber(root, pageNumber); page.addClass('active'); page.attr('aria-current', true); setActivePageNumber(root, pageNumber); adjustPagingBarSize(root); } // Make sure the control buttons are disabled as the user navigates // to either end of the limits. if (lastPageNumber && pageNumber >= lastPageNumber) { disableNextControlButtons(root); } else { enableNextControlButtons(root); } if (pageNumber > 1) { enablePreviousControlButtons(root); } else { disablePreviousControlButtons(root); } generateAriaLabels(root); // This event requires a payload that contains a list of all pages that // were activated. In the case of the paging bar we only show one page at // a time. PubSub.publish(id + PagedContentEvents.SHOW_PAGES, [{ pageNumber: pageNumber, limit: limit, offset: offset }]); pendingPromise.resolve(); }; /** * Add event listeners for interactions with the paging bar as well as listening * for custom paged content events. * * Each event will trigger different logic to update parts of the paging bar's * display. * * @param {object} root The root element. * @param {string} id A uniqie id for this instance. */ var registerEventListeners = function(root, id) { var ignoreControlWhileLoading = root.attr('data-ignore-control-while-loading'); var loading = false; if (ignoreControlWhileLoading == "") { // Default to ignoring control while loading if not specified. ignoreControlWhileLoading = true; } CustomEvents.define(root, [ CustomEvents.events.activate ]); root.on(CustomEvents.events.activate, SELECTORS.PAGE_ITEM, function(e, data) { data.originalEvent.preventDefault(); data.originalEvent.stopPropagation(); if (ignoreControlWhileLoading && loading) { // Do nothing if configured to ignore control while loading. return; } var page = $(e.target).closest(SELECTORS.PAGE_ITEM); if (!page.hasClass('disabled')) { var pageNumber = getPageNumber(root, page); showPage(root, pageNumber, id); loading = true; } }); // This event is fired when all of the items have been loaded. Typically used // in an "infinite" pages context when we don't know the exact number of pages // ahead of time. PubSub.subscribe(id + PagedContentEvents.ALL_ITEMS_LOADED, function(pageNumber) { loading = false; var currentLastPage = getLastPageNumber(root); if (!currentLastPage || pageNumber < currentLastPage) { // Somehow the value we've got saved is higher than the new // value we just received. Perhaps events came out of order. // In any case, save the lowest value. setLastPageNumber(root, pageNumber); } if (pageNumber === 1 && root.attr('data-hide-control-on-single-page')) { // If all items were loaded on the first page then we can hide // the paging bar because there are no other pages to load. hide(root); disableNextControlButtons(root); disablePreviousControlButtons(root); } else { show(root); disableNextControlButtons(root); } }); // This event is fired after all of the requested pages have been rendered. PubSub.subscribe(id + PagedContentEvents.PAGES_SHOWN, function() { // All pages have been shown so turn off the loading flag. loading = false; }); // This is triggered when the paging limit is modified. PubSub.subscribe(id + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT, function(limit) { // Update the limit. setLimit(root, limit); setLastPageNumber(root, 0); setActivePageNumber(root, 0); show(root); // Reload the data from page 1 again. showPage(root, 1, id); }); }; /** * Initialise the paging bar. * @param {object} root The root element. * @param {string} id A uniqie id for this instance. */ var init = function(root, id) { root = $(root); var pages = root.find(SELECTORS.PAGE); generatePageNumbers(root, pages); registerEventListeners(root, id); if (hasActivePageNumber(root)) { var activePageNumber = getActivePageNumber(root); // If the the paging bar was rendered with an active page selected // then make sure we fired off the event to tell the content page to // show. getPageByNumber(root, activePageNumber).click(); if (activePageNumber == 1) { // If the first page is active then disable the previous buttons. disablePreviousControlButtons(root); } } else { // There was no active page number so load the first page using // the next button. This allows the infinite pagination to work. getNextButton(root).click(); } adjustPagingBarSize(root); }; return { init: init, disableNextControlButtons: disableNextControlButtons, enableNextControlButtons: enableNextControlButtons, disablePreviousControlButtons: disablePreviousControlButtons, enablePreviousControlButtons: enablePreviousControlButtons, showPage: showPage, rootSelector: SELECTORS.ROOT, }; }); truncate.js 0000644 00000015073 15152050146 0006734 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Description of import/upgrade into Moodle: * 1.) Download from https://github.com/pathable/truncate * 2.) Copy jquery.truncate.js into lib/amd/src/truncate.js * 3.) Edit truncate.js to return the $.truncate function as truncate * 4.) Apply Moodle changes from git commit 7172b33e241c4d42cff01f78bf8570408f43fdc2 */ /** * Module for text truncation. * * Implementation provided by Pathable (thanks!). * See: https://github.com/pathable/truncate * * @module core/truncate * @copyright 2017 Pathable * 2017 Mathias Bynens * 2017 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery'], function($) { // Matches trailing non-space characters. var chop = /(\s*\S+|\s)$/; // Matches the first word in the string. var start = /^(\S*)/; // Matches any space characters. var space = /\s/; // Special thanks to Mathias Bynens for the multi-byte char // implementation. Much love. // see: https://github.com/mathiasbynens/String.prototype.at/blob/master/at.js var charLengthAt = function(text, position) { var string = String(text); var size = string.length; // `ToInteger` var index = position ? Number(position) : 0; if (index != index) { // better `isNaN` index = 0; } // Account for out-of-bounds indices // The odd lower bound is because the ToInteger operation is // going to round `n` to `0` for `-1 < n <= 0`. if (index <= -1 || index >= size) { return ''; } // Second half of `ToInteger` index = index | 0; // Get the first code unit and code unit value var cuFirst = string.charCodeAt(index); var cuSecond; var nextIndex = index + 1; var len = 1; if ( // Check if it’s the start of a surrogate pair. cuFirst >= 0xD800 && cuFirst <= 0xDBFF && // high surrogate size > nextIndex // there is a next code unit ) { cuSecond = string.charCodeAt(nextIndex); if (cuSecond >= 0xDC00 && cuSecond <= 0xDFFF) { // low surrogate len = 2; } } return len; }; var lengthMultiByte = function(text) { var count = 0; for (var i = 0; i < text.length; i += charLengthAt(text, i)) { count++; } return count; }; var getSliceLength = function(text, amount) { if (!text.length) { return 0; } var length = 0; var count = 0; do { length += charLengthAt(text, length); count++; } while (length < text.length && count < amount); return length; }; // Return a truncated html string. Delegates to $.fn.truncate. $.truncate = function(html, options) { return $('<div></div>').append(html).truncate(options).html(); }; // Truncate the contents of an element in place. $.fn.truncate = function(options) { if (!isNaN(parseFloat(options))) options = {length: options}; var o = $.extend({}, $.truncate.defaults, options); return this.each(function() { var self = $(this); if (o.noBreaks) self.find('br').replaceWith(' '); var ellipsisLength = o.ellipsis.length; var text = self.text(); var textLength = lengthMultiByte(text); var excess = textLength - o.length + ellipsisLength; if (textLength < o.length) return; if (o.stripTags) self.text(text); // Chop off any partial words if appropriate. if (o.words && excess > 0) { var sliced = text.slice(0, getSliceLength(text, o.length - ellipsisLength) + 1); var replaced = sliced.replace(chop, ''); var truncated = lengthMultiByte(replaced); var oneWord = sliced.match(space) ? false : true; if (o.keepFirstWord && truncated === 0) { excess = textLength - lengthMultiByte(start.exec(text)[0]) - ellipsisLength; } else if (oneWord && truncated === 0) { excess = textLength - o.length + ellipsisLength; } else { excess = textLength - truncated - 1; } } // The requested length is larger than the text. No need for ellipsis. if (excess > textLength) { excess = textLength - o.length; } if (excess < 0 || !excess && !o.truncated) return; // Iterate over each child node in reverse, removing excess text. $.each(self.contents().get().reverse(), function(i, el) { var $el = $(el); var text = $el.text(); var length = lengthMultiByte(text); // If the text is longer than the excess, remove the node and continue. if (length <= excess) { o.truncated = true; excess -= length; $el.remove(); return; } // Remove the excess text and append the ellipsis. if (el.nodeType === 3) { var splitAmount = length - excess; splitAmount = splitAmount >= 0 ? getSliceLength(text, splitAmount) : 0; $(el.splitText(splitAmount)).replaceWith(o.ellipsis); return false; } // Recursively truncate child nodes. $el.truncate($.extend(o, {length: length - excess + ellipsisLength})); return false; }); }); }; $.truncate.defaults = { // Strip all html elements, leaving only plain text. stripTags: false, // Only truncate at word boundaries. words: false, // When 'words' is active, keeps the first word in the string // even if it's longer than a target length. keepFirstWord: false, // Replace instances of <br> with a single space. noBreaks: false, // The maximum length of the truncated html. length: Infinity, // The character to use as the ellipsis. The word joiner (U+2060) can be // used to prevent a hanging ellipsis, but displays incorrectly in Chrome // on Windows 7. // http://code.google.com/p/chromium/issues/detail?id=68323 //ellipsis: '\u2026' // '\u2060\u2026' ellipsis: '\u2026' // '\u2060\u2026' }; return { truncate: $.truncate, }; }); chart_output_chartjs.js 0000644 00000025565 15152050146 0011355 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart output for chart.js. * * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @module core/chart_output_chartjs */ define([ 'jquery', 'core/chartjs', 'core/chart_axis', 'core/chart_bar', 'core/chart_output_base', 'core/chart_line', 'core/chart_pie', 'core/chart_series' ], function($, Chartjs, Axis, Bar, Base, Line, Pie, Series) { /** * Makes an axis ID. * * @param {String} xy Accepts 'x' and 'y'. * @param {Number} index The axis index. * @return {String} */ var makeAxisId = function(xy, index) { return 'axis-' + xy + '-' + index; }; /** * Chart output for Chart.js. * * @class * @extends {module:core/chart_output_base} */ function Output() { Base.prototype.constructor.apply(this, arguments); // Make sure that we've got a canvas tag. this._canvas = this._node; if (this._canvas.prop('tagName') != 'CANVAS') { this._canvas = $('<canvas>'); this._node.append(this._canvas); } this._build(); } Output.prototype = Object.create(Base.prototype); /** * Reference to the chart config object. * * @type {Object} * @protected */ Output.prototype._config = null; /** * Reference to the instance of chart.js. * * @type {Object} * @protected */ Output.prototype._chartjs = null; /** * Reference to the canvas node. * * @type {Jquery} * @protected */ Output.prototype._canvas = null; /** * Builds the config and the chart. * * @protected */ Output.prototype._build = function() { this._config = this._makeConfig(); this._chartjs = new Chartjs(this._canvas[0], this._config); }; /** * Clean data. * * @param {(String|String[])} data A single string or an array of strings. * @returns {(String|String[])} * @protected */ Output.prototype._cleanData = function(data) { if (data instanceof Array) { return data.map(function(value) { return $('<span>').html(value).text(); }); } else { return $('<span>').html(data).text(); } }; /** * Get the chart type and handles the Chart.js specific chart types. * * By default returns the current chart TYPE value. Also does the handling of specific chart types, for example * check if the bar chart should be horizontal and the pie chart should be displayed as a doughnut. * * @method getChartType * @returns {String} the chart type. * @protected */ Output.prototype._getChartType = function() { var type = this._chart.getType(); // Bars can be displayed vertically and horizontally, defining horizontalBar type. if (this._chart.getType() === Bar.prototype.TYPE && this._chart.getHorizontal() === true) { type = 'horizontalBar'; } else if (this._chart.getType() === Pie.prototype.TYPE && this._chart.getDoughnut() === true) { // Pie chart can be displayed as doughnut. type = 'doughnut'; } return type; }; /** * Make the axis config. * * @protected * @param {module:core/chart_axis} axis The axis. * @param {String} xy Accepts 'x' or 'y'. * @param {Number} index The axis index. * @return {Object} The axis config. */ Output.prototype._makeAxisConfig = function(axis, xy, index) { var scaleData = { id: makeAxisId(xy, index) }; if (axis.getPosition() !== Axis.prototype.POS_DEFAULT) { scaleData.position = axis.getPosition(); } if (axis.getLabel() !== null) { scaleData.title = { display: true, text: this._cleanData(axis.getLabel()) }; } if (axis.getStepSize() !== null) { scaleData.ticks = scaleData.ticks || {}; scaleData.ticks.stepSize = axis.getStepSize(); } if (axis.getMax() !== null) { scaleData.ticks = scaleData.ticks || {}; scaleData.ticks.max = axis.getMax(); } if (axis.getMin() !== null) { scaleData.ticks = scaleData.ticks || {}; scaleData.ticks.min = axis.getMin(); } return scaleData; }; /** * Make the config config. * * @protected * @return {Object} The axis config. */ Output.prototype._makeConfig = function() { var charType = this._getChartType(); var config = { type: charType, data: { labels: this._cleanData(this._chart.getLabels()), datasets: this._makeDatasetsConfig() }, options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: this._chart.getTitle() !== null, text: this._cleanData(this._chart.getTitle()) } } } }; if (charType === 'horizontalBar') { config.type = 'bar'; config.options.indexAxis = 'y'; } var legendOptions = this._chart.getLegendOptions(); if (legendOptions) { config.options.plugins.legend = legendOptions; } this._chart.getXAxes().forEach(function(axis, i) { var axisLabels = axis.getLabels(); config.options.scales = config.options.scales || {}; config.options.scales.x = config.options.scales.x || {}; config.options.scales.x[i] = this._makeAxisConfig(axis, 'x', i); if (axisLabels !== null) { config.options.scales.x[i].ticks.callback = function(value, index) { return axisLabels[index] || ''; }; } config.options.scales.x.stacked = this._isStacked(); }.bind(this)); this._chart.getYAxes().forEach(function(axis, i) { var axisLabels = axis.getLabels(); config.options.scales = config.options.scales || {}; config.options.scales.y = config.options.scales.yAxes || {}; config.options.scales.y[i] = this._makeAxisConfig(axis, 'y', i); if (axisLabels !== null) { config.options.scales.y[i].ticks.callback = function(value) { return axisLabels[parseInt(value, 10)] || ''; }; } config.options.scales.y.stacked = this._isStacked(); }.bind(this)); config.options.plugins.tooltip = { callbacks: { label: this._makeTooltip.bind(this) } }; return config; }; /** * Get the datasets configurations. * * @protected * @return {Object[]} */ Output.prototype._makeDatasetsConfig = function() { var sets = this._chart.getSeries().map(function(series) { var colors = series.hasColoredValues() ? series.getColors() : series.getColor(); var dataset = { label: this._cleanData(series.getLabel()), data: series.getValues(), type: series.getType(), fill: series.getFill(), backgroundColor: colors, // Pie charts look better without borders. borderColor: this._chart.getType() == Pie.prototype.TYPE ? '#fff' : colors, tension: this._isSmooth(series) ? 0.3 : 0 }; if (series.getXAxis() !== null) { dataset.xAxisID = makeAxisId('x', series.getXAxis()); } if (series.getYAxis() !== null) { dataset.yAxisID = makeAxisId('y', series.getYAxis()); } return dataset; }.bind(this)); return sets; }; /** * Get the chart data, add labels and rebuild the tooltip. * * @param {Object[]} tooltipItem The tooltip item object. * @returns {Array} * @protected */ Output.prototype._makeTooltip = function(tooltipItem) { // Get series and chart data to rebuild the tooltip and add labels. var series = this._chart.getSeries()[tooltipItem.datasetIndex]; var serieLabel = series.getLabel(); var chartData = tooltipItem.dataset.data; var tooltipData = chartData[tooltipItem.dataIndex]; // Build default tooltip. var tooltip = []; // Pie and doughnut charts tooltip are different. if (this._chart.getType() === Pie.prototype.TYPE) { var chartLabels = this._cleanData(this._chart.getLabels()); tooltip.push(chartLabels[tooltipItem.dataIndex] + ' - ' + this._cleanData(serieLabel) + ': ' + tooltipData); } else { tooltip.push(this._cleanData(serieLabel) + ': ' + tooltipData); } return tooltip; }; /** * Verify if the chart line is smooth or not. * * @protected * @param {module:core/chart_series} series The series. * @returns {Bool} */ Output.prototype._isSmooth = function(series) { var smooth = false; if (this._chart.getType() === Line.prototype.TYPE) { smooth = series.getSmooth(); if (smooth === null) { smooth = this._chart.getSmooth(); } } else if (series.getType() === Series.prototype.TYPE_LINE) { smooth = series.getSmooth(); } return smooth; }; /** * Verify if the bar chart is stacked or not. * * @protected * @returns {Bool} */ Output.prototype._isStacked = function() { var stacked = false; // Stacking is (currently) only supported for bar charts. if (this._chart.getType() === Bar.prototype.TYPE) { stacked = this._chart.getStacked(); } return stacked; }; /** @override */ Output.prototype.update = function() { $.extend(true, this._config, this._makeConfig()); this._chartjs.update(); }; return Output; }); dragdrop.js 0000644 00000031651 15152050146 0006711 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /* * JavaScript to handle drag operations, including automatic scrolling. * * Note: this module is defined statically. It is a singleton. You * can only have one use of it active at any time. However, you * can only drag one thing at a time, this is not a problem in practice. * * @module core/dragdrop * @copyright 2016 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.6 */ define(['jquery', 'core/autoscroll'], function($, autoScroll) { var dragdrop = { /** * A boolean or options argument depending on whether browser supports passive events. * @private */ eventCaptureOptions: {passive: false, capture: true}, /** * Drag proxy if any. * @private */ dragProxy: null, /** * Function called on move. * @private */ onMove: null, /** * Function called on drop. * @private */ onDrop: null, /** * Initial position of proxy at drag start. */ initialPosition: null, /** * Initial page X of cursor at drag start. */ initialX: null, /** * Initial page Y of cursor at drag start. */ initialY: null, /** * If touch event is in progress, this will be the id, otherwise null */ touching: null, /** * Prepares to begin a drag operation - call with a mousedown or touchstart event. * * If the returned object has 'start' true, then you can set up a drag proxy, and call * start. This function will call preventDefault automatically regardless of whether * starting or not. * * @public * @param {Object} event Event (should be either mousedown or touchstart) * @return {Object} Object with start (boolean flag) and x, y (only if flag true) values */ prepare: function(event) { event.preventDefault(); var start; if (event.type === 'touchstart') { // For touch, start if there's at least one touch and we are not currently doing // a touch event. start = (dragdrop.touching === null) && event.changedTouches.length > 0; } else { // For mousedown, start if it's the left button. start = event.which === 1; } if (start) { var details = dragdrop.getEventXY(event); details.start = true; return details; } else { return {start: false}; } }, /** * Call to start a drag operation, in response to a mouse down or touch start event. * Normally call this after calling prepare and receiving start true (you can probably * skip prepare if only supporting drag not touch). * * Note: The caller is responsible for creating a 'drag proxy' which is the * thing that actually gets dragged. At present, this doesn't really work * properly unless it is added directly within the body tag. * * You also need to ensure that there is CSS so the proxy is absolutely positioned, * and styled to look like it is floating. * * You also need to absolutely position the proxy where you want it to start. * * @public * @param {Object} event Event (should be either mousedown or touchstart) * @param {jQuery} dragProxy An absolute-positioned element for dragging * @param {Object} onMove Function that receives X and Y page locations for a move * @param {Object} onDrop Function that receives X and Y page locations when dropped */ start: function(event, dragProxy, onMove, onDrop) { var xy = dragdrop.getEventXY(event); dragdrop.initialX = xy.x; dragdrop.initialY = xy.y; dragdrop.initialPosition = dragProxy.offset(); dragdrop.dragProxy = dragProxy; dragdrop.onMove = onMove; dragdrop.onDrop = onDrop; switch (event.type) { case 'mousedown': // Cannot use jQuery 'on' because events need to not be passive. dragdrop.addEventSpecial('mousemove', dragdrop.mouseMove); dragdrop.addEventSpecial('mouseup', dragdrop.mouseUp); break; case 'touchstart': dragdrop.addEventSpecial('touchend', dragdrop.touchEnd); dragdrop.addEventSpecial('touchcancel', dragdrop.touchEnd); dragdrop.addEventSpecial('touchmove', dragdrop.touchMove); dragdrop.touching = event.changedTouches[0].identifier; break; default: throw new Error('Unexpected event type: ' + event.type); } autoScroll.start(dragdrop.scroll); }, /** * Adds an event listener with special event capture options (capture, not passive). If the * browser does not support passive events, it will fall back to the boolean for capture. * * @private * @param {Object} event Event type string * @param {Object} handler Handler function */ addEventSpecial: function(event, handler) { try { window.addEventListener(event, handler, dragdrop.eventCaptureOptions); } catch (ex) { dragdrop.eventCaptureOptions = true; window.addEventListener(event, handler, dragdrop.eventCaptureOptions); } }, /** * Gets X/Y co-ordinates of an event, which can be either touchstart or mousedown. * * @private * @param {Object} event Event (should be either mousedown or touchstart) * @return {Object} X/Y co-ordinates */ getEventXY: function(event) { switch (event.type) { case 'touchstart': return {x: event.changedTouches[0].pageX, y: event.changedTouches[0].pageY}; case 'mousedown': return {x: event.pageX, y: event.pageY}; default: throw new Error('Unexpected event type: ' + event.type); } }, /** * Event handler for touch move. * * @private * @param {Object} e Event */ touchMove: function(e) { e.preventDefault(); for (var i = 0; i < e.changedTouches.length; i++) { if (e.changedTouches[i].identifier === dragdrop.touching) { dragdrop.handleMove(e.changedTouches[i].pageX, e.changedTouches[i].pageY); } } }, /** * Event handler for mouse move. * * @private * @param {Object} e Event */ mouseMove: function(e) { dragdrop.handleMove(e.pageX, e.pageY); }, /** * Shared handler for move event (mouse or touch). * * @private * @param {number} pageX X co-ordinate * @param {number} pageY Y co-ordinate */ handleMove: function(pageX, pageY) { // Move the drag proxy, not letting you move it out of screen or window bounds. var current = dragdrop.dragProxy.offset(); var topOffset = current.top - parseInt(dragdrop.dragProxy.css('top')); var leftOffset = current.left - parseInt(dragdrop.dragProxy.css('left')); var maxY = $(document).height() - dragdrop.dragProxy.outerHeight() - topOffset; var maxX = $(document).width() - dragdrop.dragProxy.outerWidth() - leftOffset; var minY = -topOffset; var minX = -leftOffset; var initial = dragdrop.initialPosition; var position = { top: Math.max(minY, Math.min(maxY, initial.top + (pageY - dragdrop.initialY) - topOffset)), left: Math.max(minX, Math.min(maxX, initial.left + (pageX - dragdrop.initialX) - leftOffset)) }; dragdrop.dragProxy.css(position); // Trigger move handler. dragdrop.onMove(pageX, pageY, dragdrop.dragProxy); }, /** * Event handler for touch end. * * @private * @param {Object} e Event */ touchEnd: function(e) { e.preventDefault(); for (var i = 0; i < e.changedTouches.length; i++) { if (e.changedTouches[i].identifier === dragdrop.touching) { dragdrop.handleEnd(e.changedTouches[i].pageX, e.changedTouches[i].pageY); } } }, /** * Event handler for mouse up. * * @private * @param {Object} e Event */ mouseUp: function(e) { dragdrop.handleEnd(e.pageX, e.pageY); }, /** * Shared handler for end drag (mouse or touch). * * @private * @param {number} pageX X * @param {number} pageY Y */ handleEnd: function(pageX, pageY) { if (dragdrop.touching !== null) { window.removeEventListener('touchend', dragdrop.touchEnd, dragdrop.eventCaptureOptions); window.removeEventListener('touchcancel', dragdrop.touchEnd, dragdrop.eventCaptureOptions); window.removeEventListener('touchmove', dragdrop.touchMove, dragdrop.eventCaptureOptions); dragdrop.touching = null; } else { window.removeEventListener('mousemove', dragdrop.mouseMove, dragdrop.eventCaptureOptions); window.removeEventListener('mouseup', dragdrop.mouseUp, dragdrop.eventCaptureOptions); } autoScroll.stop(); dragdrop.onDrop(pageX, pageY, dragdrop.dragProxy); }, /** * Called when the page scrolls. * * @private * @param {number} offset Amount of scroll */ scroll: function(offset) { // Move the proxy to match. var maxY = $(document).height() - dragdrop.dragProxy.outerHeight(); var currentPosition = dragdrop.dragProxy.offset(); currentPosition.top = Math.min(maxY, currentPosition.top + offset); dragdrop.dragProxy.css(currentPosition); } }; return { /** * Prepares to begin a drag operation - call with a mousedown or touchstart event. * * If the returned object has 'start' true, then you can set up a drag proxy, and call * start. This function will call preventDefault automatically regardless of whether * starting or not. * * @param {Object} event Event (should be either mousedown or touchstart) * @return {Object} Object with start (boolean flag) and x, y (only if flag true) values */ prepare: dragdrop.prepare, /** * Call to start a drag operation, in response to a mouse down or touch start event. * Normally call this after calling prepare and receiving start true (you can probably * skip prepare if only supporting drag not touch). * * Note: The caller is responsible for creating a 'drag proxy' which is the * thing that actually gets dragged. At present, this doesn't really work * properly unless it is added directly within the body tag. * * You also need to ensure that there is CSS so the proxy is absolutely positioned, * and styled to look like it is floating. * * You also need to absolutely position the proxy where you want it to start. * * @param {Object} event Event (should be either mousedown or touchstart) * @param {jQuery} dragProxy An absolute-positioned element for dragging * @param {Object} onMove Function that receives X and Y page locations for a move * @param {Object} onDrop Function that receives X and Y page locations when dropped */ start: dragdrop.start }; }); chart_bar.js 0000644 00000006121 15152050146 0007026 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart bar. * * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @module core/chart_bar */ define(['core/chart_base'], function(Base) { /** * Bar chart. * * @extends {module:core/chart_base} * @class */ function Bar() { Base.prototype.constructor.apply(this, arguments); } Bar.prototype = Object.create(Base.prototype); /** * Whether the bars should be displayed horizontally or not. * * @type {Bool} * @protected */ Bar.prototype._horizontal = false; /** * Whether the bars should be stacked or not. * * @type {Bool} * @protected */ Bar.prototype._stacked = false; /** @override */ Bar.prototype.TYPE = 'bar'; /** @override */ Bar.prototype.create = function(Klass, data) { var chart = Base.prototype.create.apply(this, arguments); chart.setHorizontal(data.horizontal); chart.setStacked(data.stacked); return chart; }; /** @override */ Bar.prototype._setDefaults = function() { Base.prototype._setDefaults.apply(this, arguments); var axis = this.getYAxis(0, true); axis.setMin(0); }; /** * Get whether the bars should be displayed horizontally or not. * * @returns {Bool} */ Bar.prototype.getHorizontal = function() { return this._horizontal; }; /** * Get whether the bars should be stacked or not. * * @returns {Bool} */ Bar.prototype.getStacked = function() { return this._stacked; }; /** * Set whether the bars should be displayed horizontally or not. * * It sets the X Axis to zero if the min value is null. * * @param {Bool} horizontal True if the bars should be displayed horizontally, false otherwise. */ Bar.prototype.setHorizontal = function(horizontal) { var axis = this.getXAxis(0, true); if (axis.getMin() === null) { axis.setMin(0); } this._horizontal = Boolean(horizontal); }; /** * Set whether the bars should be stacked or not. * * @method setStacked * @param {Bool} stacked True if the chart should be stacked or false otherwise. */ Bar.prototype.setStacked = function(stacked) { this._stacked = Boolean(stacked); }; return Bar; }); adapter.js 0000644 00000366125 15152050146 0006536 0 ustar 00 /** * Description of import/upgrade into Moodle: * * 1. Visit https://github.com/webrtc/adapter/releases. * 2. Check if the version has been updated from what is listed in lib/thirdpartylibs.xml in the Moodle wwwroot. * 3. If it has - * 1. Download the source code. * 2. Copy the content of the file release/adapter.js from the archive (ignore the first line). * 3. Replace the content below "return (function e(t,n,r) .." in this file with the content you copied. * 4. Ensure to update lib/thirdpartylibs.xml with any changes. */ // ESLint directives. /* eslint-disable */ // JSHint directives. /* jshint ignore:start */ define([], function() { return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ /* * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. * * Use of this source code is governed by a BSD-style license * that can be found in the LICENSE file in the root of the source * tree. */ /* eslint-env node */ 'use strict'; var _adapter_factory = require('./adapter_factory.js'); var adapter = (0, _adapter_factory.adapterFactory)({ window: typeof window === 'undefined' ? undefined : window }); module.exports = adapter; // this is the difference from adapter_core. },{"./adapter_factory.js":2}],2:[function(require,module,exports){ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.adapterFactory = adapterFactory; var _utils = require('./utils'); var utils = _interopRequireWildcard(_utils); var _chrome_shim = require('./chrome/chrome_shim'); var chromeShim = _interopRequireWildcard(_chrome_shim); var _firefox_shim = require('./firefox/firefox_shim'); var firefoxShim = _interopRequireWildcard(_firefox_shim); var _safari_shim = require('./safari/safari_shim'); var safariShim = _interopRequireWildcard(_safari_shim); var _common_shim = require('./common_shim'); var commonShim = _interopRequireWildcard(_common_shim); var _sdp = require('sdp'); var sdp = _interopRequireWildcard(_sdp); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } // Shimming starts here. /* * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. * * Use of this source code is governed by a BSD-style license * that can be found in the LICENSE file in the root of the source * tree. */ function adapterFactory() { var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, window = _ref.window; var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : { shimChrome: true, shimFirefox: true, shimSafari: true }; // Utils. var logging = utils.log; var browserDetails = utils.detectBrowser(window); var adapter = { browserDetails: browserDetails, commonShim: commonShim, extractVersion: utils.extractVersion, disableLog: utils.disableLog, disableWarnings: utils.disableWarnings, // Expose sdp as a convenience. For production apps include directly. sdp: sdp }; // Shim browser if found. switch (browserDetails.browser) { case 'chrome': if (!chromeShim || !chromeShim.shimPeerConnection || !options.shimChrome) { logging('Chrome shim is not included in this adapter release.'); return adapter; } if (browserDetails.version === null) { logging('Chrome shim can not determine version, not shimming.'); return adapter; } logging('adapter.js shimming chrome.'); // Export to the adapter global object visible in the browser. adapter.browserShim = chromeShim; // Must be called before shimPeerConnection. commonShim.shimAddIceCandidateNullOrEmpty(window, browserDetails); chromeShim.shimGetUserMedia(window, browserDetails); chromeShim.shimMediaStream(window, browserDetails); chromeShim.shimPeerConnection(window, browserDetails); chromeShim.shimOnTrack(window, browserDetails); chromeShim.shimAddTrackRemoveTrack(window, browserDetails); chromeShim.shimGetSendersWithDtmf(window, browserDetails); chromeShim.shimGetStats(window, browserDetails); chromeShim.shimSenderReceiverGetStats(window, browserDetails); chromeShim.fixNegotiationNeeded(window, browserDetails); commonShim.shimRTCIceCandidate(window, browserDetails); commonShim.shimConnectionState(window, browserDetails); commonShim.shimMaxMessageSize(window, browserDetails); commonShim.shimSendThrowTypeError(window, browserDetails); commonShim.removeExtmapAllowMixed(window, browserDetails); break; case 'firefox': if (!firefoxShim || !firefoxShim.shimPeerConnection || !options.shimFirefox) { logging('Firefox shim is not included in this adapter release.'); return adapter; } logging('adapter.js shimming firefox.'); // Export to the adapter global object visible in the browser. adapter.browserShim = firefoxShim; // Must be called before shimPeerConnection. commonShim.shimAddIceCandidateNullOrEmpty(window, browserDetails); firefoxShim.shimGetUserMedia(window, browserDetails); firefoxShim.shimPeerConnection(window, browserDetails); firefoxShim.shimOnTrack(window, browserDetails); firefoxShim.shimRemoveStream(window, browserDetails); firefoxShim.shimSenderGetStats(window, browserDetails); firefoxShim.shimReceiverGetStats(window, browserDetails); firefoxShim.shimRTCDataChannel(window, browserDetails); firefoxShim.shimAddTransceiver(window, browserDetails); firefoxShim.shimGetParameters(window, browserDetails); firefoxShim.shimCreateOffer(window, browserDetails); firefoxShim.shimCreateAnswer(window, browserDetails); commonShim.shimRTCIceCandidate(window, browserDetails); commonShim.shimConnectionState(window, browserDetails); commonShim.shimMaxMessageSize(window, browserDetails); commonShim.shimSendThrowTypeError(window, browserDetails); break; case 'safari': if (!safariShim || !options.shimSafari) { logging('Safari shim is not included in this adapter release.'); return adapter; } logging('adapter.js shimming safari.'); // Export to the adapter global object visible in the browser. adapter.browserShim = safariShim; // Must be called before shimCallbackAPI. commonShim.shimAddIceCandidateNullOrEmpty(window, browserDetails); safariShim.shimRTCIceServerUrls(window, browserDetails); safariShim.shimCreateOfferLegacy(window, browserDetails); safariShim.shimCallbacksAPI(window, browserDetails); safariShim.shimLocalStreamsAPI(window, browserDetails); safariShim.shimRemoteStreamsAPI(window, browserDetails); safariShim.shimTrackEventTransceiver(window, browserDetails); safariShim.shimGetUserMedia(window, browserDetails); safariShim.shimAudioContext(window, browserDetails); commonShim.shimRTCIceCandidate(window, browserDetails); commonShim.shimMaxMessageSize(window, browserDetails); commonShim.shimSendThrowTypeError(window, browserDetails); commonShim.removeExtmapAllowMixed(window, browserDetails); break; default: logging('Unsupported browser!'); break; } return adapter; } // Browser shims. },{"./chrome/chrome_shim":3,"./common_shim":6,"./firefox/firefox_shim":7,"./safari/safari_shim":10,"./utils":11,"sdp":12}],3:[function(require,module,exports){ /* * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. * * Use of this source code is governed by a BSD-style license * that can be found in the LICENSE file in the root of the source * tree. */ /* eslint-env node */ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.shimGetDisplayMedia = exports.shimGetUserMedia = undefined; var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; var _getusermedia = require('./getusermedia'); Object.defineProperty(exports, 'shimGetUserMedia', { enumerable: true, get: function get() { return _getusermedia.shimGetUserMedia; } }); var _getdisplaymedia = require('./getdisplaymedia'); Object.defineProperty(exports, 'shimGetDisplayMedia', { enumerable: true, get: function get() { return _getdisplaymedia.shimGetDisplayMedia; } }); exports.shimMediaStream = shimMediaStream; exports.shimOnTrack = shimOnTrack; exports.shimGetSendersWithDtmf = shimGetSendersWithDtmf; exports.shimGetStats = shimGetStats; exports.shimSenderReceiverGetStats = shimSenderReceiverGetStats; exports.shimAddTrackRemoveTrackWithNative = shimAddTrackRemoveTrackWithNative; exports.shimAddTrackRemoveTrack = shimAddTrackRemoveTrack; exports.shimPeerConnection = shimPeerConnection; exports.fixNegotiationNeeded = fixNegotiationNeeded; var _utils = require('../utils.js'); var utils = _interopRequireWildcard(_utils); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function shimMediaStream(window) { window.MediaStream = window.MediaStream || window.webkitMediaStream; } function shimOnTrack(window) { if ((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCPeerConnection && !('ontrack' in window.RTCPeerConnection.prototype)) { Object.defineProperty(window.RTCPeerConnection.prototype, 'ontrack', { get: function get() { return this._ontrack; }, set: function set(f) { if (this._ontrack) { this.removeEventListener('track', this._ontrack); } this.addEventListener('track', this._ontrack = f); }, enumerable: true, configurable: true }); var origSetRemoteDescription = window.RTCPeerConnection.prototype.setRemoteDescription; window.RTCPeerConnection.prototype.setRemoteDescription = function setRemoteDescription() { var _this = this; if (!this._ontrackpoly) { this._ontrackpoly = function (e) { // onaddstream does not fire when a track is added to an existing // stream. But stream.onaddtrack is implemented so we use that. e.stream.addEventListener('addtrack', function (te) { var receiver = void 0; if (window.RTCPeerConnection.prototype.getReceivers) { receiver = _this.getReceivers().find(function (r) { return r.track && r.track.id === te.track.id; }); } else { receiver = { track: te.track }; } var event = new Event('track'); event.track = te.track; event.receiver = receiver; event.transceiver = { receiver: receiver }; event.streams = [e.stream]; _this.dispatchEvent(event); }); e.stream.getTracks().forEach(function (track) { var receiver = void 0; if (window.RTCPeerConnection.prototype.getReceivers) { receiver = _this.getReceivers().find(function (r) { return r.track && r.track.id === track.id; }); } else { receiver = { track: track }; } var event = new Event('track'); event.track = track; event.receiver = receiver; event.transceiver = { receiver: receiver }; event.streams = [e.stream]; _this.dispatchEvent(event); }); }; this.addEventListener('addstream', this._ontrackpoly); } return origSetRemoteDescription.apply(this, arguments); }; } else { // even if RTCRtpTransceiver is in window, it is only used and // emitted in unified-plan. Unfortunately this means we need // to unconditionally wrap the event. utils.wrapPeerConnectionEvent(window, 'track', function (e) { if (!e.transceiver) { Object.defineProperty(e, 'transceiver', { value: { receiver: e.receiver } }); } return e; }); } } function shimGetSendersWithDtmf(window) { // Overrides addTrack/removeTrack, depends on shimAddTrackRemoveTrack. if ((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCPeerConnection && !('getSenders' in window.RTCPeerConnection.prototype) && 'createDTMFSender' in window.RTCPeerConnection.prototype) { var shimSenderWithDtmf = function shimSenderWithDtmf(pc, track) { return { track: track, get dtmf() { if (this._dtmf === undefined) { if (track.kind === 'audio') { this._dtmf = pc.createDTMFSender(track); } else { this._dtmf = null; } } return this._dtmf; }, _pc: pc }; }; // augment addTrack when getSenders is not available. if (!window.RTCPeerConnection.prototype.getSenders) { window.RTCPeerConnection.prototype.getSenders = function getSenders() { this._senders = this._senders || []; return this._senders.slice(); // return a copy of the internal state. }; var origAddTrack = window.RTCPeerConnection.prototype.addTrack; window.RTCPeerConnection.prototype.addTrack = function addTrack(track, stream) { var sender = origAddTrack.apply(this, arguments); if (!sender) { sender = shimSenderWithDtmf(this, track); this._senders.push(sender); } return sender; }; var origRemoveTrack = window.RTCPeerConnection.prototype.removeTrack; window.RTCPeerConnection.prototype.removeTrack = function removeTrack(sender) { origRemoveTrack.apply(this, arguments); var idx = this._senders.indexOf(sender); if (idx !== -1) { this._senders.splice(idx, 1); } }; } var origAddStream = window.RTCPeerConnection.prototype.addStream; window.RTCPeerConnection.prototype.addStream = function addStream(stream) { var _this2 = this; this._senders = this._senders || []; origAddStream.apply(this, [stream]); stream.getTracks().forEach(function (track) { _this2._senders.push(shimSenderWithDtmf(_this2, track)); }); }; var origRemoveStream = window.RTCPeerConnection.prototype.removeStream; window.RTCPeerConnection.prototype.removeStream = function removeStream(stream) { var _this3 = this; this._senders = this._senders || []; origRemoveStream.apply(this, [stream]); stream.getTracks().forEach(function (track) { var sender = _this3._senders.find(function (s) { return s.track === track; }); if (sender) { // remove sender _this3._senders.splice(_this3._senders.indexOf(sender), 1); } }); }; } else if ((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCPeerConnection && 'getSenders' in window.RTCPeerConnection.prototype && 'createDTMFSender' in window.RTCPeerConnection.prototype && window.RTCRtpSender && !('dtmf' in window.RTCRtpSender.prototype)) { var origGetSenders = window.RTCPeerConnection.prototype.getSenders; window.RTCPeerConnection.prototype.getSenders = function getSenders() { var _this4 = this; var senders = origGetSenders.apply(this, []); senders.forEach(function (sender) { return sender._pc = _this4; }); return senders; }; Object.defineProperty(window.RTCRtpSender.prototype, 'dtmf', { get: function get() { if (this._dtmf === undefined) { if (this.track.kind === 'audio') { this._dtmf = this._pc.createDTMFSender(this.track); } else { this._dtmf = null; } } return this._dtmf; } }); } } function shimGetStats(window) { if (!window.RTCPeerConnection) { return; } var origGetStats = window.RTCPeerConnection.prototype.getStats; window.RTCPeerConnection.prototype.getStats = function getStats() { var _this5 = this; var _arguments = Array.prototype.slice.call(arguments), selector = _arguments[0], onSucc = _arguments[1], onErr = _arguments[2]; // If selector is a function then we are in the old style stats so just // pass back the original getStats format to avoid breaking old users. if (arguments.length > 0 && typeof selector === 'function') { return origGetStats.apply(this, arguments); } // When spec-style getStats is supported, return those when called with // either no arguments or the selector argument is null. if (origGetStats.length === 0 && (arguments.length === 0 || typeof selector !== 'function')) { return origGetStats.apply(this, []); } var fixChromeStats_ = function fixChromeStats_(response) { var standardReport = {}; var reports = response.result(); reports.forEach(function (report) { var standardStats = { id: report.id, timestamp: report.timestamp, type: { localcandidate: 'local-candidate', remotecandidate: 'remote-candidate' }[report.type] || report.type }; report.names().forEach(function (name) { standardStats[name] = report.stat(name); }); standardReport[standardStats.id] = standardStats; }); return standardReport; }; // shim getStats with maplike support var makeMapStats = function makeMapStats(stats) { return new Map(Object.keys(stats).map(function (key) { return [key, stats[key]]; })); }; if (arguments.length >= 2) { var successCallbackWrapper_ = function successCallbackWrapper_(response) { onSucc(makeMapStats(fixChromeStats_(response))); }; return origGetStats.apply(this, [successCallbackWrapper_, selector]); } // promise-support return new Promise(function (resolve, reject) { origGetStats.apply(_this5, [function (response) { resolve(makeMapStats(fixChromeStats_(response))); }, reject]); }).then(onSucc, onErr); }; } function shimSenderReceiverGetStats(window) { if (!((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCPeerConnection && window.RTCRtpSender && window.RTCRtpReceiver)) { return; } // shim sender stats. if (!('getStats' in window.RTCRtpSender.prototype)) { var origGetSenders = window.RTCPeerConnection.prototype.getSenders; if (origGetSenders) { window.RTCPeerConnection.prototype.getSenders = function getSenders() { var _this6 = this; var senders = origGetSenders.apply(this, []); senders.forEach(function (sender) { return sender._pc = _this6; }); return senders; }; } var origAddTrack = window.RTCPeerConnection.prototype.addTrack; if (origAddTrack) { window.RTCPeerConnection.prototype.addTrack = function addTrack() { var sender = origAddTrack.apply(this, arguments); sender._pc = this; return sender; }; } window.RTCRtpSender.prototype.getStats = function getStats() { var sender = this; return this._pc.getStats().then(function (result) { return ( /* Note: this will include stats of all senders that * send a track with the same id as sender.track as * it is not possible to identify the RTCRtpSender. */ utils.filterStats(result, sender.track, true) ); }); }; } // shim receiver stats. if (!('getStats' in window.RTCRtpReceiver.prototype)) { var origGetReceivers = window.RTCPeerConnection.prototype.getReceivers; if (origGetReceivers) { window.RTCPeerConnection.prototype.getReceivers = function getReceivers() { var _this7 = this; var receivers = origGetReceivers.apply(this, []); receivers.forEach(function (receiver) { return receiver._pc = _this7; }); return receivers; }; } utils.wrapPeerConnectionEvent(window, 'track', function (e) { e.receiver._pc = e.srcElement; return e; }); window.RTCRtpReceiver.prototype.getStats = function getStats() { var receiver = this; return this._pc.getStats().then(function (result) { return utils.filterStats(result, receiver.track, false); }); }; } if (!('getStats' in window.RTCRtpSender.prototype && 'getStats' in window.RTCRtpReceiver.prototype)) { return; } // shim RTCPeerConnection.getStats(track). var origGetStats = window.RTCPeerConnection.prototype.getStats; window.RTCPeerConnection.prototype.getStats = function getStats() { if (arguments.length > 0 && arguments[0] instanceof window.MediaStreamTrack) { var track = arguments[0]; var sender = void 0; var receiver = void 0; var err = void 0; this.getSenders().forEach(function (s) { if (s.track === track) { if (sender) { err = true; } else { sender = s; } } }); this.getReceivers().forEach(function (r) { if (r.track === track) { if (receiver) { err = true; } else { receiver = r; } } return r.track === track; }); if (err || sender && receiver) { return Promise.reject(new DOMException('There are more than one sender or receiver for the track.', 'InvalidAccessError')); } else if (sender) { return sender.getStats(); } else if (receiver) { return receiver.getStats(); } return Promise.reject(new DOMException('There is no sender or receiver for the track.', 'InvalidAccessError')); } return origGetStats.apply(this, arguments); }; } function shimAddTrackRemoveTrackWithNative(window) { // shim addTrack/removeTrack with native variants in order to make // the interactions with legacy getLocalStreams behave as in other browsers. // Keeps a mapping stream.id => [stream, rtpsenders...] window.RTCPeerConnection.prototype.getLocalStreams = function getLocalStreams() { var _this8 = this; this._shimmedLocalStreams = this._shimmedLocalStreams || {}; return Object.keys(this._shimmedLocalStreams).map(function (streamId) { return _this8._shimmedLocalStreams[streamId][0]; }); }; var origAddTrack = window.RTCPeerConnection.prototype.addTrack; window.RTCPeerConnection.prototype.addTrack = function addTrack(track, stream) { if (!stream) { return origAddTrack.apply(this, arguments); } this._shimmedLocalStreams = this._shimmedLocalStreams || {}; var sender = origAddTrack.apply(this, arguments); if (!this._shimmedLocalStreams[stream.id]) { this._shimmedLocalStreams[stream.id] = [stream, sender]; } else if (this._shimmedLocalStreams[stream.id].indexOf(sender) === -1) { this._shimmedLocalStreams[stream.id].push(sender); } return sender; }; var origAddStream = window.RTCPeerConnection.prototype.addStream; window.RTCPeerConnection.prototype.addStream = function addStream(stream) { var _this9 = this; this._shimmedLocalStreams = this._shimmedLocalStreams || {}; stream.getTracks().forEach(function (track) { var alreadyExists = _this9.getSenders().find(function (s) { return s.track === track; }); if (alreadyExists) { throw new DOMException('Track already exists.', 'InvalidAccessError'); } }); var existingSenders = this.getSenders(); origAddStream.apply(this, arguments); var newSenders = this.getSenders().filter(function (newSender) { return existingSenders.indexOf(newSender) === -1; }); this._shimmedLocalStreams[stream.id] = [stream].concat(newSenders); }; var origRemoveStream = window.RTCPeerConnection.prototype.removeStream; window.RTCPeerConnection.prototype.removeStream = function removeStream(stream) { this._shimmedLocalStreams = this._shimmedLocalStreams || {}; delete this._shimmedLocalStreams[stream.id]; return origRemoveStream.apply(this, arguments); }; var origRemoveTrack = window.RTCPeerConnection.prototype.removeTrack; window.RTCPeerConnection.prototype.removeTrack = function removeTrack(sender) { var _this10 = this; this._shimmedLocalStreams = this._shimmedLocalStreams || {}; if (sender) { Object.keys(this._shimmedLocalStreams).forEach(function (streamId) { var idx = _this10._shimmedLocalStreams[streamId].indexOf(sender); if (idx !== -1) { _this10._shimmedLocalStreams[streamId].splice(idx, 1); } if (_this10._shimmedLocalStreams[streamId].length === 1) { delete _this10._shimmedLocalStreams[streamId]; } }); } return origRemoveTrack.apply(this, arguments); }; } function shimAddTrackRemoveTrack(window, browserDetails) { if (!window.RTCPeerConnection) { return; } // shim addTrack and removeTrack. if (window.RTCPeerConnection.prototype.addTrack && browserDetails.version >= 65) { return shimAddTrackRemoveTrackWithNative(window); } // also shim pc.getLocalStreams when addTrack is shimmed // to return the original streams. var origGetLocalStreams = window.RTCPeerConnection.prototype.getLocalStreams; window.RTCPeerConnection.prototype.getLocalStreams = function getLocalStreams() { var _this11 = this; var nativeStreams = origGetLocalStreams.apply(this); this._reverseStreams = this._reverseStreams || {}; return nativeStreams.map(function (stream) { return _this11._reverseStreams[stream.id]; }); }; var origAddStream = window.RTCPeerConnection.prototype.addStream; window.RTCPeerConnection.prototype.addStream = function addStream(stream) { var _this12 = this; this._streams = this._streams || {}; this._reverseStreams = this._reverseStreams || {}; stream.getTracks().forEach(function (track) { var alreadyExists = _this12.getSenders().find(function (s) { return s.track === track; }); if (alreadyExists) { throw new DOMException('Track already exists.', 'InvalidAccessError'); } }); // Add identity mapping for consistency with addTrack. // Unless this is being used with a stream from addTrack. if (!this._reverseStreams[stream.id]) { var newStream = new window.MediaStream(stream.getTracks()); this._streams[stream.id] = newStream; this._reverseStreams[newStream.id] = stream; stream = newStream; } origAddStream.apply(this, [stream]); }; var origRemoveStream = window.RTCPeerConnection.prototype.removeStream; window.RTCPeerConnection.prototype.removeStream = function removeStream(stream) { this._streams = this._streams || {}; this._reverseStreams = this._reverseStreams || {}; origRemoveStream.apply(this, [this._streams[stream.id] || stream]); delete this._reverseStreams[this._streams[stream.id] ? this._streams[stream.id].id : stream.id]; delete this._streams[stream.id]; }; window.RTCPeerConnection.prototype.addTrack = function addTrack(track, stream) { var _this13 = this; if (this.signalingState === 'closed') { throw new DOMException('The RTCPeerConnection\'s signalingState is \'closed\'.', 'InvalidStateError'); } var streams = [].slice.call(arguments, 1); if (streams.length !== 1 || !streams[0].getTracks().find(function (t) { return t === track; })) { // this is not fully correct but all we can manage without // [[associated MediaStreams]] internal slot. throw new DOMException('The adapter.js addTrack polyfill only supports a single ' + ' stream which is associated with the specified track.', 'NotSupportedError'); } var alreadyExists = this.getSenders().find(function (s) { return s.track === track; }); if (alreadyExists) { throw new DOMException('Track already exists.', 'InvalidAccessError'); } this._streams = this._streams || {}; this._reverseStreams = this._reverseStreams || {}; var oldStream = this._streams[stream.id]; if (oldStream) { // this is using odd Chrome behaviour, use with caution: // https://bugs.chromium.org/p/webrtc/issues/detail?id=7815 // Note: we rely on the high-level addTrack/dtmf shim to // create the sender with a dtmf sender. oldStream.addTrack(track); // Trigger ONN async. Promise.resolve().then(function () { _this13.dispatchEvent(new Event('negotiationneeded')); }); } else { var newStream = new window.MediaStream([track]); this._streams[stream.id] = newStream; this._reverseStreams[newStream.id] = stream; this.addStream(newStream); } return this.getSenders().find(function (s) { return s.track === track; }); }; // replace the internal stream id with the external one and // vice versa. function replaceInternalStreamId(pc, description) { var sdp = description.sdp; Object.keys(pc._reverseStreams || []).forEach(function (internalId) { var externalStream = pc._reverseStreams[internalId]; var internalStream = pc._streams[externalStream.id]; sdp = sdp.replace(new RegExp(internalStream.id, 'g'), externalStream.id); }); return new RTCSessionDescription({ type: description.type, sdp: sdp }); } function replaceExternalStreamId(pc, description) { var sdp = description.sdp; Object.keys(pc._reverseStreams || []).forEach(function (internalId) { var externalStream = pc._reverseStreams[internalId]; var internalStream = pc._streams[externalStream.id]; sdp = sdp.replace(new RegExp(externalStream.id, 'g'), internalStream.id); }); return new RTCSessionDescription({ type: description.type, sdp: sdp }); } ['createOffer', 'createAnswer'].forEach(function (method) { var nativeMethod = window.RTCPeerConnection.prototype[method]; var methodObj = _defineProperty({}, method, function () { var _this14 = this; var args = arguments; var isLegacyCall = arguments.length && typeof arguments[0] === 'function'; if (isLegacyCall) { return nativeMethod.apply(this, [function (description) { var desc = replaceInternalStreamId(_this14, description); args[0].apply(null, [desc]); }, function (err) { if (args[1]) { args[1].apply(null, err); } }, arguments[2]]); } return nativeMethod.apply(this, arguments).then(function (description) { return replaceInternalStreamId(_this14, description); }); }); window.RTCPeerConnection.prototype[method] = methodObj[method]; }); var origSetLocalDescription = window.RTCPeerConnection.prototype.setLocalDescription; window.RTCPeerConnection.prototype.setLocalDescription = function setLocalDescription() { if (!arguments.length || !arguments[0].type) { return origSetLocalDescription.apply(this, arguments); } arguments[0] = replaceExternalStreamId(this, arguments[0]); return origSetLocalDescription.apply(this, arguments); }; // TODO: mangle getStats: https://w3c.github.io/webrtc-stats/#dom-rtcmediastreamstats-streamidentifier var origLocalDescription = Object.getOwnPropertyDescriptor(window.RTCPeerConnection.prototype, 'localDescription'); Object.defineProperty(window.RTCPeerConnection.prototype, 'localDescription', { get: function get() { var description = origLocalDescription.get.apply(this); if (description.type === '') { return description; } return replaceInternalStreamId(this, description); } }); window.RTCPeerConnection.prototype.removeTrack = function removeTrack(sender) { var _this15 = this; if (this.signalingState === 'closed') { throw new DOMException('The RTCPeerConnection\'s signalingState is \'closed\'.', 'InvalidStateError'); } // We can not yet check for sender instanceof RTCRtpSender // since we shim RTPSender. So we check if sender._pc is set. if (!sender._pc) { throw new DOMException('Argument 1 of RTCPeerConnection.removeTrack ' + 'does not implement interface RTCRtpSender.', 'TypeError'); } var isLocal = sender._pc === this; if (!isLocal) { throw new DOMException('Sender was not created by this connection.', 'InvalidAccessError'); } // Search for the native stream the senders track belongs to. this._streams = this._streams || {}; var stream = void 0; Object.keys(this._streams).forEach(function (streamid) { var hasTrack = _this15._streams[streamid].getTracks().find(function (track) { return sender.track === track; }); if (hasTrack) { stream = _this15._streams[streamid]; } }); if (stream) { if (stream.getTracks().length === 1) { // if this is the last track of the stream, remove the stream. This // takes care of any shimmed _senders. this.removeStream(this._reverseStreams[stream.id]); } else { // relying on the same odd chrome behaviour as above. stream.removeTrack(sender.track); } this.dispatchEvent(new Event('negotiationneeded')); } }; } function shimPeerConnection(window, browserDetails) { if (!window.RTCPeerConnection && window.webkitRTCPeerConnection) { // very basic support for old versions. window.RTCPeerConnection = window.webkitRTCPeerConnection; } if (!window.RTCPeerConnection) { return; } // shim implicit creation of RTCSessionDescription/RTCIceCandidate if (browserDetails.version < 53) { ['setLocalDescription', 'setRemoteDescription', 'addIceCandidate'].forEach(function (method) { var nativeMethod = window.RTCPeerConnection.prototype[method]; var methodObj = _defineProperty({}, method, function () { arguments[0] = new (method === 'addIceCandidate' ? window.RTCIceCandidate : window.RTCSessionDescription)(arguments[0]); return nativeMethod.apply(this, arguments); }); window.RTCPeerConnection.prototype[method] = methodObj[method]; }); } } // Attempt to fix ONN in plan-b mode. function fixNegotiationNeeded(window, browserDetails) { utils.wrapPeerConnectionEvent(window, 'negotiationneeded', function (e) { var pc = e.target; if (browserDetails.version < 72 || pc.getConfiguration && pc.getConfiguration().sdpSemantics === 'plan-b') { if (pc.signalingState !== 'stable') { return; } } return e; }); } },{"../utils.js":11,"./getdisplaymedia":4,"./getusermedia":5}],4:[function(require,module,exports){ /* * Copyright (c) 2018 The adapter.js project authors. All Rights Reserved. * * Use of this source code is governed by a BSD-style license * that can be found in the LICENSE file in the root of the source * tree. */ /* eslint-env node */ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.shimGetDisplayMedia = shimGetDisplayMedia; function shimGetDisplayMedia(window, getSourceId) { if (window.navigator.mediaDevices && 'getDisplayMedia' in window.navigator.mediaDevices) { return; } if (!window.navigator.mediaDevices) { return; } // getSourceId is a function that returns a promise resolving with // the sourceId of the screen/window/tab to be shared. if (typeof getSourceId !== 'function') { console.error('shimGetDisplayMedia: getSourceId argument is not ' + 'a function'); return; } window.navigator.mediaDevices.getDisplayMedia = function getDisplayMedia(constraints) { return getSourceId(constraints).then(function (sourceId) { var widthSpecified = constraints.video && constraints.video.width; var heightSpecified = constraints.video && constraints.video.height; var frameRateSpecified = constraints.video && constraints.video.frameRate; constraints.video = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: sourceId, maxFrameRate: frameRateSpecified || 3 } }; if (widthSpecified) { constraints.video.mandatory.maxWidth = widthSpecified; } if (heightSpecified) { constraints.video.mandatory.maxHeight = heightSpecified; } return window.navigator.mediaDevices.getUserMedia(constraints); }); }; } },{}],5:[function(require,module,exports){ /* * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. * * Use of this source code is governed by a BSD-style license * that can be found in the LICENSE file in the root of the source * tree. */ /* eslint-env node */ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; exports.shimGetUserMedia = shimGetUserMedia; var _utils = require('../utils.js'); var utils = _interopRequireWildcard(_utils); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } var logging = utils.log; function shimGetUserMedia(window, browserDetails) { var navigator = window && window.navigator; if (!navigator.mediaDevices) { return; } var constraintsToChrome_ = function constraintsToChrome_(c) { if ((typeof c === 'undefined' ? 'undefined' : _typeof(c)) !== 'object' || c.mandatory || c.optional) { return c; } var cc = {}; Object.keys(c).forEach(function (key) { if (key === 'require' || key === 'advanced' || key === 'mediaSource') { return; } var r = _typeof(c[key]) === 'object' ? c[key] : { ideal: c[key] }; if (r.exact !== undefined && typeof r.exact === 'number') { r.min = r.max = r.exact; } var oldname_ = function oldname_(prefix, name) { if (prefix) { return prefix + name.charAt(0).toUpperCase() + name.slice(1); } return name === 'deviceId' ? 'sourceId' : name; }; if (r.ideal !== undefined) { cc.optional = cc.optional || []; var oc = {}; if (typeof r.ideal === 'number') { oc[oldname_('min', key)] = r.ideal; cc.optional.push(oc); oc = {}; oc[oldname_('max', key)] = r.ideal; cc.optional.push(oc); } else { oc[oldname_('', key)] = r.ideal; cc.optional.push(oc); } } if (r.exact !== undefined && typeof r.exact !== 'number') { cc.mandatory = cc.mandatory || {}; cc.mandatory[oldname_('', key)] = r.exact; } else { ['min', 'max'].forEach(function (mix) { if (r[mix] !== undefined) { cc.mandatory = cc.mandatory || {}; cc.mandatory[oldname_(mix, key)] = r[mix]; } }); } }); if (c.advanced) { cc.optional = (cc.optional || []).concat(c.advanced); } return cc; }; var shimConstraints_ = function shimConstraints_(constraints, func) { if (browserDetails.version >= 61) { return func(constraints); } constraints = JSON.parse(JSON.stringify(constraints)); if (constraints && _typeof(constraints.audio) === 'object') { var remap = function remap(obj, a, b) { if (a in obj && !(b in obj)) { obj[b] = obj[a]; delete obj[a]; } }; constraints = JSON.parse(JSON.stringify(constraints)); remap(constraints.audio, 'autoGainControl', 'googAutoGainControl'); remap(constraints.audio, 'noiseSuppression', 'googNoiseSuppression'); constraints.audio = constraintsToChrome_(constraints.audio); } if (constraints && _typeof(constraints.video) === 'object') { // Shim facingMode for mobile & surface pro. var face = constraints.video.facingMode; face = face && ((typeof face === 'undefined' ? 'undefined' : _typeof(face)) === 'object' ? face : { ideal: face }); var getSupportedFacingModeLies = browserDetails.version < 66; if (face && (face.exact === 'user' || face.exact === 'environment' || face.ideal === 'user' || face.ideal === 'environment') && !(navigator.mediaDevices.getSupportedConstraints && navigator.mediaDevices.getSupportedConstraints().facingMode && !getSupportedFacingModeLies)) { delete constraints.video.facingMode; var matches = void 0; if (face.exact === 'environment' || face.ideal === 'environment') { matches = ['back', 'rear']; } else if (face.exact === 'user' || face.ideal === 'user') { matches = ['front']; } if (matches) { // Look for matches in label, or use last cam for back (typical). return navigator.mediaDevices.enumerateDevices().then(function (devices) { devices = devices.filter(function (d) { return d.kind === 'videoinput'; }); var dev = devices.find(function (d) { return matches.some(function (match) { return d.label.toLowerCase().includes(match); }); }); if (!dev && devices.length && matches.includes('back')) { dev = devices[devices.length - 1]; // more likely the back cam } if (dev) { constraints.video.deviceId = face.exact ? { exact: dev.deviceId } : { ideal: dev.deviceId }; } constraints.video = constraintsToChrome_(constraints.video); logging('chrome: ' + JSON.stringify(constraints)); return func(constraints); }); } } constraints.video = constraintsToChrome_(constraints.video); } logging('chrome: ' + JSON.stringify(constraints)); return func(constraints); }; var shimError_ = function shimError_(e) { if (browserDetails.version >= 64) { return e; } return { name: { PermissionDeniedError: 'NotAllowedError', PermissionDismissedError: 'NotAllowedError', InvalidStateError: 'NotAllowedError', DevicesNotFoundError: 'NotFoundError', ConstraintNotSatisfiedError: 'OverconstrainedError', TrackStartError: 'NotReadableError', MediaDeviceFailedDueToShutdown: 'NotAllowedError', MediaDeviceKillSwitchOn: 'NotAllowedError', TabCaptureError: 'AbortError', ScreenCaptureError: 'AbortError', DeviceCaptureError: 'AbortError' }[e.name] || e.name, message: e.message, constraint: e.constraint || e.constraintName, toString: function toString() { return this.name + (this.message && ': ') + this.message; } }; }; var getUserMedia_ = function getUserMedia_(constraints, onSuccess, onError) { shimConstraints_(constraints, function (c) { navigator.webkitGetUserMedia(c, onSuccess, function (e) { if (onError) { onError(shimError_(e)); } }); }); }; navigator.getUserMedia = getUserMedia_.bind(navigator); // Even though Chrome 45 has navigator.mediaDevices and a getUserMedia // function which returns a Promise, it does not accept spec-style // constraints. if (navigator.mediaDevices.getUserMedia) { var origGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); navigator.mediaDevices.getUserMedia = function (cs) { return shimConstraints_(cs, function (c) { return origGetUserMedia(c).then(function (stream) { if (c.audio && !stream.getAudioTracks().length || c.video && !stream.getVideoTracks().length) { stream.getTracks().forEach(function (track) { track.stop(); }); throw new DOMException('', 'NotFoundError'); } return stream; }, function (e) { return Promise.reject(shimError_(e)); }); }); }; } } },{"../utils.js":11}],6:[function(require,module,exports){ /* * Copyright (c) 2017 The WebRTC project authors. All Rights Reserved. * * Use of this source code is governed by a BSD-style license * that can be found in the LICENSE file in the root of the source * tree. */ /* eslint-env node */ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; exports.shimRTCIceCandidate = shimRTCIceCandidate; exports.shimMaxMessageSize = shimMaxMessageSize; exports.shimSendThrowTypeError = shimSendThrowTypeError; exports.shimConnectionState = shimConnectionState; exports.removeExtmapAllowMixed = removeExtmapAllowMixed; exports.shimAddIceCandidateNullOrEmpty = shimAddIceCandidateNullOrEmpty; var _sdp = require('sdp'); var _sdp2 = _interopRequireDefault(_sdp); var _utils = require('./utils'); var utils = _interopRequireWildcard(_utils); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function shimRTCIceCandidate(window) { // foundation is arbitrarily chosen as an indicator for full support for // https://w3c.github.io/webrtc-pc/#rtcicecandidate-interface if (!window.RTCIceCandidate || window.RTCIceCandidate && 'foundation' in window.RTCIceCandidate.prototype) { return; } var NativeRTCIceCandidate = window.RTCIceCandidate; window.RTCIceCandidate = function RTCIceCandidate(args) { // Remove the a= which shouldn't be part of the candidate string. if ((typeof args === 'undefined' ? 'undefined' : _typeof(args)) === 'object' && args.candidate && args.candidate.indexOf('a=') === 0) { args = JSON.parse(JSON.stringify(args)); args.candidate = args.candidate.substr(2); } if (args.candidate && args.candidate.length) { // Augment the native candidate with the parsed fields. var nativeCandidate = new NativeRTCIceCandidate(args); var parsedCandidate = _sdp2.default.parseCandidate(args.candidate); var augmentedCandidate = Object.assign(nativeCandidate, parsedCandidate); // Add a serializer that does not serialize the extra attributes. augmentedCandidate.toJSON = function toJSON() { return { candidate: augmentedCandidate.candidate, sdpMid: augmentedCandidate.sdpMid, sdpMLineIndex: augmentedCandidate.sdpMLineIndex, usernameFragment: augmentedCandidate.usernameFragment }; }; return augmentedCandidate; } return new NativeRTCIceCandidate(args); }; window.RTCIceCandidate.prototype = NativeRTCIceCandidate.prototype; // Hook up the augmented candidate in onicecandidate and // addEventListener('icecandidate', ...) utils.wrapPeerConnectionEvent(window, 'icecandidate', function (e) { if (e.candidate) { Object.defineProperty(e, 'candidate', { value: new window.RTCIceCandidate(e.candidate), writable: 'false' }); } return e; }); } function shimMaxMessageSize(window, browserDetails) { if (!window.RTCPeerConnection) { return; } if (!('sctp' in window.RTCPeerConnection.prototype)) { Object.defineProperty(window.RTCPeerConnection.prototype, 'sctp', { get: function get() { return typeof this._sctp === 'undefined' ? null : this._sctp; } }); } var sctpInDescription = function sctpInDescription(description) { if (!description || !description.sdp) { return false; } var sections = _sdp2.default.splitSections(description.sdp); sections.shift(); return sections.some(function (mediaSection) { var mLine = _sdp2.default.parseMLine(mediaSection); return mLine && mLine.kind === 'application' && mLine.protocol.indexOf('SCTP') !== -1; }); }; var getRemoteFirefoxVersion = function getRemoteFirefoxVersion(description) { // TODO: Is there a better solution for detecting Firefox? var match = description.sdp.match(/mozilla...THIS_IS_SDPARTA-(\d+)/); if (match === null || match.length < 2) { return -1; } var version = parseInt(match[1], 10); // Test for NaN (yes, this is ugly) return version !== version ? -1 : version; }; var getCanSendMaxMessageSize = function getCanSendMaxMessageSize(remoteIsFirefox) { // Every implementation we know can send at least 64 KiB. // Note: Although Chrome is technically able to send up to 256 KiB, the // data does not reach the other peer reliably. // See: https://bugs.chromium.org/p/webrtc/issues/detail?id=8419 var canSendMaxMessageSize = 65536; if (browserDetails.browser === 'firefox') { if (browserDetails.version < 57) { if (remoteIsFirefox === -1) { // FF < 57 will send in 16 KiB chunks using the deprecated PPID // fragmentation. canSendMaxMessageSize = 16384; } else { // However, other FF (and RAWRTC) can reassemble PPID-fragmented // messages. Thus, supporting ~2 GiB when sending. canSendMaxMessageSize = 2147483637; } } else if (browserDetails.version < 60) { // Currently, all FF >= 57 will reset the remote maximum message size // to the default value when a data channel is created at a later // stage. :( // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1426831 canSendMaxMessageSize = browserDetails.version === 57 ? 65535 : 65536; } else { // FF >= 60 supports sending ~2 GiB canSendMaxMessageSize = 2147483637; } } return canSendMaxMessageSize; }; var getMaxMessageSize = function getMaxMessageSize(description, remoteIsFirefox) { // Note: 65536 bytes is the default value from the SDP spec. Also, // every implementation we know supports receiving 65536 bytes. var maxMessageSize = 65536; // FF 57 has a slightly incorrect default remote max message size, so // we need to adjust it here to avoid a failure when sending. // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1425697 if (browserDetails.browser === 'firefox' && browserDetails.version === 57) { maxMessageSize = 65535; } var match = _sdp2.default.matchPrefix(description.sdp, 'a=max-message-size:'); if (match.length > 0) { maxMessageSize = parseInt(match[0].substr(19), 10); } else if (browserDetails.browser === 'firefox' && remoteIsFirefox !== -1) { // If the maximum message size is not present in the remote SDP and // both local and remote are Firefox, the remote peer can receive // ~2 GiB. maxMessageSize = 2147483637; } return maxMessageSize; }; var origSetRemoteDescription = window.RTCPeerConnection.prototype.setRemoteDescription; window.RTCPeerConnection.prototype.setRemoteDescription = function setRemoteDescription() { this._sctp = null; // Chrome decided to not expose .sctp in plan-b mode. // As usual, adapter.js has to do an 'ugly worakaround' // to cover up the mess. if (browserDetails.browser === 'chrome' && browserDetails.version >= 76) { var _getConfiguration = this.getConfiguration(), sdpSemantics = _getConfiguration.sdpSemantics; if (sdpSemantics === 'plan-b') { Object.defineProperty(this, 'sctp', { get: function get() { return typeof this._sctp === 'undefined' ? null : this._sctp; }, enumerable: true, configurable: true }); } } if (sctpInDescription(arguments[0])) { // Check if the remote is FF. var isFirefox = getRemoteFirefoxVersion(arguments[0]); // Get the maximum message size the local peer is capable of sending var canSendMMS = getCanSendMaxMessageSize(isFirefox); // Get the maximum message size of the remote peer. var remoteMMS = getMaxMessageSize(arguments[0], isFirefox); // Determine final maximum message size var maxMessageSize = void 0; if (canSendMMS === 0 && remoteMMS === 0) { maxMessageSize = Number.POSITIVE_INFINITY; } else if (canSendMMS === 0 || remoteMMS === 0) { maxMessageSize = Math.max(canSendMMS, remoteMMS); } else { maxMessageSize = Math.min(canSendMMS, remoteMMS); } // Create a dummy RTCSctpTransport object and the 'maxMessageSize' // attribute. var sctp = {}; Object.defineProperty(sctp, 'maxMessageSize', { get: function get() { return maxMessageSize; } }); this._sctp = sctp; } return origSetRemoteDescription.apply(this, arguments); }; } function shimSendThrowTypeError(window) { if (!(window.RTCPeerConnection && 'createDataChannel' in window.RTCPeerConnection.prototype)) { return; } // Note: Although Firefox >= 57 has a native implementation, the maximum // message size can be reset for all data channels at a later stage. // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1426831 function wrapDcSend(dc, pc) { var origDataChannelSend = dc.send; dc.send = function send() { var data = arguments[0]; var length = data.length || data.size || data.byteLength; if (dc.readyState === 'open' && pc.sctp && length > pc.sctp.maxMessageSize) { throw new TypeError('Message too large (can send a maximum of ' + pc.sctp.maxMessageSize + ' bytes)'); } return origDataChannelSend.apply(dc, arguments); }; } var origCreateDataChannel = window.RTCPeerConnection.prototype.createDataChannel; window.RTCPeerConnection.prototype.createDataChannel = function createDataChannel() { var dataChannel = origCreateDataChannel.apply(this, arguments); wrapDcSend(dataChannel, this); return dataChannel; }; utils.wrapPeerConnectionEvent(window, 'datachannel', function (e) { wrapDcSend(e.channel, e.target); return e; }); } /* shims RTCConnectionState by pretending it is the same as iceConnectionState. * See https://bugs.chromium.org/p/webrtc/issues/detail?id=6145#c12 * for why this is a valid hack in Chrome. In Firefox it is slightly incorrect * since DTLS failures would be hidden. See * https://bugzilla.mozilla.org/show_bug.cgi?id=1265827 * for the Firefox tracking bug. */ function shimConnectionState(window) { if (!window.RTCPeerConnection || 'connectionState' in window.RTCPeerConnection.prototype) { return; } var proto = window.RTCPeerConnection.prototype; Object.defineProperty(proto, 'connectionState', { get: function get() { return { completed: 'connected', checking: 'connecting' }[this.iceConnectionState] || this.iceConnectionState; }, enumerable: true, configurable: true }); Object.defineProperty(proto, 'onconnectionstatechange', { get: function get() { return this._onconnectionstatechange || null; }, set: function set(cb) { if (this._onconnectionstatechange) { this.removeEventListener('connectionstatechange', this._onconnectionstatechange); delete this._onconnectionstatechange; } if (cb) { this.addEventListener('connectionstatechange', this._onconnectionstatechange = cb); } }, enumerable: true, configurable: true }); ['setLocalDescription', 'setRemoteDescription'].forEach(function (method) { var origMethod = proto[method]; proto[method] = function () { if (!this._connectionstatechangepoly) { this._connectionstatechangepoly = function (e) { var pc = e.target; if (pc._lastConnectionState !== pc.connectionState) { pc._lastConnectionState = pc.connectionState; var newEvent = new Event('connectionstatechange', e); pc.dispatchEvent(newEvent); } return e; }; this.addEventListener('iceconnectionstatechange', this._connectionstatechangepoly); } return origMethod.apply(this, arguments); }; }); } function removeExtmapAllowMixed(window, browserDetails) { /* remove a=extmap-allow-mixed for webrtc.org < M71 */ if (!window.RTCPeerConnection) { return; } if (browserDetails.browser === 'chrome' && browserDetails.version >= 71) { return; } if (browserDetails.browser === 'safari' && browserDetails.version >= 605) { return; } var nativeSRD = window.RTCPeerConnection.prototype.setRemoteDescription; window.RTCPeerConnection.prototype.setRemoteDescription = function setRemoteDescription(desc) { if (desc && desc.sdp && desc.sdp.indexOf('\na=extmap-allow-mixed') !== -1) { var sdp = desc.sdp.split('\n').filter(function (line) { return line.trim() !== 'a=extmap-allow-mixed'; }).join('\n'); // Safari enforces read-only-ness of RTCSessionDescription fields. if (window.RTCSessionDescription && desc instanceof window.RTCSessionDescription) { arguments[0] = new window.RTCSessionDescription({ type: desc.type, sdp: sdp }); } else { desc.sdp = sdp; } } return nativeSRD.apply(this, arguments); }; } function shimAddIceCandidateNullOrEmpty(window, browserDetails) { // Support for addIceCandidate(null or undefined) // as well as addIceCandidate({candidate: "", ...}) // https://bugs.chromium.org/p/chromium/issues/detail?id=978582 // Note: must be called before other polyfills which change the signature. if (!(window.RTCPeerConnection && window.RTCPeerConnection.prototype)) { return; } var nativeAddIceCandidate = window.RTCPeerConnection.prototype.addIceCandidate; if (!nativeAddIceCandidate || nativeAddIceCandidate.length === 0) { return; } window.RTCPeerConnection.prototype.addIceCandidate = function addIceCandidate() { if (!arguments[0]) { if (arguments[1]) { arguments[1].apply(null); } return Promise.resolve(); } // Firefox 68+ emits and processes {candidate: "", ...}, ignore // in older versions. // Native support for ignoring exists for Chrome M77+. // Safari ignores as well, exact version unknown but works in the same // version that also ignores addIceCandidate(null). if ((browserDetails.browser === 'chrome' && browserDetails.version < 78 || browserDetails.browser === 'firefox' && browserDetails.version < 68 || browserDetails.browser === 'safari') && arguments[0] && arguments[0].candidate === '') { return Promise.resolve(); } return nativeAddIceCandidate.apply(this, arguments); }; } },{"./utils":11,"sdp":12}],7:[function(require,module,exports){ /* * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. * * Use of this source code is governed by a BSD-style license * that can be found in the LICENSE file in the root of the source * tree. */ /* eslint-env node */ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.shimGetDisplayMedia = exports.shimGetUserMedia = undefined; var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; var _getusermedia = require('./getusermedia'); Object.defineProperty(exports, 'shimGetUserMedia', { enumerable: true, get: function get() { return _getusermedia.shimGetUserMedia; } }); var _getdisplaymedia = require('./getdisplaymedia'); Object.defineProperty(exports, 'shimGetDisplayMedia', { enumerable: true, get: function get() { return _getdisplaymedia.shimGetDisplayMedia; } }); exports.shimOnTrack = shimOnTrack; exports.shimPeerConnection = shimPeerConnection; exports.shimSenderGetStats = shimSenderGetStats; exports.shimReceiverGetStats = shimReceiverGetStats; exports.shimRemoveStream = shimRemoveStream; exports.shimRTCDataChannel = shimRTCDataChannel; exports.shimAddTransceiver = shimAddTransceiver; exports.shimGetParameters = shimGetParameters; exports.shimCreateOffer = shimCreateOffer; exports.shimCreateAnswer = shimCreateAnswer; var _utils = require('../utils'); var utils = _interopRequireWildcard(_utils); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function shimOnTrack(window) { if ((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCTrackEvent && 'receiver' in window.RTCTrackEvent.prototype && !('transceiver' in window.RTCTrackEvent.prototype)) { Object.defineProperty(window.RTCTrackEvent.prototype, 'transceiver', { get: function get() { return { receiver: this.receiver }; } }); } } function shimPeerConnection(window, browserDetails) { if ((typeof window === 'undefined' ? 'undefined' : _typeof(window)) !== 'object' || !(window.RTCPeerConnection || window.mozRTCPeerConnection)) { return; // probably media.peerconnection.enabled=false in about:config } if (!window.RTCPeerConnection && window.mozRTCPeerConnection) { // very basic support for old versions. window.RTCPeerConnection = window.mozRTCPeerConnection; } if (browserDetails.version < 53) { // shim away need for obsolete RTCIceCandidate/RTCSessionDescription. ['setLocalDescription', 'setRemoteDescription', 'addIceCandidate'].forEach(function (method) { var nativeMethod = window.RTCPeerConnection.prototype[method]; var methodObj = _defineProperty({}, method, function () { arguments[0] = new (method === 'addIceCandidate' ? window.RTCIceCandidate : window.RTCSessionDescription)(arguments[0]); return nativeMethod.apply(this, arguments); }); window.RTCPeerConnection.prototype[method] = methodObj[method]; }); } var modernStatsTypes = { inboundrtp: 'inbound-rtp', outboundrtp: 'outbound-rtp', candidatepair: 'candidate-pair', localcandidate: 'local-candidate', remotecandidate: 'remote-candidate' }; var nativeGetStats = window.RTCPeerConnection.prototype.getStats; window.RTCPeerConnection.prototype.getStats = function getStats() { var _arguments = Array.prototype.slice.call(arguments), selector = _arguments[0], onSucc = _arguments[1], onErr = _arguments[2]; return nativeGetStats.apply(this, [selector || null]).then(function (stats) { if (browserDetails.version < 53 && !onSucc) { // Shim only promise getStats with spec-hyphens in type names // Leave callback version alone; misc old uses of forEach before Map try { stats.forEach(function (stat) { stat.type = modernStatsTypes[stat.type] || stat.type; }); } catch (e) { if (e.name !== 'TypeError') { throw e; } // Avoid TypeError: "type" is read-only, in old versions. 34-43ish stats.forEach(function (stat, i) { stats.set(i, Object.assign({}, stat, { type: modernStatsTypes[stat.type] || stat.type })); }); } } return stats; }).then(onSucc, onErr); }; } function shimSenderGetStats(window) { if (!((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCPeerConnection && window.RTCRtpSender)) { return; } if (window.RTCRtpSender && 'getStats' in window.RTCRtpSender.prototype) { return; } var origGetSenders = window.RTCPeerConnection.prototype.getSenders; if (origGetSenders) { window.RTCPeerConnection.prototype.getSenders = function getSenders() { var _this = this; var senders = origGetSenders.apply(this, []); senders.forEach(function (sender) { return sender._pc = _this; }); return senders; }; } var origAddTrack = window.RTCPeerConnection.prototype.addTrack; if (origAddTrack) { window.RTCPeerConnection.prototype.addTrack = function addTrack() { var sender = origAddTrack.apply(this, arguments); sender._pc = this; return sender; }; } window.RTCRtpSender.prototype.getStats = function getStats() { return this.track ? this._pc.getStats(this.track) : Promise.resolve(new Map()); }; } function shimReceiverGetStats(window) { if (!((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCPeerConnection && window.RTCRtpSender)) { return; } if (window.RTCRtpSender && 'getStats' in window.RTCRtpReceiver.prototype) { return; } var origGetReceivers = window.RTCPeerConnection.prototype.getReceivers; if (origGetReceivers) { window.RTCPeerConnection.prototype.getReceivers = function getReceivers() { var _this2 = this; var receivers = origGetReceivers.apply(this, []); receivers.forEach(function (receiver) { return receiver._pc = _this2; }); return receivers; }; } utils.wrapPeerConnectionEvent(window, 'track', function (e) { e.receiver._pc = e.srcElement; return e; }); window.RTCRtpReceiver.prototype.getStats = function getStats() { return this._pc.getStats(this.track); }; } function shimRemoveStream(window) { if (!window.RTCPeerConnection || 'removeStream' in window.RTCPeerConnection.prototype) { return; } window.RTCPeerConnection.prototype.removeStream = function removeStream(stream) { var _this3 = this; utils.deprecated('removeStream', 'removeTrack'); this.getSenders().forEach(function (sender) { if (sender.track && stream.getTracks().includes(sender.track)) { _this3.removeTrack(sender); } }); }; } function shimRTCDataChannel(window) { // rename DataChannel to RTCDataChannel (native fix in FF60): // https://bugzilla.mozilla.org/show_bug.cgi?id=1173851 if (window.DataChannel && !window.RTCDataChannel) { window.RTCDataChannel = window.DataChannel; } } function shimAddTransceiver(window) { // https://github.com/webrtcHacks/adapter/issues/998#issuecomment-516921647 // Firefox ignores the init sendEncodings options passed to addTransceiver // https://bugzilla.mozilla.org/show_bug.cgi?id=1396918 if (!((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCPeerConnection)) { return; } var origAddTransceiver = window.RTCPeerConnection.prototype.addTransceiver; if (origAddTransceiver) { window.RTCPeerConnection.prototype.addTransceiver = function addTransceiver() { this.setParametersPromises = []; var initParameters = arguments[1]; var shouldPerformCheck = initParameters && 'sendEncodings' in initParameters; if (shouldPerformCheck) { // If sendEncodings params are provided, validate grammar initParameters.sendEncodings.forEach(function (encodingParam) { if ('rid' in encodingParam) { var ridRegex = /^[a-z0-9]{0,16}$/i; if (!ridRegex.test(encodingParam.rid)) { throw new TypeError('Invalid RID value provided.'); } } if ('scaleResolutionDownBy' in encodingParam) { if (!(parseFloat(encodingParam.scaleResolutionDownBy) >= 1.0)) { throw new RangeError('scale_resolution_down_by must be >= 1.0'); } } if ('maxFramerate' in encodingParam) { if (!(parseFloat(encodingParam.maxFramerate) >= 0)) { throw new RangeError('max_framerate must be >= 0.0'); } } }); } var transceiver = origAddTransceiver.apply(this, arguments); if (shouldPerformCheck) { // Check if the init options were applied. If not we do this in an // asynchronous way and save the promise reference in a global object. // This is an ugly hack, but at the same time is way more robust than // checking the sender parameters before and after the createOffer // Also note that after the createoffer we are not 100% sure that // the params were asynchronously applied so we might miss the // opportunity to recreate offer. var sender = transceiver.sender; var params = sender.getParameters(); if (!('encodings' in params) || // Avoid being fooled by patched getParameters() below. params.encodings.length === 1 && Object.keys(params.encodings[0]).length === 0) { params.encodings = initParameters.sendEncodings; sender.sendEncodings = initParameters.sendEncodings; this.setParametersPromises.push(sender.setParameters(params).then(function () { delete sender.sendEncodings; }).catch(function () { delete sender.sendEncodings; })); } } return transceiver; }; } } function shimGetParameters(window) { if (!((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCRtpSender)) { return; } var origGetParameters = window.RTCRtpSender.prototype.getParameters; if (origGetParameters) { window.RTCRtpSender.prototype.getParameters = function getParameters() { var params = origGetParameters.apply(this, arguments); if (!('encodings' in params)) { params.encodings = [].concat(this.sendEncodings || [{}]); } return params; }; } } function shimCreateOffer(window) { // https://github.com/webrtcHacks/adapter/issues/998#issuecomment-516921647 // Firefox ignores the init sendEncodings options passed to addTransceiver // https://bugzilla.mozilla.org/show_bug.cgi?id=1396918 if (!((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCPeerConnection)) { return; } var origCreateOffer = window.RTCPeerConnection.prototype.createOffer; window.RTCPeerConnection.prototype.createOffer = function createOffer() { var _this4 = this, _arguments2 = arguments; if (this.setParametersPromises && this.setParametersPromises.length) { return Promise.all(this.setParametersPromises).then(function () { return origCreateOffer.apply(_this4, _arguments2); }).finally(function () { _this4.setParametersPromises = []; }); } return origCreateOffer.apply(this, arguments); }; } function shimCreateAnswer(window) { // https://github.com/webrtcHacks/adapter/issues/998#issuecomment-516921647 // Firefox ignores the init sendEncodings options passed to addTransceiver // https://bugzilla.mozilla.org/show_bug.cgi?id=1396918 if (!((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCPeerConnection)) { return; } var origCreateAnswer = window.RTCPeerConnection.prototype.createAnswer; window.RTCPeerConnection.prototype.createAnswer = function createAnswer() { var _this5 = this, _arguments3 = arguments; if (this.setParametersPromises && this.setParametersPromises.length) { return Promise.all(this.setParametersPromises).then(function () { return origCreateAnswer.apply(_this5, _arguments3); }).finally(function () { _this5.setParametersPromises = []; }); } return origCreateAnswer.apply(this, arguments); }; } },{"../utils":11,"./getdisplaymedia":8,"./getusermedia":9}],8:[function(require,module,exports){ /* * Copyright (c) 2018 The adapter.js project authors. All Rights Reserved. * * Use of this source code is governed by a BSD-style license * that can be found in the LICENSE file in the root of the source * tree. */ /* eslint-env node */ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.shimGetDisplayMedia = shimGetDisplayMedia; function shimGetDisplayMedia(window, preferredMediaSource) { if (window.navigator.mediaDevices && 'getDisplayMedia' in window.navigator.mediaDevices) { return; } if (!window.navigator.mediaDevices) { return; } window.navigator.mediaDevices.getDisplayMedia = function getDisplayMedia(constraints) { if (!(constraints && constraints.video)) { var err = new DOMException('getDisplayMedia without video ' + 'constraints is undefined'); err.name = 'NotFoundError'; // from https://heycam.github.io/webidl/#idl-DOMException-error-names err.code = 8; return Promise.reject(err); } if (constraints.video === true) { constraints.video = { mediaSource: preferredMediaSource }; } else { constraints.video.mediaSource = preferredMediaSource; } return window.navigator.mediaDevices.getUserMedia(constraints); }; } },{}],9:[function(require,module,exports){ /* * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. * * Use of this source code is governed by a BSD-style license * that can be found in the LICENSE file in the root of the source * tree. */ /* eslint-env node */ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; exports.shimGetUserMedia = shimGetUserMedia; var _utils = require('../utils'); var utils = _interopRequireWildcard(_utils); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } function shimGetUserMedia(window, browserDetails) { var navigator = window && window.navigator; var MediaStreamTrack = window && window.MediaStreamTrack; navigator.getUserMedia = function (constraints, onSuccess, onError) { // Replace Firefox 44+'s deprecation warning with unprefixed version. utils.deprecated('navigator.getUserMedia', 'navigator.mediaDevices.getUserMedia'); navigator.mediaDevices.getUserMedia(constraints).then(onSuccess, onError); }; if (!(browserDetails.version > 55 && 'autoGainControl' in navigator.mediaDevices.getSupportedConstraints())) { var remap = function remap(obj, a, b) { if (a in obj && !(b in obj)) { obj[b] = obj[a]; delete obj[a]; } }; var nativeGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); navigator.mediaDevices.getUserMedia = function (c) { if ((typeof c === 'undefined' ? 'undefined' : _typeof(c)) === 'object' && _typeof(c.audio) === 'object') { c = JSON.parse(JSON.stringify(c)); remap(c.audio, 'autoGainControl', 'mozAutoGainControl'); remap(c.audio, 'noiseSuppression', 'mozNoiseSuppression'); } return nativeGetUserMedia(c); }; if (MediaStreamTrack && MediaStreamTrack.prototype.getSettings) { var nativeGetSettings = MediaStreamTrack.prototype.getSettings; MediaStreamTrack.prototype.getSettings = function () { var obj = nativeGetSettings.apply(this, arguments); remap(obj, 'mozAutoGainControl', 'autoGainControl'); remap(obj, 'mozNoiseSuppression', 'noiseSuppression'); return obj; }; } if (MediaStreamTrack && MediaStreamTrack.prototype.applyConstraints) { var nativeApplyConstraints = MediaStreamTrack.prototype.applyConstraints; MediaStreamTrack.prototype.applyConstraints = function (c) { if (this.kind === 'audio' && (typeof c === 'undefined' ? 'undefined' : _typeof(c)) === 'object') { c = JSON.parse(JSON.stringify(c)); remap(c, 'autoGainControl', 'mozAutoGainControl'); remap(c, 'noiseSuppression', 'mozNoiseSuppression'); } return nativeApplyConstraints.apply(this, [c]); }; } } } },{"../utils":11}],10:[function(require,module,exports){ /* * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. * * Use of this source code is governed by a BSD-style license * that can be found in the LICENSE file in the root of the source * tree. */ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; exports.shimLocalStreamsAPI = shimLocalStreamsAPI; exports.shimRemoteStreamsAPI = shimRemoteStreamsAPI; exports.shimCallbacksAPI = shimCallbacksAPI; exports.shimGetUserMedia = shimGetUserMedia; exports.shimConstraints = shimConstraints; exports.shimRTCIceServerUrls = shimRTCIceServerUrls; exports.shimTrackEventTransceiver = shimTrackEventTransceiver; exports.shimCreateOfferLegacy = shimCreateOfferLegacy; exports.shimAudioContext = shimAudioContext; var _utils = require('../utils'); var utils = _interopRequireWildcard(_utils); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } function shimLocalStreamsAPI(window) { if ((typeof window === 'undefined' ? 'undefined' : _typeof(window)) !== 'object' || !window.RTCPeerConnection) { return; } if (!('getLocalStreams' in window.RTCPeerConnection.prototype)) { window.RTCPeerConnection.prototype.getLocalStreams = function getLocalStreams() { if (!this._localStreams) { this._localStreams = []; } return this._localStreams; }; } if (!('addStream' in window.RTCPeerConnection.prototype)) { var _addTrack = window.RTCPeerConnection.prototype.addTrack; window.RTCPeerConnection.prototype.addStream = function addStream(stream) { var _this = this; if (!this._localStreams) { this._localStreams = []; } if (!this._localStreams.includes(stream)) { this._localStreams.push(stream); } // Try to emulate Chrome's behaviour of adding in audio-video order. // Safari orders by track id. stream.getAudioTracks().forEach(function (track) { return _addTrack.call(_this, track, stream); }); stream.getVideoTracks().forEach(function (track) { return _addTrack.call(_this, track, stream); }); }; window.RTCPeerConnection.prototype.addTrack = function addTrack(track) { var _this2 = this; for (var _len = arguments.length, streams = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { streams[_key - 1] = arguments[_key]; } if (streams) { streams.forEach(function (stream) { if (!_this2._localStreams) { _this2._localStreams = [stream]; } else if (!_this2._localStreams.includes(stream)) { _this2._localStreams.push(stream); } }); } return _addTrack.apply(this, arguments); }; } if (!('removeStream' in window.RTCPeerConnection.prototype)) { window.RTCPeerConnection.prototype.removeStream = function removeStream(stream) { var _this3 = this; if (!this._localStreams) { this._localStreams = []; } var index = this._localStreams.indexOf(stream); if (index === -1) { return; } this._localStreams.splice(index, 1); var tracks = stream.getTracks(); this.getSenders().forEach(function (sender) { if (tracks.includes(sender.track)) { _this3.removeTrack(sender); } }); }; } } function shimRemoteStreamsAPI(window) { if ((typeof window === 'undefined' ? 'undefined' : _typeof(window)) !== 'object' || !window.RTCPeerConnection) { return; } if (!('getRemoteStreams' in window.RTCPeerConnection.prototype)) { window.RTCPeerConnection.prototype.getRemoteStreams = function getRemoteStreams() { return this._remoteStreams ? this._remoteStreams : []; }; } if (!('onaddstream' in window.RTCPeerConnection.prototype)) { Object.defineProperty(window.RTCPeerConnection.prototype, 'onaddstream', { get: function get() { return this._onaddstream; }, set: function set(f) { var _this4 = this; if (this._onaddstream) { this.removeEventListener('addstream', this._onaddstream); this.removeEventListener('track', this._onaddstreampoly); } this.addEventListener('addstream', this._onaddstream = f); this.addEventListener('track', this._onaddstreampoly = function (e) { e.streams.forEach(function (stream) { if (!_this4._remoteStreams) { _this4._remoteStreams = []; } if (_this4._remoteStreams.includes(stream)) { return; } _this4._remoteStreams.push(stream); var event = new Event('addstream'); event.stream = stream; _this4.dispatchEvent(event); }); }); } }); var origSetRemoteDescription = window.RTCPeerConnection.prototype.setRemoteDescription; window.RTCPeerConnection.prototype.setRemoteDescription = function setRemoteDescription() { var pc = this; if (!this._onaddstreampoly) { this.addEventListener('track', this._onaddstreampoly = function (e) { e.streams.forEach(function (stream) { if (!pc._remoteStreams) { pc._remoteStreams = []; } if (pc._remoteStreams.indexOf(stream) >= 0) { return; } pc._remoteStreams.push(stream); var event = new Event('addstream'); event.stream = stream; pc.dispatchEvent(event); }); }); } return origSetRemoteDescription.apply(pc, arguments); }; } } function shimCallbacksAPI(window) { if ((typeof window === 'undefined' ? 'undefined' : _typeof(window)) !== 'object' || !window.RTCPeerConnection) { return; } var prototype = window.RTCPeerConnection.prototype; var origCreateOffer = prototype.createOffer; var origCreateAnswer = prototype.createAnswer; var setLocalDescription = prototype.setLocalDescription; var setRemoteDescription = prototype.setRemoteDescription; var addIceCandidate = prototype.addIceCandidate; prototype.createOffer = function createOffer(successCallback, failureCallback) { var options = arguments.length >= 2 ? arguments[2] : arguments[0]; var promise = origCreateOffer.apply(this, [options]); if (!failureCallback) { return promise; } promise.then(successCallback, failureCallback); return Promise.resolve(); }; prototype.createAnswer = function createAnswer(successCallback, failureCallback) { var options = arguments.length >= 2 ? arguments[2] : arguments[0]; var promise = origCreateAnswer.apply(this, [options]); if (!failureCallback) { return promise; } promise.then(successCallback, failureCallback); return Promise.resolve(); }; var withCallback = function withCallback(description, successCallback, failureCallback) { var promise = setLocalDescription.apply(this, [description]); if (!failureCallback) { return promise; } promise.then(successCallback, failureCallback); return Promise.resolve(); }; prototype.setLocalDescription = withCallback; withCallback = function withCallback(description, successCallback, failureCallback) { var promise = setRemoteDescription.apply(this, [description]); if (!failureCallback) { return promise; } promise.then(successCallback, failureCallback); return Promise.resolve(); }; prototype.setRemoteDescription = withCallback; withCallback = function withCallback(candidate, successCallback, failureCallback) { var promise = addIceCandidate.apply(this, [candidate]); if (!failureCallback) { return promise; } promise.then(successCallback, failureCallback); return Promise.resolve(); }; prototype.addIceCandidate = withCallback; } function shimGetUserMedia(window) { var navigator = window && window.navigator; if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { // shim not needed in Safari 12.1 var mediaDevices = navigator.mediaDevices; var _getUserMedia = mediaDevices.getUserMedia.bind(mediaDevices); navigator.mediaDevices.getUserMedia = function (constraints) { return _getUserMedia(shimConstraints(constraints)); }; } if (!navigator.getUserMedia && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { navigator.getUserMedia = function getUserMedia(constraints, cb, errcb) { navigator.mediaDevices.getUserMedia(constraints).then(cb, errcb); }.bind(navigator); } } function shimConstraints(constraints) { if (constraints && constraints.video !== undefined) { return Object.assign({}, constraints, { video: utils.compactObject(constraints.video) }); } return constraints; } function shimRTCIceServerUrls(window) { if (!window.RTCPeerConnection) { return; } // migrate from non-spec RTCIceServer.url to RTCIceServer.urls var OrigPeerConnection = window.RTCPeerConnection; window.RTCPeerConnection = function RTCPeerConnection(pcConfig, pcConstraints) { if (pcConfig && pcConfig.iceServers) { var newIceServers = []; for (var i = 0; i < pcConfig.iceServers.length; i++) { var server = pcConfig.iceServers[i]; if (!server.hasOwnProperty('urls') && server.hasOwnProperty('url')) { utils.deprecated('RTCIceServer.url', 'RTCIceServer.urls'); server = JSON.parse(JSON.stringify(server)); server.urls = server.url; delete server.url; newIceServers.push(server); } else { newIceServers.push(pcConfig.iceServers[i]); } } pcConfig.iceServers = newIceServers; } return new OrigPeerConnection(pcConfig, pcConstraints); }; window.RTCPeerConnection.prototype = OrigPeerConnection.prototype; // wrap static methods. Currently just generateCertificate. if ('generateCertificate' in OrigPeerConnection) { Object.defineProperty(window.RTCPeerConnection, 'generateCertificate', { get: function get() { return OrigPeerConnection.generateCertificate; } }); } } function shimTrackEventTransceiver(window) { // Add event.transceiver member over deprecated event.receiver if ((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object' && window.RTCTrackEvent && 'receiver' in window.RTCTrackEvent.prototype && !('transceiver' in window.RTCTrackEvent.prototype)) { Object.defineProperty(window.RTCTrackEvent.prototype, 'transceiver', { get: function get() { return { receiver: this.receiver }; } }); } } function shimCreateOfferLegacy(window) { var origCreateOffer = window.RTCPeerConnection.prototype.createOffer; window.RTCPeerConnection.prototype.createOffer = function createOffer(offerOptions) { if (offerOptions) { if (typeof offerOptions.offerToReceiveAudio !== 'undefined') { // support bit values offerOptions.offerToReceiveAudio = !!offerOptions.offerToReceiveAudio; } var audioTransceiver = this.getTransceivers().find(function (transceiver) { return transceiver.receiver.track.kind === 'audio'; }); if (offerOptions.offerToReceiveAudio === false && audioTransceiver) { if (audioTransceiver.direction === 'sendrecv') { if (audioTransceiver.setDirection) { audioTransceiver.setDirection('sendonly'); } else { audioTransceiver.direction = 'sendonly'; } } else if (audioTransceiver.direction === 'recvonly') { if (audioTransceiver.setDirection) { audioTransceiver.setDirection('inactive'); } else { audioTransceiver.direction = 'inactive'; } } } else if (offerOptions.offerToReceiveAudio === true && !audioTransceiver) { this.addTransceiver('audio'); } if (typeof offerOptions.offerToReceiveVideo !== 'undefined') { // support bit values offerOptions.offerToReceiveVideo = !!offerOptions.offerToReceiveVideo; } var videoTransceiver = this.getTransceivers().find(function (transceiver) { return transceiver.receiver.track.kind === 'video'; }); if (offerOptions.offerToReceiveVideo === false && videoTransceiver) { if (videoTransceiver.direction === 'sendrecv') { if (videoTransceiver.setDirection) { videoTransceiver.setDirection('sendonly'); } else { videoTransceiver.direction = 'sendonly'; } } else if (videoTransceiver.direction === 'recvonly') { if (videoTransceiver.setDirection) { videoTransceiver.setDirection('inactive'); } else { videoTransceiver.direction = 'inactive'; } } } else if (offerOptions.offerToReceiveVideo === true && !videoTransceiver) { this.addTransceiver('video'); } } return origCreateOffer.apply(this, arguments); }; } function shimAudioContext(window) { if ((typeof window === 'undefined' ? 'undefined' : _typeof(window)) !== 'object' || window.AudioContext) { return; } window.AudioContext = window.webkitAudioContext; } },{"../utils":11}],11:[function(require,module,exports){ /* * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. * * Use of this source code is governed by a BSD-style license * that can be found in the LICENSE file in the root of the source * tree. */ /* eslint-env node */ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; exports.extractVersion = extractVersion; exports.wrapPeerConnectionEvent = wrapPeerConnectionEvent; exports.disableLog = disableLog; exports.disableWarnings = disableWarnings; exports.log = log; exports.deprecated = deprecated; exports.detectBrowser = detectBrowser; exports.compactObject = compactObject; exports.walkStats = walkStats; exports.filterStats = filterStats; function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } var logDisabled_ = true; var deprecationWarnings_ = true; /** * Extract browser version out of the provided user agent string. * * @param {!string} uastring userAgent string. * @param {!string} expr Regular expression used as match criteria. * @param {!number} pos position in the version string to be returned. * @return {!number} browser version. */ function extractVersion(uastring, expr, pos) { var match = uastring.match(expr); return match && match.length >= pos && parseInt(match[pos], 10); } // Wraps the peerconnection event eventNameToWrap in a function // which returns the modified event object (or false to prevent // the event). function wrapPeerConnectionEvent(window, eventNameToWrap, wrapper) { if (!window.RTCPeerConnection) { return; } var proto = window.RTCPeerConnection.prototype; var nativeAddEventListener = proto.addEventListener; proto.addEventListener = function (nativeEventName, cb) { if (nativeEventName !== eventNameToWrap) { return nativeAddEventListener.apply(this, arguments); } var wrappedCallback = function wrappedCallback(e) { var modifiedEvent = wrapper(e); if (modifiedEvent) { if (cb.handleEvent) { cb.handleEvent(modifiedEvent); } else { cb(modifiedEvent); } } }; this._eventMap = this._eventMap || {}; if (!this._eventMap[eventNameToWrap]) { this._eventMap[eventNameToWrap] = new Map(); } this._eventMap[eventNameToWrap].set(cb, wrappedCallback); return nativeAddEventListener.apply(this, [nativeEventName, wrappedCallback]); }; var nativeRemoveEventListener = proto.removeEventListener; proto.removeEventListener = function (nativeEventName, cb) { if (nativeEventName !== eventNameToWrap || !this._eventMap || !this._eventMap[eventNameToWrap]) { return nativeRemoveEventListener.apply(this, arguments); } if (!this._eventMap[eventNameToWrap].has(cb)) { return nativeRemoveEventListener.apply(this, arguments); } var unwrappedCb = this._eventMap[eventNameToWrap].get(cb); this._eventMap[eventNameToWrap].delete(cb); if (this._eventMap[eventNameToWrap].size === 0) { delete this._eventMap[eventNameToWrap]; } if (Object.keys(this._eventMap).length === 0) { delete this._eventMap; } return nativeRemoveEventListener.apply(this, [nativeEventName, unwrappedCb]); }; Object.defineProperty(proto, 'on' + eventNameToWrap, { get: function get() { return this['_on' + eventNameToWrap]; }, set: function set(cb) { if (this['_on' + eventNameToWrap]) { this.removeEventListener(eventNameToWrap, this['_on' + eventNameToWrap]); delete this['_on' + eventNameToWrap]; } if (cb) { this.addEventListener(eventNameToWrap, this['_on' + eventNameToWrap] = cb); } }, enumerable: true, configurable: true }); } function disableLog(bool) { if (typeof bool !== 'boolean') { return new Error('Argument type: ' + (typeof bool === 'undefined' ? 'undefined' : _typeof(bool)) + '. Please use a boolean.'); } logDisabled_ = bool; return bool ? 'adapter.js logging disabled' : 'adapter.js logging enabled'; } /** * Disable or enable deprecation warnings * @param {!boolean} bool set to true to disable warnings. */ function disableWarnings(bool) { if (typeof bool !== 'boolean') { return new Error('Argument type: ' + (typeof bool === 'undefined' ? 'undefined' : _typeof(bool)) + '. Please use a boolean.'); } deprecationWarnings_ = !bool; return 'adapter.js deprecation warnings ' + (bool ? 'disabled' : 'enabled'); } function log() { if ((typeof window === 'undefined' ? 'undefined' : _typeof(window)) === 'object') { if (logDisabled_) { return; } if (typeof console !== 'undefined' && typeof console.log === 'function') { console.log.apply(console, arguments); } } } /** * Shows a deprecation warning suggesting the modern and spec-compatible API. */ function deprecated(oldMethod, newMethod) { if (!deprecationWarnings_) { return; } console.warn(oldMethod + ' is deprecated, please use ' + newMethod + ' instead.'); } /** * Browser detector. * * @return {object} result containing browser and version * properties. */ function detectBrowser(window) { // Returned result object. var result = { browser: null, version: null }; // Fail early if it's not a browser if (typeof window === 'undefined' || !window.navigator) { result.browser = 'Not a browser.'; return result; } var navigator = window.navigator; if (navigator.mozGetUserMedia) { // Firefox. result.browser = 'firefox'; result.version = extractVersion(navigator.userAgent, /Firefox\/(\d+)\./, 1); } else if (navigator.webkitGetUserMedia || window.isSecureContext === false && window.webkitRTCPeerConnection && !window.RTCIceGatherer) { // Chrome, Chromium, Webview, Opera. // Version matches Chrome/WebRTC version. // Chrome 74 removed webkitGetUserMedia on http as well so we need the // more complicated fallback to webkitRTCPeerConnection. result.browser = 'chrome'; result.version = extractVersion(navigator.userAgent, /Chrom(e|ium)\/(\d+)\./, 2); } else if (window.RTCPeerConnection && navigator.userAgent.match(/AppleWebKit\/(\d+)\./)) { // Safari. result.browser = 'safari'; result.version = extractVersion(navigator.userAgent, /AppleWebKit\/(\d+)\./, 1); result.supportsUnifiedPlan = window.RTCRtpTransceiver && 'currentDirection' in window.RTCRtpTransceiver.prototype; } else { // Default fallthrough: not supported. result.browser = 'Not a supported browser.'; return result; } return result; } /** * Checks if something is an object. * * @param {*} val The something you want to check. * @return true if val is an object, false otherwise. */ function isObject(val) { return Object.prototype.toString.call(val) === '[object Object]'; } /** * Remove all empty objects and undefined values * from a nested object -- an enhanced and vanilla version * of Lodash's `compact`. */ function compactObject(data) { if (!isObject(data)) { return data; } return Object.keys(data).reduce(function (accumulator, key) { var isObj = isObject(data[key]); var value = isObj ? compactObject(data[key]) : data[key]; var isEmptyObject = isObj && !Object.keys(value).length; if (value === undefined || isEmptyObject) { return accumulator; } return Object.assign(accumulator, _defineProperty({}, key, value)); }, {}); } /* iterates the stats graph recursively. */ function walkStats(stats, base, resultSet) { if (!base || resultSet.has(base.id)) { return; } resultSet.set(base.id, base); Object.keys(base).forEach(function (name) { if (name.endsWith('Id')) { walkStats(stats, stats.get(base[name]), resultSet); } else if (name.endsWith('Ids')) { base[name].forEach(function (id) { walkStats(stats, stats.get(id), resultSet); }); } }); } /* filter getStats for a sender/receiver track. */ function filterStats(result, track, outbound) { var streamStatsType = outbound ? 'outbound-rtp' : 'inbound-rtp'; var filteredResult = new Map(); if (track === null) { return filteredResult; } var trackStats = []; result.forEach(function (value) { if (value.type === 'track' && value.trackIdentifier === track.id) { trackStats.push(value); } }); trackStats.forEach(function (trackStat) { result.forEach(function (stats) { if (stats.type === streamStatsType && stats.trackId === trackStat.id) { walkStats(result, stats, filteredResult); } }); }); return filteredResult; } },{}],12:[function(require,module,exports){ /* eslint-env node */ 'use strict'; // SDP helpers. var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; var SDPUtils = {}; // Generate an alphanumeric identifier for cname or mids. // TODO: use UUIDs instead? https://gist.github.com/jed/982883 SDPUtils.generateIdentifier = function () { return Math.random().toString(36).substr(2, 10); }; // The RTCP CNAME used by all peerconnections from the same JS. SDPUtils.localCName = SDPUtils.generateIdentifier(); // Splits SDP into lines, dealing with both CRLF and LF. SDPUtils.splitLines = function (blob) { return blob.trim().split('\n').map(function (line) { return line.trim(); }); }; // Splits SDP into sessionpart and mediasections. Ensures CRLF. SDPUtils.splitSections = function (blob) { var parts = blob.split('\nm='); return parts.map(function (part, index) { return (index > 0 ? 'm=' + part : part).trim() + '\r\n'; }); }; // returns the session description. SDPUtils.getDescription = function (blob) { var sections = SDPUtils.splitSections(blob); return sections && sections[0]; }; // returns the individual media sections. SDPUtils.getMediaSections = function (blob) { var sections = SDPUtils.splitSections(blob); sections.shift(); return sections; }; // Returns lines that start with a certain prefix. SDPUtils.matchPrefix = function (blob, prefix) { return SDPUtils.splitLines(blob).filter(function (line) { return line.indexOf(prefix) === 0; }); }; // Parses an ICE candidate line. Sample input: // candidate:702786350 2 udp 41819902 8.8.8.8 60769 typ relay raddr 8.8.8.8 // rport 55996" SDPUtils.parseCandidate = function (line) { var parts = void 0; // Parse both variants. if (line.indexOf('a=candidate:') === 0) { parts = line.substring(12).split(' '); } else { parts = line.substring(10).split(' '); } var candidate = { foundation: parts[0], component: { 1: 'rtp', 2: 'rtcp' }[parts[1]], protocol: parts[2].toLowerCase(), priority: parseInt(parts[3], 10), ip: parts[4], address: parts[4], // address is an alias for ip. port: parseInt(parts[5], 10), // skip parts[6] == 'typ' type: parts[7] }; for (var i = 8; i < parts.length; i += 2) { switch (parts[i]) { case 'raddr': candidate.relatedAddress = parts[i + 1]; break; case 'rport': candidate.relatedPort = parseInt(parts[i + 1], 10); break; case 'tcptype': candidate.tcpType = parts[i + 1]; break; case 'ufrag': candidate.ufrag = parts[i + 1]; // for backward compatibility. candidate.usernameFragment = parts[i + 1]; break; default: // extension handling, in particular ufrag. Don't overwrite. if (candidate[parts[i]] === undefined) { candidate[parts[i]] = parts[i + 1]; } break; } } return candidate; }; // Translates a candidate object into SDP candidate attribute. SDPUtils.writeCandidate = function (candidate) { var sdp = []; sdp.push(candidate.foundation); var component = candidate.component; if (component === 'rtp') { sdp.push(1); } else if (component === 'rtcp') { sdp.push(2); } else { sdp.push(component); } sdp.push(candidate.protocol.toUpperCase()); sdp.push(candidate.priority); sdp.push(candidate.address || candidate.ip); sdp.push(candidate.port); var type = candidate.type; sdp.push('typ'); sdp.push(type); if (type !== 'host' && candidate.relatedAddress && candidate.relatedPort) { sdp.push('raddr'); sdp.push(candidate.relatedAddress); sdp.push('rport'); sdp.push(candidate.relatedPort); } if (candidate.tcpType && candidate.protocol.toLowerCase() === 'tcp') { sdp.push('tcptype'); sdp.push(candidate.tcpType); } if (candidate.usernameFragment || candidate.ufrag) { sdp.push('ufrag'); sdp.push(candidate.usernameFragment || candidate.ufrag); } return 'candidate:' + sdp.join(' '); }; // Parses an ice-options line, returns an array of option tags. // a=ice-options:foo bar SDPUtils.parseIceOptions = function (line) { return line.substr(14).split(' '); }; // Parses an rtpmap line, returns RTCRtpCoddecParameters. Sample input: // a=rtpmap:111 opus/48000/2 SDPUtils.parseRtpMap = function (line) { var parts = line.substr(9).split(' '); var parsed = { payloadType: parseInt(parts.shift(), 10) // was: id }; parts = parts[0].split('/'); parsed.name = parts[0]; parsed.clockRate = parseInt(parts[1], 10); // was: clockrate parsed.channels = parts.length === 3 ? parseInt(parts[2], 10) : 1; // legacy alias, got renamed back to channels in ORTC. parsed.numChannels = parsed.channels; return parsed; }; // Generate an a=rtpmap line from RTCRtpCodecCapability or // RTCRtpCodecParameters. SDPUtils.writeRtpMap = function (codec) { var pt = codec.payloadType; if (codec.preferredPayloadType !== undefined) { pt = codec.preferredPayloadType; } var channels = codec.channels || codec.numChannels || 1; return 'a=rtpmap:' + pt + ' ' + codec.name + '/' + codec.clockRate + (channels !== 1 ? '/' + channels : '') + '\r\n'; }; // Parses an a=extmap line (headerextension from RFC 5285). Sample input: // a=extmap:2 urn:ietf:params:rtp-hdrext:toffset // a=extmap:2/sendonly urn:ietf:params:rtp-hdrext:toffset SDPUtils.parseExtmap = function (line) { var parts = line.substr(9).split(' '); return { id: parseInt(parts[0], 10), direction: parts[0].indexOf('/') > 0 ? parts[0].split('/')[1] : 'sendrecv', uri: parts[1] }; }; // Generates a=extmap line from RTCRtpHeaderExtensionParameters or // RTCRtpHeaderExtension. SDPUtils.writeExtmap = function (headerExtension) { return 'a=extmap:' + (headerExtension.id || headerExtension.preferredId) + (headerExtension.direction && headerExtension.direction !== 'sendrecv' ? '/' + headerExtension.direction : '') + ' ' + headerExtension.uri + '\r\n'; }; // Parses an ftmp line, returns dictionary. Sample input: // a=fmtp:96 vbr=on;cng=on // Also deals with vbr=on; cng=on SDPUtils.parseFmtp = function (line) { var parsed = {}; var kv = void 0; var parts = line.substr(line.indexOf(' ') + 1).split(';'); for (var j = 0; j < parts.length; j++) { kv = parts[j].trim().split('='); parsed[kv[0].trim()] = kv[1]; } return parsed; }; // Generates an a=ftmp line from RTCRtpCodecCapability or RTCRtpCodecParameters. SDPUtils.writeFmtp = function (codec) { var line = ''; var pt = codec.payloadType; if (codec.preferredPayloadType !== undefined) { pt = codec.preferredPayloadType; } if (codec.parameters && Object.keys(codec.parameters).length) { var params = []; Object.keys(codec.parameters).forEach(function (param) { if (codec.parameters[param]) { params.push(param + '=' + codec.parameters[param]); } else { params.push(param); } }); line += 'a=fmtp:' + pt + ' ' + params.join(';') + '\r\n'; } return line; }; // Parses an rtcp-fb line, returns RTCPRtcpFeedback object. Sample input: // a=rtcp-fb:98 nack rpsi SDPUtils.parseRtcpFb = function (line) { var parts = line.substr(line.indexOf(' ') + 1).split(' '); return { type: parts.shift(), parameter: parts.join(' ') }; }; // Generate a=rtcp-fb lines from RTCRtpCodecCapability or RTCRtpCodecParameters. SDPUtils.writeRtcpFb = function (codec) { var lines = ''; var pt = codec.payloadType; if (codec.preferredPayloadType !== undefined) { pt = codec.preferredPayloadType; } if (codec.rtcpFeedback && codec.rtcpFeedback.length) { // FIXME: special handling for trr-int? codec.rtcpFeedback.forEach(function (fb) { lines += 'a=rtcp-fb:' + pt + ' ' + fb.type + (fb.parameter && fb.parameter.length ? ' ' + fb.parameter : '') + '\r\n'; }); } return lines; }; // Parses an RFC 5576 ssrc media attribute. Sample input: // a=ssrc:3735928559 cname:something SDPUtils.parseSsrcMedia = function (line) { var sp = line.indexOf(' '); var parts = { ssrc: parseInt(line.substr(7, sp - 7), 10) }; var colon = line.indexOf(':', sp); if (colon > -1) { parts.attribute = line.substr(sp + 1, colon - sp - 1); parts.value = line.substr(colon + 1); } else { parts.attribute = line.substr(sp + 1); } return parts; }; SDPUtils.parseSsrcGroup = function (line) { var parts = line.substr(13).split(' '); return { semantics: parts.shift(), ssrcs: parts.map(function (ssrc) { return parseInt(ssrc, 10); }) }; }; // Extracts the MID (RFC 5888) from a media section. // returns the MID or undefined if no mid line was found. SDPUtils.getMid = function (mediaSection) { var mid = SDPUtils.matchPrefix(mediaSection, 'a=mid:')[0]; if (mid) { return mid.substr(6); } }; SDPUtils.parseFingerprint = function (line) { var parts = line.substr(14).split(' '); return { algorithm: parts[0].toLowerCase(), // algorithm is case-sensitive in Edge. value: parts[1] }; }; // Extracts DTLS parameters from SDP media section or sessionpart. // FIXME: for consistency with other functions this should only // get the fingerprint line as input. See also getIceParameters. SDPUtils.getDtlsParameters = function (mediaSection, sessionpart) { var lines = SDPUtils.matchPrefix(mediaSection + sessionpart, 'a=fingerprint:'); // Note: a=setup line is ignored since we use the 'auto' role. // Note2: 'algorithm' is not case sensitive except in Edge. return { role: 'auto', fingerprints: lines.map(SDPUtils.parseFingerprint) }; }; // Serializes DTLS parameters to SDP. SDPUtils.writeDtlsParameters = function (params, setupType) { var sdp = 'a=setup:' + setupType + '\r\n'; params.fingerprints.forEach(function (fp) { sdp += 'a=fingerprint:' + fp.algorithm + ' ' + fp.value + '\r\n'; }); return sdp; }; // Parses a=crypto lines into // https://rawgit.com/aboba/edgertc/master/msortc-rs4.html#dictionary-rtcsrtpsdesparameters-members SDPUtils.parseCryptoLine = function (line) { var parts = line.substr(9).split(' '); return { tag: parseInt(parts[0], 10), cryptoSuite: parts[1], keyParams: parts[2], sessionParams: parts.slice(3) }; }; SDPUtils.writeCryptoLine = function (parameters) { return 'a=crypto:' + parameters.tag + ' ' + parameters.cryptoSuite + ' ' + (_typeof(parameters.keyParams) === 'object' ? SDPUtils.writeCryptoKeyParams(parameters.keyParams) : parameters.keyParams) + (parameters.sessionParams ? ' ' + parameters.sessionParams.join(' ') : '') + '\r\n'; }; // Parses the crypto key parameters into // https://rawgit.com/aboba/edgertc/master/msortc-rs4.html#rtcsrtpkeyparam* SDPUtils.parseCryptoKeyParams = function (keyParams) { if (keyParams.indexOf('inline:') !== 0) { return null; } var parts = keyParams.substr(7).split('|'); return { keyMethod: 'inline', keySalt: parts[0], lifeTime: parts[1], mkiValue: parts[2] ? parts[2].split(':')[0] : undefined, mkiLength: parts[2] ? parts[2].split(':')[1] : undefined }; }; SDPUtils.writeCryptoKeyParams = function (keyParams) { return keyParams.keyMethod + ':' + keyParams.keySalt + (keyParams.lifeTime ? '|' + keyParams.lifeTime : '') + (keyParams.mkiValue && keyParams.mkiLength ? '|' + keyParams.mkiValue + ':' + keyParams.mkiLength : ''); }; // Extracts all SDES parameters. SDPUtils.getCryptoParameters = function (mediaSection, sessionpart) { var lines = SDPUtils.matchPrefix(mediaSection + sessionpart, 'a=crypto:'); return lines.map(SDPUtils.parseCryptoLine); }; // Parses ICE information from SDP media section or sessionpart. // FIXME: for consistency with other functions this should only // get the ice-ufrag and ice-pwd lines as input. SDPUtils.getIceParameters = function (mediaSection, sessionpart) { var ufrag = SDPUtils.matchPrefix(mediaSection + sessionpart, 'a=ice-ufrag:')[0]; var pwd = SDPUtils.matchPrefix(mediaSection + sessionpart, 'a=ice-pwd:')[0]; if (!(ufrag && pwd)) { return null; } return { usernameFragment: ufrag.substr(12), password: pwd.substr(10) }; }; // Serializes ICE parameters to SDP. SDPUtils.writeIceParameters = function (params) { var sdp = 'a=ice-ufrag:' + params.usernameFragment + '\r\n' + 'a=ice-pwd:' + params.password + '\r\n'; if (params.iceLite) { sdp += 'a=ice-lite\r\n'; } return sdp; }; // Parses the SDP media section and returns RTCRtpParameters. SDPUtils.parseRtpParameters = function (mediaSection) { var description = { codecs: [], headerExtensions: [], fecMechanisms: [], rtcp: [] }; var lines = SDPUtils.splitLines(mediaSection); var mline = lines[0].split(' '); for (var i = 3; i < mline.length; i++) { // find all codecs from mline[3..] var pt = mline[i]; var rtpmapline = SDPUtils.matchPrefix(mediaSection, 'a=rtpmap:' + pt + ' ')[0]; if (rtpmapline) { var codec = SDPUtils.parseRtpMap(rtpmapline); var fmtps = SDPUtils.matchPrefix(mediaSection, 'a=fmtp:' + pt + ' '); // Only the first a=fmtp:<pt> is considered. codec.parameters = fmtps.length ? SDPUtils.parseFmtp(fmtps[0]) : {}; codec.rtcpFeedback = SDPUtils.matchPrefix(mediaSection, 'a=rtcp-fb:' + pt + ' ').map(SDPUtils.parseRtcpFb); description.codecs.push(codec); // parse FEC mechanisms from rtpmap lines. switch (codec.name.toUpperCase()) { case 'RED': case 'ULPFEC': description.fecMechanisms.push(codec.name.toUpperCase()); break; default: // only RED and ULPFEC are recognized as FEC mechanisms. break; } } } SDPUtils.matchPrefix(mediaSection, 'a=extmap:').forEach(function (line) { description.headerExtensions.push(SDPUtils.parseExtmap(line)); }); // FIXME: parse rtcp. return description; }; // Generates parts of the SDP media section describing the capabilities / // parameters. SDPUtils.writeRtpDescription = function (kind, caps) { var sdp = ''; // Build the mline. sdp += 'm=' + kind + ' '; sdp += caps.codecs.length > 0 ? '9' : '0'; // reject if no codecs. sdp += ' UDP/TLS/RTP/SAVPF '; sdp += caps.codecs.map(function (codec) { if (codec.preferredPayloadType !== undefined) { return codec.preferredPayloadType; } return codec.payloadType; }).join(' ') + '\r\n'; sdp += 'c=IN IP4 0.0.0.0\r\n'; sdp += 'a=rtcp:9 IN IP4 0.0.0.0\r\n'; // Add a=rtpmap lines for each codec. Also fmtp and rtcp-fb. caps.codecs.forEach(function (codec) { sdp += SDPUtils.writeRtpMap(codec); sdp += SDPUtils.writeFmtp(codec); sdp += SDPUtils.writeRtcpFb(codec); }); var maxptime = 0; caps.codecs.forEach(function (codec) { if (codec.maxptime > maxptime) { maxptime = codec.maxptime; } }); if (maxptime > 0) { sdp += 'a=maxptime:' + maxptime + '\r\n'; } if (caps.headerExtensions) { caps.headerExtensions.forEach(function (extension) { sdp += SDPUtils.writeExtmap(extension); }); } // FIXME: write fecMechanisms. return sdp; }; // Parses the SDP media section and returns an array of // RTCRtpEncodingParameters. SDPUtils.parseRtpEncodingParameters = function (mediaSection) { var encodingParameters = []; var description = SDPUtils.parseRtpParameters(mediaSection); var hasRed = description.fecMechanisms.indexOf('RED') !== -1; var hasUlpfec = description.fecMechanisms.indexOf('ULPFEC') !== -1; // filter a=ssrc:... cname:, ignore PlanB-msid var ssrcs = SDPUtils.matchPrefix(mediaSection, 'a=ssrc:').map(function (line) { return SDPUtils.parseSsrcMedia(line); }).filter(function (parts) { return parts.attribute === 'cname'; }); var primarySsrc = ssrcs.length > 0 && ssrcs[0].ssrc; var secondarySsrc = void 0; var flows = SDPUtils.matchPrefix(mediaSection, 'a=ssrc-group:FID').map(function (line) { var parts = line.substr(17).split(' '); return parts.map(function (part) { return parseInt(part, 10); }); }); if (flows.length > 0 && flows[0].length > 1 && flows[0][0] === primarySsrc) { secondarySsrc = flows[0][1]; } description.codecs.forEach(function (codec) { if (codec.name.toUpperCase() === 'RTX' && codec.parameters.apt) { var encParam = { ssrc: primarySsrc, codecPayloadType: parseInt(codec.parameters.apt, 10) }; if (primarySsrc && secondarySsrc) { encParam.rtx = { ssrc: secondarySsrc }; } encodingParameters.push(encParam); if (hasRed) { encParam = JSON.parse(JSON.stringify(encParam)); encParam.fec = { ssrc: primarySsrc, mechanism: hasUlpfec ? 'red+ulpfec' : 'red' }; encodingParameters.push(encParam); } } }); if (encodingParameters.length === 0 && primarySsrc) { encodingParameters.push({ ssrc: primarySsrc }); } // we support both b=AS and b=TIAS but interpret AS as TIAS. var bandwidth = SDPUtils.matchPrefix(mediaSection, 'b='); if (bandwidth.length) { if (bandwidth[0].indexOf('b=TIAS:') === 0) { bandwidth = parseInt(bandwidth[0].substr(7), 10); } else if (bandwidth[0].indexOf('b=AS:') === 0) { // use formula from JSEP to convert b=AS to TIAS value. bandwidth = parseInt(bandwidth[0].substr(5), 10) * 1000 * 0.95 - 50 * 40 * 8; } else { bandwidth = undefined; } encodingParameters.forEach(function (params) { params.maxBitrate = bandwidth; }); } return encodingParameters; }; // parses http://draft.ortc.org/#rtcrtcpparameters* SDPUtils.parseRtcpParameters = function (mediaSection) { var rtcpParameters = {}; // Gets the first SSRC. Note that with RTX there might be multiple // SSRCs. var remoteSsrc = SDPUtils.matchPrefix(mediaSection, 'a=ssrc:').map(function (line) { return SDPUtils.parseSsrcMedia(line); }).filter(function (obj) { return obj.attribute === 'cname'; })[0]; if (remoteSsrc) { rtcpParameters.cname = remoteSsrc.value; rtcpParameters.ssrc = remoteSsrc.ssrc; } // Edge uses the compound attribute instead of reducedSize // compound is !reducedSize var rsize = SDPUtils.matchPrefix(mediaSection, 'a=rtcp-rsize'); rtcpParameters.reducedSize = rsize.length > 0; rtcpParameters.compound = rsize.length === 0; // parses the rtcp-mux attrіbute. // Note that Edge does not support unmuxed RTCP. var mux = SDPUtils.matchPrefix(mediaSection, 'a=rtcp-mux'); rtcpParameters.mux = mux.length > 0; return rtcpParameters; }; SDPUtils.writeRtcpParameters = function (rtcpParameters) { var sdp = ''; if (rtcpParameters.reducedSize) { sdp += 'a=rtcp-rsize\r\n'; } if (rtcpParameters.mux) { sdp += 'a=rtcp-mux\r\n'; } if (rtcpParameters.ssrc !== undefined && rtcpParameters.cname) { sdp += 'a=ssrc:' + rtcpParameters.ssrc + ' cname:' + rtcpParameters.cname + '\r\n'; } return sdp; }; // parses either a=msid: or a=ssrc:... msid lines and returns // the id of the MediaStream and MediaStreamTrack. SDPUtils.parseMsid = function (mediaSection) { var parts = void 0; var spec = SDPUtils.matchPrefix(mediaSection, 'a=msid:'); if (spec.length === 1) { parts = spec[0].substr(7).split(' '); return { stream: parts[0], track: parts[1] }; } var planB = SDPUtils.matchPrefix(mediaSection, 'a=ssrc:').map(function (line) { return SDPUtils.parseSsrcMedia(line); }).filter(function (msidParts) { return msidParts.attribute === 'msid'; }); if (planB.length > 0) { parts = planB[0].value.split(' '); return { stream: parts[0], track: parts[1] }; } }; // SCTP // parses draft-ietf-mmusic-sctp-sdp-26 first and falls back // to draft-ietf-mmusic-sctp-sdp-05 SDPUtils.parseSctpDescription = function (mediaSection) { var mline = SDPUtils.parseMLine(mediaSection); var maxSizeLine = SDPUtils.matchPrefix(mediaSection, 'a=max-message-size:'); var maxMessageSize = void 0; if (maxSizeLine.length > 0) { maxMessageSize = parseInt(maxSizeLine[0].substr(19), 10); } if (isNaN(maxMessageSize)) { maxMessageSize = 65536; } var sctpPort = SDPUtils.matchPrefix(mediaSection, 'a=sctp-port:'); if (sctpPort.length > 0) { return { port: parseInt(sctpPort[0].substr(12), 10), protocol: mline.fmt, maxMessageSize: maxMessageSize }; } var sctpMapLines = SDPUtils.matchPrefix(mediaSection, 'a=sctpmap:'); if (sctpMapLines.length > 0) { var parts = sctpMapLines[0].substr(10).split(' '); return { port: parseInt(parts[0], 10), protocol: parts[1], maxMessageSize: maxMessageSize }; } }; // SCTP // outputs the draft-ietf-mmusic-sctp-sdp-26 version that all browsers // support by now receiving in this format, unless we originally parsed // as the draft-ietf-mmusic-sctp-sdp-05 format (indicated by the m-line // protocol of DTLS/SCTP -- without UDP/ or TCP/) SDPUtils.writeSctpDescription = function (media, sctp) { var output = []; if (media.protocol !== 'DTLS/SCTP') { output = ['m=' + media.kind + ' 9 ' + media.protocol + ' ' + sctp.protocol + '\r\n', 'c=IN IP4 0.0.0.0\r\n', 'a=sctp-port:' + sctp.port + '\r\n']; } else { output = ['m=' + media.kind + ' 9 ' + media.protocol + ' ' + sctp.port + '\r\n', 'c=IN IP4 0.0.0.0\r\n', 'a=sctpmap:' + sctp.port + ' ' + sctp.protocol + ' 65535\r\n']; } if (sctp.maxMessageSize !== undefined) { output.push('a=max-message-size:' + sctp.maxMessageSize + '\r\n'); } return output.join(''); }; // Generate a session ID for SDP. // https://tools.ietf.org/html/draft-ietf-rtcweb-jsep-20#section-5.2.1 // recommends using a cryptographically random +ve 64-bit value // but right now this should be acceptable and within the right range SDPUtils.generateSessionId = function () { return Math.random().toString().substr(2, 21); }; // Write boiler plate for start of SDP // sessId argument is optional - if not supplied it will // be generated randomly // sessVersion is optional and defaults to 2 // sessUser is optional and defaults to 'thisisadapterortc' SDPUtils.writeSessionBoilerplate = function (sessId, sessVer, sessUser) { var sessionId = void 0; var version = sessVer !== undefined ? sessVer : 2; if (sessId) { sessionId = sessId; } else { sessionId = SDPUtils.generateSessionId(); } var user = sessUser || 'thisisadapterortc'; // FIXME: sess-id should be an NTP timestamp. return 'v=0\r\n' + 'o=' + user + ' ' + sessionId + ' ' + version + ' IN IP4 127.0.0.1\r\n' + 's=-\r\n' + 't=0 0\r\n'; }; // Gets the direction from the mediaSection or the sessionpart. SDPUtils.getDirection = function (mediaSection, sessionpart) { // Look for sendrecv, sendonly, recvonly, inactive, default to sendrecv. var lines = SDPUtils.splitLines(mediaSection); for (var i = 0; i < lines.length; i++) { switch (lines[i]) { case 'a=sendrecv': case 'a=sendonly': case 'a=recvonly': case 'a=inactive': return lines[i].substr(2); default: // FIXME: What should happen here? } } if (sessionpart) { return SDPUtils.getDirection(sessionpart); } return 'sendrecv'; }; SDPUtils.getKind = function (mediaSection) { var lines = SDPUtils.splitLines(mediaSection); var mline = lines[0].split(' '); return mline[0].substr(2); }; SDPUtils.isRejected = function (mediaSection) { return mediaSection.split(' ', 2)[1] === '0'; }; SDPUtils.parseMLine = function (mediaSection) { var lines = SDPUtils.splitLines(mediaSection); var parts = lines[0].substr(2).split(' '); return { kind: parts[0], port: parseInt(parts[1], 10), protocol: parts[2], fmt: parts.slice(3).join(' ') }; }; SDPUtils.parseOLine = function (mediaSection) { var line = SDPUtils.matchPrefix(mediaSection, 'o=')[0]; var parts = line.substr(2).split(' '); return { username: parts[0], sessionId: parts[1], sessionVersion: parseInt(parts[2], 10), netType: parts[3], addressType: parts[4], address: parts[5] }; }; // a very naive interpretation of a valid SDP. SDPUtils.isValidSDP = function (blob) { if (typeof blob !== 'string' || blob.length === 0) { return false; } var lines = SDPUtils.splitLines(blob); for (var i = 0; i < lines.length; i++) { if (lines[i].length < 2 || lines[i].charAt(1) !== '=') { return false; } // TODO: check the modifier a bit more. } return true; }; // Expose public methods. if ((typeof module === 'undefined' ? 'undefined' : _typeof(module)) === 'object') { module.exports = SDPUtils; } },{}]},{},[1])(1) }); usermenu.js 0000644 00000012210 15152050146 0006740 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Initializes and handles events in the user menu. * * @module core/usermenu * @copyright 2021 Moodle * @author Mihail Geshoski <mihail@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import $ from 'jquery'; import {space, enter} from 'core/key_codes'; /** * User menu constants. */ const selectors = { userMenu: '.usermenu', userMenuCarousel: '.usermenu #usermenu-carousel', userMenuCarouselItem: '.usermenu #usermenu-carousel .carousel-item', userMenuCarouselItemActive: '.usermenu #usermenu-carousel .carousel-item.active', userMenuCarouselNavigationLink: '.usermenu #usermenu-carousel .carousel-navigation-link', }; /** * Register event listeners. */ const registerEventListeners = () => { const userMenu = document.querySelector(selectors.userMenu); // Handle the 'shown.bs.dropdown' event (Fired when the dropdown menu is fully displayed). $(selectors.userMenu).on('shown.bs.dropdown', () => { const activeCarouselItem = document.querySelector(selectors.userMenuCarouselItemActive); // Set the focus on the active carousel item. activeCarouselItem.focus(); userMenu.querySelectorAll(selectors.userMenuCarouselItem).forEach(element => { // Resize all non-active carousel items to match the height and width of the current active (main) // carousel item to avoid sizing inconsistencies. This has to be done once the dropdown menu is fully // displayed ('shown.bs.dropdown') as the offsetWidth and offsetHeight cannot be obtained when the // element is hidden. if (!element.classList.contains('active')) { element.style.width = activeCarouselItem.offsetWidth + 'px'; element.style.height = activeCarouselItem.offsetHeight + 'px'; } }); }); // Handle click events in the user menu. userMenu.addEventListener('click', (e) => { // Handle click event on the carousel navigation (control) links in the user menu. if (e.target.matches(selectors.userMenuCarouselNavigationLink)) { carouselManagement(e); } }); userMenu.addEventListener('keydown', e => { // Handle keydown event on the carousel navigation (control) links in the user menu. if ((e.keyCode === space || e.keyCode === enter) && e.target.matches(selectors.userMenuCarouselNavigationLink)) { e.preventDefault(); carouselManagement(e); } }); /** * We do the same actions here even if the caller was a click or button press. * * @param {Event} e The triggering element and key presses etc. */ const carouselManagement = e => { // By default the user menu dropdown element closes on a click event. This behaviour is not desirable // as we need to be able to navigate through the carousel items (submenus of the user menu) within the // user menu. Therefore, we need to prevent the propagation of this event and then manually call the // carousel transition. e.stopPropagation(); // The id of the targeted carousel item. const targetedCarouselItemId = e.target.dataset.carouselTargetId; const targetedCarouselItem = userMenu.querySelector('#' + targetedCarouselItemId); // Get the position (index) of the targeted carousel item within the parent container element. const index = Array.from(targetedCarouselItem.parentNode.children).indexOf(targetedCarouselItem); // Navigate to the targeted carousel item. $(selectors.userMenuCarousel).carousel(index); }; // Handle the 'hide.bs.dropdown' event (Fired when the dropdown menu is being closed). $(selectors.userMenu).on('hide.bs.dropdown', () => { // Reset the state once the user menu dropdown is closed and return back to the first (main) carousel item // if necessary. $(selectors.userMenuCarousel).carousel(0); }); // Handle the 'slid.bs.carousel' event (Fired when the carousel has completed its slide transition). $(selectors.userMenuCarousel).on('slid.bs.carousel', () => { const activeCarouselItem = userMenu.querySelector(selectors.userMenuCarouselItemActive); // Set the focus on the newly activated carousel item. activeCarouselItem.focus(); }); }; /** * Initialize the user menu. */ const init = () => { registerEventListeners(); }; export default { init: init, }; form-course-selector.js 0000644 00000007430 15152050146 0011164 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Course selector adaptor for auto-complete form element. * * @module core/form-course-selector * @copyright 2016 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.1 */ define(['core/ajax', 'jquery'], function(ajax, $) { return { // Public variables and functions. processResults: function(selector, data) { // Mangle the results into an array of objects. var results = []; var i = 0; var excludelist = String($(selector).data('exclude')).split(','); for (i = 0; i < data.courses.length; i++) { if (excludelist.indexOf(String(data.courses[i].id)) === -1) { results.push({value: data.courses[i].id, label: data.courses[i].displayname}); } } return results; }, transport: function(selector, query, success, failure) { var el = $(selector); // Parse some data-attributes from the form element. var requiredcapabilities = el.data('requiredcapabilities'); if (requiredcapabilities.trim() !== "") { requiredcapabilities = requiredcapabilities.split(','); } else { requiredcapabilities = []; } var limittoenrolled = el.data('limittoenrolled'); var includefrontpage = el.data('includefrontpage'); var onlywithcompletion = el.data('onlywithcompletion'); // Build the query. var promises = null; if (typeof query === "undefined") { query = ''; } var searchargs = { criterianame: 'search', criteriavalue: query, page: 0, perpage: 100, requiredcapabilities: requiredcapabilities, limittoenrolled: limittoenrolled, onlywithcompletion: onlywithcompletion }; var calls = [{ methodname: 'core_course_search_courses', args: searchargs }]; if (includefrontpage) { calls.push({ methodname: 'core_course_get_courses', args: { options: { ids: [includefrontpage] } } }); } // Go go go! promises = ajax.call(calls); $.when.apply($.when, promises).done(function(data, site) { if (site && site.length == 1) { var frontpage = site.pop(); var matches = query === '' || frontpage.fullname.toUpperCase().indexOf(query.toUpperCase()) > -1 || frontpage.shortname.toUpperCase().indexOf(query.toUpperCase()) > -1; if (matches) { data.courses.splice(0, 0, frontpage); } } success(data); }).fail(failure); } }; }); modal_events.js 0000644 00000002424 15152050146 0007563 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Contain the events a modal can fire. * * @module core/modal_events * @class modal_events * @copyright 2016 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define([], function() { return { // Default events. shown: 'modal:shown', hidden: 'modal:hidden', destroyed: 'modal:destroyed', bodyRendered: 'modal:bodyRendered', outsideClick: 'modal:outsideClick', // ModalSaveCancel events. save: 'modal-save-cancel:save', cancel: 'modal-save-cancel:cancel', }; }); utils.js 0000644 00000005354 15152050146 0006250 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Utility functions. * * @module core/utils * @copyright 2019 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Create a wrapper function to throttle the execution of the given * * function to at most once every specified period. * * If the function is attempted to be executed while it's in cooldown * (during the wait period) then it'll immediately execute again as * soon as the cooldown is over. * * @method * @param {Function} func The function to throttle * @param {Number} wait The number of milliseconds to wait between executions * @return {Function} */ export const throttle = (func, wait) => { let onCooldown = false; let runAgain = null; const run = function(...args) { if (runAgain === null) { // This is the first time the function has been called. runAgain = false; } else { // This function has been called a second time during the wait period // so re-run it once the wait period is over. runAgain = true; } if (onCooldown) { // Function has already run for this wait period. return; } func.apply(this, args); onCooldown = true; setTimeout(() => { const recurse = runAgain; onCooldown = false; runAgain = null; if (recurse) { run(args); } }, wait); }; return run; }; /** * Create a wrapper function to debounce the execution of the given * function. Each attempt to execute the function will reset the cooldown * period. * * @method * @param {Function} func The function to debounce * @param {Number} wait The number of milliseconds to wait after the final attempt to execute * @return {Function} */ export const debounce = (func, wait) => { let timeout = null; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => { func.apply(this, args); }, wait); }; }; pending.js 0000644 00000003111 15152050146 0006521 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A helper to manage pendingJS checks. * * @module core/pending * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.6 */ define(['jquery'], function($) { /** * Request a new pendingPromise to be resolved. * * When the action you are performing is complete, simply call resolve on the returned Promise. * * @param {Object} pendingKey An optional key value to use * @return {Promise} */ var request = function(pendingKey) { var pendingPromise = $.Deferred(); pendingKey = pendingKey || {}; M.util.js_pending(pendingKey); pendingPromise.then(function() { return M.util.js_complete(pendingKey); }) .catch(); return pendingPromise; }; request.prototype.constructor = request; return request; }); modal_backdrop.js 0000644 00000010732 15152050146 0010045 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Contain the logic for modal backdrops. * * @module core/modal_backdrop * @copyright 2016 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery', 'core/templates', 'core/notification', 'core/fullscreen'], function($, Templates, Notification, Fullscreen) { var SELECTORS = { ROOT: '[data-region="modal-backdrop"]', }; /** * Constructor for ModalBackdrop. * * @class core/modal_backdrop * @param {object} root The root element for the modal backdrop */ var ModalBackdrop = function(root) { this.root = $(root); this.isAttached = false; this.attachmentPoint = document.createElement('div'); document.body.append(this.attachmentPoint); if (!this.root.is(SELECTORS.ROOT)) { Notification.exception({message: 'Element is not a modal backdrop'}); } }; /** * Get the root element of this modal backdrop. * * @method getRoot * @return {object} jQuery object */ ModalBackdrop.prototype.getRoot = function() { return this.root; }; /** * Gets the jQuery wrapped node that the Modal should be attached to. * * @returns {jQuery} */ ModalBackdrop.prototype.getAttachmentPoint = function() { return $(Fullscreen.getElement() || this.attachmentPoint); }; /** * Add the modal backdrop to the page, if it hasn't already been added. * * @method attachToDOM */ ModalBackdrop.prototype.attachToDOM = function() { this.getAttachmentPoint().append(this.root); if (this.isAttached) { return; } this.isAttached = true; }; /** * Set the z-index value for this backdrop. * * @method setZIndex * @param {int} value The z-index value */ ModalBackdrop.prototype.setZIndex = function(value) { this.root.css('z-index', value); }; /** * Check if this backdrop is visible. * * @method isVisible * @return {bool} */ ModalBackdrop.prototype.isVisible = function() { return this.root.hasClass('show'); }; /** * Check if this backdrop has CSS transitions applied. * * @method hasTransitions * @return {bool} */ ModalBackdrop.prototype.hasTransitions = function() { return this.getRoot().hasClass('fade'); }; /** * Display this backdrop. The backdrop will be attached to the DOM if it hasn't * already been. * * @method show */ ModalBackdrop.prototype.show = function() { if (this.isVisible()) { return; } this.attachToDOM(); this.root.removeClass('hide').addClass('show'); }; /** * Hide this backdrop. * * @method hide */ ModalBackdrop.prototype.hide = function() { if (!this.isVisible()) { return; } if (this.hasTransitions()) { // Wait for CSS transitions to complete before hiding the element. this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', function() { this.getRoot().removeClass('show').addClass('hide'); }.bind(this)); } else { this.getRoot().removeClass('show').addClass('hide'); } // Ensure the modal is moved onto the body node if it is still attached to the DOM. if ($(document.body).find(this.getRoot()).length) { $(document.body).append(this.getRoot()); } }; /** * Remove this backdrop from the DOM. * * @method destroy */ ModalBackdrop.prototype.destroy = function() { this.root.remove(); this.attachmentPoint.remove(); }; return ModalBackdrop; }); modal_save_cancel.js 0000644 00000004324 15152050146 0010523 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Contain the logic for the save/cancel modal. * * @module core/modal_save_cancel * @copyright 2016 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Modal from 'core/modal'; import Notification from 'core/notification'; /** * The Save/Cancel Modal. * * @class * @extends module:core/modal */ export default class extends Modal { constructor(root) { super(root); if (!this.getFooter().find(this.getActionSelector('save')).length) { Notification.exception({message: 'No save button found'}); } if (!this.getFooter().find(this.getActionSelector('cancel')).length) { Notification.exception({message: 'No cancel button found'}); } } /** * Register all event listeners. */ registerEventListeners() { // Call the parent registration. super.registerEventListeners(); // Register to close on save/cancel. this.registerCloseOnSave(); this.registerCloseOnCancel(); } /** * Override parent implementation to prevent changing the footer content. */ setFooter() { Notification.exception({message: 'Can not change the footer of a save cancel modal'}); return; } /** * Set the title of the save button. * * @param {String|Promise} value The button text, or a Promise which will resolve it * @returns{Promise} */ setSaveButtonText(value) { return this.setButtonText('save', value); } } yui.js 0000644 00000002203 15152050146 0005704 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Expose the global YUI variable. Note: This is only for scripts that are writing AMD * wrappers for YUI functionality. This is not for plugins. * * @module core/yui * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 2.9 */ define(function() { // This module exposes only the global yui instance. /* global Y */ return /** @alias module:core/yui */ Y; }); modal_registry.js 0000644 00000004601 15152050146 0010126 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A registry for the different types of modal. * * @module core/modal_registry * @class modal_registry * @copyright 2016 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['core/notification', 'core/prefetch'], function(Notification, Prefetch) { // A singleton registry for all modules to access. Allows types to be // added at runtime. var registry = {}; /** * Get a registered type of modal. * * @method get * @param {string} type The type of modal to get * @return {object} The registered config for the modal */ var get = function(type) { return registry[type]; }; /** * Register a modal with the registry. * * @method register * @param {string} type The type of modal (must be unique) * @param {function} module The modal module (must be a constructor function of type core/modal) * @param {string} template The template name of the modal */ var register = function(type, module, template) { if (get(type)) { Notification.exception({message: "Modal of type '" + type + "' is already registered"}); } if (!module || typeof module !== 'function') { Notification.exception({message: "You must provide a modal module"}); } if (!template) { Notification.exception({message: "You must provide a modal template"}); } registry[type] = { module: module, template: template, }; // Prefetch the template. Prefetch.prefetchTemplate(template); }; return { register: register, get: get, }; }); paged_content.js 0000644 00000005567 15152050146 0007730 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Javascript to load and render a paged content section. * * @module core/paged_content * @copyright 2018 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define( [ 'jquery', 'core/paged_content_pages', 'core/paged_content_paging_bar', 'core/paged_content_paging_bar_limit_selector', 'core/paged_content_paging_dropdown' ], function( $, Pages, PagingBar, PagingBarLimitSelector, Dropdown ) { /** * Initialise the paged content region by running the pages * module and initialising any paging controls in the DOM. * * @param {object} root The paged content container element * @param {function} renderPagesContentCallback (optional) A callback function to render a * content page. See core/paged_content_pages for * more defails. * @param {string} namespaceOverride (optional) Provide a unique namespace override. If none provided defaults * to generate html's id */ var init = function(root, renderPagesContentCallback, namespaceOverride) { root = $(root); var pagesContainer = root.find(Pages.rootSelector); var pagingBarContainer = root.find(PagingBar.rootSelector); var dropdownContainer = root.find(Dropdown.rootSelector); var pagingBarLimitSelectorContainer = root.find(PagingBarLimitSelector.rootSelector); var id = root.attr('id'); // Set the id to the custom namespace provided if (namespaceOverride) { id = namespaceOverride; } Pages.init(pagesContainer, id, renderPagesContentCallback); if (pagingBarContainer.length) { PagingBar.init(pagingBarContainer, id); } if (pagingBarLimitSelectorContainer.length) { PagingBarLimitSelector.init(pagingBarLimitSelectorContainer, id); } if (dropdownContainer.length) { Dropdown.init(dropdownContainer, id); } }; return { init: init, rootSelector: '[data-region="paged-content-container"]' }; }); chart_axis.js 0000644 00000016620 15152050146 0007233 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart axis. * * @module core/chart_axis * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define([], function() { /** * Chart axis class. * * This is used to represent an axis, whether X or Y. * * @class core/chart_axis */ function Axis() { // Please eslint no-empty-function. } /** * Default axis position. * @const {Null} */ Axis.prototype.POS_DEFAULT = null; /** * Bottom axis position. * @const {String} */ Axis.prototype.POS_BOTTOM = 'bottom'; /** * Left axis position. * @const {String} */ Axis.prototype.POS_LEFT = 'left'; /** * Right axis position. * @const {String} */ Axis.prototype.POS_RIGHT = 'right'; /** * Top axis position. * @const {String} */ Axis.prototype.POS_TOP = 'top'; /** * Label of the axis. * @type {String} * @protected */ Axis.prototype._label = null; /** * Labels of the ticks. * @type {String[]} * @protected */ Axis.prototype._labels = null; /** * Maximum value of the axis. * @type {Number} * @protected */ Axis.prototype._max = null; /** * Minimum value of the axis. * @type {Number} * @protected */ Axis.prototype._min = null; /** * Position of the axis. * @type {String} * @protected */ Axis.prototype._position = null; /** * Steps on the axis. * @type {Number} * @protected */ Axis.prototype._stepSize = null; /** * Create a new instance of an axis from serialised data. * * @static * @method create * @param {Object} obj The data of the axis. * @return {module:core/chart_axis} */ Axis.prototype.create = function(obj) { var s = new Axis(); s.setPosition(obj.position); s.setLabel(obj.label); s.setStepSize(obj.stepSize); s.setMax(obj.max); s.setMin(obj.min); s.setLabels(obj.labels); return s; }; /** * Get the label of the axis. * * @method getLabel * @return {String} */ Axis.prototype.getLabel = function() { return this._label; }; /** * Get the labels of the ticks of the axis. * * @method getLabels * @return {String[]} */ Axis.prototype.getLabels = function() { return this._labels; }; /** * Get the maximum value of the axis. * * @method getMax * @return {Number} */ Axis.prototype.getMax = function() { return this._max; }; /** * Get the minimum value of the axis. * * @method getMin * @return {Number} */ Axis.prototype.getMin = function() { return this._min; }; /** * Get the position of the axis. * * @method getPosition * @return {String} */ Axis.prototype.getPosition = function() { return this._position; }; /** * Get the step size of the axis. * * @method getStepSize * @return {Number} */ Axis.prototype.getStepSize = function() { return this._stepSize; }; /** * Set the label of the axis. * * @method setLabel * @param {String} label The label. */ Axis.prototype.setLabel = function(label) { this._label = label || null; }; /** * Set the labels of the values on the axis. * * This automatically sets the [_stepSize]{@link module:core/chart_axis#_stepSize}, * [_min]{@link module:core/chart_axis#_min} and [_max]{@link module:core/chart_axis#_max} * to define a scale from 0 to the number of labels when none of the previously * mentioned values have been modified. * * You can use other values so long that your values in a series are mapped * to the values represented by your _min, _max and _stepSize. * * @method setLabels * @param {String[]} labels The labels. */ Axis.prototype.setLabels = function(labels) { this._labels = labels || null; // By default we set the grid according to the labels. if (this._labels !== null && this._stepSize === null && (this._min === null || this._min === 0) && this._max === null) { this.setStepSize(1); this.setMin(0); this.setMax(labels.length - 1); } }; /** * Set the maximum value on the axis. * * When this is not set (or set to null) it is left for the output * library to best guess what should be used. * * @method setMax * @param {Number} max The value. */ Axis.prototype.setMax = function(max) { this._max = typeof max !== 'undefined' ? max : null; }; /** * Set the minimum value on the axis. * * When this is not set (or set to null) it is left for the output * library to best guess what should be used. * * @method setMin * @param {Number} min The value. */ Axis.prototype.setMin = function(min) { this._min = typeof min !== 'undefined' ? min : null; }; /** * Set the position of the axis. * * This does not validate whether or not the constant used is valid * as the axis itself is not aware whether it represents the X or Y axis. * * The output library has to have a fallback in case the values are incorrect. * When this is not set to {@link module:core/chart_axis#POS_DEFAULT} it is up * to the output library to choose what position fits best. * * @method setPosition * @param {String} position The value. */ Axis.prototype.setPosition = function(position) { if (position != this.POS_DEFAULT && position != this.POS_BOTTOM && position != this.POS_LEFT && position != this.POS_RIGHT && position != this.POS_TOP) { throw new Error('Invalid axis position.'); } this._position = position; }; /** * Set the stepSize on the axis. * * This is used to determine where ticks are displayed on the axis between min and max. * * @method setStepSize * @param {Number} stepSize The value. */ Axis.prototype.setStepSize = function(stepSize) { if (typeof stepSize === 'undefined' || stepSize === null) { stepSize = null; } else if (isNaN(Number(stepSize))) { throw new Error('Value for stepSize is not a number.'); } else { stepSize = Number(stepSize); } this._stepSize = stepSize; }; return Axis; }); showhidesettings.js 0000644 00000027503 15152050146 0010503 0 ustar 00 /** * Show/hide admin settings based on other settings selected * * @copyright 2018 Davo Smith, Synergy Learning * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery'], function($) { var dependencies; // ------------------------------------------------- // Support functions, used by dependency functions. // ------------------------------------------------- /** * Check to see if the given element is the hidden element that makes sure checkbox * elements always submit a value. * @param {jQuery} $el * @returns {boolean} */ function isCheckboxHiddenElement($el) { return ($el.is('input[type=hidden]') && $el.siblings('input[type=checkbox][name="' + $el.attr('name') + '"]').length); } /** * Check to see if this is a radio button with the wrong value (i.e. a radio button from * the group we are interested in, but not the specific one we wanted). * @param {jQuery} $el * @param {string} value * @returns {boolean} */ function isWrongRadioButton($el, value) { return ($el.is('input[type=radio]') && $el.attr('value') !== value); } /** * Is this element relevant when we're looking for checked / not checked status? * @param {jQuery} $el * @param {string} value * @returns {boolean} */ function isCheckedRelevant($el, value) { return (!isCheckboxHiddenElement($el) && !isWrongRadioButton($el, value)); } /** * Is this an unchecked radio button? (If it is, we want to skip it, as * we're only interested in the value of the radio button that is checked) * @param {jQuery} $el * @returns {boolean} */ function isUncheckedRadioButton($el) { return ($el.is('input[type=radio]') && !$el.prop('checked')); } /** * Is this an unchecked checkbox? * @param {jQuery} $el * @returns {boolean} */ function isUncheckedCheckbox($el) { return ($el.is('input[type=checkbox]') && !$el.prop('checked')); } /** * Is this a multi-select select element? * @param {jQuery} $el * @returns {boolean} */ function isMultiSelect($el) { return ($el.is('select') && $el.prop('multiple')); } /** * Does the multi-select exactly match the list of values provided? * @param {jQuery} $el * @param {array} values * @returns {boolean} */ function multiSelectMatches($el, values) { var selected = $el.val() || []; if (!values.length) { // No values - nothing to match against. return false; } if (selected.length !== values.length) { // Different number of expected and actual values - cannot possibly be a match. return false; } for (var i in selected) { if (selected.hasOwnProperty(i)) { if (values.indexOf(selected[i]) === -1) { return false; // Found a non-matching value - give up immediately. } } } // Didn't find a non-matching value, so we have a match. return true; } // ------------------------------- // Specific dependency functions. // ------------------------------- var depFns = { notchecked: function($dependon, value) { var hide = false; value = String(value); $dependon.each(function(idx, el) { var $el = $(el); if (isCheckedRelevant($el, value)) { hide = hide || !$el.prop('checked'); } }); return hide; }, checked: function($dependon, value) { var hide = false; value = String(value); $dependon.each(function(idx, el) { var $el = $(el); if (isCheckedRelevant($el, value)) { hide = hide || $el.prop('checked'); } }); return hide; }, noitemselected: function($dependon) { var hide = false; $dependon.each(function(idx, el) { var $el = $(el); hide = hide || ($el.prop('selectedIndex') === -1); }); return hide; }, eq: function($dependon, value) { var hide = false; var hiddenVal = false; value = String(value); $dependon.each(function(idx, el) { var $el = $(el); if (isUncheckedRadioButton($el)) { // For radio buttons, we're only interested in the one that is checked. return; } if (isCheckboxHiddenElement($el)) { // This is the hidden input that is part of the checkbox setting. // We will use this value, if the associated checkbox is unchecked. hiddenVal = ($el.val() === value); return; } if (isUncheckedCheckbox($el)) { // Checkbox is not checked - hide depends on the 'unchecked' value stored in // the associated hidden element, which we have already found, above. hide = hide || hiddenVal; return; } if (isMultiSelect($el)) { // Expect a list of values to match, separated by '|' - all of them must // match the values selected. var values = value.split('|'); hide = multiSelectMatches($el, values); return; } // All other element types - just compare the value directly. hide = hide || ($el.val() === value); }); return hide; }, 'in': function($dependon, value) { var hide = false; var hiddenVal = false; var values = value.split('|'); $dependon.each(function(idx, el) { var $el = $(el); if (isUncheckedRadioButton($el)) { // For radio buttons, we're only interested in the one that is checked. return; } if (isCheckboxHiddenElement($el)) { // This is the hidden input that is part of the checkbox setting. // We will use this value, if the associated checkbox is unchecked. hiddenVal = (values.indexOf($el.val()) > -1); return; } if (isUncheckedCheckbox($el)) { // Checkbox is not checked - hide depends on the 'unchecked' value stored in // the associated hidden element, which we have already found, above. hide = hide || hiddenVal; return; } if (isMultiSelect($el)) { // For multiselect, we check to see if the list of values provided matches the list selected. hide = multiSelectMatches($el, values); return; } // All other element types - check to see if the value is in the list. hide = hide || (values.indexOf($el.val()) > -1); }); return hide; }, defaultCondition: function($dependon, value) { // Not equal. var hide = false; var hiddenVal = false; value = String(value); $dependon.each(function(idx, el) { var $el = $(el); if (isUncheckedRadioButton($el)) { // For radio buttons, we're only interested in the one that is checked. return; } if (isCheckboxHiddenElement($el)) { // This is the hidden input that is part of the checkbox setting. // We will use this value, if the associated checkbox is unchecked. hiddenVal = ($el.val() !== value); return; } if (isUncheckedCheckbox($el)) { // Checkbox is not checked - hide depends on the 'unchecked' value stored in // the associated hidden element, which we have already found, above. hide = hide || hiddenVal; return; } if (isMultiSelect($el)) { // Expect a list of values to match, separated by '|' - all of them must // match the values selected to *not* hide the element. var values = value.split('|'); hide = !multiSelectMatches($el, values); return; } // All other element types - just compare the value directly. hide = hide || ($el.val() !== value); }); return hide; } }; /** * Find the element with the given name * @param {String} name * @returns {*|jQuery|HTMLElement} */ function getElementsByName(name) { // For the array elements, we use [name^="something["] to find the elements that their name begins with 'something['/ // This is to find both name = 'something[]' and name='something[index]'. return $('[name="' + name + '"],[name^="' + name + '["]'); } /** * Check to see whether a particular condition is met * @param {*|jQuery|HTMLElement} $dependon * @param {String} condition * @param {mixed} value * @returns {Boolean} */ function checkDependency($dependon, condition, value) { if (typeof depFns[condition] === "function") { return depFns[condition]($dependon, value); } return depFns.defaultCondition($dependon, value); } /** * Show / hide the elements that depend on some elements. */ function updateDependencies() { // Process all dependency conditions. var toHide = {}; $.each(dependencies, function(dependonname) { var dependon = getElementsByName(dependonname); $.each(dependencies[dependonname], function(condition, values) { $.each(values, function(value, elements) { var hide = checkDependency(dependon, condition, value); $.each(elements, function(idx, elToHide) { if (toHide.hasOwnProperty(elToHide)) { toHide[elToHide] = toHide[elToHide] || hide; } else { toHide[elToHide] = hide; } }); }); }); }); // Update the hidden status of all relevant elements. $.each(toHide, function(elToHide, hide) { getElementsByName(elToHide).each(function(idx, el) { var $parent = $(el).closest('.form-item'); if ($parent.length) { if (hide) { $parent.hide(); } else { $parent.show(); } } }); }); } /** * Initialise the event handlers. */ function initHandlers() { $.each(dependencies, function(depname) { var $el = getElementsByName(depname); if ($el.length) { $el.on('change', updateDependencies); } }); updateDependencies(); } /** * Hide the 'this setting may be hidden' messages. */ function hideDependencyInfo() { $('.form-dependenton').hide(); } return { init: function(opts) { dependencies = opts.dependencies; initHandlers(); hideDependencyInfo(); } }; }); fullscreen.js 0000644 00000003174 15152050146 0007250 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Detects if an element is fullscreen. * * @module core/fullscreen * @copyright 2020 University of Nottingham * @author Neill Magill <neill.magill@nottingham.ac.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Gets the element that is fullscreen or null if no element is fullscreen. * * @method * @returns {HTMLElement} */ export const getElement = () => { let element = null; if (document.fullscreenElement) { element = document.fullscreenElement; } else if (document.mozFullscreenElement) { // Fallback for older Firefox. element = document.mozFullscreenElement; } else if (document.msFullscreenElement) { // Fallback for Edge and IE. element = document.msFullscreenElement; } else if (document.webkitFullscreenElement) { // Fallback for Chrome, Edge and Safari. element = document.webkitFullscreenElement; } return element; }; chart_builder.js 0000644 00000003232 15152050146 0007710 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart builder. * * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery'], function($) { /** * Chart builder. * * @exports core/chart_builder */ var module = { /** * Make a chart instance. * * This takes data, most likely generated in PHP, and creates a chart instance from it * deferring most of the logic to {@link module:core/chart_base.create}. * * @param {Object} data The data. * @return {Promise} A promise resolved with the chart instance. */ make: function(data) { var deferred = $.Deferred(); require(['core/chart_' + data.type], function(Klass) { var instance = Klass.prototype.create(Klass, data); deferred.resolve(instance); }); return deferred.promise(); } }; return module; }); normalise.js 0000644 00000003146 15152050146 0007076 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Normalisation helpers. * * @module core/normalise * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import jQuery from 'jquery'; /** * Normalise a list of Nodes into an Array of Nodes. * * @method getList * @param {(Array|jQuery|NodeList|HTMLElement)} nodes * @returns {HTMLElement[]} */ export const getList = nodes => { if (nodes instanceof HTMLElement) { // A single record to conver to a NodeList. return [nodes]; } if (nodes instanceof Array) { // A single record to conver to a NodeList. return nodes; } if (nodes instanceof NodeList) { // Already a NodeList. return Array.from(nodes); } if (nodes instanceof jQuery) { // A jQuery object to a NodeList. return nodes.get(); } // Fallback to just having a go. return Array.from(nodes); }; edit_switch.js 0000644 00000006721 15152050146 0007415 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Controls the edit switch. * * @module core/edit_switch * @copyright 2021 Bas Brands <bas@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import {call as fetchMany} from 'core/ajax'; import {dispatchEvent} from 'core/event_dispatcher'; import {exception as displayException} from 'core/notification'; /** * Change the Edit mode. * * @param {number} context The contextid that editing is being set for * @param {bool} setmode Whether editing is set or not * @return {Promise} Resolved with an array file the stored file url. */ const setEditMode = (context, setmode) => fetchMany([{ methodname: 'core_change_editmode', args: { context, setmode, }, }])[0]; /** * Toggle the edit switch * * @method * @protected * @param {HTMLElement} editSwitch */ const toggleEditSwitch = editSwitch => { if (editSwitch.checked) { editSwitch.setAttribute('aria-checked', true); } else { editSwitch.setAttribute('aria-checked', false); } const event = notifyEditModeSet(editSwitch, editSwitch.checked); if (!event.defaultPrevented) { editSwitch.setAttribute('disabled', true); window.location = editSwitch.dataset.pageurl; } }; /** * Names of events for core/edit_switch. * * @static * @property {String} editModeSet See {@link event:core/edit_switch/editModeSet} */ export const eventTypes = { /** * An event triggered when the edit mode toggled. * * @event core/edit_switch/editModeSet * @type {CustomEvent} * @property {HTMLElement} target The switch used to toggle the edit mode * @property {object} detail * @property {bool} detail.editMode */ editModeSet: 'core/edit_switch/editModeSet', }; /** * Dispatch the editModeSet event after changing the edit mode. * * This event is cancelable. * * The default action is to reload the page after toggling the edit mode. * * @method * @protected * @param {HTMLElement} container * @param {bool} editMode * @returns {CustomEvent} */ const notifyEditModeSet = (container, editMode) => dispatchEvent( eventTypes.editModeSet, {editMode}, container, {cancelable: true} ); /** * Add the eventlistener for the editswitch. * * @param {string} editingSwitchId The id of the editing switch to listen for */ export const init = editingSwitchId => { const editSwitch = document.getElementById(editingSwitchId); editSwitch.addEventListener('change', () => { setEditMode(editSwitch.dataset.context, editSwitch.checked) .then(result => { if (result.success) { toggleEditSwitch(editSwitch); } else { editSwitch.checked = false; } return; }) .catch(displayException); }); }; fragment.js 0000644 00000012032 15152050146 0006702 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A way to call HTML fragments to be inserted as required via JavaScript. * * @module core/fragment * @copyright 2016 Adrian Greeve <adrian@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.1 */ define(['jquery', 'core/ajax'], function($, ajax) { /** * Loads an HTML fragment through a callback. * * @method loadFragment * @param {string} component Component where callback is located. * @param {string} callback Callback function name. * @param {integer} contextid Context ID of the fragment. * @param {object} params Parameters for the callback. * @return {Promise} JQuery promise object resolved when the fragment has been loaded. */ var loadFragment = function(component, callback, contextid, params) { // Change params into required webservice format. var formattedparams = []; for (var index in params) { formattedparams.push({ name: index, value: params[index] }); } return ajax.call([{ methodname: 'core_get_fragment', args: { component: component, callback: callback, contextid: contextid, args: formattedparams } }])[0]; }; /** * Converts the JS that was received from collecting JS requirements on the $PAGE so it can be added to the existing page * * @param {string} js * @return {string} */ var processCollectedJavascript = function(js) { var jsNodes = $(js); var allScript = ''; jsNodes.each(function(index, scriptNode) { scriptNode = $(scriptNode); var tagName = scriptNode.prop('tagName'); if (tagName && (tagName.toLowerCase() == 'script')) { if (scriptNode.attr('src')) { // We only reload the script if it was not loaded already. var exists = false; $('script').each(function(index, s) { if ($(s).attr('src') == scriptNode.attr('src')) { exists = true; } return !exists; }); if (!exists) { allScript += ' { '; allScript += ' node = document.createElement("script"); '; allScript += ' node.type = "text/javascript"; '; allScript += ' node.src = decodeURI("' + encodeURI(scriptNode.attr('src')) + '"); '; allScript += ' document.getElementsByTagName("head")[0].appendChild(node); '; allScript += ' } '; } } else { allScript += ' ' + scriptNode.text(); } } }); return allScript; }; return { /** * Appends HTML and JavaScript fragments to specified nodes. * Callbacks called by this AMD module are responsible for doing the appropriate security checks * to access the information that is returned. This only does minimal validation on the context. * * @method fragmentAppend * @param {string} component Component where callback is located. * @param {string} callback Callback function name. * @param {integer} contextid Context ID of the fragment. * @param {object} params Parameters for the callback. * @return {Deferred} new promise that is resolved with the html and js. */ loadFragment: function(component, callback, contextid, params) { var promise = $.Deferred(); loadFragment(component, callback, contextid, params).then(function(data) { promise.resolve(data.html, processCollectedJavascript(data.javascript)); }).fail(function(ex) { promise.reject(ex); }); return promise.promise(); }, /** * Converts the JS that was received from collecting JS requirements on the $PAGE so it can be added to the existing page * * @param {string} js * @return {string} */ processCollectedJavascript: function(js) { return processCollectedJavascript(js); } }; }); key_codes.js 0000644 00000002530 15152050146 0007046 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A list of human readable names for the keycodes. * * @module core/key_codes * @copyright 2016 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.2 */ define(function() { /** * @type {object} */ return { 'tab': 9, 'enter': 13, 'shift': 16, 'ctrl': 17, 'alt': 18, 'escape': 27, 'space': 32, 'end': 35, 'home': 36, 'arrowLeft': 37, 'arrowUp': 38, 'arrowRight': 39, 'arrowDown': 40, '8': 56, 'asterix': 106, 'pageUp': 33, 'pageDown': 34, }; }); chart_line.js 0000644 00000004117 15152050146 0007214 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart line. * * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @module core/chart_line */ define(['core/chart_base'], function(Base) { /** * Line chart. * * @extends {module:core/chart_base} * @class */ function Line() { Base.prototype.constructor.apply(this, arguments); } Line.prototype = Object.create(Base.prototype); /** @override */ Line.prototype.TYPE = 'line'; /** * Whether the line should be smooth or not. * * By default the chart lines are not smooth. * * @type {Bool} * @protected */ Line.prototype._smooth = false; /** @override */ Line.prototype.create = function(Klass, data) { var chart = Base.prototype.create.apply(this, arguments); chart.setSmooth(data.smooth); return chart; }; /** * Get whether the line should be smooth or not. * * @method getSmooth * @returns {Bool} */ Line.prototype.getSmooth = function() { return this._smooth; }; /** * Set whether the line should be smooth or not. * * @method setSmooth * @param {Bool} smooth True if the line chart should be smooth, false otherwise. */ Line.prototype.setSmooth = function(smooth) { this._smooth = Boolean(smooth); }; return Line; }); event.js 0000644 00000006163 15152050146 0006230 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Global registry of core events that can be triggered/listened for. * * @module core/event * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.0 */ import {notifyEditorContentRestored} from 'core_editor/events'; import {notifyFilterContentUpdated} from 'core_filters/events'; import {notifyFormSubmittedByJavascript} from 'core_form/events'; // These are only imported for legacy. import $ from 'jquery'; import Y from 'core/yui'; // These are AMD only events - no backwards compatibility for new things. // Note: No new events should be created here. const Events = { FORM_FIELD_VALIDATION: "core_form-field-validation" }; /** * Load the legacy YUI module which defines events in M.core.event and return it. * * @method getLegacyEvents * @return {Promise} * @deprecated */ const getLegacyEvents = () => { const result = $.Deferred(); window.console.warn("The getLegacyEvents function has been deprecated. Please update your code to use native events."); Y.use('event', 'moodle-core-event', function() { result.resolve(window.M.core.event); }); return result.promise(); }; /** * Get a curried function to warn that a function has been moved and renamed * * @param {String} oldFunctionName * @param {String} newModule * @param {String} newFunctionName * @param {Function} newFunctionRef * @returns {Function} */ const getRenamedLegacyFunction = (oldFunctionName, newModule, newFunctionName, newFunctionRef) => (...args) => { window.console.warn( `The core/event::${oldFunctionName}() function has been moved to ${newModule}::${newFunctionName}. ` + `Please update your code to use the new module.` ); return newFunctionRef(...args); }; export default { Events, getLegacyEvents, notifyEditorContentRestored: getRenamedLegacyFunction( 'notifyEditorContentRestored', 'core_editor/events', 'notifyEditorContentRestored', notifyEditorContentRestored ), notifyFilterContentUpdated: getRenamedLegacyFunction( 'notifyFilterContentUpdated', 'core_filters/events', 'notifyFilterContentUpdated', notifyFilterContentUpdated ), notifyFormSubmitAjax: getRenamedLegacyFunction( 'notifyFormSubmitAjax', 'core_form/events', 'notifyFormSubmittedByJavascript', notifyFormSubmittedByJavascript ), }; popper.js 0000644 00000236311 15152050146 0006414 0 ustar 00 /**! * @fileOverview Kickass library to create and place poppers near their reference elements. * @version 1.12.6 * @license * Copyright (c) 2016 Federico Zivolo and contributors * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global.Popper = factory()); }(this, (function () { 'use strict'; var isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined'; var longerTimeoutBrowsers = ['Edge', 'Trident', 'Firefox']; var timeoutDuration = 0; for (var i = 0; i < longerTimeoutBrowsers.length; i += 1) { if (isBrowser && navigator.userAgent.indexOf(longerTimeoutBrowsers[i]) >= 0) { timeoutDuration = 1; break; } } function microtaskDebounce(fn) { var called = false; return function () { if (called) { return; } called = true; Promise.resolve().then(function () { called = false; fn(); }); }; } function taskDebounce(fn) { var scheduled = false; return function () { if (!scheduled) { scheduled = true; setTimeout(function () { scheduled = false; fn(); }, timeoutDuration); } }; } var supportsMicroTasks = isBrowser && window.Promise; /** * Create a debounced version of a method, that's asynchronously deferred * but called in the minimum time possible. * * @method * @memberof Popper.Utils * @argument {Function} fn * @returns {Function} */ var debounce = supportsMicroTasks ? microtaskDebounce : taskDebounce; /** * Check if the given variable is a function * @method * @memberof Popper.Utils * @argument {Any} functionToCheck - variable to check * @returns {Boolean} answer to: is a function? */ function isFunction(functionToCheck) { var getType = {}; return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]'; } /** * Get CSS computed property of the given element * @method * @memberof Popper.Utils * @argument {Eement} element * @argument {String} property */ function getStyleComputedProperty(element, property) { if (element.nodeType !== 1) { return []; } // NOTE: 1 DOM access here var css = window.getComputedStyle(element, null); return property ? css[property] : css; } /** * Returns the parentNode or the host of the element * @method * @memberof Popper.Utils * @argument {Element} element * @returns {Element} parent */ function getParentNode(element) { if (element.nodeName === 'HTML') { return element; } return element.parentNode || element.host; } /** * Returns the scrolling parent of the given element * @method * @memberof Popper.Utils * @argument {Element} element * @returns {Element} scroll parent */ function getScrollParent(element) { // Return body, `getScroll` will take care to get the correct `scrollTop` from it if (!element) { return window.document.body; } switch (element.nodeName) { case 'HTML': case 'BODY': return element.ownerDocument.body; case '#document': return element.body; } // Firefox want us to check `-x` and `-y` variations as well var _getStyleComputedProp = getStyleComputedProperty(element), overflow = _getStyleComputedProp.overflow, overflowX = _getStyleComputedProp.overflowX, overflowY = _getStyleComputedProp.overflowY; if (/(auto|scroll)/.test(overflow + overflowY + overflowX)) { return element; } return getScrollParent(getParentNode(element)); } /** * Returns the offset parent of the given element * @method * @memberof Popper.Utils * @argument {Element} element * @returns {Element} offset parent */ function getOffsetParent(element) { // NOTE: 1 DOM access here var offsetParent = element && element.offsetParent; var nodeName = offsetParent && offsetParent.nodeName; if (!nodeName || nodeName === 'BODY' || nodeName === 'HTML') { if (element) { return element.ownerDocument.documentElement; } return window.document.documentElement; } // .offsetParent will return the closest TD or TABLE in case // no offsetParent is present, I hate this job... if (['TD', 'TABLE'].indexOf(offsetParent.nodeName) !== -1 && getStyleComputedProperty(offsetParent, 'position') === 'static') { return getOffsetParent(offsetParent); } return offsetParent; } function isOffsetContainer(element) { var nodeName = element.nodeName; if (nodeName === 'BODY') { return false; } return nodeName === 'HTML' || getOffsetParent(element.firstElementChild) === element; } /** * Finds the root node (document, shadowDOM root) of the given element * @method * @memberof Popper.Utils * @argument {Element} node * @returns {Element} root node */ function getRoot(node) { if (node.parentNode !== null) { return getRoot(node.parentNode); } return node; } /** * Finds the offset parent common to the two provided nodes * @method * @memberof Popper.Utils * @argument {Element} element1 * @argument {Element} element2 * @returns {Element} common offset parent */ function findCommonOffsetParent(element1, element2) { // This check is needed to avoid errors in case one of the elements isn't defined for any reason if (!element1 || !element1.nodeType || !element2 || !element2.nodeType) { return window.document.documentElement; } // Here we make sure to give as "start" the element that comes first in the DOM var order = element1.compareDocumentPosition(element2) & Node.DOCUMENT_POSITION_FOLLOWING; var start = order ? element1 : element2; var end = order ? element2 : element1; // Get common ancestor container var range = document.createRange(); range.setStart(start, 0); range.setEnd(end, 0); var commonAncestorContainer = range.commonAncestorContainer; // Both nodes are inside #document if (element1 !== commonAncestorContainer && element2 !== commonAncestorContainer || start.contains(end)) { if (isOffsetContainer(commonAncestorContainer)) { return commonAncestorContainer; } return getOffsetParent(commonAncestorContainer); } // one of the nodes is inside shadowDOM, find which one var element1root = getRoot(element1); if (element1root.host) { return findCommonOffsetParent(element1root.host, element2); } else { return findCommonOffsetParent(element1, getRoot(element2).host); } } /** * Gets the scroll value of the given element in the given side (top and left) * @method * @memberof Popper.Utils * @argument {Element} element * @argument {String} side `top` or `left` * @returns {number} amount of scrolled pixels */ function getScroll(element) { var side = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'top'; var upperSide = side === 'top' ? 'scrollTop' : 'scrollLeft'; var nodeName = element.nodeName; if (nodeName === 'BODY' || nodeName === 'HTML') { var html = element.ownerDocument.documentElement; var scrollingElement = element.ownerDocument.scrollingElement || html; return scrollingElement[upperSide]; } return element[upperSide]; } /* * Sum or subtract the element scroll values (left and top) from a given rect object * @method * @memberof Popper.Utils * @param {Object} rect - Rect object you want to change * @param {HTMLElement} element - The element from the function reads the scroll values * @param {Boolean} subtract - set to true if you want to subtract the scroll values * @return {Object} rect - The modifier rect object */ function includeScroll(rect, element) { var subtract = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; var scrollTop = getScroll(element, 'top'); var scrollLeft = getScroll(element, 'left'); var modifier = subtract ? -1 : 1; rect.top += scrollTop * modifier; rect.bottom += scrollTop * modifier; rect.left += scrollLeft * modifier; rect.right += scrollLeft * modifier; return rect; } /* * Helper to detect borders of a given element * @method * @memberof Popper.Utils * @param {CSSStyleDeclaration} styles * Result of `getStyleComputedProperty` on the given element * @param {String} axis - `x` or `y` * @return {number} borders - The borders size of the given axis */ function getBordersSize(styles, axis) { var sideA = axis === 'x' ? 'Left' : 'Top'; var sideB = sideA === 'Left' ? 'Right' : 'Bottom'; return +styles['border' + sideA + 'Width'].split('px')[0] + +styles['border' + sideB + 'Width'].split('px')[0]; } /** * Tells if you are running Internet Explorer 10 * @method * @memberof Popper.Utils * @returns {Boolean} isIE10 */ var isIE10 = undefined; var isIE10$1 = function () { if (isIE10 === undefined) { isIE10 = navigator.appVersion.indexOf('MSIE 10') !== -1; } return isIE10; }; function getSize(axis, body, html, computedStyle) { return Math.max(body['offset' + axis], body['scroll' + axis], html['client' + axis], html['offset' + axis], html['scroll' + axis], isIE10$1() ? html['offset' + axis] + computedStyle['margin' + (axis === 'Height' ? 'Top' : 'Left')] + computedStyle['margin' + (axis === 'Height' ? 'Bottom' : 'Right')] : 0); } function getWindowSizes() { var body = window.document.body; var html = window.document.documentElement; var computedStyle = isIE10$1() && window.getComputedStyle(html); return { height: getSize('Height', body, html, computedStyle), width: getSize('Width', body, html, computedStyle) }; } var classCallCheck = function (instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }; var createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); var defineProperty = function (obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }; var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; /** * Given element offsets, generate an output similar to getBoundingClientRect * @method * @memberof Popper.Utils * @argument {Object} offsets * @returns {Object} ClientRect like output */ function getClientRect(offsets) { return _extends({}, offsets, { right: offsets.left + offsets.width, bottom: offsets.top + offsets.height }); } /** * Get bounding client rect of given element * @method * @memberof Popper.Utils * @param {HTMLElement} element * @return {Object} client rect */ function getBoundingClientRect(element) { var rect = {}; // IE10 10 FIX: Please, don't ask, the element isn't // considered in DOM in some circumstances... // This isn't reproducible in IE10 compatibility mode of IE11 if (isIE10$1()) { try { rect = element.getBoundingClientRect(); var scrollTop = getScroll(element, 'top'); var scrollLeft = getScroll(element, 'left'); rect.top += scrollTop; rect.left += scrollLeft; rect.bottom += scrollTop; rect.right += scrollLeft; } catch (err) {} } else { rect = element.getBoundingClientRect(); } var result = { left: rect.left, top: rect.top, width: rect.right - rect.left, height: rect.bottom - rect.top }; // subtract scrollbar size from sizes var sizes = element.nodeName === 'HTML' ? getWindowSizes() : {}; var width = sizes.width || element.clientWidth || result.right - result.left; var height = sizes.height || element.clientHeight || result.bottom - result.top; var horizScrollbar = element.offsetWidth - width; var vertScrollbar = element.offsetHeight - height; // if an hypothetical scrollbar is detected, we must be sure it's not a `border` // we make this check conditional for performance reasons if (horizScrollbar || vertScrollbar) { var styles = getStyleComputedProperty(element); horizScrollbar -= getBordersSize(styles, 'x'); vertScrollbar -= getBordersSize(styles, 'y'); result.width -= horizScrollbar; result.height -= vertScrollbar; } return getClientRect(result); } function getOffsetRectRelativeToArbitraryNode(children, parent) { var isIE10 = isIE10$1(); var isHTML = parent.nodeName === 'HTML'; var childrenRect = getBoundingClientRect(children); var parentRect = getBoundingClientRect(parent); var scrollParent = getScrollParent(children); var styles = getStyleComputedProperty(parent); var borderTopWidth = +styles.borderTopWidth.split('px')[0]; var borderLeftWidth = +styles.borderLeftWidth.split('px')[0]; var offsets = getClientRect({ top: childrenRect.top - parentRect.top - borderTopWidth, left: childrenRect.left - parentRect.left - borderLeftWidth, width: childrenRect.width, height: childrenRect.height }); offsets.marginTop = 0; offsets.marginLeft = 0; // Subtract margins of documentElement in case it's being used as parent // we do this only on HTML because it's the only element that behaves // differently when margins are applied to it. The margins are included in // the box of the documentElement, in the other cases not. if (!isIE10 && isHTML) { var marginTop = +styles.marginTop.split('px')[0]; var marginLeft = +styles.marginLeft.split('px')[0]; offsets.top -= borderTopWidth - marginTop; offsets.bottom -= borderTopWidth - marginTop; offsets.left -= borderLeftWidth - marginLeft; offsets.right -= borderLeftWidth - marginLeft; // Attach marginTop and marginLeft because in some circumstances we may need them offsets.marginTop = marginTop; offsets.marginLeft = marginLeft; } if (isIE10 ? parent.contains(scrollParent) : parent === scrollParent && scrollParent.nodeName !== 'BODY') { offsets = includeScroll(offsets, parent); } return offsets; } function getViewportOffsetRectRelativeToArtbitraryNode(element) { var html = element.ownerDocument.documentElement; var relativeOffset = getOffsetRectRelativeToArbitraryNode(element, html); var width = Math.max(html.clientWidth, window.innerWidth || 0); var height = Math.max(html.clientHeight, window.innerHeight || 0); var scrollTop = getScroll(html); var scrollLeft = getScroll(html, 'left'); var offset = { top: scrollTop - relativeOffset.top + relativeOffset.marginTop, left: scrollLeft - relativeOffset.left + relativeOffset.marginLeft, width: width, height: height }; return getClientRect(offset); } /** * Check if the given element is fixed or is inside a fixed parent * @method * @memberof Popper.Utils * @argument {Element} element * @argument {Element} customContainer * @returns {Boolean} answer to "isFixed?" */ function isFixed(element) { var nodeName = element.nodeName; if (nodeName === 'BODY' || nodeName === 'HTML') { return false; } if (getStyleComputedProperty(element, 'position') === 'fixed') { return true; } return isFixed(getParentNode(element)); } /** * Computed the boundaries limits and return them * @method * @memberof Popper.Utils * @param {HTMLElement} popper * @param {HTMLElement} reference * @param {number} padding * @param {HTMLElement} boundariesElement - Element used to define the boundaries * @returns {Object} Coordinates of the boundaries */ function getBoundaries(popper, reference, padding, boundariesElement) { // NOTE: 1 DOM access here var boundaries = { top: 0, left: 0 }; var offsetParent = findCommonOffsetParent(popper, reference); // Handle viewport case if (boundariesElement === 'viewport') { boundaries = getViewportOffsetRectRelativeToArtbitraryNode(offsetParent); } else { // Handle other cases based on DOM element used as boundaries var boundariesNode = void 0; if (boundariesElement === 'scrollParent') { boundariesNode = getScrollParent(getParentNode(popper)); if (boundariesNode.nodeName === 'BODY') { boundariesNode = popper.ownerDocument.documentElement; } } else if (boundariesElement === 'window') { boundariesNode = popper.ownerDocument.documentElement; } else { boundariesNode = boundariesElement; } var offsets = getOffsetRectRelativeToArbitraryNode(boundariesNode, offsetParent); // In case of HTML, we need a different computation if (boundariesNode.nodeName === 'HTML' && !isFixed(offsetParent)) { var _getWindowSizes = getWindowSizes(), height = _getWindowSizes.height, width = _getWindowSizes.width; boundaries.top += offsets.top - offsets.marginTop; boundaries.bottom = height + offsets.top; boundaries.left += offsets.left - offsets.marginLeft; boundaries.right = width + offsets.left; } else { // for all the other DOM elements, this one is good boundaries = offsets; } } // Add paddings boundaries.left += padding; boundaries.top += padding; boundaries.right -= padding; boundaries.bottom -= padding; return boundaries; } function getArea(_ref) { var width = _ref.width, height = _ref.height; return width * height; } /** * Utility used to transform the `auto` placement to the placement with more * available space. * @method * @memberof Popper.Utils * @argument {Object} data - The data object generated by update method * @argument {Object} options - Modifiers configuration and options * @returns {Object} The data object, properly modified */ function computeAutoPlacement(placement, refRect, popper, reference, boundariesElement) { var padding = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 0; if (placement.indexOf('auto') === -1) { return placement; } var boundaries = getBoundaries(popper, reference, padding, boundariesElement); var rects = { top: { width: boundaries.width, height: refRect.top - boundaries.top }, right: { width: boundaries.right - refRect.right, height: boundaries.height }, bottom: { width: boundaries.width, height: boundaries.bottom - refRect.bottom }, left: { width: refRect.left - boundaries.left, height: boundaries.height } }; var sortedAreas = Object.keys(rects).map(function (key) { return _extends({ key: key }, rects[key], { area: getArea(rects[key]) }); }).sort(function (a, b) { return b.area - a.area; }); var filteredAreas = sortedAreas.filter(function (_ref2) { var width = _ref2.width, height = _ref2.height; return width >= popper.clientWidth && height >= popper.clientHeight; }); var computedPlacement = filteredAreas.length > 0 ? filteredAreas[0].key : sortedAreas[0].key; var variation = placement.split('-')[1]; return computedPlacement + (variation ? '-' + variation : ''); } /** * Get offsets to the reference element * @method * @memberof Popper.Utils * @param {Object} state * @param {Element} popper - the popper element * @param {Element} reference - the reference element (the popper will be relative to this) * @returns {Object} An object containing the offsets which will be applied to the popper */ function getReferenceOffsets(state, popper, reference) { var commonOffsetParent = findCommonOffsetParent(popper, reference); return getOffsetRectRelativeToArbitraryNode(reference, commonOffsetParent); } /** * Get the outer sizes of the given element (offset size + margins) * @method * @memberof Popper.Utils * @argument {Element} element * @returns {Object} object containing width and height properties */ function getOuterSizes(element) { var styles = window.getComputedStyle(element); var x = parseFloat(styles.marginTop) + parseFloat(styles.marginBottom); var y = parseFloat(styles.marginLeft) + parseFloat(styles.marginRight); var result = { width: element.offsetWidth + y, height: element.offsetHeight + x }; return result; } /** * Get the opposite placement of the given one * @method * @memberof Popper.Utils * @argument {String} placement * @returns {String} flipped placement */ function getOppositePlacement(placement) { var hash = { left: 'right', right: 'left', bottom: 'top', top: 'bottom' }; return placement.replace(/left|right|bottom|top/g, function (matched) { return hash[matched]; }); } /** * Get offsets to the popper * @method * @memberof Popper.Utils * @param {Object} position - CSS position the Popper will get applied * @param {HTMLElement} popper - the popper element * @param {Object} referenceOffsets - the reference offsets (the popper will be relative to this) * @param {String} placement - one of the valid placement options * @returns {Object} popperOffsets - An object containing the offsets which will be applied to the popper */ function getPopperOffsets(popper, referenceOffsets, placement) { placement = placement.split('-')[0]; // Get popper node sizes var popperRect = getOuterSizes(popper); // Add position, width and height to our offsets object var popperOffsets = { width: popperRect.width, height: popperRect.height }; // depending by the popper placement we have to compute its offsets slightly differently var isHoriz = ['right', 'left'].indexOf(placement) !== -1; var mainSide = isHoriz ? 'top' : 'left'; var secondarySide = isHoriz ? 'left' : 'top'; var measurement = isHoriz ? 'height' : 'width'; var secondaryMeasurement = !isHoriz ? 'height' : 'width'; popperOffsets[mainSide] = referenceOffsets[mainSide] + referenceOffsets[measurement] / 2 - popperRect[measurement] / 2; if (placement === secondarySide) { popperOffsets[secondarySide] = referenceOffsets[secondarySide] - popperRect[secondaryMeasurement]; } else { popperOffsets[secondarySide] = referenceOffsets[getOppositePlacement(secondarySide)]; } return popperOffsets; } /** * Mimics the `find` method of Array * @method * @memberof Popper.Utils * @argument {Array} arr * @argument prop * @argument value * @returns index or -1 */ function find(arr, check) { // use native find if supported if (Array.prototype.find) { return arr.find(check); } // use `filter` to obtain the same behavior of `find` return arr.filter(check)[0]; } /** * Return the index of the matching object * @method * @memberof Popper.Utils * @argument {Array} arr * @argument prop * @argument value * @returns index or -1 */ function findIndex(arr, prop, value) { // use native findIndex if supported if (Array.prototype.findIndex) { return arr.findIndex(function (cur) { return cur[prop] === value; }); } // use `find` + `indexOf` if `findIndex` isn't supported var match = find(arr, function (obj) { return obj[prop] === value; }); return arr.indexOf(match); } /** * Loop trough the list of modifiers and run them in order, * each of them will then edit the data object. * @method * @memberof Popper.Utils * @param {dataObject} data * @param {Array} modifiers * @param {String} ends - Optional modifier name used as stopper * @returns {dataObject} */ function runModifiers(modifiers, data, ends) { var modifiersToRun = ends === undefined ? modifiers : modifiers.slice(0, findIndex(modifiers, 'name', ends)); modifiersToRun.forEach(function (modifier) { if (modifier['function']) { // eslint-disable-line dot-notation console.warn('`modifier.function` is deprecated, use `modifier.fn`!'); } var fn = modifier['function'] || modifier.fn; // eslint-disable-line dot-notation if (modifier.enabled && isFunction(fn)) { // Add properties to offsets to make them a complete clientRect object // we do this before each modifier to make sure the previous one doesn't // mess with these values data.offsets.popper = getClientRect(data.offsets.popper); data.offsets.reference = getClientRect(data.offsets.reference); data = fn(data, modifier); } }); return data; } /** * Updates the position of the popper, computing the new offsets and applying * the new style.<br /> * Prefer `scheduleUpdate` over `update` because of performance reasons. * @method * @memberof Popper */ function update() { // if popper is destroyed, don't perform any further update if (this.state.isDestroyed) { return; } var data = { instance: this, styles: {}, arrowStyles: {}, attributes: {}, flipped: false, offsets: {} }; // compute reference element offsets data.offsets.reference = getReferenceOffsets(this.state, this.popper, this.reference); // compute auto placement, store placement inside the data object, // modifiers will be able to edit `placement` if needed // and refer to originalPlacement to know the original value data.placement = computeAutoPlacement(this.options.placement, data.offsets.reference, this.popper, this.reference, this.options.modifiers.flip.boundariesElement, this.options.modifiers.flip.padding); // store the computed placement inside `originalPlacement` data.originalPlacement = data.placement; // compute the popper offsets data.offsets.popper = getPopperOffsets(this.popper, data.offsets.reference, data.placement); data.offsets.popper.position = 'absolute'; // run the modifiers data = runModifiers(this.modifiers, data); // the first `update` will call `onCreate` callback // the other ones will call `onUpdate` callback if (!this.state.isCreated) { this.state.isCreated = true; this.options.onCreate(data); } else { this.options.onUpdate(data); } } /** * Helper used to know if the given modifier is enabled. * @method * @memberof Popper.Utils * @returns {Boolean} */ function isModifierEnabled(modifiers, modifierName) { return modifiers.some(function (_ref) { var name = _ref.name, enabled = _ref.enabled; return enabled && name === modifierName; }); } /** * Get the prefixed supported property name * @method * @memberof Popper.Utils * @argument {String} property (camelCase) * @returns {String} prefixed property (camelCase or PascalCase, depending on the vendor prefix) */ function getSupportedPropertyName(property) { var prefixes = [false, 'ms', 'Webkit', 'Moz', 'O']; var upperProp = property.charAt(0).toUpperCase() + property.slice(1); for (var i = 0; i < prefixes.length - 1; i++) { var prefix = prefixes[i]; var toCheck = prefix ? '' + prefix + upperProp : property; if (typeof window.document.body.style[toCheck] !== 'undefined') { return toCheck; } } return null; } /** * Destroy the popper * @method * @memberof Popper */ function destroy() { this.state.isDestroyed = true; // touch DOM only if `applyStyle` modifier is enabled if (isModifierEnabled(this.modifiers, 'applyStyle')) { this.popper.removeAttribute('x-placement'); this.popper.style.left = ''; this.popper.style.position = ''; this.popper.style.top = ''; this.popper.style[getSupportedPropertyName('transform')] = ''; } this.disableEventListeners(); // remove the popper if user explicity asked for the deletion on destroy // do not use `remove` because IE11 doesn't support it if (this.options.removeOnDestroy) { this.popper.parentNode.removeChild(this.popper); } return this; } /** * Get the window associated with the element * @argument {Element} element * @returns {Window} */ function getWindow(element) { var ownerDocument = element.ownerDocument; return ownerDocument ? ownerDocument.defaultView : window; } function attachToScrollParents(scrollParent, event, callback, scrollParents) { var isBody = scrollParent.nodeName === 'BODY'; var target = isBody ? scrollParent.ownerDocument.defaultView : scrollParent; target.addEventListener(event, callback, { passive: true }); if (!isBody) { attachToScrollParents(getScrollParent(target.parentNode), event, callback, scrollParents); } scrollParents.push(target); } /** * Setup needed event listeners used to update the popper position * @method * @memberof Popper.Utils * @private */ function setupEventListeners(reference, options, state, updateBound) { // Resize event listener on window state.updateBound = updateBound; getWindow(reference).addEventListener('resize', state.updateBound, { passive: true }); // Scroll event listener on scroll parents var scrollElement = getScrollParent(reference); attachToScrollParents(scrollElement, 'scroll', state.updateBound, state.scrollParents); state.scrollElement = scrollElement; state.eventsEnabled = true; return state; } /** * It will add resize/scroll events and start recalculating * position of the popper element when they are triggered. * @method * @memberof Popper */ function enableEventListeners() { if (!this.state.eventsEnabled) { this.state = setupEventListeners(this.reference, this.options, this.state, this.scheduleUpdate); } } /** * Remove event listeners used to update the popper position * @method * @memberof Popper.Utils * @private */ function removeEventListeners(reference, state) { // Remove resize event listener on window getWindow(reference).removeEventListener('resize', state.updateBound); // Remove scroll event listener on scroll parents state.scrollParents.forEach(function (target) { target.removeEventListener('scroll', state.updateBound); }); // Reset state state.updateBound = null; state.scrollParents = []; state.scrollElement = null; state.eventsEnabled = false; return state; } /** * It will remove resize/scroll events and won't recalculate popper position * when they are triggered. It also won't trigger onUpdate callback anymore, * unless you call `update` method manually. * @method * @memberof Popper */ function disableEventListeners() { if (this.state.eventsEnabled) { window.cancelAnimationFrame(this.scheduleUpdate); this.state = removeEventListeners(this.reference, this.state); } } /** * Tells if a given input is a number * @method * @memberof Popper.Utils * @param {*} input to check * @return {Boolean} */ function isNumeric(n) { return n !== '' && !isNaN(parseFloat(n)) && isFinite(n); } /** * Set the style to the given popper * @method * @memberof Popper.Utils * @argument {Element} element - Element to apply the style to * @argument {Object} styles * Object with a list of properties and values which will be applied to the element */ function setStyles(element, styles) { Object.keys(styles).forEach(function (prop) { var unit = ''; // add unit if the value is numeric and is one of the following if (['width', 'height', 'top', 'right', 'bottom', 'left'].indexOf(prop) !== -1 && isNumeric(styles[prop])) { unit = 'px'; } element.style[prop] = styles[prop] + unit; }); } /** * Set the attributes to the given popper * @method * @memberof Popper.Utils * @argument {Element} element - Element to apply the attributes to * @argument {Object} styles * Object with a list of properties and values which will be applied to the element */ function setAttributes(element, attributes) { Object.keys(attributes).forEach(function (prop) { var value = attributes[prop]; if (value !== false) { element.setAttribute(prop, attributes[prop]); } else { element.removeAttribute(prop); } }); } /** * @function * @memberof Modifiers * @argument {Object} data - The data object generated by `update` method * @argument {Object} data.styles - List of style properties - values to apply to popper element * @argument {Object} data.attributes - List of attribute properties - values to apply to popper element * @argument {Object} options - Modifiers configuration and options * @returns {Object} The same data object */ function applyStyle(data) { // any property present in `data.styles` will be applied to the popper, // in this way we can make the 3rd party modifiers add custom styles to it // Be aware, modifiers could override the properties defined in the previous // lines of this modifier! setStyles(data.instance.popper, data.styles); // any property present in `data.attributes` will be applied to the popper, // they will be set as HTML attributes of the element setAttributes(data.instance.popper, data.attributes); // if arrowElement is defined and arrowStyles has some properties if (data.arrowElement && Object.keys(data.arrowStyles).length) { setStyles(data.arrowElement, data.arrowStyles); } return data; } /** * Set the x-placement attribute before everything else because it could be used * to add margins to the popper margins needs to be calculated to get the * correct popper offsets. * @method * @memberof Popper.modifiers * @param {HTMLElement} reference - The reference element used to position the popper * @param {HTMLElement} popper - The HTML element used as popper. * @param {Object} options - Popper.js options */ function applyStyleOnLoad(reference, popper, options, modifierOptions, state) { // compute reference element offsets var referenceOffsets = getReferenceOffsets(state, popper, reference); // compute auto placement, store placement inside the data object, // modifiers will be able to edit `placement` if needed // and refer to originalPlacement to know the original value var placement = computeAutoPlacement(options.placement, referenceOffsets, popper, reference, options.modifiers.flip.boundariesElement, options.modifiers.flip.padding); popper.setAttribute('x-placement', placement); // Apply `position` to popper before anything else because // without the position applied we can't guarantee correct computations setStyles(popper, { position: 'absolute' }); return options; } /** * @function * @memberof Modifiers * @argument {Object} data - The data object generated by `update` method * @argument {Object} options - Modifiers configuration and options * @returns {Object} The data object, properly modified */ function computeStyle(data, options) { var x = options.x, y = options.y; var popper = data.offsets.popper; // Remove this legacy support in Popper.js v2 var legacyGpuAccelerationOption = find(data.instance.modifiers, function (modifier) { return modifier.name === 'applyStyle'; }).gpuAcceleration; if (legacyGpuAccelerationOption !== undefined) { console.warn('WARNING: `gpuAcceleration` option moved to `computeStyle` modifier and will not be supported in future versions of Popper.js!'); } var gpuAcceleration = legacyGpuAccelerationOption !== undefined ? legacyGpuAccelerationOption : options.gpuAcceleration; var offsetParent = getOffsetParent(data.instance.popper); var offsetParentRect = getBoundingClientRect(offsetParent); // Styles var styles = { position: popper.position }; // floor sides to avoid blurry text var offsets = { left: Math.floor(popper.left), top: Math.floor(popper.top), bottom: Math.floor(popper.bottom), right: Math.floor(popper.right) }; var sideA = x === 'bottom' ? 'top' : 'bottom'; var sideB = y === 'right' ? 'left' : 'right'; // if gpuAcceleration is set to `true` and transform is supported, // we use `translate3d` to apply the position to the popper we // automatically use the supported prefixed version if needed var prefixedProperty = getSupportedPropertyName('transform'); // now, let's make a step back and look at this code closely (wtf?) // If the content of the popper grows once it's been positioned, it // may happen that the popper gets misplaced because of the new content // overflowing its reference element // To avoid this problem, we provide two options (x and y), which allow // the consumer to define the offset origin. // If we position a popper on top of a reference element, we can set // `x` to `top` to make the popper grow towards its top instead of // its bottom. var left = void 0, top = void 0; if (sideA === 'bottom') { top = -offsetParentRect.height + offsets.bottom; } else { top = offsets.top; } if (sideB === 'right') { left = -offsetParentRect.width + offsets.right; } else { left = offsets.left; } if (gpuAcceleration && prefixedProperty) { styles[prefixedProperty] = 'translate3d(' + left + 'px, ' + top + 'px, 0)'; styles[sideA] = 0; styles[sideB] = 0; styles.willChange = 'transform'; } else { // othwerise, we use the standard `top`, `left`, `bottom` and `right` properties var invertTop = sideA === 'bottom' ? -1 : 1; var invertLeft = sideB === 'right' ? -1 : 1; styles[sideA] = top * invertTop; styles[sideB] = left * invertLeft; styles.willChange = sideA + ', ' + sideB; } // Attributes var attributes = { 'x-placement': data.placement }; // Update `data` attributes, styles and arrowStyles data.attributes = _extends({}, attributes, data.attributes); data.styles = _extends({}, styles, data.styles); data.arrowStyles = _extends({}, data.offsets.arrow, data.arrowStyles); return data; } /** * Helper used to know if the given modifier depends from another one.<br /> * It checks if the needed modifier is listed and enabled. * @method * @memberof Popper.Utils * @param {Array} modifiers - list of modifiers * @param {String} requestingName - name of requesting modifier * @param {String} requestedName - name of requested modifier * @returns {Boolean} */ function isModifierRequired(modifiers, requestingName, requestedName) { var requesting = find(modifiers, function (_ref) { var name = _ref.name; return name === requestingName; }); var isRequired = !!requesting && modifiers.some(function (modifier) { return modifier.name === requestedName && modifier.enabled && modifier.order < requesting.order; }); if (!isRequired) { var _requesting = '`' + requestingName + '`'; var requested = '`' + requestedName + '`'; console.warn(requested + ' modifier is required by ' + _requesting + ' modifier in order to work, be sure to include it before ' + _requesting + '!'); } return isRequired; } /** * @function * @memberof Modifiers * @argument {Object} data - The data object generated by update method * @argument {Object} options - Modifiers configuration and options * @returns {Object} The data object, properly modified */ function arrow(data, options) { // arrow depends on keepTogether in order to work if (!isModifierRequired(data.instance.modifiers, 'arrow', 'keepTogether')) { return data; } var arrowElement = options.element; // if arrowElement is a string, suppose it's a CSS selector if (typeof arrowElement === 'string') { arrowElement = data.instance.popper.querySelector(arrowElement); // if arrowElement is not found, don't run the modifier if (!arrowElement) { return data; } } else { // if the arrowElement isn't a query selector we must check that the // provided DOM node is child of its popper node if (!data.instance.popper.contains(arrowElement)) { console.warn('WARNING: `arrow.element` must be child of its popper element!'); return data; } } var placement = data.placement.split('-')[0]; var _data$offsets = data.offsets, popper = _data$offsets.popper, reference = _data$offsets.reference; var isVertical = ['left', 'right'].indexOf(placement) !== -1; var len = isVertical ? 'height' : 'width'; var sideCapitalized = isVertical ? 'Top' : 'Left'; var side = sideCapitalized.toLowerCase(); var altSide = isVertical ? 'left' : 'top'; var opSide = isVertical ? 'bottom' : 'right'; var arrowElementSize = getOuterSizes(arrowElement)[len]; // // extends keepTogether behavior making sure the popper and its // reference have enough pixels in conjuction // // top/left side if (reference[opSide] - arrowElementSize < popper[side]) { data.offsets.popper[side] -= popper[side] - (reference[opSide] - arrowElementSize); } // bottom/right side if (reference[side] + arrowElementSize > popper[opSide]) { data.offsets.popper[side] += reference[side] + arrowElementSize - popper[opSide]; } // compute center of the popper var center = reference[side] + reference[len] / 2 - arrowElementSize / 2; // Compute the sideValue using the updated popper offsets // take popper margin in account because we don't have this info available var popperMarginSide = getStyleComputedProperty(data.instance.popper, 'margin' + sideCapitalized).replace('px', ''); var sideValue = center - getClientRect(data.offsets.popper)[side] - popperMarginSide; // prevent arrowElement from being placed not contiguously to its popper sideValue = Math.max(Math.min(popper[len] - arrowElementSize, sideValue), 0); data.arrowElement = arrowElement; data.offsets.arrow = {}; data.offsets.arrow[side] = Math.round(sideValue); data.offsets.arrow[altSide] = ''; // make sure to unset any eventual altSide value from the DOM node return data; } /** * Get the opposite placement variation of the given one * @method * @memberof Popper.Utils * @argument {String} placement variation * @returns {String} flipped placement variation */ function getOppositeVariation(variation) { if (variation === 'end') { return 'start'; } else if (variation === 'start') { return 'end'; } return variation; } /** * List of accepted placements to use as values of the `placement` option.<br /> * Valid placements are: * - `auto` * - `top` * - `right` * - `bottom` * - `left` * * Each placement can have a variation from this list: * - `-start` * - `-end` * * Variations are interpreted easily if you think of them as the left to right * written languages. Horizontally (`top` and `bottom`), `start` is left and `end` * is right.<br /> * Vertically (`left` and `right`), `start` is top and `end` is bottom. * * Some valid examples are: * - `top-end` (on top of reference, right aligned) * - `right-start` (on right of reference, top aligned) * - `bottom` (on bottom, centered) * - `auto-right` (on the side with more space available, alignment depends by placement) * * @static * @type {Array} * @enum {String} * @readonly * @method placements * @memberof Popper */ var placements = ['auto-start', 'auto', 'auto-end', 'top-start', 'top', 'top-end', 'right-start', 'right', 'right-end', 'bottom-end', 'bottom', 'bottom-start', 'left-end', 'left', 'left-start']; // Get rid of `auto` `auto-start` and `auto-end` var validPlacements = placements.slice(3); /** * Given an initial placement, returns all the subsequent placements * clockwise (or counter-clockwise). * * @method * @memberof Popper.Utils * @argument {String} placement - A valid placement (it accepts variations) * @argument {Boolean} counter - Set to true to walk the placements counterclockwise * @returns {Array} placements including their variations */ function clockwise(placement) { var counter = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; var index = validPlacements.indexOf(placement); var arr = validPlacements.slice(index + 1).concat(validPlacements.slice(0, index)); return counter ? arr.reverse() : arr; } var BEHAVIORS = { FLIP: 'flip', CLOCKWISE: 'clockwise', COUNTERCLOCKWISE: 'counterclockwise' }; /** * @function * @memberof Modifiers * @argument {Object} data - The data object generated by update method * @argument {Object} options - Modifiers configuration and options * @returns {Object} The data object, properly modified */ function flip(data, options) { // if `inner` modifier is enabled, we can't use the `flip` modifier if (isModifierEnabled(data.instance.modifiers, 'inner')) { return data; } if (data.flipped && data.placement === data.originalPlacement) { // seems like flip is trying to loop, probably there's not enough space on any of the flippable sides return data; } var boundaries = getBoundaries(data.instance.popper, data.instance.reference, options.padding, options.boundariesElement); var placement = data.placement.split('-')[0]; var placementOpposite = getOppositePlacement(placement); var variation = data.placement.split('-')[1] || ''; var flipOrder = []; switch (options.behavior) { case BEHAVIORS.FLIP: flipOrder = [placement, placementOpposite]; break; case BEHAVIORS.CLOCKWISE: flipOrder = clockwise(placement); break; case BEHAVIORS.COUNTERCLOCKWISE: flipOrder = clockwise(placement, true); break; default: flipOrder = options.behavior; } flipOrder.forEach(function (step, index) { if (placement !== step || flipOrder.length === index + 1) { return data; } placement = data.placement.split('-')[0]; placementOpposite = getOppositePlacement(placement); var popperOffsets = data.offsets.popper; var refOffsets = data.offsets.reference; // using floor because the reference offsets may contain decimals we are not going to consider here var floor = Math.floor; var overlapsRef = placement === 'left' && floor(popperOffsets.right) > floor(refOffsets.left) || placement === 'right' && floor(popperOffsets.left) < floor(refOffsets.right) || placement === 'top' && floor(popperOffsets.bottom) > floor(refOffsets.top) || placement === 'bottom' && floor(popperOffsets.top) < floor(refOffsets.bottom); var overflowsLeft = floor(popperOffsets.left) < floor(boundaries.left); var overflowsRight = floor(popperOffsets.right) > floor(boundaries.right); var overflowsTop = floor(popperOffsets.top) < floor(boundaries.top); var overflowsBottom = floor(popperOffsets.bottom) > floor(boundaries.bottom); var overflowsBoundaries = placement === 'left' && overflowsLeft || placement === 'right' && overflowsRight || placement === 'top' && overflowsTop || placement === 'bottom' && overflowsBottom; // flip the variation if required var isVertical = ['top', 'bottom'].indexOf(placement) !== -1; var flippedVariation = !!options.flipVariations && (isVertical && variation === 'start' && overflowsLeft || isVertical && variation === 'end' && overflowsRight || !isVertical && variation === 'start' && overflowsTop || !isVertical && variation === 'end' && overflowsBottom); if (overlapsRef || overflowsBoundaries || flippedVariation) { // this boolean to detect any flip loop data.flipped = true; if (overlapsRef || overflowsBoundaries) { placement = flipOrder[index + 1]; } if (flippedVariation) { variation = getOppositeVariation(variation); } data.placement = placement + (variation ? '-' + variation : ''); // this object contains `position`, we want to preserve it along with // any additional property we may add in the future data.offsets.popper = _extends({}, data.offsets.popper, getPopperOffsets(data.instance.popper, data.offsets.reference, data.placement)); data = runModifiers(data.instance.modifiers, data, 'flip'); } }); return data; } /** * @function * @memberof Modifiers * @argument {Object} data - The data object generated by update method * @argument {Object} options - Modifiers configuration and options * @returns {Object} The data object, properly modified */ function keepTogether(data) { var _data$offsets = data.offsets, popper = _data$offsets.popper, reference = _data$offsets.reference; var placement = data.placement.split('-')[0]; var floor = Math.floor; var isVertical = ['top', 'bottom'].indexOf(placement) !== -1; var side = isVertical ? 'right' : 'bottom'; var opSide = isVertical ? 'left' : 'top'; var measurement = isVertical ? 'width' : 'height'; if (popper[side] < floor(reference[opSide])) { data.offsets.popper[opSide] = floor(reference[opSide]) - popper[measurement]; } if (popper[opSide] > floor(reference[side])) { data.offsets.popper[opSide] = floor(reference[side]); } return data; } /** * Converts a string containing value + unit into a px value number * @function * @memberof {modifiers~offset} * @private * @argument {String} str - Value + unit string * @argument {String} measurement - `height` or `width` * @argument {Object} popperOffsets * @argument {Object} referenceOffsets * @returns {Number|String} * Value in pixels, or original string if no values were extracted */ function toValue(str, measurement, popperOffsets, referenceOffsets) { // separate value from unit var split = str.match(/((?:\-|\+)?\d*\.?\d*)(.*)/); var value = +split[1]; var unit = split[2]; // If it's not a number it's an operator, I guess if (!value) { return str; } if (unit.indexOf('%') === 0) { var element = void 0; switch (unit) { case '%p': element = popperOffsets; break; case '%': case '%r': default: element = referenceOffsets; } var rect = getClientRect(element); return rect[measurement] / 100 * value; } else if (unit === 'vh' || unit === 'vw') { // if is a vh or vw, we calculate the size based on the viewport var size = void 0; if (unit === 'vh') { size = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); } else { size = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); } return size / 100 * value; } else { // if is an explicit pixel unit, we get rid of the unit and keep the value // if is an implicit unit, it's px, and we return just the value return value; } } /** * Parse an `offset` string to extrapolate `x` and `y` numeric offsets. * @function * @memberof {modifiers~offset} * @private * @argument {String} offset * @argument {Object} popperOffsets * @argument {Object} referenceOffsets * @argument {String} basePlacement * @returns {Array} a two cells array with x and y offsets in numbers */ function parseOffset(offset, popperOffsets, referenceOffsets, basePlacement) { var offsets = [0, 0]; // Use height if placement is left or right and index is 0 otherwise use width // in this way the first offset will use an axis and the second one // will use the other one var useHeight = ['right', 'left'].indexOf(basePlacement) !== -1; // Split the offset string to obtain a list of values and operands // The regex addresses values with the plus or minus sign in front (+10, -20, etc) var fragments = offset.split(/(\+|\-)/).map(function (frag) { return frag.trim(); }); // Detect if the offset string contains a pair of values or a single one // they could be separated by comma or space var divider = fragments.indexOf(find(fragments, function (frag) { return frag.search(/,|\s/) !== -1; })); if (fragments[divider] && fragments[divider].indexOf(',') === -1) { console.warn('Offsets separated by white space(s) are deprecated, use a comma (,) instead.'); } // If divider is found, we divide the list of values and operands to divide // them by ofset X and Y. var splitRegex = /\s*,\s*|\s+/; var ops = divider !== -1 ? [fragments.slice(0, divider).concat([fragments[divider].split(splitRegex)[0]]), [fragments[divider].split(splitRegex)[1]].concat(fragments.slice(divider + 1))] : [fragments]; // Convert the values with units to absolute pixels to allow our computations ops = ops.map(function (op, index) { // Most of the units rely on the orientation of the popper var measurement = (index === 1 ? !useHeight : useHeight) ? 'height' : 'width'; var mergeWithPrevious = false; return op // This aggregates any `+` or `-` sign that aren't considered operators // e.g.: 10 + +5 => [10, +, +5] .reduce(function (a, b) { if (a[a.length - 1] === '' && ['+', '-'].indexOf(b) !== -1) { a[a.length - 1] = b; mergeWithPrevious = true; return a; } else if (mergeWithPrevious) { a[a.length - 1] += b; mergeWithPrevious = false; return a; } else { return a.concat(b); } }, []) // Here we convert the string values into number values (in px) .map(function (str) { return toValue(str, measurement, popperOffsets, referenceOffsets); }); }); // Loop trough the offsets arrays and execute the operations ops.forEach(function (op, index) { op.forEach(function (frag, index2) { if (isNumeric(frag)) { offsets[index] += frag * (op[index2 - 1] === '-' ? -1 : 1); } }); }); return offsets; } /** * @function * @memberof Modifiers * @argument {Object} data - The data object generated by update method * @argument {Object} options - Modifiers configuration and options * @argument {Number|String} options.offset=0 * The offset value as described in the modifier description * @returns {Object} The data object, properly modified */ function offset(data, _ref) { var offset = _ref.offset; var placement = data.placement, _data$offsets = data.offsets, popper = _data$offsets.popper, reference = _data$offsets.reference; var basePlacement = placement.split('-')[0]; var offsets = void 0; if (isNumeric(+offset)) { offsets = [+offset, 0]; } else { offsets = parseOffset(offset, popper, reference, basePlacement); } if (basePlacement === 'left') { popper.top += offsets[0]; popper.left -= offsets[1]; } else if (basePlacement === 'right') { popper.top += offsets[0]; popper.left += offsets[1]; } else if (basePlacement === 'top') { popper.left += offsets[0]; popper.top -= offsets[1]; } else if (basePlacement === 'bottom') { popper.left += offsets[0]; popper.top += offsets[1]; } data.popper = popper; return data; } /** * @function * @memberof Modifiers * @argument {Object} data - The data object generated by `update` method * @argument {Object} options - Modifiers configuration and options * @returns {Object} The data object, properly modified */ function preventOverflow(data, options) { var boundariesElement = options.boundariesElement || getOffsetParent(data.instance.popper); // If offsetParent is the reference element, we really want to // go one step up and use the next offsetParent as reference to // avoid to make this modifier completely useless and look like broken if (data.instance.reference === boundariesElement) { boundariesElement = getOffsetParent(boundariesElement); } var boundaries = getBoundaries(data.instance.popper, data.instance.reference, options.padding, boundariesElement); options.boundaries = boundaries; var order = options.priority; var popper = data.offsets.popper; var check = { primary: function primary(placement) { var value = popper[placement]; if (popper[placement] < boundaries[placement] && !options.escapeWithReference) { value = Math.max(popper[placement], boundaries[placement]); } return defineProperty({}, placement, value); }, secondary: function secondary(placement) { var mainSide = placement === 'right' ? 'left' : 'top'; var value = popper[mainSide]; if (popper[placement] > boundaries[placement] && !options.escapeWithReference) { value = Math.min(popper[mainSide], boundaries[placement] - (placement === 'right' ? popper.width : popper.height)); } return defineProperty({}, mainSide, value); } }; order.forEach(function (placement) { var side = ['left', 'top'].indexOf(placement) !== -1 ? 'primary' : 'secondary'; popper = _extends({}, popper, check[side](placement)); }); data.offsets.popper = popper; return data; } /** * @function * @memberof Modifiers * @argument {Object} data - The data object generated by `update` method * @argument {Object} options - Modifiers configuration and options * @returns {Object} The data object, properly modified */ function shift(data) { var placement = data.placement; var basePlacement = placement.split('-')[0]; var shiftvariation = placement.split('-')[1]; // if shift shiftvariation is specified, run the modifier if (shiftvariation) { var _data$offsets = data.offsets, reference = _data$offsets.reference, popper = _data$offsets.popper; var isVertical = ['bottom', 'top'].indexOf(basePlacement) !== -1; var side = isVertical ? 'left' : 'top'; var measurement = isVertical ? 'width' : 'height'; var shiftOffsets = { start: defineProperty({}, side, reference[side]), end: defineProperty({}, side, reference[side] + reference[measurement] - popper[measurement]) }; data.offsets.popper = _extends({}, popper, shiftOffsets[shiftvariation]); } return data; } /** * @function * @memberof Modifiers * @argument {Object} data - The data object generated by update method * @argument {Object} options - Modifiers configuration and options * @returns {Object} The data object, properly modified */ function hide(data) { if (!isModifierRequired(data.instance.modifiers, 'hide', 'preventOverflow')) { return data; } var refRect = data.offsets.reference; var bound = find(data.instance.modifiers, function (modifier) { return modifier.name === 'preventOverflow'; }).boundaries; if (refRect.bottom < bound.top || refRect.left > bound.right || refRect.top > bound.bottom || refRect.right < bound.left) { // Avoid unnecessary DOM access if visibility hasn't changed if (data.hide === true) { return data; } data.hide = true; data.attributes['x-out-of-boundaries'] = ''; } else { // Avoid unnecessary DOM access if visibility hasn't changed if (data.hide === false) { return data; } data.hide = false; data.attributes['x-out-of-boundaries'] = false; } return data; } /** * @function * @memberof Modifiers * @argument {Object} data - The data object generated by `update` method * @argument {Object} options - Modifiers configuration and options * @returns {Object} The data object, properly modified */ function inner(data) { var placement = data.placement; var basePlacement = placement.split('-')[0]; var _data$offsets = data.offsets, popper = _data$offsets.popper, reference = _data$offsets.reference; var isHoriz = ['left', 'right'].indexOf(basePlacement) !== -1; var subtractLength = ['top', 'left'].indexOf(basePlacement) === -1; popper[isHoriz ? 'left' : 'top'] = reference[basePlacement] - (subtractLength ? popper[isHoriz ? 'width' : 'height'] : 0); data.placement = getOppositePlacement(placement); data.offsets.popper = getClientRect(popper); return data; } /** * Modifier function, each modifier can have a function of this type assigned * to its `fn` property.<br /> * These functions will be called on each update, this means that you must * make sure they are performant enough to avoid performance bottlenecks. * * @function ModifierFn * @argument {dataObject} data - The data object generated by `update` method * @argument {Object} options - Modifiers configuration and options * @returns {dataObject} The data object, properly modified */ /** * Modifiers are plugins used to alter the behavior of your poppers.<br /> * Popper.js uses a set of 9 modifiers to provide all the basic functionalities * needed by the library. * * Usually you don't want to override the `order`, `fn` and `onLoad` props. * All the other properties are configurations that could be tweaked. * @namespace modifiers */ var modifiers = { /** * Modifier used to shift the popper on the start or end of its reference * element.<br /> * It will read the variation of the `placement` property.<br /> * It can be one either `-end` or `-start`. * @memberof modifiers * @inner */ shift: { /** @prop {number} order=100 - Index used to define the order of execution */ order: 100, /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ enabled: true, /** @prop {ModifierFn} */ fn: shift }, /** * The `offset` modifier can shift your popper on both its axis. * * It accepts the following units: * - `px` or unitless, interpreted as pixels * - `%` or `%r`, percentage relative to the length of the reference element * - `%p`, percentage relative to the length of the popper element * - `vw`, CSS viewport width unit * - `vh`, CSS viewport height unit * * For length is intended the main axis relative to the placement of the popper.<br /> * This means that if the placement is `top` or `bottom`, the length will be the * `width`. In case of `left` or `right`, it will be the height. * * You can provide a single value (as `Number` or `String`), or a pair of values * as `String` divided by a comma or one (or more) white spaces.<br /> * The latter is a deprecated method because it leads to confusion and will be * removed in v2.<br /> * Additionally, it accepts additions and subtractions between different units. * Note that multiplications and divisions aren't supported. * * Valid examples are: * ``` * 10 * '10%' * '10, 10' * '10%, 10' * '10 + 10%' * '10 - 5vh + 3%' * '-10px + 5vh, 5px - 6%' * ``` * > **NB**: If you desire to apply offsets to your poppers in a way that may make them overlap * > with their reference element, unfortunately, you will have to disable the `flip` modifier. * > More on this [reading this issue](https://github.com/FezVrasta/popper.js/issues/373) * * @memberof modifiers * @inner */ offset: { /** @prop {number} order=200 - Index used to define the order of execution */ order: 200, /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ enabled: true, /** @prop {ModifierFn} */ fn: offset, /** @prop {Number|String} offset=0 * The offset value as described in the modifier description */ offset: 0 }, /** * Modifier used to prevent the popper from being positioned outside the boundary. * * An scenario exists where the reference itself is not within the boundaries.<br /> * We can say it has "escaped the boundaries" — or just "escaped".<br /> * In this case we need to decide whether the popper should either: * * - detach from the reference and remain "trapped" in the boundaries, or * - if it should ignore the boundary and "escape with its reference" * * When `escapeWithReference` is set to`true` and reference is completely * outside its boundaries, the popper will overflow (or completely leave) * the boundaries in order to remain attached to the edge of the reference. * * @memberof modifiers * @inner */ preventOverflow: { /** @prop {number} order=300 - Index used to define the order of execution */ order: 300, /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ enabled: true, /** @prop {ModifierFn} */ fn: preventOverflow, /** * @prop {Array} [priority=['left','right','top','bottom']] * Popper will try to prevent overflow following these priorities by default, * then, it could overflow on the left and on top of the `boundariesElement` */ priority: ['left', 'right', 'top', 'bottom'], /** * @prop {number} padding=5 * Amount of pixel used to define a minimum distance between the boundaries * and the popper this makes sure the popper has always a little padding * between the edges of its container */ padding: 5, /** * @prop {String|HTMLElement} boundariesElement='scrollParent' * Boundaries used by the modifier, can be `scrollParent`, `window`, * `viewport` or any DOM element. */ boundariesElement: 'scrollParent' }, /** * Modifier used to make sure the reference and its popper stay near eachothers * without leaving any gap between the two. Expecially useful when the arrow is * enabled and you want to assure it to point to its reference element. * It cares only about the first axis, you can still have poppers with margin * between the popper and its reference element. * @memberof modifiers * @inner */ keepTogether: { /** @prop {number} order=400 - Index used to define the order of execution */ order: 400, /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ enabled: true, /** @prop {ModifierFn} */ fn: keepTogether }, /** * This modifier is used to move the `arrowElement` of the popper to make * sure it is positioned between the reference element and its popper element. * It will read the outer size of the `arrowElement` node to detect how many * pixels of conjuction are needed. * * It has no effect if no `arrowElement` is provided. * @memberof modifiers * @inner */ arrow: { /** @prop {number} order=500 - Index used to define the order of execution */ order: 500, /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ enabled: true, /** @prop {ModifierFn} */ fn: arrow, /** @prop {String|HTMLElement} element='[x-arrow]' - Selector or node used as arrow */ element: '[x-arrow]' }, /** * Modifier used to flip the popper's placement when it starts to overlap its * reference element. * * Requires the `preventOverflow` modifier before it in order to work. * * **NOTE:** this modifier will interrupt the current update cycle and will * restart it if it detects the need to flip the placement. * @memberof modifiers * @inner */ flip: { /** @prop {number} order=600 - Index used to define the order of execution */ order: 600, /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ enabled: true, /** @prop {ModifierFn} */ fn: flip, /** * @prop {String|Array} behavior='flip' * The behavior used to change the popper's placement. It can be one of * `flip`, `clockwise`, `counterclockwise` or an array with a list of valid * placements (with optional variations). */ behavior: 'flip', /** * @prop {number} padding=5 * The popper will flip if it hits the edges of the `boundariesElement` */ padding: 5, /** * @prop {String|HTMLElement} boundariesElement='viewport' * The element which will define the boundaries of the popper position, * the popper will never be placed outside of the defined boundaries * (except if keepTogether is enabled) */ boundariesElement: 'viewport' }, /** * Modifier used to make the popper flow toward the inner of the reference element. * By default, when this modifier is disabled, the popper will be placed outside * the reference element. * @memberof modifiers * @inner */ inner: { /** @prop {number} order=700 - Index used to define the order of execution */ order: 700, /** @prop {Boolean} enabled=false - Whether the modifier is enabled or not */ enabled: false, /** @prop {ModifierFn} */ fn: inner }, /** * Modifier used to hide the popper when its reference element is outside of the * popper boundaries. It will set a `x-out-of-boundaries` attribute which can * be used to hide with a CSS selector the popper when its reference is * out of boundaries. * * Requires the `preventOverflow` modifier before it in order to work. * @memberof modifiers * @inner */ hide: { /** @prop {number} order=800 - Index used to define the order of execution */ order: 800, /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ enabled: true, /** @prop {ModifierFn} */ fn: hide }, /** * Computes the style that will be applied to the popper element to gets * properly positioned. * * Note that this modifier will not touch the DOM, it just prepares the styles * so that `applyStyle` modifier can apply it. This separation is useful * in case you need to replace `applyStyle` with a custom implementation. * * This modifier has `850` as `order` value to maintain backward compatibility * with previous versions of Popper.js. Expect the modifiers ordering method * to change in future major versions of the library. * * @memberof modifiers * @inner */ computeStyle: { /** @prop {number} order=850 - Index used to define the order of execution */ order: 850, /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ enabled: true, /** @prop {ModifierFn} */ fn: computeStyle, /** * @prop {Boolean} gpuAcceleration=true * If true, it uses the CSS 3d transformation to position the popper. * Otherwise, it will use the `top` and `left` properties. */ gpuAcceleration: true, /** * @prop {string} [x='bottom'] * Where to anchor the X axis (`bottom` or `top`). AKA X offset origin. * Change this if your popper should grow in a direction different from `bottom` */ x: 'bottom', /** * @prop {string} [x='left'] * Where to anchor the Y axis (`left` or `right`). AKA Y offset origin. * Change this if your popper should grow in a direction different from `right` */ y: 'right' }, /** * Applies the computed styles to the popper element. * * All the DOM manipulations are limited to this modifier. This is useful in case * you want to integrate Popper.js inside a framework or view library and you * want to delegate all the DOM manipulations to it. * * Note that if you disable this modifier, you must make sure the popper element * has its position set to `absolute` before Popper.js can do its work! * * Just disable this modifier and define you own to achieve the desired effect. * * @memberof modifiers * @inner */ applyStyle: { /** @prop {number} order=900 - Index used to define the order of execution */ order: 900, /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ enabled: true, /** @prop {ModifierFn} */ fn: applyStyle, /** @prop {Function} */ onLoad: applyStyleOnLoad, /** * @deprecated since version 1.10.0, the property moved to `computeStyle` modifier * @prop {Boolean} gpuAcceleration=true * If true, it uses the CSS 3d transformation to position the popper. * Otherwise, it will use the `top` and `left` properties. */ gpuAcceleration: undefined } }; /** * The `dataObject` is an object containing all the informations used by Popper.js * this object get passed to modifiers and to the `onCreate` and `onUpdate` callbacks. * @name dataObject * @property {Object} data.instance The Popper.js instance * @property {String} data.placement Placement applied to popper * @property {String} data.originalPlacement Placement originally defined on init * @property {Boolean} data.flipped True if popper has been flipped by flip modifier * @property {Boolean} data.hide True if the reference element is out of boundaries, useful to know when to hide the popper. * @property {HTMLElement} data.arrowElement Node used as arrow by arrow modifier * @property {Object} data.styles Any CSS property defined here will be applied to the popper, it expects the JavaScript nomenclature (eg. `marginBottom`) * @property {Object} data.arrowStyles Any CSS property defined here will be applied to the popper arrow, it expects the JavaScript nomenclature (eg. `marginBottom`) * @property {Object} data.boundaries Offsets of the popper boundaries * @property {Object} data.offsets The measurements of popper, reference and arrow elements. * @property {Object} data.offsets.popper `top`, `left`, `width`, `height` values * @property {Object} data.offsets.reference `top`, `left`, `width`, `height` values * @property {Object} data.offsets.arrow] `top` and `left` offsets, only one of them will be different from 0 */ /** * Default options provided to Popper.js constructor.<br /> * These can be overriden using the `options` argument of Popper.js.<br /> * To override an option, simply pass as 3rd argument an object with the same * structure of this object, example: * ``` * new Popper(ref, pop, { * modifiers: { * preventOverflow: { enabled: false } * } * }) * ``` * @type {Object} * @static * @memberof Popper */ var Defaults = { /** * Popper's placement * @prop {Popper.placements} placement='bottom' */ placement: 'bottom', /** * Whether events (resize, scroll) are initially enabled * @prop {Boolean} eventsEnabled=true */ eventsEnabled: true, /** * Set to true if you want to automatically remove the popper when * you call the `destroy` method. * @prop {Boolean} removeOnDestroy=false */ removeOnDestroy: false, /** * Callback called when the popper is created.<br /> * By default, is set to no-op.<br /> * Access Popper.js instance with `data.instance`. * @prop {onCreate} */ onCreate: function onCreate() {}, /** * Callback called when the popper is updated, this callback is not called * on the initialization/creation of the popper, but only on subsequent * updates.<br /> * By default, is set to no-op.<br /> * Access Popper.js instance with `data.instance`. * @prop {onUpdate} */ onUpdate: function onUpdate() {}, /** * List of modifiers used to modify the offsets before they are applied to the popper. * They provide most of the functionalities of Popper.js * @prop {modifiers} */ modifiers: modifiers }; /** * @callback onCreate * @param {dataObject} data */ /** * @callback onUpdate * @param {dataObject} data */ // Utils // Methods var Popper = function () { /** * Create a new Popper.js instance * @class Popper * @param {HTMLElement|referenceObject} reference - The reference element used to position the popper * @param {HTMLElement} popper - The HTML element used as popper. * @param {Object} options - Your custom options to override the ones defined in [Defaults](#defaults) * @return {Object} instance - The generated Popper.js instance */ function Popper(reference, popper) { var _this = this; var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; classCallCheck(this, Popper); this.scheduleUpdate = function () { return requestAnimationFrame(_this.update); }; // make update() debounced, so that it only runs at most once-per-tick this.update = debounce(this.update.bind(this)); // with {} we create a new object with the options inside it this.options = _extends({}, Popper.Defaults, options); // init state this.state = { isDestroyed: false, isCreated: false, scrollParents: [] }; // get reference and popper elements (allow jQuery wrappers) this.reference = reference && reference.jquery ? reference[0] : reference; this.popper = popper && popper.jquery ? popper[0] : popper; // Deep merge modifiers options this.options.modifiers = {}; Object.keys(_extends({}, Popper.Defaults.modifiers, options.modifiers)).forEach(function (name) { _this.options.modifiers[name] = _extends({}, Popper.Defaults.modifiers[name] || {}, options.modifiers ? options.modifiers[name] : {}); }); // Refactoring modifiers' list (Object => Array) this.modifiers = Object.keys(this.options.modifiers).map(function (name) { return _extends({ name: name }, _this.options.modifiers[name]); }) // sort the modifiers by order .sort(function (a, b) { return a.order - b.order; }); // modifiers have the ability to execute arbitrary code when Popper.js get inited // such code is executed in the same order of its modifier // they could add new properties to their options configuration // BE AWARE: don't add options to `options.modifiers.name` but to `modifierOptions`! this.modifiers.forEach(function (modifierOptions) { if (modifierOptions.enabled && isFunction(modifierOptions.onLoad)) { modifierOptions.onLoad(_this.reference, _this.popper, _this.options, modifierOptions, _this.state); } }); // fire the first update to position the popper in the right place this.update(); var eventsEnabled = this.options.eventsEnabled; if (eventsEnabled) { // setup event listeners, they will take care of update the position in specific situations this.enableEventListeners(); } this.state.eventsEnabled = eventsEnabled; } // We can't use class properties because they don't get listed in the // class prototype and break stuff like Sinon stubs createClass(Popper, [{ key: 'update', value: function update$$1() { return update.call(this); } }, { key: 'destroy', value: function destroy$$1() { return destroy.call(this); } }, { key: 'enableEventListeners', value: function enableEventListeners$$1() { return enableEventListeners.call(this); } }, { key: 'disableEventListeners', value: function disableEventListeners$$1() { return disableEventListeners.call(this); } /** * Schedule an update, it will run on the next UI update available * @method scheduleUpdate * @memberof Popper */ /** * Collection of utilities useful when writing custom modifiers. * Starting from version 1.7, this method is available only if you * include `popper-utils.js` before `popper.js`. * * **DEPRECATION**: This way to access PopperUtils is deprecated * and will be removed in v2! Use the PopperUtils module directly instead. * Due to the high instability of the methods contained in Utils, we can't * guarantee them to follow semver. Use them at your own risk! * @static * @private * @type {Object} * @deprecated since version 1.8 * @member Utils * @memberof Popper */ }]); return Popper; }(); /** * The `referenceObject` is an object that provides an interface compatible with Popper.js * and lets you use it as replacement of a real DOM node.<br /> * You can use this method to position a popper relatively to a set of coordinates * in case you don't have a DOM node to use as reference. * * ``` * new Popper(referenceObject, popperNode); * ``` * * NB: This feature isn't supported in Internet Explorer 10 * @name referenceObject * @property {Function} data.getBoundingClientRect * A function that returns a set of coordinates compatible with the native `getBoundingClientRect` method. * @property {number} data.clientWidth * An ES6 getter that will return the width of the virtual reference element. * @property {number} data.clientHeight * An ES6 getter that will return the height of the virtual reference element. */ Popper.Utils = (typeof window !== 'undefined' ? window : global).PopperUtils; Popper.placements = placements; Popper.Defaults = Defaults; return Popper; }))); //# sourceMappingURL=popper.js.map local/modal/alert.js 0000644 00000002344 15152050146 0010401 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Alert modal. * * @module core/modal_alert * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Modal from 'core/modal'; /** * The Alert Modal * * @class * @extends Modal */ export default class extends Modal { /** * Register all event listeners. */ registerEventListeners() { // Call the parent registration. super.registerEventListeners(); // Register to close on cancel. this.registerCloseOnCancel(); } } local/reactive/statemanager.js 0000644 00000071646 15152050146 0012466 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Reactive simple state manager. * * The state manager contains the state data, trigger update events and * can lock and unlock the state data. * * This file contains the three main elements of the state manager: * - State manager: the public class to alter the state, dispatch events and process update messages. * - Proxy handler: a private class to keep track of the state object changes. * - StateMap class: a private class extending Map class that triggers event when a state list is modifed. * * @module core/local/reactive/stateManager * @class core/local/reactive/stateManager * @copyright 2021 Ferran Recio <ferran@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * State manager class. * * This class handle the reactive state and ensure only valid mutations can modify the state. * It also provide methods to apply batch state update messages (see processUpdates function doc * for more details on update messages). * * Implementing a deep state manager is complex and will require many frontend resources. To keep * the state fast and simple, the state can ONLY store two kind of data: * - Object with attributes * - Sets of objects with id attributes. * * This is an example of a valid state: * * { * course: { * name: 'course name', * shortname: 'courseshort', * sectionlist: [21, 34] * }, * sections: [ * {id: 21, name: 'Topic 1', visible: true}, * {id: 34, name: 'Topic 2', visible: false, * ], * } * * The following cases are NOT allowed at a state ROOT level (throws an exception if they are assigned): * - Simple values (strings, boolean...). * - Arrays of simple values. * - Array of objects without ID attribute (all arrays will be converted to maps and requires an ID). * * Thanks to those limitations it can simplify the state update messages and the event names. If You * need to store simple data, just group them in an object. * * To grant any state change triggers the proper events, the class uses two private structures: * - proxy handler: any object stored in the state is proxied using this class. * - StateMap class: any object set in the state will be converted to StateMap using the * objects id attribute. */ export default class StateManager { /** * Create a basic reactive state store. * * The state manager is meant to work with native JS events. To ensure each reactive module can use * it in its own way, the parent element must provide a valid event dispatcher function and an optional * DOM element to anchor the event. * * @param {function} dispatchEvent the function to dispatch the custom event when the state changes. * @param {element} target the state changed custom event target (document if none provided) */ constructor(dispatchEvent, target) { // The dispatch event function. /** @package */ this.dispatchEvent = dispatchEvent; // The DOM container to trigger events. /** @package */ this.target = target ?? document; // State can be altered freely until initial state is set. /** @package */ this.readonly = false; // List of state changes pending to be published as events. /** @package */ this.eventsToPublish = []; // The update state types functions. /** @package */ this.updateTypes = { "create": this.defaultCreate.bind(this), "update": this.defaultUpdate.bind(this), "delete": this.defaultDelete.bind(this), "put": this.defaultPut.bind(this), "override": this.defaultOverride.bind(this), "remove": this.defaultRemove.bind(this), "prepareFields": this.defaultPrepareFields.bind(this), }; // The state_loaded event is special because it only happens one but all components // may react to that state, even if they are registered after the setIinitialState. // For these reason we use a promise for that event. this.initialPromise = new Promise((resolve) => { const initialStateDone = (event) => { resolve(event.detail.state); }; this.target.addEventListener('state:loaded', initialStateDone); }); } /** * Loads the initial state. * * Note this method will trigger a state changed event with "state:loaded" actionname. * * The state mode will be set to read only when the initial state is loaded. * * @param {object} initialState */ setInitialState(initialState) { if (this.state !== undefined) { throw Error('Initial state can only be initialized ones'); } // Create the state object. const state = new Proxy({}, new Handler('state', this, true)); for (const [prop, propValue] of Object.entries(initialState)) { state[prop] = propValue; } this.state = state; // When the state is loaded we can lock it to prevent illegal changes. this.readonly = true; this.dispatchEvent({ action: 'state:loaded', state: this.state, }, this.target); } /** * Generate a promise that will be resolved when the initial state is loaded. * * In most cases the final state will be loaded using an ajax call. This is the reason * why states manager are created unlocked and won't be reactive until the initial state is set. * * @return {Promise} the resulting promise */ getInitialPromise() { return this.initialPromise; } /** * Locks or unlocks the state to prevent illegal updates. * * Mutations use this method to modify the state. Once the state is updated, they must * block again the state. * * All changes done while the state is writable will be registered using registerStateAction. * When the state is set again to read only the method will trigger _publishEvents to communicate * changes to all watchers. * * @param {bool} readonly if the state is in read only mode enabled */ setReadOnly(readonly) { this.readonly = readonly; let mode = 'off'; // When the state is in readonly again is time to publish all pending events. if (this.readonly) { mode = 'on'; this._publishEvents(); } // Dispatch a read only event. this.dispatchEvent({ action: `readmode:${mode}`, state: this.state, element: null, }, this.target); } /** * Add methods to process update state messages. * * The state manager provide a default update, create and delete methods. However, * some applications may require to override the default methods or even add new ones * like "refresh" or "error". * * @param {Object} newFunctions the new update types functions. */ addUpdateTypes(newFunctions) { for (const [updateType, updateFunction] of Object.entries(newFunctions)) { if (typeof updateFunction === 'function') { this.updateTypes[updateType] = updateFunction.bind(newFunctions); } } } /** * Process a state updates array and do all the necessary changes. * * Note this method unlocks the state while it is executing and relocks it * when finishes. * * @param {array} updates * @param {Object} updateTypes optional functions to override the default update types. */ processUpdates(updates, updateTypes) { if (!Array.isArray(updates)) { throw Error('State updates must be an array'); } this.setReadOnly(false); updates.forEach((update) => { if (update.name === undefined) { throw Error('Missing state update name'); } this.processUpdate( update.name, update.action, update.fields, updateTypes ); }); this.setReadOnly(true); } /** * Process a single state update. * * Note this method will not lock or unlock the state by itself. * * @param {string} updateName the state element to update * @param {string} action to action to perform * @param {object} fields the new data * @param {Object} updateTypes optional functions to override the default update types. */ processUpdate(updateName, action, fields, updateTypes) { if (!fields) { throw Error('Missing state update fields'); } if (updateTypes === undefined) { updateTypes = {}; } action = action ?? 'update'; const method = updateTypes[action] ?? this.updateTypes[action]; if (method === undefined) { throw Error(`Unkown update action ${action}`); } // Some state data may require some cooking before sending to the // state. Reactive instances can overrdide the default fieldDefaults // method to add extra logic to all updates. const prepareFields = updateTypes.prepareFields ?? this.updateTypes.prepareFields; method(this, updateName, prepareFields(this, updateName, fields)); } /** * Prepare fields for processing. * * This method is used to add default values or calculations from the frontend side. * * @param {Object} stateManager the state manager * @param {String} updateName the state element to update * @param {Object} fields the new data * @returns {Object} final fields data */ defaultPrepareFields(stateManager, updateName, fields) { return fields; } /** * Process a create state message. * * @param {Object} stateManager the state manager * @param {String} updateName the state element to update * @param {Object} fields the new data */ defaultCreate(stateManager, updateName, fields) { let state = stateManager.state; // Create can be applied only to lists, not to objects. if (state[updateName] instanceof StateMap) { state[updateName].add(fields); return; } state[updateName] = fields; } /** * Process a delete state message. * * @param {Object} stateManager the state manager * @param {String} updateName the state element to update * @param {Object} fields the new data */ defaultDelete(stateManager, updateName, fields) { // Get the current value. let current = stateManager.get(updateName, fields.id); if (!current) { throw Error(`Inexistent ${updateName} ${fields.id}`); } // Process deletion. let state = stateManager.state; if (state[updateName] instanceof StateMap) { state[updateName].delete(fields.id); return; } delete state[updateName]; } /** * Process a remove state message. * * @param {Object} stateManager the state manager * @param {String} updateName the state element to update * @param {Object} fields the new data */ defaultRemove(stateManager, updateName, fields) { // Get the current value. let current = stateManager.get(updateName, fields.id); if (!current) { return; } // Process deletion. let state = stateManager.state; if (state[updateName] instanceof StateMap) { state[updateName].delete(fields.id); return; } delete state[updateName]; } /** * Process a update state message. * * @param {Object} stateManager the state manager * @param {String} updateName the state element to update * @param {Object} fields the new data */ defaultUpdate(stateManager, updateName, fields) { // Get the current value. let current = stateManager.get(updateName, fields.id); if (!current) { throw Error(`Inexistent ${updateName} ${fields.id}`); } // Execute updates. for (const [fieldName, fieldValue] of Object.entries(fields)) { current[fieldName] = fieldValue; } } /** * Process a put state message. * * @param {Object} stateManager the state manager * @param {String} updateName the state element to update * @param {Object} fields the new data */ defaultPut(stateManager, updateName, fields) { // Get the current value. let current = stateManager.get(updateName, fields.id); if (current) { // Update attributes. for (const [fieldName, fieldValue] of Object.entries(fields)) { current[fieldName] = fieldValue; } } else { // Create new object. let state = stateManager.state; if (state[updateName] instanceof StateMap) { state[updateName].add(fields); return; } state[updateName] = fields; } } /** * Process an override state message. * * @param {Object} stateManager the state manager * @param {String} updateName the state element to update * @param {Object} fields the new data */ defaultOverride(stateManager, updateName, fields) { // Get the current value. let current = stateManager.get(updateName, fields.id); if (current) { // Remove any unnecessary fields. for (const [fieldName] of Object.entries(current)) { if (fields[fieldName] === undefined) { delete current[fieldName]; } } // Update field. for (const [fieldName, fieldValue] of Object.entries(fields)) { current[fieldName] = fieldValue; } } else { // Create the element if not exists. let state = stateManager.state; if (state[updateName] instanceof StateMap) { state[updateName].add(fields); return; } state[updateName] = fields; } } /** * Get an element from the state or form an alternative state object. * * The altstate param is used by external update functions that gets the current * state as param. * * @param {String} name the state object name * @param {*} id and object id for state maps. * @return {Object|undefined} the state object found */ get(name, id) { const state = this.state; let current = state[name]; if (current instanceof StateMap) { if (id === undefined) { throw Error(`Missing id for ${name} state update`); } current = state[name].get(id); } return current; } /** * Register a state modification and generate the necessary events. * * This method is used mainly by proxy helpers to dispatch state change event. * However, mutations can use it to inform components about non reactive changes * in the state (only the two first levels of the state are reactive). * * Each action can produce several events: * - The specific attribute updated, created or deleter (example: "cm.visible:updated") * - The general state object updated, created or deleted (example: "cm:updated") * - If the element has an ID attribute, the specific event with id (example: "cm[42].visible:updated") * - If the element has an ID attribute, the general event with id (example: "cm[42]:updated") * - A generic state update event "state:update" * * @param {string} field the affected state field name * @param {string|null} prop the affecter field property (null if affect the full object) * @param {string} action the action done (created/updated/deleted) * @param {*} data the affected data */ registerStateAction(field, prop, action, data) { let parentAction = 'updated'; if (prop !== null) { this.eventsToPublish.push({ eventName: `${field}.${prop}:${action}`, eventData: data, action, }); } else { parentAction = action; } // Trigger extra events if the element has an ID attribute. if (data.id !== undefined) { if (prop !== null) { this.eventsToPublish.push({ eventName: `${field}[${data.id}].${prop}:${action}`, eventData: data, action, }); } this.eventsToPublish.push({ eventName: `${field}[${data.id}]:${parentAction}`, eventData: data, action: parentAction, }); } // Register the general change. this.eventsToPublish.push({ eventName: `${field}:${parentAction}`, eventData: data, action: parentAction, }); // Register state updated event. this.eventsToPublish.push({ eventName: `state:updated`, eventData: data, action: 'updated', }); } /** * Internal method to publish events. * * This is a private method, it will be invoked when the state is set back to read only mode. */ _publishEvents() { const fieldChanges = this.eventsToPublish; this.eventsToPublish = []; // Dispatch a transaction start event. this.dispatchEvent({ action: 'transaction:start', state: this.state, element: null, changes: fieldChanges, }, this.target); // State changes can be registered in any order. However it will avoid many // components errors if they are sorted to have creations-updates-deletes in case // some component needs to create or destroy DOM elements before updating them. fieldChanges.sort((a, b) => { const weights = { created: 0, updated: 1, deleted: 2, }; const aweight = weights[a.action] ?? 0; const bweight = weights[b.action] ?? 0; // In case both have the same weight, the eventName length decide. if (aweight === bweight) { return a.eventName.length - b.eventName.length; } return aweight - bweight; }); // List of the published events to prevent redundancies. let publishedEvents = new Set(); fieldChanges.forEach((event) => { const eventkey = `${event.eventName}.${event.eventData.id ?? 0}`; if (!publishedEvents.has(eventkey)) { this.dispatchEvent({ action: event.eventName, state: this.state, element: event.eventData }, this.target); publishedEvents.add(eventkey); } }); // Dispatch a transaction end event. this.dispatchEvent({ action: 'transaction:end', state: this.state, element: null, }, this.target); } } // Proxy helpers. /** * The proxy handler. * * This class will inform any value change directly to the state manager. * * The proxied variable will throw an error if it is altered when the state manager is * in read only mode. */ class Handler { /** * Class constructor. * * @param {string} name the variable name used for identify triggered actions * @param {StateManager} stateManager the state manager object * @param {boolean} proxyValues if new values must be proxied (used only at state root level) */ constructor(name, stateManager, proxyValues) { this.name = name; this.stateManager = stateManager; this.proxyValues = proxyValues ?? false; } /** * Set trap to trigger events when the state changes. * * @param {object} obj the source object (not proxied) * @param {string} prop the attribute to set * @param {*} value the value to save * @param {*} receiver the proxied element to be attached to events * @returns {boolean} if the value is set */ set(obj, prop, value, receiver) { // Only mutations should be able to set state values. if (this.stateManager.readonly) { throw new Error(`State locked. Use mutations to change ${prop} value in ${this.name}.`); } // Check any data change. if (JSON.stringify(obj[prop]) === JSON.stringify(value)) { return true; } const action = (obj[prop] !== undefined) ? 'updated' : 'created'; // Proxy value if necessary (used at state root level). if (this.proxyValues) { if (Array.isArray(value)) { obj[prop] = new StateMap(prop, this.stateManager).loadValues(value); } else { obj[prop] = new Proxy(value, new Handler(prop, this.stateManager)); } } else { obj[prop] = value; } // If the state is not ready yet means the initial state is not yet loaded. if (this.stateManager.state === undefined) { return true; } this.stateManager.registerStateAction(this.name, prop, action, receiver); return true; } /** * Delete property trap to trigger state change events. * * @param {*} obj the affected object (not proxied) * @param {*} prop the prop to delete * @returns {boolean} if prop is deleted */ deleteProperty(obj, prop) { // Only mutations should be able to set state values. if (this.stateManager.readonly) { throw new Error(`State locked. Use mutations to delete ${prop} in ${this.name}.`); } if (prop in obj) { delete obj[prop]; this.stateManager.registerStateAction(this.name, prop, 'deleted', obj); } return true; } } /** * Class to add events dispatching to the JS Map class. * * When the state has a list of objects (with IDs) it will be converted into a StateMap. * StateMap is used almost in the same way as a regular JS map. Because all elements have an * id attribute, it has some specific methods: * - add: a convenient method to add an element without specifying the key ("id" attribute will be used as a key). * - loadValues: to add many elements at once wihout specifying keys ("id" attribute will be used). * * Apart, the main difference between regular Map and MapState is that this one will inform any change to the * state manager. */ class StateMap extends Map { /** * Create a reactive Map. * * @param {string} name the property name * @param {StateManager} stateManager the state manager * @param {iterable} iterable an iterable object to create the Map */ constructor(name, stateManager, iterable) { // We don't have any "this" until be call super. super(iterable); this.name = name; this.stateManager = stateManager; } /** * Set an element into the map. * * Each value needs it's own id attribute. Objects without id will be rejected. * The function will throw an error if the value id and the key are not the same. * * @param {*} key the key to store * @param {*} value the value to store * @returns {Map} the resulting Map object */ set(key, value) { // Only mutations should be able to set state values. if (this.stateManager.readonly) { throw new Error(`State locked. Use mutations to change ${key} value in ${this.name}.`); } // Normalize keys as string to prevent json decoding errors. key = this.normalizeKey(key); this.checkValue(value); if (key === undefined || key === null) { throw Error('State lists keys cannot be null or undefined'); } // ID is mandatory and should be the same as the key. if (this.normalizeKey(value.id) !== key) { throw new Error(`State error: ${this.name} list element ID (${value.id}) and key (${key}) mismatch`); } const action = (super.has(key)) ? 'updated' : 'created'; // Save proxied data into the list. const result = super.set(key, new Proxy(value, new Handler(this.name, this.stateManager))); // If the state is not ready yet means the initial state is not yet loaded. if (this.stateManager.state === undefined) { return result; } this.stateManager.registerStateAction(this.name, null, action, super.get(key)); return result; } /** * Check if a value is valid to be stored in a a State List. * * Only objects with id attribute can be stored in State lists. * * This method throws an error if the value is not valid. * * @param {object} value (with ID) */ checkValue(value) { if (!typeof value === 'object' && value !== null) { throw Error('State lists can contain objects only'); } if (value.id === undefined) { throw Error('State lists elements must contain at least an id attribute'); } } /** * Return a normalized key value for state map. * * Regular maps uses strict key comparissons but state maps are indexed by ID.JSON conversions * and webservices sometimes do unexpected types conversions so we convert any integer key to string. * * @param {*} key the provided key * @returns {string} */ normalizeKey(key) { return String(key).valueOf(); } /** * Insert a new element int a list. * * Each value needs it's own id attribute. Objects withouts id will be rejected. * * @param {object} value the value to add (needs an id attribute) * @returns {Map} the resulting Map object */ add(value) { this.checkValue(value); return this.set(value.id, value); } /** * Return a state map element. * * @param {*} key the element id * @return {Object} */ get(key) { return super.get(this.normalizeKey(key)); } /** * Check whether an element with the specified key exists or not. * * @param {*} key the key to find * @return {boolean} */ has(key) { return super.has(this.normalizeKey(key)); } /** * Delete an element from the map. * * @param {*} key * @returns {boolean} */ delete(key) { // State maps uses only string keys to avoid strict comparisons. key = this.normalizeKey(key); // Only mutations should be able to set state values. if (this.stateManager.readonly) { throw new Error(`State locked. Use mutations to change ${key} value in ${this.name}.`); } const previous = super.get(key); const result = super.delete(key); if (!result) { return result; } this.stateManager.registerStateAction(this.name, null, 'deleted', previous); return result; } /** * Return a suitable structure for JSON conversion. * * This function is needed because new values are compared in JSON. StateMap has Private * attributes which cannot be stringified (like this.stateManager which will produce an * infinite recursivity). * * @returns {array} */ toJSON() { let result = []; this.forEach((value) => { result.push(value); }); return result; } /** * Insert a full list of values using the id attributes as keys. * * This method is used mainly to initialize the list. Note each element is indexed by its "id" attribute. * This is a basic restriction of StateMap. All elements need an id attribute, otherwise it won't be saved. * * @param {iterable} values the values to load * @returns {StateMap} return the this value */ loadValues(values) { values.forEach((data) => { this.checkValue(data); let key = data.id; let newvalue = new Proxy(data, new Handler(this.name, this.stateManager)); this.set(key, newvalue); }); return this; } } local/reactive/dragdrop.js 0000644 00000044315 15152050146 0011606 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Drag and drop helper component. * * This component is used to delegate drag and drop handling. * * To delegate the logic to this particular element the component should create a new instance * passing "this" as param. The component will use all the necessary callbacks and add all the * necessary listeners to the component element. * * Component attributes used by dragdrop module: * - element: the draggable or dropzone element. * - (optional) classes: object with alternative CSS classes * - (optional) fullregion: page element affeted by the elementy dragging. Use this attribute if * the draggable element affects a bigger region (for example a draggable * title). * - (optional) autoconfigDraggable: by default, the component will be draggable if it has a * getDraggableData method. If this value is false draggable * property must be defined using setDraggable method. * - (optional) relativeDrag: by default the drag image is located at point (0,0) relative to the * mouse position to prevent the mouse from covering it. If this attribute * is true the drag image will be located at the click offset. * * Methods the parent component should have for making it draggable: * * - getDraggableData(): Object|data * Return the data that will be passed to any valid dropzone while it is dragged. * If the component has this method, the dragdrop module will enable the dragging, * this is the only required method for dragging. * If at the dragging moment this method returns a false|null|undefined, the dragging * actions won't be captured. * * - (optional) dragStart(Object dropdata, Event event): void * - (optional) dragEnd(Object dropdata, Event event): void * Callbacks dragdrop will call when the element is dragged and getDraggableData * return some data. * * Methods the parent component should have for enabling it as a dropzone: * * - validateDropData(Object dropdata): boolean * If that method exists, the dragdrop module will automathically configure the element as dropzone. * This method will return true if the dropdata is accepted. In case it returns false, no drag and * drop event will be listened for this specific dragged dropdata. * * - (Optional) showDropZone(Object dropdata, Event event): void * - (Optional) hideDropZone(Object dropdata, Event event): void * Methods called when a valid dragged data pass over the element. * * - (Optional) drop(Object dropdata, Event event): void * Called when a valid dragged element is dropped over the element. * * Note that none of this methods will be called if validateDropData * returns a false value. * * This module will also add or remove several CSS classes from both dragged elements and dropzones. * See the "this.classes" in the create method for more details. In case the parent component wants * to use the same classes, it can use the getClasses method. On the other hand, if the parent * component has an alternative "classes" attribute, this will override the default drag and drop * classes. * * @module core/local/reactive/dragdrop * @class core/local/reactive/dragdrop * @copyright 2021 Ferran Recio <ferran@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import BaseComponent from 'core/local/reactive/basecomponent'; // Map with the dragged element generate by an specific reactive applications. // Potentially, any component can generate a draggable element to interact with other // page elements. However, the dragged data is specific and could only interact with // components of the same reactive instance. let activeDropData = new Map(); // Drag & Drop API provides the final drop point and incremental movements but we can // provide also starting points and displacements. Absolute displacements simplifies // moving components with aboslute position around the page. let dragStartPoint = {}; export default class extends BaseComponent { /** * Constructor hook. * * @param {BaseComponent} parent the parent component. */ create(parent) { // Optional component name for debugging. this.name = `${parent.name ?? 'unkown'}_dragdrop`; // Default drag and drop classes. this.classes = Object.assign( { // This class indicate a dragging action is active at a page level. BODYDRAGGING: 'dragging', // Added when draggable and drop are ready. DRAGGABLEREADY: 'draggable', DROPREADY: 'dropready', // When a valid drag element is over the element. DRAGOVER: 'dragover', // When a the component is dragged. DRAGGING: 'dragging', // Dropzones classes names. DROPUP: 'drop-up', DROPDOWN: 'drop-down', DROPZONE: 'drop-zone', // Drag icon class. DRAGICON: 'dragicon', }, parent?.classes ?? {} ); // Add the affected region if any. this.fullregion = parent.fullregion; // Keep parent to execute drap and drop handlers. this.parent = parent; // Check if parent handle draggable manually. this.autoconfigDraggable = this.parent.draggable ?? true; // Drag image relative position. this.relativeDrag = this.parent.relativeDrag ?? false; // Sub HTML elements will trigger extra dragEnter and dragOver all the time. // To prevent that from affecting dropzones, we need to count the enters and leaves. this.entercount = 0; // Stores if the droparea is shown or not. this.dropzonevisible = false; } /** * Return the component drag and drop CSS classes. * * @returns {Object} the dragdrop css classes */ getClasses() { return this.classes; } /** * Initial state ready method. * * This method will add all the necessary event listeners to the component depending on the * parent methods. * - Add drop events to the element if the parent component has validateDropData method. * - Configure the elements draggable if the parent component has getDraggableData method. */ stateReady() { // Add drop events to the element if the parent component has dropable types. if (typeof this.parent.validateDropData === 'function') { this.element.classList.add(this.classes.DROPREADY); this.addEventListener(this.element, 'dragenter', this._dragEnter); this.addEventListener(this.element, 'dragleave', this._dragLeave); this.addEventListener(this.element, 'dragover', this._dragOver); this.addEventListener(this.element, 'drop', this._drop); } // Configure the elements draggable if the parent component has dragable data. if (this.autoconfigDraggable && typeof this.parent.getDraggableData === 'function') { this.setDraggable(true); } } /** * Enable or disable the draggable property. * * @param {bool} value the new draggable value */ setDraggable(value) { if (typeof this.parent.getDraggableData !== 'function') { throw new Error(`Draggable components must have a getDraggableData method`); } this.element.setAttribute('draggable', value); if (value) { this.addEventListener(this.element, 'dragstart', this._dragStart); this.addEventListener(this.element, 'dragend', this._dragEnd); this.element.classList.add(this.classes.DRAGGABLEREADY); } else { this.removeEventListener(this.element, 'dragstart', this._dragStart); this.removeEventListener(this.element, 'dragend', this._dragEnd); this.element.classList.remove(this.classes.DRAGGABLEREADY); } } /** * Drag start event handler. * * This method will generate the current dropable data. This data is the one used to determine * if a droparea accepts the dropping or not. * * @param {Event} event the event. */ _dragStart(event) { // Cancel dragging if any editable form element is focussed. if (document.activeElement.matches(`textarea, input`)) { event.preventDefault(); return; } const dropdata = this.parent.getDraggableData(); if (!dropdata) { return; } // Save the starting point. dragStartPoint = { pageX: event.pageX, pageY: event.pageY, }; // If the drag event is accepted we prevent any other draggable element from interfiering. event.stopPropagation(); // Save the drop data of the current reactive intance. activeDropData.set(this.reactive, dropdata); // Add some CSS classes to indicate the state. document.body.classList.add(this.classes.BODYDRAGGING); this.element.classList.add(this.classes.DRAGGING); this.fullregion?.classList.add(this.classes.DRAGGING); // Force the drag image. This makes the UX more consistent in case the // user dragged an internal element like a link or some other element. let dragImage = this.element; if (this.parent.setDragImage !== undefined) { const customImage = this.parent.setDragImage(dropdata, event); if (customImage) { dragImage = customImage; } } // Define the image position relative to the mouse. const position = {x: 0, y: 0}; if (this.relativeDrag) { position.x = event.offsetX; position.y = event.offsetY; } event.dataTransfer.setDragImage(dragImage, position.x, position.y); this._callParentMethod('dragStart', dropdata, event); } /** * Drag end event handler. * * @param {Event} event the event. */ _dragEnd(event) { const dropdata = activeDropData.get(this.reactive); if (!dropdata) { return; } // Remove the current dropdata. activeDropData.delete(this.reactive); // Remove the dragging classes. document.body.classList.remove(this.classes.BODYDRAGGING); this.element.classList.remove(this.classes.DRAGGING); this.fullregion?.classList.remove(this.classes.DRAGGING); // We add the total movement to the event in case the component // wants to move its absolute position. this._addEventTotalMovement(event); this._callParentMethod('dragEnd', dropdata, event); } /** * Drag enter event handler. * * The JS drag&drop API triggers several dragenter events on the same element because it bubbles the * child events as well. To prevent this form affecting the dropzones display, this methods use * "entercount" to determine if it's one extra child event or a valid one. * * @param {Event} event the event. */ _dragEnter(event) { const dropdata = this._processEvent(event); if (dropdata) { this.entercount++; this.element.classList.add(this.classes.DRAGOVER); if (this.entercount == 1 && !this.dropzonevisible) { this.dropzonevisible = true; this.element.classList.add(this.classes.DRAGOVER); this._callParentMethod('showDropZone', dropdata, event); } } } /** * Drag over event handler. * * We only use dragover event when a draggable action starts inside a valid dropzone. In those cases * the API won't trigger any dragEnter because the dragged alement was already there. We use the * dropzonevisible to determine if the component needs to display the dropzones or not. * * @param {Event} event the event. */ _dragOver(event) { const dropdata = this._processEvent(event); if (dropdata && !this.dropzonevisible) { this.dropzonevisible = true; this.element.classList.add(this.classes.DRAGOVER); this._callParentMethod('showDropZone', dropdata, event); } } /** * Drag over leave handler. * * The JS drag&drop API triggers several dragleave events on the same element because it bubbles the * child events as well. To prevent this form affecting the dropzones display, this methods use * "entercount" to determine if it's one extra child event or a valid one. * * @param {Event} event the event. */ _dragLeave(event) { const dropdata = this._processEvent(event); if (dropdata) { this.entercount--; if (this.entercount == 0 && this.dropzonevisible) { this.dropzonevisible = false; this.element.classList.remove(this.classes.DRAGOVER); this._callParentMethod('hideDropZone', dropdata, event); } } } /** * Drop event handler. * * This method will call both hideDropZones and drop methods on the parent component. * * @param {Event} event the event. */ _drop(event) { const dropdata = this._processEvent(event); if (dropdata) { this.entercount = 0; if (this.dropzonevisible) { this.dropzonevisible = false; this._callParentMethod('hideDropZone', dropdata, event); } this.element.classList.remove(this.classes.DRAGOVER); this._callParentMethod('drop', dropdata, event); // An accepted drop resets the initial position. // Save the starting point. dragStartPoint = {}; } } /** * Process a drag and drop event and delegate logic to the parent component. * * @param {Event} event the drag and drop event * @return {Object|false} the dropdata or null if the event should not be processed */ _processEvent(event) { const dropdata = this._getDropData(event); if (!dropdata) { return null; } if (this.parent.validateDropData(dropdata)) { // All accepted drag&drop event must prevent bubbling and defaults, otherwise // parent dragdrop instances could capture it by mistake. event.preventDefault(); event.stopPropagation(); this._addEventTotalMovement(event); return dropdata; } return null; } /** * Add the total amout of movement to a mouse event. * * @param {MouseEvent} event */ _addEventTotalMovement(event) { if (dragStartPoint.pageX === undefined || event.pageX === undefined) { return; } event.fixedMovementX = event.pageX - dragStartPoint.pageX; event.fixedMovementY = event.pageY - dragStartPoint.pageY; event.initialPageX = dragStartPoint.pageX; event.initialPageY = dragStartPoint.pageY; // The element possible new top. const current = this.element.getBoundingClientRect(); // Add the new position fixed position. event.newFixedTop = current.top + event.fixedMovementY; event.newFixedLeft = current.left + event.fixedMovementX; // The affected region possible new top. if (this.fullregion !== undefined) { const current = this.fullregion.getBoundingClientRect(); event.newRegionFixedxTop = current.top + event.fixedMovementY; event.newRegionFixedxLeft = current.left + event.fixedMovementX; } } /** * Convenient method for calling parent component functions if present. * * @param {string} methodname the name of the method * @param {Object} dropdata the current drop data object * @param {Event} event the original event */ _callParentMethod(methodname, dropdata, event) { if (typeof this.parent[methodname] === 'function') { this.parent[methodname](dropdata, event); } } /** * Get the current dropdata for a specific event. * * The browser can generate drag&drop events related to several user interactions: * - Drag a page elements: this case is registered in the activeDropData map * - Drag some HTML selections: ignored for now * - Drag a file over the browser: file drag may appear in the future but for now they are ignored. * * @param {Event} event the original event. * @returns {Object|undefined} with the dragged data (or undefined if none) */ _getDropData(event) { if (this._containsOnlyFiles(event)) { return undefined; } return activeDropData.get(this.reactive); } /** * Check if the dragged event contains only files. * * Files dragging does not generate drop data because they came from outside the page and the component * must check it before validating the event. * * @param {Event} event the original event. * @returns {boolean} if the drag dataTransfers contains files. */ _containsOnlyFiles(event) { if (event.dataTransfer.types && event.dataTransfer.types.length > 0) { // Chrome drag page images as files. To differentiate a real file from a page // image we need to check if all the dataTransfers types are files. return event.dataTransfer.types.every(type => type === 'Files'); } return false; } } local/reactive/debugpanel.js 0000644 00000043527 15152050146 0012116 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Reactive module debug panel. * * This module contains all the UI components for the reactive debug tools. * Those tools are only available if the debug is enables and could be used * from the footer. * * @module core/local/reactive/debugpanel * @copyright 2021 Ferran Recio <ferran@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import {BaseComponent, DragDrop, debug} from 'core/reactive'; import log from 'core/log'; import {debounce} from 'core/utils'; /** * Init the main reactive panel. * * @param {element|string} target the DOM main element or its ID * @param {object} selectors optional css selector overrides */ export const init = (target, selectors) => { const element = document.getElementById(target); // Check if the debug reactive module is available. if (debug === undefined) { element.remove(); return; } // Create the main component. new GlobalDebugPanel({ element, reactive: debug, selectors, }); }; /** * Init an instance reactive subpanel. * * @param {element|string} target the DOM main element or its ID * @param {object} selectors optional css selector overrides */ export const initsubpanel = (target, selectors) => { const element = document.getElementById(target); // Check if the debug reactive module is available. if (debug === undefined) { element.remove(); return; } // Create the main component. new DebugInstanceSubpanel({ element, reactive: debug, selectors, }); }; /** * Component for the main reactive dev panel. * * This component shows the list of reactive instances and handle the buttons * to open a specific instance panel. */ class GlobalDebugPanel extends BaseComponent { /** * Constructor hook. */ create() { // Optional component name for debugging. this.name = 'GlobalDebugPanel'; // Default query selectors. this.selectors = { LOADERS: `[data-for='loaders']`, SUBPANEL: `[data-for='subpanel']`, NOINSTANCES: `[data-for='noinstances']`, LOG: `[data-for='log']`, }; this.classes = { HIDE: `d-none`, }; // The list of loaded debuggers. this.subPanels = new Set(); } /** * Initial state ready method. * * @param {object} state the initial state */ stateReady(state) { this._updateReactivesPanels({state}); // Remove loading wheel. this.getElement(this.selectors.SUBPANEL).innerHTML = ''; } /** * Component watchers. * * @returns {Array} of watchers */ getWatchers() { return [ {watch: `reactives:created`, handler: this._updateReactivesPanels}, ]; } /** * Update the list of reactive instances. * @param {Object} args * @param {Object} args.state the current state */ _updateReactivesPanels({state}) { this.getElement(this.selectors.NOINSTANCES)?.classList?.toggle( this.classes.HIDE, state.reactives.size > 0 ); // Generate loading buttons. state.reactives.forEach( instance => { this._createLoader(instance); } ); } /** * Create a debug panel button for a specific reactive instance. * * @param {object} instance hte instance data */ _createLoader(instance) { if (this.subPanels.has(instance.id)) { return; } this.subPanels.add(instance.id); const loaders = this.getElement(this.selectors.LOADERS); const btn = document.createElement("button"); btn.innerHTML = instance.id; btn.dataset.id = instance.id; loaders.appendChild(btn); // Add click event. this.addEventListener(btn, 'click', () => this._openPanel(btn, instance)); } /** * Open a debug panel. * * @param {Element} btn the button element * @param {object} instance the instance data */ async _openPanel(btn, instance) { try { const target = this.getElement(this.selectors.SUBPANEL); const data = {...instance}; await this.renderComponent(target, 'core/local/reactive/debuginstancepanel', data); } catch (error) { log.error('Cannot load reactive debug subpanel'); throw error; } } } /** * Component for the main reactive dev panel. * * This component shows the list of reactive instances and handle the buttons * to open a specific instance panel. */ class DebugInstanceSubpanel extends BaseComponent { /** * Constructor hook. */ create() { // Optional component name for debugging. this.name = 'DebugInstanceSubpanel'; // Default query selectors. this.selectors = { NAME: `[data-for='name']`, CLOSE: `[data-for='close']`, READMODE: `[data-for='readmode']`, HIGHLIGHT: `[data-for='highlight']`, LOG: `[data-for='log']`, STATE: `[data-for='state']`, CLEAN: `[data-for='clean']`, PIN: `[data-for='pin']`, SAVE: `[data-for='save']`, INVALID: `[data-for='invalid']`, }; this.id = this.element.dataset.id; this.controller = M.reactive[this.id]; // The component is created always pinned. this.draggable = false; // We want the element to be dragged like modal. this.relativeDrag = true; // Save warning (will be loaded when state is ready. this.strings = { savewarning: '', }; } /** * Initial state ready method. * */ stateReady() { // Enable drag and drop. this.dragdrop = new DragDrop(this); // Close button. this.addEventListener( this.getElement(this.selectors.CLOSE), 'click', this.remove ); // Highlight button. if (this.controller.highlight) { this._toggleButtonText(this.getElement(this.selectors.HIGHLIGHT)); } this.addEventListener( this.getElement(this.selectors.HIGHLIGHT), 'click', () => { this.controller.highlight = !this.controller.highlight; this._toggleButtonText(this.getElement(this.selectors.HIGHLIGHT)); } ); // Edit mode button. this.addEventListener( this.getElement(this.selectors.READMODE), 'click', this._toggleEditMode ); // Clean log and state. this.addEventListener( this.getElement(this.selectors.CLEAN), 'click', this._cleanAreas ); // Unpin panel butotn. this.addEventListener( this.getElement(this.selectors.PIN), 'click', this._togglePin ); // Save button, state format error message and state textarea. this.getElement(this.selectors.SAVE).disabled = true; this.addEventListener( this.getElement(this.selectors.STATE), 'keyup', debounce(this._checkJSON, 500) ); this.addEventListener( this.getElement(this.selectors.SAVE), 'click', this._saveState ); // Save the default save warning message. this.strings.savewarning = this.getElement(this.selectors.INVALID)?.innerHTML ?? ''; // Add current state. this._refreshState(); } /** * Remove all subcomponents dependencies. */ destroy() { if (this.dragdrop !== undefined) { this.dragdrop.unregister(); } } /** * Component watchers. * * @returns {Array} of watchers */ getWatchers() { return [ {watch: `reactives[${this.id}].lastChanges:updated`, handler: this._refreshLog}, {watch: `reactives[${this.id}].modified:updated`, handler: this._refreshState}, {watch: `reactives[${this.id}].readOnly:updated`, handler: this._refreshReadOnly}, ]; } /** * Wtacher method to refresh the log panel. * * @param {object} args * @param {HTMLElement} args.element */ _refreshLog({element}) { const list = element?.lastChanges ?? []; const logContent = list.join("\n"); // Append last log. const target = this.getElement(this.selectors.LOG); target.value += `\n\n= Transaction =\n ${logContent}`; target.scrollTop = target.scrollHeight; } /** * Listener method to clean the log area. */ _cleanAreas() { let target = this.getElement(this.selectors.LOG); target.value = ''; this._refreshState(); } /** * Watcher to refresh the state information. */ _refreshState() { const target = this.getElement(this.selectors.STATE); target.value = JSON.stringify(this.controller.state, null, 4); } /** * Watcher to update the read only information. */ _refreshReadOnly() { // Toggle the read mode button. const target = this.getElement(this.selectors.READMODE); if (target.dataset.readonly === undefined) { target.dataset.readonly = target.innerHTML; } if (this.controller.readOnly) { target.innerHTML = target.dataset.readonly; } else { target.innerHTML = target.dataset.alt; } } /** * Listener to toggle the edit mode of the component. */ _toggleEditMode() { this.controller.readOnly = !this.controller.readOnly; } /** * Check that the edited state JSON is valid. * * Not all valid JSON are suitable for transforming the state. For example, * the first level attributes cannot change the type. * * @return {undefined|array} Array of state updates. */ _checkJSON() { const invalid = this.getElement(this.selectors.INVALID); const save = this.getElement(this.selectors.SAVE); const edited = this.getElement(this.selectors.STATE).value; const currentStateData = this.controller.stateData; // Check if the json is tha same as state. if (edited == JSON.stringify(this.controller.state, null, 4)) { invalid.style.color = ''; invalid.innerHTML = ''; save.disabled = true; return undefined; } // Check if the json format is valid. try { const newState = JSON.parse(edited); // Check the first level did not change types. const result = this._generateStateUpdates(currentStateData, newState); // Enable save button. invalid.style.color = ''; invalid.innerHTML = this.strings.savewarning; save.disabled = false; return result; } catch (error) { invalid.style.color = 'red'; invalid.innerHTML = error.message ?? 'Invalid JSON sctructure'; save.disabled = true; return undefined; } } /** * Listener to save the current edited state into the real state. */ _saveState() { const updates = this._checkJSON(); if (!updates) { return; } // Sent the updates to the state manager. this.controller.processUpdates(updates); } /** * Check that the edited state JSON is valid. * * Not all valid JSON are suitable for transforming the state. For example, * the first level attributes cannot change the type. This method do a two * steps comparison between the current state data and the new state data. * * A reactive state cannot be overridden like any other variable. To keep * the watchers updated is necessary to transform the current state into * the new one. As a result, this method generates all the necessary state * updates to convert the state into the new state. * * @param {object} currentStateData * @param {object} newStateData * @return {array} Array of state updates. * @throws {Error} is the structure is not compatible */ _generateStateUpdates(currentStateData, newStateData) { const updates = []; const ids = {}; // Step 1: Add all overrides newStateData. for (const [key, newValue] of Object.entries(newStateData)) { // Check is it is new. if (Array.isArray(newValue)) { ids[key] = {}; newValue.forEach(element => { if (element.id === undefined) { throw Error(`Array ${key} element without id attribute`); } updates.push({ name: key, action: 'override', fields: element, }); const index = String(element.id).valueOf(); ids[key][index] = true; }); } else { updates.push({ name: key, action: 'override', fields: newValue, }); } } // Step 2: delete unnecesary data from currentStateData. for (const [key, oldValue] of Object.entries(currentStateData)) { let deleteField = false; // Check if the attribute is still there. if (newStateData[key] === undefined) { deleteField = true; } if (Array.isArray(oldValue)) { if (!deleteField && ids[key] === undefined) { throw Error(`Array ${key} cannot change to object.`); } oldValue.forEach(element => { const index = String(element.id).valueOf(); let deleteEntry = deleteField; // Check if the id is there. if (!deleteEntry && ids[key][index] === undefined) { deleteEntry = true; } if (deleteEntry) { updates.push({ name: key, action: 'delete', fields: element, }); } }); } else { if (!deleteField && ids[key] !== undefined) { throw Error(`Object ${key} cannot change to array.`); } if (deleteField) { updates.push({ name: key, action: 'delete', fields: oldValue, }); } } } // Delete all elements without action. return updates; } // Drag and drop methods. /** * Get the draggable data of this component. * * @returns {Object} exported course module drop data */ getDraggableData() { return this.draggable; } /** * The element drop end hook. * * @param {Object} dropdata the dropdata * @param {Event} event the dropdata */ dragEnd(dropdata, event) { this.element.style.top = `${event.newFixedTop}px`; this.element.style.left = `${event.newFixedLeft}px`; } /** * Pin and unpin the panel. */ _togglePin() { this.draggable = !this.draggable; this.dragdrop.setDraggable(this.draggable); if (this.draggable) { this._unpin(); } else { this._pin(); } } /** * Unpin the panel form the footer. */ _unpin() { // Find the initial spot. const pageCenterY = window.innerHeight / 2; const pageCenterX = window.innerWidth / 2; // Put the element in the middle of the screen const style = { position: 'fixed', resize: 'both', overflow: 'auto', height: '400px', width: '400px', top: `${pageCenterY - 200}px`, left: `${pageCenterX - 200}px`, }; Object.assign(this.element.style, style); // Small also the text areas. this.getElement(this.selectors.STATE).style.height = '50px'; this.getElement(this.selectors.LOG).style.height = '50px'; this._toggleButtonText(this.getElement(this.selectors.PIN)); } /** * Pin the panel into the footer. */ _pin() { const props = [ 'position', 'resize', 'overflow', 'top', 'left', 'height', 'width', ]; props.forEach( prop => this.element.style.removeProperty(prop) ); this._toggleButtonText(this.getElement(this.selectors.PIN)); } /** * Toogle the button text with the data-alt value. * * @param {Element} element the button element */ _toggleButtonText(element) { [element.innerHTML, element.dataset.alt] = [element.dataset.alt, element.innerHTML]; } } local/reactive/basecomponent.js 0000644 00000042547 15152050146 0012646 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. import Templates from 'core/templates'; /** * Reactive UI component base class. * * Each UI reactive component should extend this class to interact with a reactive state. * * @module core/local/reactive/basecomponent * @class core/local/reactive/basecomponent * @copyright 2020 Ferran Recio <ferran@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ export default class { /** * The component descriptor data structure. * * This structure is used by any component and init method to define the way the component will interact * with the interface and whith reactive instance operates. The logic behind this object is to avoid * unnecessary dependancies between the final interface and the state logic. * * Any component interacts with a single main DOM element (description.element) but it can use internal * selector to select elements within this main element (descriptor.selectors). By default each component * will provide it's own default selectors, but those can be overridden by the "descriptor.selectors" * property in case the mustache wants to reuse the same component logic but with a different interface. * * @typedef {object} descriptor * @property {Reactive} reactive an optional reactive module to register in * @property {DOMElement} element all components needs an element to anchor events * @property {object} [selectors] an optional object to override query selectors */ /** * The class constructor. * * The only param this method gets is a constructor with all the mandatory * and optional component data. Component will receive the same descriptor * as create method param. * * This method will call the "create" method before registering the component into * the reactive module. This way any component can add default selectors and events. * * @param {descriptor} descriptor data to create the object. */ constructor(descriptor) { if (descriptor.element === undefined || !(descriptor.element instanceof HTMLElement)) { throw Error(`Reactive components needs a main DOM element to dispatch events`); } this.element = descriptor.element; // Variable to track event listeners. this.eventHandlers = new Map([]); this.eventListeners = []; // Empty default component selectors. this.selectors = {}; // Empty default event list from the static method. this.events = this.constructor.getEvents(); // Call create function to get the component defaults. this.create(descriptor); // Overwrite the components selectors if necessary. if (descriptor.selectors !== undefined) { this.addSelectors(descriptor.selectors); } // Register into a reactive instance. if (descriptor.reactive === undefined) { // Ask parent components for registration. this.element.dispatchEvent(new CustomEvent( 'core/reactive:requestRegistration', { bubbles: true, detail: {component: this}, } )); } else { this.reactive = descriptor.reactive; this.reactive.registerComponent(this); // Add a listener to register child components. this.addEventListener( this.element, 'core/reactive:requestRegistration', (event) => { if (event?.detail?.component) { event.stopPropagation(); this.registerChildComponent(event.detail.component); } } ); } } /** * Return the component custom event names. * * Components may override this method to provide their own events. * * Component custom events is an important part of component reusability. This function * is static because is part of the component definition and should be accessible from * outsite the instances. However, values will be available at instance level in the * this.events object. * * @returns {Object} the component events. */ static getEvents() { return {}; } /** * Component create function. * * Default init method will call "create" when all internal attributes are set * but before the component is not yet registered in the reactive module. * * In this method any component can define its own defaults such as: * - this.selectors {object} the default query selectors of this component. * - this.events {object} a list of event names this component dispatch * - extract any data from the main dom element (this.element) * - set any other data the component uses * * @param {descriptor} descriptor the component descriptor */ // eslint-disable-next-line no-unused-vars create(descriptor) { // Components may override this method to initialize selects, events or other data. } /** * Component destroy hook. * * BaseComponent call this method when a component is unregistered or removed. * * Components may override this method to clean the HTML or do some action when the * component is unregistered or removed. */ destroy() { // Components can override this method. } /** * Return the list of watchers that component has. * * Each watcher is represented by an object with two attributes: * - watch (string) the specific state event to watch. Example 'section.visible:updated' * - handler (function) the function to call when the watching state change happens * * Any component shoudl override this method to define their state watchers. * * @returns {array} array of watchers. */ getWatchers() { return []; } /** * Reactive module will call this method when the state is ready. * * Component can override this method to update/load the component HTML or to bind * listeners to HTML entities. */ stateReady() { // Components can override this method. } /** * Get the main DOM element of this component or a subelement. * * @param {string|undefined} query optional subelement query * @param {string|undefined} dataId optional data-id value * @returns {element|undefined} the DOM element (if any) */ getElement(query, dataId) { if (query === undefined && dataId === undefined) { return this.element; } const dataSelector = (dataId) ? `[data-id='${dataId}']` : ''; const selector = `${query ?? ''}${dataSelector}`; return this.element.querySelector(selector); } /** * Get the all subelement that match a query selector. * * @param {string|undefined} query optional subelement query * @param {string|undefined} dataId optional data-id value * @returns {NodeList} the DOM elements */ getElements(query, dataId) { const dataSelector = (dataId) ? `[data-id='${dataId}']` : ''; const selector = `${query ?? ''}${dataSelector}`; return this.element.querySelectorAll(selector); } /** * Add or update the component selectors. * * @param {Object} newSelectors an object of new selectors. */ addSelectors(newSelectors) { for (const [selectorName, selector] of Object.entries(newSelectors)) { this.selectors[selectorName] = selector; } } /** * Return a component selector. * * @param {string} selectorName the selector name * @return {string|undefined} the query selector */ getSelector(selectorName) { return this.selectors[selectorName]; } /** * Dispatch a custom event on this.element. * * This is just a convenient method to dispatch custom events from within a component. * Components are free to use an alternative function to dispatch custom * events. The only restriction is that it should be dispatched on this.element * and specify "bubbles:true" to alert any component listeners. * * @param {string} eventName the event name * @param {*} detail event detail data */ dispatchEvent(eventName, detail) { this.element.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail, })); } /** * Render a new Component using a mustache file. * * It is important to note that this method should NOT be used for loading regular mustache files * as it returns a Promise that will only be resolved if the mustache registers a component instance. * * @param {element} target the DOM element that contains the component * @param {string} file the component mustache file to render * @param {*} data the mustache data * @return {Promise} a promise of the resulting component instance */ renderComponent(target, file, data) { return new Promise((resolve, reject) => { target.addEventListener('ComponentRegistration:Success', ({detail}) => { resolve(detail.component); }); target.addEventListener('ComponentRegistration:Fail', () => { reject(`Registration of ${file} fails.`); }); Templates.renderForPromise( file, data ).then(({html, js}) => { Templates.replaceNodeContents(target, html, js); return true; }).catch(error => { reject(`Rendering of ${file} throws an error.`); throw error; }); }); } /** * Add and bind an event listener to a target and keep track of all event listeners. * * The native element.addEventListener method is not object oriented friently as the * "this" represents the element that triggers the event and not the listener class. * As components can be unregister and removed at any time, the BaseComponent provides * this method to keep track of all component listeners and do all of the bind stuff. * * @param {Element} target the event target * @param {string} type the event name * @param {function} listener the class method that recieve the event */ addEventListener(target, type, listener) { // Check if we have the bind version of that listener. let bindListener = this.eventHandlers.get(listener); if (bindListener === undefined) { bindListener = listener.bind(this); this.eventHandlers.set(listener, bindListener); } target.addEventListener(type, bindListener); // Keep track of all component event listeners in case we need to remove them. this.eventListeners.push({ target, type, bindListener, }); } /** * Remove an event listener from a component. * * This method allows components to remove listeners without keeping track of the * listeners bind versions of the method. Both addEventListener and removeEventListener * keeps internally the relation between the original class method and the bind one. * * @param {Element} target the event target * @param {string} type the event name * @param {function} listener the class method that recieve the event */ removeEventListener(target, type, listener) { // Check if we have the bind version of that listener. let bindListener = this.eventHandlers.get(listener); if (bindListener === undefined) { // This listener has not been added. return; } target.removeEventListener(type, bindListener); } /** * Remove all event listeners from this component. * * This method is called also when the component is unregistered or removed. * * Note that only listeners registered with the addEventListener method * will be removed. Other manual listeners will keep active. */ removeAllEventListeners() { this.eventListeners.forEach(({target, type, bindListener}) => { target.removeEventListener(type, bindListener); }); this.eventListeners = []; } /** * Remove a previously rendered component instance. * * This method will remove the component HTML and unregister it from the * reactive module. */ remove() { this.unregister(); this.element.remove(); } /** * Unregister the component from the reactive module. * * This method will disable the component logic, event listeners and watchers * but it won't remove any HTML created by the component. However, it will trigger * the destroy hook to allow the component to clean parts of the interface. */ unregister() { this.reactive.unregisterComponent(this); this.removeAllEventListeners(); this.destroy(); } /** * Dispatch a component registration event to inform the parent node. * * The registration event is different from the rest of the component events because * is the only way in which components can communicate its existence to a possible parent. * Most components will be created by including a mustache file, child components * must emit a registration event to the parent DOM element to alert about the registration. */ dispatchRegistrationSuccess() { // The registration event does not bubble because we just want to comunicate with the parentNode. // Otherwise, any component can get multiple registrations events and could not differentiate // between child components and grand child components. if (this.element.parentNode === undefined) { return; } // This custom element is captured by renderComponent method. this.element.parentNode.dispatchEvent(new CustomEvent( 'ComponentRegistration:Success', { bubbles: false, detail: {component: this}, } )); } /** * Dispatch a component registration fail event to inform the parent node. * * As dispatchRegistrationSuccess, this method will communicate the registration fail to the * parent node to inform the possible parent component. */ dispatchRegistrationFail() { if (this.element.parentNode === undefined) { return; } // This custom element is captured only by renderComponent method. this.element.parentNode.dispatchEvent(new CustomEvent( 'ComponentRegistration:Fail', { bubbles: false, detail: {component: this}, } )); } /** * Register a child component into the reactive instance. * * @param {self} component the component to register. */ registerChildComponent(component) { component.reactive = this.reactive; this.reactive.registerComponent(component); } /** * Set the lock value and locks or unlocks the element. * * @param {boolean} locked the new locked value */ set locked(locked) { this.setElementLocked(this.element, locked); } /** * Get the current locked value from the element. * * @return {boolean} */ get locked() { return this.getElementLocked(this.element); } /** * Lock/unlock an element. * * @param {Element} target the event target * @param {boolean} locked the new locked value */ setElementLocked(target, locked) { target.dataset.locked = locked ?? false; if (locked) { // Disable interactions. target.style.pointerEvents = 'none'; target.style.userSelect = 'none'; // Check if it is draggable. if (target.hasAttribute('draggable')) { target.setAttribute('draggable', false); } target.setAttribute('aria-busy', true); } else { // Enable interactions. target.style.pointerEvents = null; target.style.userSelect = null; // Check if it was draggable. if (target.hasAttribute('draggable')) { target.setAttribute('draggable', true); } target.setAttribute('aria-busy', false); } } /** * Get the current locked value from the element. * * @param {Element} target the event target * @return {boolean} */ getElementLocked(target) { return target.dataset.locked ?? false; } } local/reactive/debug.js 0000644 00000025211 15152050146 0011064 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Reactive module debug tools. * * @module core/reactive/local/reactive/debug * @copyright 2021 Ferran Recio <ferran@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Reactive from 'core/local/reactive/reactive'; import log from 'core/log'; // The list of reactives instances. const reactiveInstances = {}; // The reactive debugging objects. const reactiveDebuggers = {}; /** * Reactive module debug tools. * * If debug is enabled, this reactive module will spy all the reactive instances and keep a record * of the changes and components they have. * * It is important to note that the Debug class is also a Reactive module. The debug instance keeps * the reactive instances data as its own state. This way it is possible to implement development tools * that whatches this data. * * @class core/reactive/local/reactive/debug/Debug * @copyright 2021 Ferran Recio <ferran@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class Debug extends Reactive { /** * Set the initial state. * * @param {object} stateData the initial state data. */ setInitialState(stateData) { super.setInitialState(stateData); log.debug(`Debug module "M.reactive" loaded.`); } /** * List the currents page reactives instances. */ get list() { return JSON.parse(JSON.stringify(this.state.reactives)); } /** * Register a new Reactive instance. * * This method is called every time a "new Reactive" is executed. * * @param {Reactive} instance the reactive instance */ registerNewInstance(instance) { // Generate a valid variable name for that instance. let name = instance.name ?? `instance${this.state.reactives.length}`; name = name.replace(/\W/g, ''); log.debug(`Registering new reactive instance "M.reactive.${name}"`); reactiveInstances[name] = instance; reactiveDebuggers[name] = new DebugInstance(reactiveInstances[name]); // Register also in the state. this.dispatch('putInstance', name, instance); // Add debug watchers to instance. const refreshMethod = () => { this.dispatch('putInstance', name, instance); }; instance.target.addEventListener('readmode:on', refreshMethod); instance.target.addEventListener('readmode:off', refreshMethod); instance.target.addEventListener('registerComponent:success', refreshMethod); instance.target.addEventListener('transaction:end', refreshMethod); // We store the last transaction into the state. const storeTransaction = ({detail}) => { const changes = detail?.changes; this.dispatch('lastTransaction', name, changes); }; instance.target.addEventListener('transaction:start', storeTransaction); } /** * Returns a debugging object for a specific Reactive instance. * * A debugging object is a class that wraps a Reactive instance to quick access some of the * reactive methods using the browser JS console. * * @param {string} name the Reactive instance name * @returns {DebugInstance} a debug object wrapping the Reactive instance */ debug(name) { return reactiveDebuggers[name]; } } /** * The debug state mutations class. * * @class core/reactive/local/reactive/debug/Mutations */ class Mutations { /** * Insert or update a new instance into the debug state. * * @param {StateManager} stateManager the debug state manager * @param {string} name the instance name * @param {Reactive} instance the reactive instance */ putInstance(stateManager, name, instance) { const state = stateManager.state; stateManager.setReadOnly(false); if (state.reactives.has(name)) { state.reactives.get(name).countcomponents = instance.components.length; state.reactives.get(name).readOnly = instance.stateManager.readonly; state.reactives.get(name).modified = new Date().getTime(); } else { state.reactives.add({ id: name, countcomponents: instance.components.length, readOnly: instance.stateManager.readonly, lastChanges: [], modified: new Date().getTime(), }); } stateManager.setReadOnly(true); } /** * Update the lastChanges attribute with a list of changes * * @param {StateManager} stateManager the debug reactive state * @param {string} name tje instance name * @param {array} changes the list of changes */ lastTransaction(stateManager, name, changes) { if (!changes || changes.length === 0) { return; } const state = stateManager.state; const lastChanges = ['transaction:start']; changes.forEach(change => { lastChanges.push(change.eventName); }); lastChanges.push('transaction:end'); stateManager.setReadOnly(false); state.reactives.get(name).lastChanges = lastChanges; stateManager.setReadOnly(true); } } /** * Class used to debug a specific instance and manipulate the state from the JS console. * * @class core/reactive/local/reactive/debug/DebugInstance * @copyright 2021 Ferran Recio <ferran@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class DebugInstance { /** * Constructor. * * @param {Reactive} instance the reactive instance */ constructor(instance) { this.instance = instance; // Add some debug data directly into the instance. This way we avoid having attributes // that will confuse the console aoutocomplete. if (instance._reactiveDebugData === undefined) { instance._reactiveDebugData = { highlighted: false, }; } } /** * Set the read only mode. * * Quick access to the instance setReadOnly method. * * @param {bool} value the new read only value */ set readOnly(value) { this.instance.stateManager.setReadOnly(value); } /** * Get the read only value * * @returns {bool} */ get readOnly() { return this.instance.stateManager.readonly; } /** * Return the current state object. * * @returns {object} */ get state() { return this.instance.state; } /** * Tooggle the reactive HTML element highlight registered in this reactive instance. * * @param {bool} value the highlight value */ set highlight(value) { this.instance._reactiveDebugData.highlighted = value; this.instance.components.forEach(({element}) => { const border = (value) ? `thick solid #0000FF` : ''; element.style.border = border; }); } /** * Get the current highligh value. * * @returns {bool} */ get highlight() { return this.instance._reactiveDebugData.highlighted; } /** * List all the components registered in this instance. * * @returns {array} */ get components() { return [...this.instance.components]; } /** * List all the state changes evenet pending to dispatch. * * @returns {array} */ get changes() { const result = []; this.instance.stateManager.eventsToPublish.forEach( (element) => { result.push(element.eventName); } ); return result; } /** * Dispatch a change in the state. * * Usually reactive modules throw an error directly to the components when something * goes wrong. However, course editor can directly display a notification. * * @method dispatch * @param {*} args */ async dispatch(...args) { this.instance.dispatch(...args); } /** * Return all the HTML elements registered in the instance components. * * @returns {array} */ get elements() { const result = []; this.instance.components.forEach(({element}) => { result.push(element); }); return result; } /** * Return a plain copy of the state data. * * @returns {object} */ get stateData() { return JSON.parse(JSON.stringify(this.state)); } /** * Process an update state array. * * @param {array} updates an array of update state messages */ processUpdates(updates) { this.instance.stateManager.processUpdates(updates); } } const stateChangedEventName = 'core_reactive_debug:stateChanged'; /** * Internal state changed event. * * @method dispatchStateChangedEvent * @param {object} detail the full state * @param {object} target the custom event target (document if none provided) */ function dispatchStateChangedEvent(detail, target) { if (target === undefined) { target = document; } target.dispatchEvent( new CustomEvent( stateChangedEventName, { bubbles: true, detail: detail, } ) ); } /** * The main init method to initialize the reactive debug. * @returns {object} */ export const initDebug = () => { const debug = new Debug({ name: 'CoreReactiveDebug', eventName: stateChangedEventName, eventDispatch: dispatchStateChangedEvent, mutations: new Mutations(), state: { reactives: [], }, }); // The reactiveDebuggers will be used as a way of access the debug instances but also to register every new // instance. To ensure this will update the reactive debug state we add the registerNewInstance method to it. reactiveDebuggers.registerNewInstance = debug.registerNewInstance.bind(debug); return { debug, debuggers: reactiveDebuggers, }; }; local/reactive/reactive.js 0000644 00000037261 15152050146 0011610 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A generic single state reactive module. * * @module core/reactive/local/reactive/reactive * @class core/reactive/local/reactive/reactive * @copyright 2021 Ferran Recio <ferran@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import log from 'core/log'; import StateManager from 'core/local/reactive/statemanager'; import Pending from 'core/pending'; // Count the number of pending operations done to ensure we have a unique id for each one. let pendingCount = 0; /** * Set up general reactive class to create a single state application with components. * * The reactive class is used for registering new UI components and manage the access to the state values * and mutations. * * When a new reactive instance is created, it will contain an empty state and and empty mutations * lists. When the state data is ready, the initial state can be loaded using the "setInitialState" * method. This will protect the state from writing and will trigger all the components "stateReady" * methods. * * State can only be altered by mutations. To replace all the mutations with a specific class, * use "setMutations" method. If you need to just add some new mutation methods, use "addMutations". * * To register new components into a reactive instance, use "registerComponent". * * Inside a component, use "dispatch" to invoke a mutation on the state (components can only access * the state in read only mode). */ export default class { /** * The component descriptor data structure. * * @typedef {object} description * @property {string} eventName the custom event name used for state changed events * @property {Function} eventDispatch the state update event dispatch function * @property {Element} [target] the target of the event dispatch. If not passed a fake element will be created * @property {Object} [mutations] an object with state mutations functions * @property {Object} [state] an object to initialize the state. */ /** * Create a basic reactive manager. * * Note that if your state is not async loaded, you can pass directly on creation by using the * description.state attribute. However, this will initialize the state, this means * setInitialState will throw an exception because the state is already defined. * * @param {description} description reactive manager description. */ constructor(description) { if (description.eventName === undefined || description.eventDispatch === undefined) { throw new Error(`Reactivity event required`); } if (description.name !== undefined) { this.name = description.name; } // Each reactive instance has its own element anchor to propagate state changes internally. // By default the module will create a fake DOM element to target custom events but // if all reactive components is constrait to a single element, this can be passed as // target in the description. this.target = description.target ?? document.createTextNode(null); this.eventName = description.eventName; this.eventDispatch = description.eventDispatch; // State manager is responsible for dispatch state change events when a mutation happens. this.stateManager = new StateManager(this.eventDispatch, this.target); // An internal registry of watchers and components. this.watchers = new Map([]); this.components = new Set([]); // Mutations can be overridden later using setMutations method. this.mutations = description.mutations ?? {}; // Register the event to alert watchers when specific state change happens. this.target.addEventListener(this.eventName, this.callWatchersHandler.bind(this)); // Add a pending operation waiting for the initial state. this.pendingState = new Pending(`core/reactive:registerInstance${pendingCount++}`); // Set initial state if we already have it. if (description.state !== undefined) { this.setInitialState(description.state); } // Check if we have a debug instance to register the instance. if (M.reactive !== undefined) { M.reactive.registerNewInstance(this); } } /** * State changed listener. * * This function take any state change and send it to the proper watchers. * * To prevent internal state changes from colliding with other reactive instances, only the * general "state changed" is triggered at document level. All the internal changes are * triggered at private target level without bubbling. This way any reactive instance can alert * only its own watchers. * * @param {CustomEvent} event */ callWatchersHandler(event) { // Execute any registered component watchers. this.target.dispatchEvent(new CustomEvent(event.detail.action, { bubbles: false, detail: event.detail, })); } /** * Set the initial state. * * @param {object} stateData the initial state data. */ setInitialState(stateData) { this.pendingState.resolve(); this.stateManager.setInitialState(stateData); } /** * Add individual functions to the mutations. * * Note new mutations will be added to the existing ones. To replace the full mutation * object with a new one, use setMutations method. * * @method addMutations * @param {Object} newFunctions an object with new mutation functions. */ addMutations(newFunctions) { // Mutations can provide an init method to do some setup in the statemanager. if (newFunctions.init !== undefined) { newFunctions.init(this.stateManager); } // Save all mutations. for (const [mutation, mutationFunction] of Object.entries(newFunctions)) { this.mutations[mutation] = mutationFunction.bind(newFunctions); } } /** * Replace the current mutations with a new object. * * This method is designed to override the full mutations class, for example by extending * the original one. To add some individual mutations, use addMutations instead. * * @param {object} manager the new mutations intance */ setMutations(manager) { this.mutations = manager; // Mutations can provide an init method to do some setup in the statemanager. if (manager.init !== undefined) { manager.init(this.stateManager); } } /** * Return the current state. * * @return {object} */ get state() { return this.stateManager.state; } /** * Get state data. * * Components access the state frequently. This convenience method is a shortcut to * this.reactive.state.stateManager.get() method. * * @param {String} name the state object name * @param {*} id an optional object id for state maps. * @return {Object|undefined} the state object found */ get(name, id) { return this.stateManager.get(name, id); } /** * Return the initial state promise. * * Typically, components do not require to use this promise because registerComponent * will trigger their stateReady method automatically. But it could be useful for complex * components that require to combine state, template and string loadings. * * @method getState * @return {Promise} */ getInitialStatePromise() { return this.stateManager.getInitialPromise(); } /** * Register a new component. * * Component can provide some optional functions to the reactive module: * - getWatchers: returns an array of watchers * - stateReady: a method to call when the initial state is loaded * * It can also provide some optional attributes: * - name: the component name (default value: "Unkown component") to customize debug messages. * * The method will also use dispatchRegistrationSuccess and dispatchRegistrationFail. Those * are BaseComponent methods to inform parent components of the registration status. * Components should not override those methods. * * @method registerComponent * @param {object} component the new component * @param {string} [component.name] the component name to display in warnings and errors. * @param {Function} [component.dispatchRegistrationSuccess] method to notify registration success * @param {Function} [component.dispatchRegistrationFail] method to notify registration fail * @param {Function} [component.getWatchers] getter of the component watchers * @param {Function} [component.stateReady] method to call when the state is ready * @return {object} the registered component */ registerComponent(component) { // Component name is an optional attribute to customize debug messages. const componentName = component.name ?? 'Unkown component'; // Components can provide special methods to communicate registration to parent components. let dispatchSuccess = () => { return; }; let dispatchFail = dispatchSuccess; if (component.dispatchRegistrationSuccess !== undefined) { dispatchSuccess = component.dispatchRegistrationSuccess.bind(component); } if (component.dispatchRegistrationFail !== undefined) { dispatchFail = component.dispatchRegistrationFail.bind(component); } // Components can be registered only one time. if (this.components.has(component)) { dispatchSuccess(); return component; } // Components are fully registered only when the state ready promise is resolved. const pendingPromise = new Pending(`core/reactive:registerComponent${pendingCount++}`); // Keep track of the event listeners. let listeners = []; // Register watchers. let handlers = []; if (component.getWatchers !== undefined) { handlers = component.getWatchers(); } handlers.forEach(({watch, handler}) => { if (watch === undefined) { dispatchFail(); throw new Error(`Missing watch attribute in ${componentName} watcher`); } if (handler === undefined) { dispatchFail(); throw new Error(`Missing handler for watcher ${watch} in ${componentName}`); } const listener = (event) => { // Prevent any watcher from losing the page focus. const currentFocus = document.activeElement; // Execute watcher. handler.apply(component, [event.detail]); // Restore focus in case it is lost. if (document.activeElement === document.body && document.body.contains(currentFocus)) { currentFocus.focus(); } }; // Save the listener information in case the component must be unregistered later. listeners.push({target: this.target, watch, listener}); // The state manager triggers a general "state changed" event at a document level. However, // for the internal watchers, each component can listen to specific state changed custom events // in the target element. This way we can use the native event loop without colliding with other // reactive instances. this.target.addEventListener(watch, listener); }); // Register state ready function. There's the possibility a component is registered after the initial state // is loaded. For those cases we have a state promise to handle this specific state change. if (component.stateReady !== undefined) { this.getInitialStatePromise() .then(state => { component.stateReady(state); pendingPromise.resolve(); return true; }) .catch(reason => { pendingPromise.resolve(); log.error(`Initial state in ${componentName} rejected due to: ${reason}`); log.error(reason); }); } // Save unregister data. this.watchers.set(component, listeners); this.components.add(component); // Dispatch an event to communicate the registration to the debug module. this.target.dispatchEvent(new CustomEvent('registerComponent:success', { bubbles: false, detail: {component}, })); dispatchSuccess(); return component; } /** * Unregister a component and its watchers. * * @param {object} component the object instance to unregister * @returns {object} the deleted component */ unregisterComponent(component) { if (!this.components.has(component)) { return component; } this.components.delete(component); // Remove event listeners. const listeners = this.watchers.get(component); if (listeners === undefined) { return component; } listeners.forEach(({target, watch, listener}) => { target.removeEventListener(watch, listener); }); this.watchers.delete(component); return component; } /** * Dispatch a change in the state. * * This method is the only way for components to alter the state. Watchers will receive a * read only state to prevent illegal changes. If some user action require a state change, the * component should dispatch a mutation to trigger all the necessary logic to alter the state. * * @method dispatch * @param {string} actionName the action name (usually the mutation name) * @param {mixed} params any number of params the mutation needs. */ async dispatch(actionName, ...params) { if (typeof actionName !== 'string') { throw new Error(`Dispatch action name must be a string`); } // JS does not have private methods yet. However, we prevent any component from calling // a method starting with "_" because the most accepted convention for private methods. if (actionName.charAt(0) === '_') { throw new Error(`Illegal Private ${actionName} mutation method dispatch`); } if (this.mutations[actionName] === undefined) { throw new Error(`Unkown ${actionName} mutation`); } const pendingPromise = new Pending(`core/reactive:${actionName}${pendingCount++}`); const mutationFunction = this.mutations[actionName]; try { await mutationFunction.apply(this.mutations, [this.stateManager, ...params]); pendingPromise.resolve(); } catch (error) { // Ensure the state is locked. this.stateManager.setReadOnly(true); pendingPromise.resolve(); throw error; } } } local/inplace_editable/events.js 0000644 00000010323 15152050146 0012742 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Inplace editable module events * * @module core/local/inplace_editable/events * @copyright 2021 Paul Holden <paulh@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import $ from 'jquery'; import {dispatchEvent} from 'core/event_dispatcher'; /** * Module events * * @constant * @property {String} elementUpdated See {@link event:core/inplace_editable:updated} * @property {String} elementUpdateFailed See {@link event:core/inplace_editable:updateFailed} */ export const eventTypes = { /** * Event triggered when an element has been updated * * @event core/inplace_editable:updated * @type {CustomEvent} * @property {HTMLElement} target The element that was updated * @property {Object} detail * @property {Object} detail.ajaxreturn The data returned from the update AJAX request * @property {String} detail.oldvalue The previous value of the element */ elementUpdated: 'core/inplace_editable:updated', /** * Event triggered when an element update has failed * * @event core/inplace_editable:updateFailed * @type {CustomEvent} * @property {HTMLElement} target The element that failed to update * @property {Object} detail * @property {Object} detail.exception The raised exception * @property {String} detail.newvalue The intended value of the element */ elementUpdateFailed: 'core/inplace_editable:updateFailed', }; /** * Notify element of successful update * * @method * @param {HTMLElement} element The element that was updated * @param {Object} ajaxreturn The data returned from the update AJAX request * @param {String} oldvalue The previous value of the element * @returns {CustomEvent} * @fires event:core/inplace_editable:updated */ export const notifyElementUpdated = (element, ajaxreturn, oldvalue) => dispatchEvent( eventTypes.elementUpdated, { ajaxreturn, oldvalue, }, element ); /** * Notify element of failed update * * @method * @param {HTMLElement} element The element that failed to update * @param {Object} exception The raised exception * @param {String} newvalue The intended value of the element * @returns {CustomEvent} * @fires event:core/inplace_editable:updateFailed */ export const notifyElementUpdateFailed = (element, exception, newvalue) => dispatchEvent( eventTypes.elementUpdateFailed, { exception, newvalue, }, element, { cancelable: true } ); let legacyEventsRegistered = false; if (!legacyEventsRegistered) { // The following event triggers are legacy and will be removed in the future. // The following approach provides a backwards-compatability layer for the new events. // Code should be updated to make use of native events. // Listen for the new native elementUpdated event, and trigger the legacy jQuery event. document.addEventListener(eventTypes.elementUpdated, event => { const legacyEvent = $.Event('updated', event.detail); $(event.target).trigger(legacyEvent); }); // Listen for the new native elementUpdateFailed event, and trigger the legacy jQuery event. document.addEventListener(eventTypes.elementUpdateFailed, event => { const legacyEvent = $.Event('updatefailed', event.detail); $(event.target).trigger(legacyEvent); // If the legacy event is cancelled, so should the native event. if (legacyEvent.isDefaultPrevented()) { event.preventDefault(); } }); legacyEventsRegistered = true; } local/aria/focuslock.js 0000644 00000024324 15152050146 0011104 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Tab locking system. * * This is based on code and examples provided in the ARIA specification. * https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html * * @module core/tablock * @copyright 2019 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Selectors from './selectors'; const lockRegionStack = []; const initialFocusElementStack = []; const finalFocusElementStack = []; let lastFocus = null; let ignoreFocusChanges = false; let isLocked = false; /** * The lock handler. * * This is the item that does a majority of the work. * The overall logic from this comes from the examles in the WCAG guidelines. * * The general idea is that if the focus is not held within by an Element within the lock region, then we replace focus * on the first element in the lock region. If the first element is the element previously selected prior to the * user-initiated focus change, then instead jump to the last element in the lock region. * * This gives us a solution which supports focus locking of any kind, which loops in both directions, and which * prevents the lock from escaping the modal entirely. * * @method * @param {Event} event The event from the focus change */ const lockHandler = event => { if (ignoreFocusChanges) { // The focus change was made by an internal call to set focus. return; } // Find the current lock region. let lockRegion = getCurrentLockRegion(); while (lockRegion) { if (document.contains(lockRegion)) { break; } // The lock region does not exist. // Perhaps it was removed without being untrapped. untrapFocus(); lockRegion = getCurrentLockRegion(); } if (!lockRegion) { return; } if (lockRegion.contains(event.target)) { lastFocus = event.target; } else { focusFirstDescendant(); if (lastFocus == document.activeElement) { focusLastDescendant(); } lastFocus = document.activeElement; } }; /** * Focus the first descendant of the current lock region. * * @method * @returns {Bool} Whether a node was focused */ const focusFirstDescendant = () => { const lockRegion = getCurrentLockRegion(); // Grab all elements in the lock region and attempt to focus each element until one is focused. // We can capture most of this in the query selector, but some cases may still reject focus. // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector // to capture this. // The use of Array.some just ensures that we stop as soon as we have a successful focus. const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable)); // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues. // We must include it in the calculation of descendants to ensure that looping works correctly. focusableElements.unshift(lockRegion); return focusableElements.some(focusableElement => attemptFocus(focusableElement)); }; /** * Focus the last descendant of the current lock region. * * @method * @returns {Bool} Whether a node was focused */ const focusLastDescendant = () => { const lockRegion = getCurrentLockRegion(); // Grab all elements in the lock region, reverse them, and attempt to focus each element until one is focused. // We can capture most of this in the query selector, but some cases may still reject focus. // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector // to capture this. // The use of Array.some just ensures that we stop as soon as we have a successful focus. const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable)).reverse(); // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues. // We must include it in the calculation of descendants to ensure that looping works correctly. focusableElements.push(lockRegion); return focusableElements.some(focusableElement => attemptFocus(focusableElement)); }; /** * Check whether the supplied focusTarget is actually focusable. * There are cases where a normally focusable element can reject focus. * * Note: This example is a wholesale copy of the WCAG example. * * @method * @param {HTMLElement} focusTarget * @returns {Bool} */ const isFocusable = focusTarget => { if (focusTarget.tabIndex > 0 || (focusTarget.tabIndex === 0 && focusTarget.getAttribute('tabIndex') !== null)) { return true; } if (focusTarget.disabled) { return false; } switch (focusTarget.nodeName) { case 'A': return !!focusTarget.href && focusTarget.rel != 'ignore'; case 'INPUT': return focusTarget.type != 'hidden' && focusTarget.type != 'file'; case 'BUTTON': case 'SELECT': case 'TEXTAREA': return true; default: return false; } }; /** * Attempt to focus the supplied focusTarget. * * Note: This example is a heavily inspired by the WCAG example. * * @method * @param {HTMLElement} focusTarget * @returns {Bool} Whether focus was successful o rnot. */ const attemptFocus = focusTarget => { if (!isFocusable(focusTarget)) { return false; } // The ignoreFocusChanges variable prevents the focus event handler from interfering and entering a fight with itself. ignoreFocusChanges = true; try { focusTarget.focus(); } catch (e) { // Ignore failures. We will just try to focus the next element in the list. // eslint-disable-line } ignoreFocusChanges = false; // If focus was successful the activeElement will be the one we focused. return (document.activeElement === focusTarget); }; /** * Get the current lock region from the top of the stack. * * @method * @returns {HTMLElement} */ const getCurrentLockRegion = () => { return lockRegionStack[lockRegionStack.length - 1]; }; /** * Add a new lock region to the stack. * * @method * @param {HTMLElement} newLockRegion */ const addLockRegionToStack = newLockRegion => { if (newLockRegion === getCurrentLockRegion()) { return; } lockRegionStack.push(newLockRegion); const currentLockRegion = getCurrentLockRegion(); // Append an empty div which can be focused just outside of the item locked. // This locks tab focus to within the tab region, and does not allow it to extend back into the window by // guaranteeing the existence of a tabable item after the lock region which can be focused but which will be caught // by the handler. const element = document.createElement('div'); element.tabIndex = 0; element.style.position = 'fixed'; element.style.top = 0; element.style.left = 0; const initialNode = element.cloneNode(); currentLockRegion.parentNode.insertBefore(initialNode, currentLockRegion); initialFocusElementStack.push(initialNode); const finalNode = element.cloneNode(); currentLockRegion.parentNode.insertBefore(finalNode, currentLockRegion.nextSibling); finalFocusElementStack.push(finalNode); }; /** * Remove the top lock region from the stack. * * @method */ const removeLastLockRegionFromStack = () => { // Take the top element off the stack, and replce the current lockRegion value. lockRegionStack.pop(); const finalNode = finalFocusElementStack.pop(); if (finalNode) { // The final focus element may have been removed if it was part of a parent item. finalNode.remove(); } const initialNode = initialFocusElementStack.pop(); if (initialNode) { // The initial focus element may have been removed if it was part of a parent item. initialNode.remove(); } }; /** * Whether any region is left in the stack. * * @return {Bool} */ const hasTrappedRegionsInStack = () => { return !!lockRegionStack.length; }; /** * Start trapping the focus and lock it to the specified newLockRegion. * * @method * @param {HTMLElement} newLockRegion The container to lock focus to */ export const trapFocus = newLockRegion => { // Update the lock region stack. // This allows us to support nesting. addLockRegionToStack(newLockRegion); if (!isLocked) { // Add the focus handler. document.addEventListener('focus', lockHandler, true); } // Attempt to focus on the first item in the lock region. if (!focusFirstDescendant()) { const currentLockRegion = getCurrentLockRegion(); // No focusable descendants found in the region yet. // This can happen when the region is locked before content is generated. // Focus on the region itself for now. const originalRegionTabIndex = currentLockRegion.tabIndex; currentLockRegion.tabIndex = 0; attemptFocus(currentLockRegion); currentLockRegion.tabIndex = originalRegionTabIndex; } // Keep track of the last item focused. lastFocus = document.activeElement; isLocked = true; }; /** * Stop trapping the focus. * * @method */ export const untrapFocus = () => { // Remove the top region from the stack. removeLastLockRegionFromStack(); if (hasTrappedRegionsInStack()) { // The focus manager still has items in the stack. return; } document.removeEventListener('focus', lockHandler, true); lastFocus = null; ignoreFocusChanges = false; isLocked = false; }; local/aria/selectors.js 0000644 00000002166 15152050146 0011117 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Selectors used for ARIA. * * @module core/local/aria/selectors * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ export default { aria: { hidden: '[aria-hidden]', }, elements: { focusable: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]', focusableToUnhide: '[data-aria-hidden-tab-index]', }, }; local/aria/aria-hidden.js 0000644 00000021525 15152050146 0011261 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * ARIA helpers related to the aria-hidden attribute. * * @module core/local/aria/aria-hidden. * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import {getList} from 'core/normalise'; import Selectors from './selectors'; // The map of MutationObserver objects for an object. const childObserverMap = new Map(); const siblingObserverMap = new Map(); /** * Determine whether the browser supports the MutationObserver system. * * @method * @returns {Bool} */ const supportsMutationObservers = () => (MutationObserver && typeof MutationObserver === 'function'); /** * Disable element focusability, disabling the tabindex for child elements which are normally focusable. * * @method * @param {HTMLElement} target */ const disableElementFocusability = target => { if (!(target instanceof HTMLElement)) { // This element is not an HTMLElement. // This can happen for Text Nodes. return; } if (target.matches(Selectors.elements.focusable)) { disableAndStoreTabIndex(target); } target.querySelectorAll(Selectors.elements.focusable).forEach(disableAndStoreTabIndex); }; /** * Remove the current tab-index and store it for later restoration. * * @method * @param {HTMLElement} element */ const disableAndStoreTabIndex = element => { if (typeof element.dataset.ariaHiddenTabIndex !== 'undefined') { // This child already has a hidden attribute. // Do not modify it as the original value will be lost. return; } // Store the old tabindex in a data attribute. if (element.getAttribute('tabindex')) { element.dataset.ariaHiddenTabIndex = element.getAttribute('tabindex'); } else { element.dataset.ariaHiddenTabIndex = ''; } element.setAttribute('tabindex', -1); }; /** * Re-enable element focusability, restoring any tabindex. * * @method * @param {HTMLElement} target */ const enableElementFocusability = target => { if (!(target instanceof HTMLElement)) { // This element is not an HTMLElement. // This can happen for Text Nodes. return; } if (target.matches(Selectors.elements.focusableToUnhide)) { restoreTabIndex(target); } target.querySelectorAll(Selectors.elements.focusableToUnhide).forEach(restoreTabIndex); }; /** * Restore the tab-index of the supplied element. * * When disabling focusability the current tab-index is stored in the ariaHiddenTabIndex data attribute. * This is used to restore the tab-index, but only whilst the parent nodes remain unhidden. * * @method * @param {HTMLElement} element */ const restoreTabIndex = element => { if (element.closest(Selectors.aria.hidden)) { // This item still has a hidden parent, or is hidden itself. Do not unhide it. return; } const oldTabIndex = element.dataset.ariaHiddenTabIndex; if (oldTabIndex === '') { element.removeAttribute('tabindex'); } else { element.setAttribute('tabindex', oldTabIndex); } delete element.dataset.ariaHiddenTabIndex; }; /** * Update the supplied DOM Module to be hidden. * * @method * @param {HTMLElement} target * @returns {Array} */ export const hide = target => getList(target).forEach(_hide); const _hide = target => { if (!(target instanceof HTMLElement)) { // This element is not an HTMLElement. // This can happen for Text Nodes. return; } if (target.closest(Selectors.aria.hidden)) { // This Element, or a parent Element, is already hidden. // Stop processing. return; } // Set the aria-hidden attribute to true. target.setAttribute('aria-hidden', true); // Based on advice from https://dequeuniversity.com/rules/axe/3.3/aria-hidden-focus, upon setting the aria-hidden // attribute, all focusable elements underneath that element should be modified such that they are not focusable. disableElementFocusability(target); if (supportsMutationObservers()) { // Add a MutationObserver to check for new children to the tree. const mutationObserver = new MutationObserver(mutationList => { mutationList.forEach(mutation => { if (mutation.type === 'childList') { mutation.addedNodes.forEach(disableElementFocusability); } else if (mutation.type === 'attributes') { // The tabindex has been updated on a hidden attribute. // Ensure that it is stored, ad set to -1 to prevent breakage. const element = mutation.target; const proposedTabIndex = element.getAttribute('tabindex'); if (proposedTabIndex !== "-1") { element.dataset.ariaHiddenTabIndex = proposedTabIndex; element.setAttribute('tabindex', -1); } } }); }); mutationObserver.observe(target, { // Watch for changes to the entire subtree. subtree: true, // Watch for new nodes. childList: true, // Watch for attribute changes to the tabindex. attributes: true, attributeFilter: ['tabindex'], }); childObserverMap.set(target, mutationObserver); } }; /** * Reverse the effect of the hide action. * * @method * @param {HTMLElement} target * @returns {Array} */ export const unhide = target => getList(target).forEach(_unhide); const _unhide = target => { if (!(target instanceof HTMLElement)) { return; } // Note: The aria-hidden attribute should be removed, and not set to false. // The presence of the attribute is sufficient for some browsers to treat it as being true, regardless of its value. target.removeAttribute('aria-hidden'); // Restore the tabindex across all child nodes of the target. enableElementFocusability(target); // Remove the focusability MutationObserver watching this tree. if (childObserverMap.has(target)) { childObserverMap.get(target).disconnect(); childObserverMap.delete(target); } }; /** * Correctly mark all siblings of the supplied target Element as hidden. * * @method * @param {HTMLElement} target * @returns {Array} */ export const hideSiblings = target => getList(target).forEach(_hideSiblings); const _hideSiblings = target => { if (!(target instanceof HTMLElement)) { return; } if (!target.parentElement) { return; } target.parentElement.childNodes.forEach(node => { if (node === target) { // Skip self; return; } hide(node); }); if (supportsMutationObservers()) { // Add a MutationObserver to check for new children to the tree. const newNodeObserver = new MutationObserver(mutationList => { mutationList.forEach(mutation => { mutation.addedNodes.forEach(node => { if (target.contains(node)) { // Skip self, and children of self. return; } hide(node); }); }); }); newNodeObserver.observe(target.parentElement, {childList: true, subtree: true}); siblingObserverMap.set(target.parentElement, newNodeObserver); } }; /** * Correctly reverse the hide action of all children of the supplied target Element. * * @method * @param {HTMLElement} target * @returns {Array} */ export const unhideSiblings = target => getList(target).forEach(_unhideSiblings); const _unhideSiblings = target => { if (!(target instanceof HTMLElement)) { return; } if (!target.parentElement) { return; } target.parentElement.childNodes.forEach(node => { if (node === target) { // Skip self; return; } unhide(node); }); // Remove the sibling MutationObserver watching this tree. if (siblingObserverMap.has(target.parentElement)) { siblingObserverMap.get(target.parentElement).disconnect(); siblingObserverMap.delete(target.parentElement); } }; local/repository/dynamic_tabs.js 0000644 00000002373 15152050146 0013054 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Module to handle dynamic tabs AJAX requests * * @module core/local/repository/dynamic_tabs * @copyright 2021 David Matamoros <davidmc@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Ajax from 'core/ajax'; /** * Return tab content * * @param {String} tab * @param {String} jsondata * @return {Promise} */ export const getContent = (tab, jsondata) => { const request = { methodname: 'core_dynamic_tabs_get_content', args: {tab: tab, jsondata: jsondata} }; return Ajax.call([request])[0]; }; moremenu.js 0000644 00000024015 15152050146 0006732 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Moves wrapping navigation items into a more menu. * * @module core/moremenu * @copyright 2021 Moodle * @author Bas Brands <bas@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import $ from 'jquery'; import menu_navigation from "core/menu_navigation"; /** * Moremenu selectors. */ const Selectors = { regions: { moredropdown: '[data-region="moredropdown"]', morebutton: '[data-region="morebutton"]' }, classes: { dropdownitem: 'dropdown-item', dropdownmoremenu: 'dropdownmoremenu', hidden: 'd-none', active: 'active', nav: 'nav', navlink: 'nav-link', observed: 'observed', }, attributes: { menu: '[role="menu"]', dropdowntoggle: '[data-toggle="dropdown"]' } }; let isTabListMenu = false; /** * Auto Collapse navigation items that wrap into a dropdown menu. * * @param {HTMLElement} menu The navbar container. */ const autoCollapse = menu => { const maxHeight = menu.parentNode.offsetHeight + 1; const moreDropdown = menu.querySelector(Selectors.regions.moredropdown); const moreButton = menu.querySelector(Selectors.regions.morebutton); // If the menu items wrap and the menu height is larger than the height of the // parent then start pushing navlinks into the moreDropdown. if (menu.offsetHeight > maxHeight) { moreButton.classList.remove(Selectors.classes.hidden); const menuNodes = Array.from(menu.children).reverse(); menuNodes.forEach(item => { if (!item.classList.contains(Selectors.classes.dropdownmoremenu)) { // After moving the menu items into the moreDropdown check again // if the menu height is still larger then the height of the parent. if (menu.offsetHeight > maxHeight) { const lastNode = menu.removeChild(item); // Move this node into the more dropdown menu. moveIntoMoreDropdown(menu, lastNode, true); } } }); } else { // If the menu height is smaller than the height of the parent, then try returning navlinks to the menu. if ('children' in moreDropdown) { // Iterate through the nodes within the more dropdown menu. Array.from(moreDropdown.children).forEach(item => { // Don't move the node to the more menu if it is explicitly defined that // this node should be displayed in the more dropdown menu at all times. if (menu.offsetHeight < maxHeight && item.dataset.forceintomoremenu !== 'true') { const lastNode = moreDropdown.removeChild(item); // Move this node from the more dropdown menu into the main section of the menu. moveOutOfMoreDropdown(menu, lastNode); } }); // If there are no more nodes in the more dropdown menu we can hide the moreButton. if (Array.from(moreDropdown.children).length === 0) { moreButton.classList.add(Selectors.classes.hidden); } } if (menu.offsetHeight > maxHeight) { autoCollapse(menu); } } menu.parentNode.classList.add(Selectors.classes.observed); }; /** * Move a node into the "more" dropdown menu. * * This method forces a given navigation node to be added and displayed within the "more" dropdown menu. * * @param {HTMLElement} menu The navbar moremenu. * @param {HTMLElement} navNode The navigation node. * @param {boolean} prepend Whether to prepend or append the node to the content in the more dropdown menu. */ const moveIntoMoreDropdown = (menu, navNode, prepend = false) => { const moreDropdown = menu.querySelector(Selectors.regions.moredropdown); const dropdownToggle = menu.querySelector(Selectors.attributes.dropdowntoggle); const navLink = navNode.querySelector('.' + Selectors.classes.navlink); // If there are navLinks that contain an active link in the moreDropdown // make the dropdownToggle in the moreButton active. if (navLink.classList.contains(Selectors.classes.active)) { dropdownToggle.classList.add(Selectors.classes.active); dropdownToggle.setAttribute('tabindex', '0'); navLink.setAttribute('tabindex', '-1'); // So that we don't have a single tabbable menu item. // Remove aria-selected if the more menu is rendered as a tab list. if (isTabListMenu) { navLink.removeAttribute('aria-selected'); } navLink.setAttribute('aria-current', 'true'); } // This will become a menu item instead of a tab. navLink.setAttribute('role', 'menuitem'); // Change the styling of the navLink to a dropdownitem and push it into // the moreDropdown. navLink.classList.remove(Selectors.classes.navlink); navLink.classList.add(Selectors.classes.dropdownitem); if (prepend) { moreDropdown.prepend(navNode); } else { moreDropdown.append(navNode); } }; /** * Move a node out of the "more" dropdown menu. * * This method forces a given node from the "more" dropdown menu to be displayed in the main section of the menu. * * @param {HTMLElement} menu The navbar moremenu. * @param {HTMLElement} navNode The navigation node. */ const moveOutOfMoreDropdown = (menu, navNode) => { const moreButton = menu.querySelector(Selectors.regions.morebutton); const dropdownToggle = menu.querySelector(Selectors.attributes.dropdowntoggle); const navLink = navNode.querySelector('.' + Selectors.classes.dropdownitem); // If the more menu is rendered as a tab list, // this will become a tab instead of a menuitem when moved out of the more menu dropdown. if (isTabListMenu) { navLink.setAttribute('role', 'tab'); } // Stop displaying the active state on the dropdownToggle if // the active navlink is removed. if (navLink.classList.contains(Selectors.classes.active)) { dropdownToggle.classList.remove(Selectors.classes.active); dropdownToggle.setAttribute('tabindex', '-1'); navLink.setAttribute('tabindex', '0'); if (isTabListMenu) { // Replace aria selection state when necessary. navLink.removeAttribute('aria-current'); navLink.setAttribute('aria-selected', 'true'); } } navLink.classList.remove(Selectors.classes.dropdownitem); navLink.classList.add(Selectors.classes.navlink); menu.insertBefore(navNode, moreButton); }; /** * Initialise the more menus. * * @param {HTMLElement} menu The navbar moremenu. */ export default menu => { isTabListMenu = menu.getAttribute('role') === 'tablist'; // Select the first menu item if there's nothing initially selected. const hash = window.location.hash; if (!hash) { const itemRole = isTabListMenu ? 'tab' : 'menuitem'; const menuListItem = menu.firstElementChild; const roleSelector = `[role=${itemRole}]`; const menuItem = menuListItem.querySelector(roleSelector); const ariaAttribute = isTabListMenu ? 'aria-selected' : 'aria-current'; if (!menu.querySelector(`[${ariaAttribute}='true']`)) { menuItem.setAttribute(ariaAttribute, 'true'); menuItem.setAttribute('tabindex', '0'); } } // Pre-populate the "more" dropdown menu with navigation nodes which are set to be displayed in this menu // by default at all times. if ('children' in menu) { const moreButton = menu.querySelector(Selectors.regions.morebutton); const menuNodes = Array.from(menu.children); menuNodes.forEach((item) => { if (!item.classList.contains(Selectors.classes.dropdownmoremenu) && item.dataset.forceintomoremenu === 'true') { // Append this node into the more dropdown menu. moveIntoMoreDropdown(menu, item, false); // After adding the node into the more dropdown menu, make sure that the more dropdown menu button // is displayed. if (moreButton.classList.contains(Selectors.classes.hidden)) { moreButton.classList.remove(Selectors.classes.hidden); } } }); } // Populate the more dropdown menu with additional nodes if necessary, depending on the current screen size. autoCollapse(menu); menu_navigation(menu); // When the screen size changes make sure the menu still fits. window.addEventListener('resize', () => { autoCollapse(menu); menu_navigation(menu); }); const toggledropdown = e => { const innerMenu = e.target.parentNode.querySelector(Selectors.attributes.menu); if (innerMenu) { innerMenu.classList.toggle('show'); } e.stopPropagation(); }; // If there are dropdowns in the MoreMenu, add a new // event listener to show the contents on click and prevent the // moreMenu from closing. $('.' + Selectors.classes.dropdownmoremenu).on('show.bs.dropdown', function() { const moreDropdown = menu.querySelector(Selectors.regions.moredropdown); moreDropdown.querySelectorAll('.dropdown').forEach((dropdown) => { dropdown.removeEventListener('click', toggledropdown, true); dropdown.addEventListener('click', toggledropdown, true); }); }); }; permissionmanager.js 0000644 00000023426 15152050146 0010633 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * @copyright 2015 Martin Mastny <mastnym@vscht.cz> * @since 3.0 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * @module core/permissionmanager */ define(['jquery', 'core/config', 'core/notification', 'core/templates', 'core/yui'], function($, config, notification, templates, Y) { /** * Used CSS selectors * @access private */ var SELECTORS = { ADDROLE: 'a.allowlink, a.prohibitlink', REMOVEROLE: 'a.preventlink, a.unprohibitlink', UNPROHIBIT: 'a.unprohibitlink' }; var rolesloadedevent = $.Event('rolesloaded'); var contextid; var contextname; var adminurl; var overideableroles; var panel = null; /** * Load all possible roles, which could be assigned from server * * @access private * @method loadOverideableRoles */ var loadOverideableRoles = function() { var params = { contextid: contextid, getroles: 1, sesskey: config.sesskey }; // Need to tell jQuery to expect JSON as the content type may not be correct (MDL-55041). $.post(adminurl + 'roles/ajax.php', params, null, 'json') .done(function(data) { try { overideableroles = data; loadOverideableRoles = function() { $('body').trigger(rolesloadedevent); }; loadOverideableRoles(); } catch (err) { notification.exception(err); } }) .fail(function(jqXHR, status, error) { notification.exception(error); }); }; /** * Perform the UI changes after server change * * @access private * @method changePermissions * @param {JQuery} row * @param {int} roleid * @param {string} action */ var changePermissions = function(row, roleid, action) { var params = { contextid: contextid, roleid: roleid, sesskey: M.cfg.sesskey, action: action, capability: row.data('name') }; $.post(adminurl + 'roles/ajax.php', params, null, 'json') .done(function(data) { var action = data; try { var templatedata = {rolename: overideableroles[roleid], roleid: roleid, adminurl: adminurl, imageurl: M.util.image_url('t/delete', 'moodle') }; switch (action) { case 'allow': templatedata.spanclass = 'allowed'; templatedata.linkclass = 'preventlink'; templatedata.action = 'prevent'; templatedata.icon = 't/delete'; templatedata.iconalt = M.util.get_string('deletexrole', 'core_role', overideableroles[roleid]); break; case 'prohibit': templatedata.spanclass = 'forbidden'; templatedata.linkclass = 'unprohibitlink'; templatedata.action = 'unprohibit'; templatedata.icon = 't/delete'; templatedata.iconalt = M.util.get_string('deletexrole', 'core_role', overideableroles[roleid]); break; case 'prevent': row.find('a[data-role-id="' + roleid + '"]').first().closest('.allowed').remove(); return; case 'unprohibit': row.find('a[data-role-id="' + roleid + '"]').first().closest('.forbidden').remove(); return; default: return; } templates.render('core/permissionmanager_role', templatedata) .done(function(content) { if (action == 'allow') { $(content).insertBefore(row.find('.allowmore').first()); } else if (action == 'prohibit') { $(content).insertBefore(row.find('.prohibitmore').first()); // Remove allowed link var allowedLink = row.find('.allowedroles').first().find('a[data-role-id="' + roleid + '"]'); if (allowedLink) { allowedLink.first().closest('.allowed').remove(); } } panel.hide(); }) .fail(notification.exception); } catch (err) { notification.exception(err); } }) .fail(function(jqXHR, status, error) { notification.exception(error); }); }; /** * Prompts user for selecting a role which is permitted * * @access private * @method handleAddRole * @param {event} e */ var handleAddRole = function(e) { e.preventDefault(); var link = $(e.currentTarget); // TODO: MDL-57778 Convert to core/modal. $('body').one('rolesloaded', function() { Y.use('moodle-core-notification-dialogue', function() { var action = link.data('action'); var row = link.closest('tr.rolecap'); var confirmationDetails = { cap: row.data('humanname'), context: contextname }; var message = M.util.get_string('role' + action + 'info', 'core_role', confirmationDetails); if (panel === null) { panel = new M.core.dialogue({ draggable: true, modal: true, closeButton: true, width: '450px' }); } panel.set('headerContent', M.util.get_string('role' + action + 'header', 'core_role')); var i, existingrolelinks; var roles = []; switch (action) { case 'allow': existingrolelinks = row.find(SELECTORS.REMOVEROLE); break; case 'prohibit': existingrolelinks = row.find(SELECTORS.UNPROHIBIT); break; } for (i in overideableroles) { var disabled = ''; var disable = existingrolelinks.filter("[data-role-id='" + i + "']").length; if (disable) { disabled = 'disabled'; } var roledetails = {roleid: i, rolename: overideableroles[i], disabled: disabled}; roles.push(roledetails); } templates.render('core/permissionmanager_panelcontent', {message: message, roles: roles}) .done(function(content) { panel.set('bodyContent', content); panel.show(); $('div.role_buttons').on('click', 'button', function(e) { var roleid = $(e.currentTarget).data('role-id'); changePermissions(row, roleid, action); }); }) .fail(notification.exception); }); }); loadOverideableRoles(); }; /** * Prompts user when removing permission * * @access private * @method handleRemoveRole * @param {event} e */ var handleRemoveRole = function(e) { e.preventDefault(); var link = $(e.currentTarget); $('body').one('rolesloaded', function() { var action = link.data('action'); var roleid = link.data('role-id'); var row = link.closest('tr.rolecap'); var questionDetails = { role: overideableroles[roleid], cap: row.data('humanname'), context: contextname }; notification.confirm(M.util.get_string('confirmunassigntitle', 'core_role'), M.util.get_string('confirmrole' + action, 'core_role', questionDetails), M.util.get_string('confirmunassignyes', 'core_role'), M.util.get_string('confirmunassignno', 'core_role'), function() { changePermissions(row, roleid, action); } ); }); loadOverideableRoles(); }; return /** @alias module:core/permissionmanager */ { /** * Initialize permissionmanager * @access public * @param {Object} args */ initialize: function(args) { contextid = args.contextid; contextname = args.contextname; adminurl = args.adminurl; var body = $('body'); body.on('click', SELECTORS.ADDROLE, handleAddRole); body.on('click', SELECTORS.REMOVEROLE, handleRemoveRole); } }; }); dynamic_tabs.js 0000644 00000015237 15152050146 0007546 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Dynamic Tabs UI element with AJAX loading of tabs content * * @module core/dynamic_tabs * @copyright 2021 David Matamoros <davidmc@moodle.com> based on code from Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import $ from 'jquery'; import Templates from 'core/templates'; import {addIconToContainer} from 'core/loadingicon'; import Notification from 'core/notification'; import Pending from 'core/pending'; import {get_strings as getStrings} from 'core/str'; import {getContent} from 'core/local/repository/dynamic_tabs'; import {isAnyWatchedFormDirty, resetAllFormDirtyStates} from 'core_form/changechecker'; const SELECTORS = { dynamicTabs: '.dynamictabs', activeTab: '.dynamictabs .nav-link.active', allActiveTabs: '.dynamictabs .nav-link[data-toggle="tab"]:not(.disabled)', tabContent: '.dynamictabs .tab-pane [data-tab-content]', tabToggle: 'a[data-toggle="tab"]', tabPane: '.dynamictabs .tab-pane', }; SELECTORS.forTabName = tabName => `.dynamictabs [data-tab-content="${tabName}"]`; SELECTORS.forTabId = tabName => `.dynamictabs [data-toggle="tab"][href="#${tabName}"]`; /** * Initialises the tabs view on the page (only one tabs view per page is supported) */ export const init = () => { const tabToggle = $(SELECTORS.tabToggle); // Listen to click, warn user if they are navigating away with unsaved form changes. tabToggle.on('click', (event) => { if (!isAnyWatchedFormDirty()) { return; } event.preventDefault(); event.stopPropagation(); getStrings([ {key: 'changesmade', component: 'moodle'}, {key: 'changesmadereallygoaway', component: 'moodle'}, {key: 'confirm', component: 'moodle'}, ]).then(([strChangesMade, strChangesMadeReally, strConfirm]) => // Reset form dirty state on confirmation, re-trigger the event. Notification.confirm(strChangesMade, strChangesMadeReally, strConfirm, null, () => { resetAllFormDirtyStates(); $(event.target).trigger(event.type); }) ).catch(Notification.exception); }); // This code listens to Bootstrap events 'show.bs.tab' and 'shown.bs.tab' which is triggered using JQuery and // can not be converted yet to native events. tabToggle .on('show.bs.tab', function() { // Clean content from previous tab. const previousTabName = getActiveTabName(); if (previousTabName) { const previousTab = document.querySelector(SELECTORS.forTabName(previousTabName)); previousTab.textContent = ''; } }) .on('shown.bs.tab', function() { const tab = $($(this).attr('href')); if (tab.length !== 1) { return; } loadTab(tab.attr('id')); }); if (!openTabFromHash()) { const tabs = document.querySelector(SELECTORS.allActiveTabs); if (tabs) { openTab(tabs.getAttribute('aria-controls')); } else { // We may hide tabs if there is only one available, just load the contents of the first tab. const tabPane = document.querySelector(SELECTORS.tabPane); if (tabPane) { tabPane.classList.add('active', 'show'); loadTab(tabPane.getAttribute('id')); } } } }; /** * Returns id/name of the currently active tab * * @return {String|null} */ const getActiveTabName = () => { const element = document.querySelector(SELECTORS.activeTab); return element?.getAttribute('aria-controls') || null; }; /** * Returns the id/name of the first tab * * @return {String|null} */ const getFirstTabName = () => { const element = document.querySelector(SELECTORS.tabContent); return element?.dataset.tabContent || null; }; /** * Loads contents of a tab using an AJAX request * * @param {String} tabName */ const loadTab = (tabName) => { // If tabName is not specified find the active tab, or if is not defined, the first available tab. tabName = tabName ?? getActiveTabName() ?? getFirstTabName(); const tab = document.querySelector(SELECTORS.forTabName(tabName)); if (!tab) { return; } const pendingPromise = new Pending('core/dynamic_tabs:loadTab:' + tabName); let tabjs = ''; addIconToContainer(tab) .then(() => { let tabArgs = {...tab.dataset}; delete tabArgs.tabClass; delete tabArgs.tabContent; return getContent(tab.dataset.tabClass, JSON.stringify(tabArgs)); }) .then((data) => { tabjs = data.javascript; return Templates.render(data.template, JSON.parse(data.content)); }) .then((html, js) => { return Templates.replaceNodeContents(tab, html, js + tabjs); }) .then(() => { pendingPromise.resolve(); return null; }) .catch(Notification.exception); }; /** * Return the tab given the tab name * * @param {String} tabName * @return {HTMLElement} */ const getTab = (tabName) => { return document.querySelector(SELECTORS.forTabId(tabName)); }; /** * Return the tab pane given the tab name * * @param {String} tabName * @return {HTMLElement} */ const getTabPane = (tabName) => { return document.getElementById(tabName); }; /** * Open the tab on page load. If this script loads before theme_boost/tab we need to open tab ourselves * * @param {String} tabName * @return {Boolean} */ const openTab = (tabName) => { const tab = getTab(tabName); if (!tab) { return false; } loadTab(tabName); tab.classList.add('active'); getTabPane(tabName).classList.add('active', 'show'); return true; }; /** * If there is a location hash that is the same as the tab name - open this tab. * * @return {Boolean} */ const openTabFromHash = () => { const hash = document.location.hash; if (hash.match(/^#\w+$/g)) { return openTab(hash.replace(/^#/g, '')); } return false; }; sortable_list.js 0000644 00000073007 15152050146 0007756 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A javascript module to handle list items drag and drop * * Example of usage: * * Create a list (for example `<ul>` or `<tbody>`) where each draggable element has a drag handle. * The best practice is to use the template core/drag_handle: * $OUTPUT->render_from_template('core/drag_handle', ['movetitle' => get_string('movecontent', 'moodle', ELEMENTNAME)]); * * Attach this JS module to this list: * * Space between define and ( critical in comment but not allowed in code in order to function * correctly with Moodle's requirejs.php * * More details: https://docs.moodle.org/dev/Sortable_list * * For the full list of possible parameters see var defaultParameters below. * * The following jQuery events are fired: * - SortableList.EVENTS.DRAGSTART : when user started dragging a list element * - SortableList.EVENTS.DRAG : when user dragged a list element to a new position * - SortableList.EVENTS.DROP : when user dropped a list element * - SortableList.EVENTS.DROPEND : when user finished dragging - either fired right after dropping or * if "Esc" was pressed during dragging * * @example * define (['jquery', 'core/sortable_list'], function($, SortableList) { * var list = new SortableList('ul.my-awesome-list'); // source list (usually <ul> or <tbody>) - selector or element * * // Listen to the events when element is dragged. * $('ul.my-awesome-list > *').on(SortableList.EVENTS.DROP, function(evt, info) { * console.log(info); * }); * * // Advanced usage. Overwrite methods getElementName, getDestinationName, moveDialogueTitle, for example: * list.getElementName = function(element) { * return $.Deferred().resolve(element.attr('data-name')); * } * }); * * @module core/sortable_list * @class core/sortable_list * @copyright 2018 Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery', 'core/log', 'core/autoscroll', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/notification'], function($, log, autoScroll, str, ModalFactory, ModalEvents, Notification) { /** * Default parameters * * @private * @type {Object} */ var defaultParameters = { targetListSelector: null, moveHandlerSelector: '[data-drag-type=move]', isHorizontal: false, autoScroll: true }; /** * Class names for different elements that may be changed during sorting * * @private * @type {Object} */ var CSS = { keyboardDragClass: 'dragdrop-keyboard-drag', isDraggedClass: 'sortable-list-is-dragged', currentPositionClass: 'sortable-list-current-position', sourceListClass: 'sortable-list-source', targetListClass: 'sortable-list-target', overElementClass: 'sortable-list-over-element' }; /** * Test the browser support for options objects on event listeners. * @return {Boolean} */ var eventListenerOptionsSupported = function() { var passivesupported = false, options, testeventname = "testpassiveeventoptions"; // Options support testing example from: // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener try { options = Object.defineProperty({}, "passive", { get: function() { passivesupported = true; } }); // We use an event name that is not likely to conflict with any real event. document.addEventListener(testeventname, options, options); // We remove the event listener as we have tested the options already. document.removeEventListener(testeventname, options, options); } catch (err) { // It's already false. passivesupported = false; } return passivesupported; }; /** * Allow to create non-passive touchstart listeners and prevent page scrolling when dragging * From: https://stackoverflow.com/a/48098097 * * @param {string} eventname * @returns {object} */ var registerNotPassiveListeners = function(eventname) { return { setup: function(x, ns, handle) { if (ns.includes('notPassive')) { this.addEventListener(eventname, handle, {passive: false}); return true; } else { return false; } } }; }; if (eventListenerOptionsSupported) { $.event.special.touchstart = registerNotPassiveListeners('touchstart'); $.event.special.touchmove = registerNotPassiveListeners('touchmove'); $.event.special.touchend = registerNotPassiveListeners('touchend'); } /** * Initialise sortable list. * * @param {(String|jQuery|Element)} root JQuery/DOM element representing sortable list (i.e. <ul>, <tbody>) or CSS selector * @param {Object} config Parameters for the list. See defaultParameters above for examples. * @param {(String|jQuery|Element)} config.targetListSelector target lists, by default same as root * @param {String} config.moveHandlerSelector CSS selector for a drag handle. By default '[data-drag-type=move]' * @param {String} config.listSelector CSS selector for target lists. By default the same as root * @param {(Boolean|Function)} config.isHorizontal Set to true if the list is horizontal (can also be a callback * with list as an argument) * @param {Boolean} config.autoScroll Engages autoscroll module for automatic vertical scrolling of the whole page, * by default true */ var SortableList = function(root, config) { this.info = null; this.proxy = null; this.proxyDelta = null; this.dragCounter = 0; this.lastEvent = null; this.config = $.extend({}, defaultParameters, config || {}); this.config.listSelector = root; if (!this.config.targetListSelector) { this.config.targetListSelector = root; } if (typeof this.config.listSelector === 'object') { // The root is an element on the page. Register a listener for this element. $(this.config.listSelector).on('mousedown touchstart.notPassive', $.proxy(this.dragStartHandler, this)); } else { // The root is a CSS selector. Register a listener that picks up the element dynamically. $('body').on('mousedown touchstart.notPassive', this.config.listSelector, $.proxy(this.dragStartHandler, this)); } if (this.config.moveHandlerSelector !== null) { $('body').on('click keypress', this.config.moveHandlerSelector, $.proxy(this.clickHandler, this)); } }; /** * Events fired by this entity * * @public * @type {Object} */ SortableList.EVENTS = { DRAGSTART: 'sortablelist-dragstart', DRAG: 'sortablelist-drag', DROP: 'sortablelist-drop', DRAGEND: 'sortablelist-dragend' }; /** * Resets the temporary classes assigned during dragging * @private */ SortableList.prototype.resetDraggedClasses = function() { var classes = [ CSS.isDraggedClass, CSS.currentPositionClass, CSS.overElementClass, CSS.targetListClass, ]; for (var i in classes) { $('.' + classes[i]).removeClass(classes[i]); } if (this.proxy) { this.proxy.remove(); this.proxy = $(); } }; /** * Calculates evt.pageX, evt.pageY, evt.clientX and evt.clientY * * For touch events pageX and pageY are taken from the first touch; * For the emulated mousemove event they are taken from the last real event. * * @private * @param {Event} evt */ SortableList.prototype.calculatePositionOnPage = function(evt) { if (evt.originalEvent && evt.originalEvent.touches && evt.originalEvent.touches[0] !== undefined) { // This is a touchmove or touchstart event, get position from the first touch position. var touch = evt.originalEvent.touches[0]; evt.pageX = touch.pageX; evt.pageY = touch.pageY; } if (evt.pageX === undefined) { // Information is not present in case of touchend or when event was emulated by autoScroll. // Take the absolute mouse position from the last event. evt.pageX = this.lastEvent.pageX; evt.pageY = this.lastEvent.pageY; } else { this.lastEvent = evt; } if (evt.clientX === undefined) { // If not provided in event calculate relative mouse position. evt.clientX = Math.round(evt.pageX - $(window).scrollLeft()); evt.clientY = Math.round(evt.pageY - $(window).scrollTop()); } }; /** * Handler from dragstart event * * @private * @param {Event} evt */ SortableList.prototype.dragStartHandler = function(evt) { if (this.info !== null) { if (this.info.type === 'click' || this.info.type === 'touchend') { // Ignore double click. return; } // Mouse down or touch while already dragging, cancel previous dragging. this.moveElement(this.info.sourceList, this.info.sourceNextElement); this.finishDragging(); } if (evt.type === 'mousedown' && evt.which !== 1) { // We only need left mouse click. If this is a mousedown event with right/middle click ignore it. return; } this.calculatePositionOnPage(evt); var movedElement = $(evt.target).closest($(evt.currentTarget).children()); if (!movedElement.length) { // Can't find the element user wants to drag. They clicked on the list but outside of any element of the list. return; } // Check that we grabbed the element by the handle. if (this.config.moveHandlerSelector !== null) { if (!$(evt.target).closest(this.config.moveHandlerSelector, movedElement).length) { return; } } evt.stopPropagation(); evt.preventDefault(); // Information about moved element with original location. // This object is passed to event observers. this.dragCounter++; this.info = { element: movedElement, sourceNextElement: movedElement.next(), sourceList: movedElement.parent(), targetNextElement: movedElement.next(), targetList: movedElement.parent(), type: evt.type, dropped: false, startX: evt.pageX, startY: evt.pageY, startTime: new Date().getTime() }; $(this.config.targetListSelector).addClass(CSS.targetListClass); var offset = movedElement.offset(); movedElement.addClass(CSS.currentPositionClass); this.proxyDelta = {x: offset.left - evt.pageX, y: offset.top - evt.pageY}; this.proxy = $(); var thisDragCounter = this.dragCounter; setTimeout($.proxy(function() { // This mousedown event may in fact be a beginning of a 'click' event. Use timeout before showing the // dragged object so we can catch click event. When timeout finishes make sure that click event // has not happened during this half a second. // Verify dragcounter to make sure the user did not manage to do two very fast drag actions one after another. if (this.info === null || this.info.type === 'click' || this.info.type === 'keypress' || this.dragCounter !== thisDragCounter) { return; } // Create a proxy - the copy of the dragged element that moves together with a mouse. this.createProxy(); }, this), 500); // Start drag. $(window).on('mousemove touchmove.notPassive mouseup touchend.notPassive', $.proxy(this.dragHandler, this)); $(window).on('keypress', $.proxy(this.dragcancelHandler, this)); // Start autoscrolling. Every time the page is scrolled emulate the mousemove event. if (this.config.autoScroll) { autoScroll.start(function() { $(window).trigger('mousemove'); }); } this.executeCallback(SortableList.EVENTS.DRAGSTART); }; /** * Creates a "proxy" object - a copy of the element that is being moved that always follows the mouse * @private */ SortableList.prototype.createProxy = function() { this.proxy = this.info.element.clone(); this.info.sourceList.append(this.proxy); this.proxy.removeAttr('id').removeClass(CSS.currentPositionClass) .addClass(CSS.isDraggedClass).css({position: 'fixed'}); this.proxy.offset({top: this.proxyDelta.y + this.lastEvent.pageY, left: this.proxyDelta.x + this.lastEvent.pageX}); }; /** * Handler for click event - when user clicks on the drag handler or presses Enter on keyboard * * @private * @param {Event} evt */ SortableList.prototype.clickHandler = function(evt) { if (evt.type === 'keypress' && evt.originalEvent.keyCode !== 13 && evt.originalEvent.keyCode !== 32) { return; } if (this.info !== null) { // Ignore double click. return; } // Find the element that this draghandle belongs to. var clickedElement = $(evt.target).closest(this.config.moveHandlerSelector), sourceList = clickedElement.closest(this.config.listSelector), movedElement = clickedElement.closest(sourceList.children()); if (!movedElement.length) { return; } evt.preventDefault(); evt.stopPropagation(); // Store information about moved element with original location. this.dragCounter++; this.info = { element: movedElement, sourceNextElement: movedElement.next(), sourceList: sourceList, targetNextElement: movedElement.next(), targetList: sourceList, dropped: false, type: evt.type, startTime: new Date().getTime() }; this.executeCallback(SortableList.EVENTS.DRAGSTART); this.displayMoveDialogue(clickedElement); }; /** * Finds the position of the mouse inside the element - on the top, on the bottom, on the right or on the left\ * * Used to determine if the moved element should be moved after or before the current element * * @private * @param {Number} pageX * @param {Number} pageY * @param {jQuery} element * @returns {(Object|null)} */ SortableList.prototype.getPositionInNode = function(pageX, pageY, element) { if (!element.length) { return null; } var node = element[0], offset = 0, rect = node.getBoundingClientRect(), y = pageY - (rect.top + window.scrollY), x = pageX - (rect.left + window.scrollX); if (x >= -offset && x <= rect.width + offset && y >= -offset && y <= rect.height + offset) { return { x: x, y: y, xRatio: rect.width ? (x / rect.width) : 0, yRatio: rect.height ? (y / rect.height) : 0 }; } return null; }; /** * Check if list is horizontal * * @param {jQuery} element * @return {Boolean} */ SortableList.prototype.isListHorizontal = function(element) { var isHorizontal = this.config.isHorizontal; if (isHorizontal === true || isHorizontal === false) { return isHorizontal; } return isHorizontal(element); }; /** * Handler for events mousemove touchmove mouseup touchend * * @private * @param {Event} evt */ SortableList.prototype.dragHandler = function(evt) { evt.preventDefault(); evt.stopPropagation(); this.calculatePositionOnPage(evt); // We can not use evt.target here because it will most likely be our proxy. // Move the proxy out of the way so we can find the element at the current mouse position. this.proxy.offset({top: -1000, left: -1000}); // Find the element at the current mouse position. var element = $(document.elementFromPoint(evt.clientX, evt.clientY)); // Find the list element and the list over the mouse position. var mainElement = this.info.element[0], isNotSelf = function() { return this !== mainElement; }, current = element.closest('.' + CSS.targetListClass + ' > :not(.' + CSS.isDraggedClass + ')').filter(isNotSelf), currentList = element.closest('.' + CSS.targetListClass), proxy = this.proxy, isNotProxy = function() { return !proxy || !proxy.length || this !== proxy[0]; }; // Add the specified class to the list element we are hovering. $('.' + CSS.overElementClass).removeClass(CSS.overElementClass); current.addClass(CSS.overElementClass); // Move proxy to the current position. this.proxy.offset({top: this.proxyDelta.y + evt.pageY, left: this.proxyDelta.x + evt.pageX}); if (currentList.length && !currentList.children().filter(isNotProxy).length) { // Mouse is over an empty list. this.moveElement(currentList, $()); } else if (current.length === 1 && !this.info.element.find(current[0]).length) { // Mouse is over an element in a list - find whether we should move the current position // above or below this element. var coordinates = this.getPositionInNode(evt.pageX, evt.pageY, current); if (coordinates) { var parent = current.parent(), ratio = this.isListHorizontal(parent) ? coordinates.xRatio : coordinates.yRatio, subList = current.find('.' + CSS.targetListClass), subListEmpty = !subList.children().filter(isNotProxy).filter(isNotSelf).length; if (subList.length && subListEmpty && ratio > 0.2 && ratio < 0.8) { // This is an element that is a parent of an empty list and we are around the middle of this element. // Treat it as if we are over this empty list. this.moveElement(subList, $()); } else if (ratio > 0.5) { // Insert after this element. this.moveElement(parent, current.next().filter(isNotProxy)); } else { // Insert before this element. this.moveElement(parent, current); } } } if (evt.type === 'mouseup' || evt.type === 'touchend') { // Drop the moved element. this.info.endX = evt.pageX; this.info.endY = evt.pageY; this.info.endTime = new Date().getTime(); this.info.dropped = true; this.info.positionChanged = this.hasPositionChanged(this.info); var oldinfo = this.info; this.executeCallback(SortableList.EVENTS.DROP); this.finishDragging(); if (evt.type === 'touchend' && this.config.moveHandlerSelector !== null && (oldinfo.endTime - oldinfo.startTime < 500) && !oldinfo.positionChanged) { // The click event is not triggered on touch screens because we call preventDefault in touchstart handler. // If the touchend quickly followed touchstart without moving, consider it a "click". this.clickHandler(evt); } } }; /** * Checks if the position of the dragged element in the list has changed * * @private * @param {Object} info * @return {Boolean} */ SortableList.prototype.hasPositionChanged = function(info) { return info.sourceList[0] !== info.targetList[0] || info.sourceNextElement.length !== info.targetNextElement.length || (info.sourceNextElement.length && info.sourceNextElement[0] !== info.targetNextElement[0]); }; /** * Moves the current position of the dragged element * * @private * @param {jQuery} parentElement * @param {jQuery} beforeElement */ SortableList.prototype.moveElement = function(parentElement, beforeElement) { var dragEl = this.info.element; if (beforeElement.length && beforeElement[0] === dragEl[0]) { // Insert before the current position of the dragged element - nothing to do. return; } if (parentElement[0] === this.info.targetList[0] && beforeElement.length === this.info.targetNextElement.length && beforeElement[0] === this.info.targetNextElement[0]) { // Insert in the same location as the current position - nothing to do. return; } if (beforeElement.length) { // Move the dragged element before the specified element. parentElement[0].insertBefore(dragEl[0], beforeElement[0]); } else if (this.proxy && this.proxy.parent().length && this.proxy.parent()[0] === parentElement[0]) { // We need to move to the end of the list but the last element in this list is a proxy. // Always leave the proxy in the end of the list. parentElement[0].insertBefore(dragEl[0], this.proxy[0]); } else { // Insert in the end of a list (when proxy is in another list). parentElement[0].appendChild(dragEl[0]); } // Save the current position of the dragged element in the list. this.info.targetList = parentElement; this.info.targetNextElement = beforeElement; this.executeCallback(SortableList.EVENTS.DRAG); }; /** * Finish dragging (when dropped or cancelled). * @private */ SortableList.prototype.finishDragging = function() { this.resetDraggedClasses(); if (this.config.autoScroll) { autoScroll.stop(); } $(window).off('mousemove touchmove.notPassive mouseup touchend.notPassive', $.proxy(this.dragHandler, this)); $(window).off('keypress', $.proxy(this.dragcancelHandler, this)); this.executeCallback(SortableList.EVENTS.DRAGEND); this.info = null; }; /** * Executes callback specified in sortable list parameters * * @private * @param {String} eventName */ SortableList.prototype.executeCallback = function(eventName) { this.info.element.trigger(eventName, this.info); }; /** * Handler from keypress event (cancel dragging when Esc is pressed) * * @private * @param {Event} evt */ SortableList.prototype.dragcancelHandler = function(evt) { if (evt.type !== 'keypress' || evt.originalEvent.keyCode !== 27) { // Only cancel dragging when Esc was pressed. return; } // Dragging was cancelled. Return item to the original position. this.moveElement(this.info.sourceList, this.info.sourceNextElement); this.finishDragging(); }; /** * Returns the name of the current element to be used in the move dialogue * * @public * @param {jQuery} element * @return {Promise} */ SortableList.prototype.getElementName = function(element) { return $.Deferred().resolve(element.text()); }; /** * Returns the label for the potential move destination, i.e. "After ElementX" or "To the top of the list" * * Note that we use "after" in the label for better UX * * @public * @param {jQuery} parentElement * @param {jQuery} afterElement * @return {Promise} */ SortableList.prototype.getDestinationName = function(parentElement, afterElement) { if (!afterElement.length) { return str.get_string('movecontenttothetop', 'moodle'); } else { return this.getElementName(afterElement) .then(function(name) { return str.get_string('movecontentafter', 'moodle', name); }); } }; /** * Returns the title for the move dialogue ("Move elementY") * * @public * @param {jQuery} element * @param {jQuery} handler * @return {Promise} */ SortableList.prototype.getMoveDialogueTitle = function(element, handler) { if (handler.attr('title')) { return $.Deferred().resolve(handler.attr('title')); } return this.getElementName(element).then(function(name) { return str.get_string('movecontent', 'moodle', name); }); }; /** * Returns the list of possible move destinations * * @private * @return {Promise} */ SortableList.prototype.getDestinationsList = function() { var addedLists = [], targets = $(this.config.targetListSelector), destinations = $('<ul/>').addClass(CSS.keyboardDragClass), result = $.when().then(function() { return destinations; }), createLink = $.proxy(function(parentElement, beforeElement, afterElement) { if (beforeElement.is(this.info.element) || afterElement.is(this.info.element)) { // Can not move before or after itself. return; } if ($.contains(this.info.element[0], parentElement[0])) { // Can not move to its own child. return; } result = result .then($.proxy(function() { return this.getDestinationName(parentElement, afterElement); }, this)) .then(function(txt) { var li = $('<li/>').appendTo(destinations); var a = $('<a href="#"/>').attr('data-core_sortable_list-quickmove', 1).appendTo(li); a.data('parent-element', parentElement).data('before-element', beforeElement).text(txt); return destinations; }); }, this), addList = function() { // Destination lists may be nested. We want to add all move destinations in the same // order they appear on the screen for the user. if ($.inArray(this, addedLists) !== -1) { return; } addedLists.push(this); var list = $(this), children = list.children(); children.each(function() { var element = $(this); createLink(list, element, element.prev()); // Add all nested lists. element.find(targets).each(addList); }); createLink(list, $(), children.last()); }; targets.each(addList); return result; }; /** * Displays the dialogue to move element. * @param {jQuery} clickedElement element to return focus to after the modal is closed * @private */ SortableList.prototype.displayMoveDialogue = function(clickedElement) { ModalFactory.create({ type: ModalFactory.types.CANCEL, title: this.getMoveDialogueTitle(this.info.element, clickedElement), body: this.getDestinationsList() }).then($.proxy(function(modal) { var quickMoveHandler = $.proxy(function(e) { e.preventDefault(); e.stopPropagation(); this.moveElement($(e.currentTarget).data('parent-element'), $(e.currentTarget).data('before-element')); this.info.endTime = new Date().getTime(); this.info.positionChanged = this.hasPositionChanged(this.info); this.info.dropped = true; clickedElement.focus(); this.executeCallback(SortableList.EVENTS.DROP); modal.hide(); }, this); modal.getRoot().on('click', '[data-core_sortable_list-quickmove]', quickMoveHandler); modal.getRoot().on(ModalEvents.hidden, $.proxy(function() { // Always destroy when hidden, it is generated dynamically each time. modal.getRoot().off('click', '[data-core_sortable_list-quickmove]', quickMoveHandler); modal.destroy(); this.finishDragging(); }, this)); modal.setLarge(); modal.show(); return modal; }, this)).catch(Notification.exception); }; return SortableList; }); paged_content_events.js 0000644 00000002260 15152050146 0011277 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Events for the paged content element. * * @module core/paged_content_events * @copyright 2018 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define([], function() { return { SHOW_PAGES: 'core-paged-content-show-pages', PAGES_SHOWN: 'core-paged-content-pages-shown', ALL_ITEMS_LOADED: 'core-paged-content-all-items-loaded', SET_ITEMS_PER_PAGE_LIMIT: 'core-paged-content-set-items-per-page-limit' }; }); templates.js 0000644 00000146563 15152050146 0007116 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Template renderer for Moodle. Load and render Moodle templates with Mustache. * * @module core/templates * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 2.9 */ define([ 'core/mustache', 'jquery', 'core/ajax', 'core/str', 'core/notification', 'core/url', 'core/config', 'core/localstorage', 'core/icon_system', 'core_filters/events', 'core/yui', 'core/log', 'core/truncate', 'core/user_date', 'core/pending', ], function( mustache, $, ajax, str, notification, coreurl, config, storage, IconSystem, filterEvents, Y, Log, Truncate, UserDate, Pending ) { // Module variables. /** @var {Number} uniqInstances Count of times this constructor has been called. */ var uniqInstances = 0; /** @var {String[]} templateCache - Cache of already loaded template strings */ var templateCache = {}; /** @var {Promise[]} templatePromises - Cache of already loaded template promises */ var templatePromises = {}; /** @var {Promise[]} cachePartialPromises - Cache of already loaded template partial promises */ var cachePartialPromises = {}; /** @var {Object} iconSystem - Object extending core/iconsystem */ var iconSystem = {}; /** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */ var loadTemplateBuffer = []; /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */ var isLoadingTemplates = false; /** @var {Array} disallowedNestedHelpers - List of helpers that can't be called within other helpers */ var disallowedNestedHelpers = ['js']; /** * Normalise the provided component such that '', 'moodle', and 'core' are treated consistently. * * @param {String} component * @returns {String} */ var getNormalisedComponent = function(component) { if (component) { if (component !== 'moodle' && component !== 'core') { return component; } } return 'core'; }; /** * Search the various caches for a template promise for the given search key. * The search key should be in the format <theme>/<component>/<template> e.g. boost/core/modal. * * If the template is found in any of the caches it will populate the other caches with * the same data as well. * * @param {String} searchKey The template search key in the format <theme>/<component>/<template> e.g. boost/core/modal * @return {Object} jQuery promise resolved with the template source */ var getTemplatePromiseFromCache = function(searchKey) { // First try the cache of promises. if (searchKey in templatePromises) { return templatePromises[searchKey]; } // Check the module cache. if (searchKey in templateCache) { // Add this to the promises cache for future. templatePromises[searchKey] = $.Deferred().resolve(templateCache[searchKey]).promise(); return templatePromises[searchKey]; } if (M.cfg.templaterev <= 0) { // Template caching is disabled. Do not store in persistent storage. return null; } // Now try local storage. var cached = storage.get('core_template/' + M.cfg.templaterev + ':' + searchKey); if (cached) { // Add this to the module cache for future. templateCache[searchKey] = cached; // Add this to the promises cache for future. templatePromises[searchKey] = $.Deferred().resolve(cached).promise(); return templatePromises[searchKey]; } return null; }; /** * Take all of the templates waiting in the buffer and load them from the server * or from the cache. * * All of the templates that need to be loaded from the server will be batched up * and sent in a single network request. */ var processLoadTemplateBuffer = function() { if (!loadTemplateBuffer.length) { return; } if (isLoadingTemplates) { return; } isLoadingTemplates = true; // Grab any templates waiting in the buffer. var templatesToLoad = loadTemplateBuffer.slice(); // This will be resolved with the list of promises for the server request. var serverRequestsDeferred = $.Deferred(); var requests = []; // Get a list of promises for each of the templates we need to load. var templatePromises = templatesToLoad.map(function(templateData) { var component = getNormalisedComponent(templateData.component); var name = templateData.name; var searchKey = templateData.searchKey; var theme = templateData.theme; var templateDeferred = templateData.deferred; var promise = null; // Double check to see if this template happened to have landed in the // cache as a dependency of an earlier template. var cachedPromise = getTemplatePromiseFromCache(searchKey); if (cachedPromise) { // We've seen this template so immediately resolve the existing promise. promise = cachedPromise; } else { // We haven't seen this template yet so we need to request it from // the server. requests.push({ methodname: 'core_output_load_template_with_dependencies', args: { component: component, template: name, themename: theme, lang: $('html').attr('lang').replace(/-/g, '_') } }); // Remember the index in the requests list for this template so that // we can get the appropriate promise back. var index = requests.length - 1; // The server deferred will be resolved with a list of all of the promises // that were sent in the order that they were added to the requests array. promise = serverRequestsDeferred.promise() .then(function(promises) { // The promise for this template will be the one that matches the index // for it's entry in the requests array. // // Make sure the promise is added to the promises cache for this template // search key so that we don't request it again. templatePromises[searchKey] = promises[index].then(function(response) { var templateSource = null; // Process all of the template dependencies for this template and add // them to the caches so that we don't request them again later. response.templates.forEach(function(data) { data.component = getNormalisedComponent(data.component); // Generate the search key for this template in the response so that we // can add it to the caches. var tempSearchKey = [theme, data.component, data.name].join('/'); // Cache all of the dependent templates because we'll need them to render // the requested template. templateCache[tempSearchKey] = data.value; if (M.cfg.templaterev > 0) { // The template cache is enabled - set the value there. storage.set('core_template/' + M.cfg.templaterev + ':' + tempSearchKey, data.value); } if (data.component == component && data.name == name) { // This is the original template that was requested so remember it to return. templateSource = data.value; } }); if (response.strings.length) { // If we have strings that the template needs then warm the string cache // with them now so that we don't need to re-fetch them. str.cache_strings(response.strings.map(function(data) { return { component: getNormalisedComponent(data.component), key: data.name, value: data.value }; })); } // Return the original template source that the user requested. return templateSource; }); return templatePromises[searchKey]; }); } return promise .then(function(source) { // When we've successfully loaded the template then resolve the deferred // in the buffer so that all of the calling code can proceed. return templateDeferred.resolve(source); }) .catch(function(error) { // If there was an error loading the template then reject the deferred // in the buffer so that all of the calling code can proceed. templateDeferred.reject(error); // Rethrow for anyone else listening. throw error; }); }); if (requests.length) { // We have requests to send so resolve the deferred with the promises. serverRequestsDeferred.resolve(ajax.call(requests, true, false, false, 0, M.cfg.templaterev)); } else { // Nothing to load so we can resolve our deferred. serverRequestsDeferred.resolve(); } // Once we've finished loading all of the templates then recurse to process // any templates that may have been added to the buffer in the time that we // were fetching. $.when.apply(null, templatePromises) .then(function() { // Remove the templates we've loaded from the buffer. loadTemplateBuffer.splice(0, templatesToLoad.length); isLoadingTemplates = false; processLoadTemplateBuffer(); return; }) .catch(function() { // Remove the templates we've loaded from the buffer. loadTemplateBuffer.splice(0, templatesToLoad.length); isLoadingTemplates = false; processLoadTemplateBuffer(); }); }; /** * Constructor * * Each call to templates.render gets it's own instance of this class. */ var Renderer = function() { this.requiredStrings = []; this.requiredJS = []; this.requiredDates = []; this.currentThemeName = ''; }; // Class variables and functions. /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */ Renderer.prototype.requiredStrings = null; /** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */ Renderer.prototype.requiredDates = []; /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */ Renderer.prototype.requiredJS = null; /** @var {String} themeName for the current render */ Renderer.prototype.currentThemeName = ''; /** * Load a template. * * @method getTemplate * @private * @param {string} templateName - should consist of the component and the name of the template like this: * core/menu (lib/templates/menu.mustache) or * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache) * @return {Promise} JQuery promise object resolved when the template has been fetched. */ Renderer.prototype.getTemplate = function(templateName) { var currentTheme = this.currentThemeName; var searchKey = currentTheme + '/' + templateName; // If we haven't already seen this template then buffer it. var cachedPromise = getTemplatePromiseFromCache(searchKey); if (cachedPromise) { return cachedPromise; } // Check the buffer to see if this template has already been added. var existingBufferRecords = loadTemplateBuffer.filter(function(record) { return record.searchKey == searchKey; }); if (existingBufferRecords.length) { // This template is already in the buffer so just return the existing // promise. No need to add it to the buffer again. return existingBufferRecords[0].deferred.promise(); } // This is the first time this has been requested so let's add it to the buffer // to be loaded. var parts = templateName.split('/'); var component = getNormalisedComponent(parts.shift()); var name = parts.join('/'); var deferred = $.Deferred(); // Add this template to the buffer to be loaded. loadTemplateBuffer.push({ component: component, name: name, theme: currentTheme, searchKey: searchKey, deferred: deferred }); // We know there is at least one thing in the buffer so kick off a processing run. processLoadTemplateBuffer(); return deferred.promise(); }; /** * Prefetch a set of templates without rendering them. * * @param {Array} templateNames The list of templates to fetch * @param {String} currentTheme */ Renderer.prototype.prefetchTemplates = function(templateNames, currentTheme) { templateNames.forEach(function(templateName) { var searchKey = currentTheme + '/' + templateName; // If we haven't already seen this template then buffer it. if (getTemplatePromiseFromCache(searchKey)) { return; } // Check the buffer to see if this template has already been added. var existingBufferRecords = loadTemplateBuffer.filter(function(record) { return record.searchKey == searchKey; }); if (existingBufferRecords.length) { // This template is already in the buffer so just return the existing promise. // No need to add it to the buffer again. return; } // This is the first time this has been requested so let's add it to the buffer to be loaded. var parts = templateName.split('/'); var component = getNormalisedComponent(parts.shift()); var name = parts.join('/'); // Add this template to the buffer to be loaded. loadTemplateBuffer.push({ component: component, name: name, theme: currentTheme, searchKey: searchKey, deferred: $.Deferred(), }); }); processLoadTemplateBuffer(); }; /** * Load a partial from the cache or ajax. * * @method partialHelper * @private * @param {string} name The partial name to load. * @return {string} */ Renderer.prototype.partialHelper = function(name) { var searchKey = this.currentThemeName + '/' + name; if (!(searchKey in templateCache)) { notification.exception(new Error('Failed to pre-fetch the template: ' + name)); } return templateCache[searchKey]; }; /** * Render a single image icon. * * @method renderIcon * @private * @param {string} key The icon key. * @param {string} component The component name. * @param {string} title The icon title * @return {Promise} */ Renderer.prototype.renderIcon = function(key, component, title) { // Preload the module to do the icon rendering based on the theme iconsystem. var modulename = config.iconsystemmodule; component = getNormalisedComponent(component); // RequireJS does not return a promise. var ready = $.Deferred(); require([modulename], function(System) { var system = new System(); if (!(system instanceof IconSystem)) { ready.reject('Invalid icon system specified' + config.iconsystemmodule); } else { iconSystem = system; system.init().then(ready.resolve).catch(notification.exception); } }); return ready.then(function(iconSystem) { return this.getTemplate(iconSystem.getTemplateName()); }.bind(this)).then(function(template) { return iconSystem.renderIcon( key, component, title, template ); }); }; /** * Render image icons. * * @method pixHelper * @private * @param {object} context The mustache context * @param {string} sectionText The text to parse arguments from. * @param {function} helper Used to render the alt attribute of the text. * @return {string} */ Renderer.prototype.pixHelper = function(context, sectionText, helper) { var parts = sectionText.split(','); var key = ''; var component = ''; var text = ''; if (parts.length > 0) { key = helper(parts.shift().trim(), context); } if (parts.length > 0) { component = helper(parts.shift().trim(), context); } if (parts.length > 0) { text = helper(parts.join(',').trim(), context); } var templateName = iconSystem.getTemplateName(); var searchKey = this.currentThemeName + '/' + templateName; var template = templateCache[searchKey]; component = getNormalisedComponent(component); // The key might have been escaped by the JS Mustache engine which // converts forward slashes to HTML entities. Let us undo that here. key = key.replace(///gi, '/'); return iconSystem.renderIcon( key, component, text, template ); }; /** * Render blocks of javascript and save them in an array. * * @method jsHelper * @private * @param {object} context The current mustache context. * @param {string} sectionText The text to save as a js block. * @param {function} helper Used to render the block. * @return {string} */ Renderer.prototype.jsHelper = function(context, sectionText, helper) { this.requiredJS.push(helper(sectionText, context)); return ''; }; /** * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}} * into a get_string call. * * @method stringHelper * @private * @param {object} context The current mustache context. * @param {string} sectionText The text to parse the arguments from. * @param {function} helper Used to render subsections of the text. * @return {string} */ Renderer.prototype.stringHelper = function(context, sectionText, helper) { var parts = sectionText.split(','); var key = ''; var component = ''; var param = ''; if (parts.length > 0) { key = parts.shift().trim(); } if (parts.length > 0) { component = parts.shift().trim(); } if (parts.length > 0) { param = parts.join(',').trim(); } component = getNormalisedComponent(component); if (param !== '') { // Allow variable expansion in the param part only. param = helper(param, context); } // Allow json formatted $a arguments. if (param.match(/^{\s*"/gm)) { // If it can't be parsed then the string is not a JSON format. try { const parsedParam = JSON.parse(param); // Handle non-exception-throwing cases, e.g. null, integer, boolean. if (parsedParam && typeof parsedParam === "object") { param = parsedParam; } } catch (err) { // This was probably not JSON. // Keep the error message visible. window.console.warn(err.message); } } var index = this.requiredStrings.length; this.requiredStrings.push({ key: key, component: component, param: param }); // The placeholder must not use {{}} as those can be misinterpreted by the engine. return '[[_s' + index + ']]'; }; /** * String helper to render {{#cleanstr}}abd component { a : 'fish'}{{/cleanstr}} * into a get_string following by an HTML escape. * * @method cleanStringHelper * @private * @param {object} context The current mustache context. * @param {string} sectionText The text to parse the arguments from. * @param {function} helper Used to render subsections of the text. * @return {string} */ Renderer.prototype.cleanStringHelper = function(context, sectionText, helper) { var str = this.stringHelper(context, sectionText, helper); // We're going to use [[_cx]] format for clean strings, where x is a number. // Hence, replacing 's' with 'c' in the placeholder that stringHelper returns. return str.replace('s', 'c'); }; /** * Quote helper used to wrap content in quotes, and escape all special JSON characters present in the content. * * @method quoteHelper * @private * @param {object} context The current mustache context. * @param {string} sectionText The text to parse the arguments from. * @param {function} helper Used to render subsections of the text. * @return {string} */ Renderer.prototype.quoteHelper = function(context, sectionText, helper) { var content = helper(sectionText.trim(), context); // Escape the {{ and JSON encode. // This involves wrapping {{, and }} in change delimeter tags. content = JSON.stringify(content); content = content.replace(/([{}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>'); return content; }; /** * Shorten text helper to truncate text and append a trailing ellipsis. * * @method shortenTextHelper * @private * @param {object} context The current mustache context. * @param {string} sectionText The text to parse the arguments from. * @param {function} helper Used to render subsections of the text. * @return {string} */ Renderer.prototype.shortenTextHelper = function(context, sectionText, helper) { // Non-greedy split on comma to grab section text into the length and // text parts. var regex = /(.*?),(.*)/; var parts = sectionText.match(regex); // The length is the part matched in the first set of parethesis. var length = parts[1].trim(); // The length is the part matched in the second set of parethesis. var text = parts[2].trim(); var content = helper(text, context); return Truncate.truncate(content, { length: length, words: true, ellipsis: '...' }); }; /** * User date helper to render user dates from timestamps. * * @method userDateHelper * @private * @param {object} context The current mustache context. * @param {string} sectionText The text to parse the arguments from. * @param {function} helper Used to render subsections of the text. * @return {string} */ Renderer.prototype.userDateHelper = function(context, sectionText, helper) { // Non-greedy split on comma to grab the timestamp and format. var regex = /(.*?),(.*)/; var parts = sectionText.match(regex); var timestamp = helper(parts[1].trim(), context); var format = helper(parts[2].trim(), context); var index = this.requiredDates.length; this.requiredDates.push({ timestamp: timestamp, format: format }); return '[[_t_' + index + ']]'; }; /** * Return a helper function to be added to the context for rendering the a * template. * * This will parse the provided text before giving it to the helper function * in order to remove any disallowed nested helpers to prevent one helper * from calling another. * * In particular to prevent the JS helper from being called from within another * helper because it can lead to security issues when the JS portion is user * provided. * * @param {function} helperFunction The helper function to add * @param {object} context The template context for the helper function * @return {Function} To be set in the context */ Renderer.prototype.addHelperFunction = function(helperFunction, context) { return function() { return function(sectionText, helper) { // Override the disallowed helpers in the template context with // a function that returns an empty string for use when executing // other helpers. This is to prevent these helpers from being // executed as part of the rendering of another helper in order to // prevent any potential security issues. var originalHelpers = disallowedNestedHelpers.reduce(function(carry, name) { if (context.hasOwnProperty(name)) { carry[name] = context[name]; } return carry; }, {}); disallowedNestedHelpers.forEach(function(helperName) { context[helperName] = function() { return ''; }; }); // Execute the helper with the modified context that doesn't include // the disallowed nested helpers. This prevents the disallowed // helpers from being called from within other helpers. var result = helperFunction.apply(this, [context, sectionText, helper]); // Restore the original helper implementation in the context so that // any further rendering has access to them again. for (var name in originalHelpers) { context[name] = originalHelpers[name]; } return result; }.bind(this); }.bind(this); }; /** * Add some common helper functions to all context objects passed to templates. * These helpers match exactly the helpers available in php. * * @method addHelpers * @private * @param {Object} context Simple types used as the context for the template. * @param {String} themeName We set this multiple times, because there are async calls. */ Renderer.prototype.addHelpers = function(context, themeName) { this.currentThemeName = themeName; this.requiredStrings = []; this.requiredJS = []; context.uniqid = (uniqInstances++); context.str = this.addHelperFunction(this.stringHelper, context); context.cleanstr = this.addHelperFunction(this.cleanStringHelper, context); context.pix = this.addHelperFunction(this.pixHelper, context); context.js = this.addHelperFunction(this.jsHelper, context); context.quote = this.addHelperFunction(this.quoteHelper, context); context.shortentext = this.addHelperFunction(this.shortenTextHelper, context); context.userdate = this.addHelperFunction(this.userDateHelper, context); context.globals = {config: config}; context.currentTheme = themeName; }; /** * Get all the JS blocks from the last rendered template. * * @method getJS * @private * @return {string} */ Renderer.prototype.getJS = function() { var js = ''; if (this.requiredJS.length > 0) { js = this.requiredJS.join(";\n"); } return js; }; /** * Treat strings in content. * * The purpose of this method is to replace the placeholders found in a string * with the their respective translated strings. * * Previously we were relying on String.replace() but the complexity increased with * the numbers of strings to replace. Now we manually walk the string and stop at each * placeholder we find, only then we replace it. Most of the time we will * replace all the placeholders in a single run, at times we will need a few * more runs when placeholders are replaced with strings that contain placeholders * themselves. * * @param {String} content The content in which string placeholders are to be found. * @param {Array} strings The strings to replace with. * @return {String} The treated content. */ Renderer.prototype.treatStringsInContent = function(content, strings) { var pattern = /\[\[_(s|c)\d+\]\]/, treated, index, strIndex, walker, char, strFinal, isClean; do { treated = ''; index = content.search(pattern); while (index > -1) { // Copy the part prior to the placeholder to the treated string. treated += content.substring(0, index); content = content.substr(index); isClean = content[3] == 'c'; strIndex = ''; walker = 4; // 4 is the length of either '[[_s' or '[[_c'. // Walk the characters to manually extract the index of the string from the placeholder. char = content.substr(walker, 1); do { strIndex += char; walker++; char = content.substr(walker, 1); } while (char != ']'); // Get the string, add it to the treated result, and remove the placeholder from the content to treat. strFinal = strings[parseInt(strIndex, 10)]; if (typeof strFinal === 'undefined') { Log.debug('Could not find string for pattern [[_' + (isClean ? 'c' : 's') + strIndex + ']].'); strFinal = ''; } if (isClean) { strFinal = mustache.escape(strFinal); } treated += strFinal; content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index. // That's either '[[_s]]' or '[[_c]]'. // Find the next placeholder. index = content.search(pattern); } // The content becomes the treated part with the rest of the content. content = treated + content; // Check if we need to walk the content again, in case strings contained placeholders. index = content.search(pattern); } while (index > -1); return content; }; /** * Treat strings in content. * * The purpose of this method is to replace the date placeholders found in the * content with the their respective translated dates. * * @param {String} content The content in which string placeholders are to be found. * @param {Array} dates The dates to replace with. * @return {String} The treated content. */ Renderer.prototype.treatDatesInContent = function(content, dates) { dates.forEach(function(date, index) { var key = '\\[\\[_t_' + index + '\\]\\]'; var re = new RegExp(key, 'g'); content = content.replace(re, date); }); return content; }; /** * Render a template and then call the callback with the result. * * @method doRender * @private * @param {string} templateSource The mustache template to render. * @param {Object} context Simple types used as the context for the template. * @param {String} themeName Name of the current theme. * @return {Promise} object */ Renderer.prototype.doRender = function(templateSource, context, themeName) { this.currentThemeName = themeName; var iconTemplate = iconSystem.getTemplateName(); var pendingPromise = new Pending('core/templates:doRender'); return this.getTemplate(iconTemplate).then(function() { this.addHelpers(context, themeName); var result = mustache.render(templateSource, context, this.partialHelper.bind(this)); return $.Deferred().resolve(result.trim(), this.getJS()).promise(); }.bind(this)) .then(function(html, js) { if (this.requiredStrings.length > 0) { return str.get_strings(this.requiredStrings).then(function(strings) { // Make sure string substitutions are done for the userdate // values as well. this.requiredDates = this.requiredDates.map(function(date) { return { timestamp: this.treatStringsInContent(date.timestamp, strings), format: this.treatStringsInContent(date.format, strings) }; }.bind(this)); // Why do we not do another call the render here? // // Because that would expose DOS holes. E.g. // I create an assignment called "{{fish" which // would get inserted in the template in the first pass // and cause the template to die on the second pass (unbalanced). html = this.treatStringsInContent(html, strings); js = this.treatStringsInContent(js, strings); return $.Deferred().resolve(html, js).promise(); }.bind(this)); } return $.Deferred().resolve(html, js).promise(); }.bind(this)) .then(function(html, js) { // This has to happen after the strings replacement because you can // use the string helper in content for the user date helper. if (this.requiredDates.length > 0) { return UserDate.get(this.requiredDates).then(function(dates) { html = this.treatDatesInContent(html, dates); js = this.treatDatesInContent(js, dates); return $.Deferred().resolve(html, js).promise(); }.bind(this)); } return $.Deferred().resolve(html, js).promise(); }.bind(this)) .then(function(html, js) { pendingPromise.resolve(); return $.Deferred().resolve(html, js).promise(); }); }; /** * Execute a block of JS returned from a template. * Call this AFTER adding the template HTML into the DOM so the nodes can be found. * * @method runTemplateJS * @param {string} source - A block of javascript. */ var runTemplateJS = function(source) { if (source.trim() !== '') { var newscript = $('<script>').attr('type', 'text/javascript').html(source); $('head').append(newscript); } }; /** * Do some DOM replacement and trigger correct events and fire javascript. * * @method domReplace * @private * @param {JQuery} element - Element or selector to replace. * @param {String} newHTML - HTML to insert / replace. * @param {String} newJS - Javascript to run after the insertion. * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node. * @return {Array} The list of new DOM Nodes * @fires event:filterContentUpdated */ var domReplace = function(element, newHTML, newJS, replaceChildNodes) { var replaceNode = $(element); if (replaceNode.length) { // First create the dom nodes so we have a reference to them. var newNodes = $(newHTML); var yuiNodes = null; // Do the replacement in the page. if (replaceChildNodes) { // Cleanup any YUI event listeners attached to any of these nodes. yuiNodes = new Y.NodeList(replaceNode.children().get()); yuiNodes.destroy(true); // JQuery will cleanup after itself. replaceNode.empty(); replaceNode.append(newNodes); } else { // Cleanup any YUI event listeners attached to any of these nodes. yuiNodes = new Y.NodeList(replaceNode.get()); yuiNodes.destroy(true); // JQuery will cleanup after itself. replaceNode.replaceWith(newNodes); } // Run any javascript associated with the new HTML. runTemplateJS(newJS); // Notify all filters about the new content. filterEvents.notifyFilterContentUpdated(newNodes); return newNodes.get(); } return []; }; /** * Scan a template source for partial tags and return a list of the found partials. * * @method scanForPartials * @private * @param {string} templateSource - source template to scan. * @return {Array} List of partials. */ Renderer.prototype.scanForPartials = function(templateSource) { var tokens = mustache.parse(templateSource), partials = []; var findPartial = function(tokens, partials) { var i, token; for (i = 0; i < tokens.length; i++) { token = tokens[i]; if (token[0] == '>' || token[0] == '<') { partials.push(token[1]); } if (token.length > 4) { findPartial(token[4], partials); } } }; findPartial(tokens, partials); return partials; }; /** * Load a template and scan it for partials. Recursively fetch the partials. * * @method cachePartials * @private * @param {string} templateName - should consist of the component and the name of the template like this: * core/menu (lib/templates/menu.mustache) or * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache) * @param {Array} parentage - A list of requested partials in this render chain. * @return {Promise} JQuery promise object resolved when all partials are in the cache. */ Renderer.prototype.cachePartials = function(templateName, parentage) { var searchKey = this.currentThemeName + '/' + templateName; if (searchKey in cachePartialPromises) { return cachePartialPromises[searchKey]; } // This promise will not be resolved until all child partials are also resolved and ready. // We create it here to allow us to check for recursive inclusion of templates. // Keep track of the requested partials in this chain. parentage = parentage || [searchKey]; cachePartialPromises[searchKey] = $.Deferred(); this.getTemplate(templateName) .then(function(templateSource) { var partials = this.scanForPartials(templateSource); var uniquePartials = partials.filter(function(partialName) { // Check for recursion. if (parentage.indexOf(this.currentThemeName + '/' + partialName) >= 0) { // Ignore templates which include a parent template already requested in the current chain. return false; } // Ignore templates that include themselves. return partialName != templateName; }.bind(this)); // Fetch any partial which has not already been fetched. var fetchThemAll = uniquePartials.map(function(partialName) { parentage.push(this.currentThemeName + '/' + partialName); return this.cachePartials(partialName, parentage); }.bind(this)); // Resolve the templateName promise when all of the children are resolved. return $.when.apply($, fetchThemAll) .then(function() { return cachePartialPromises[searchKey].resolve(templateSource); }); }.bind(this)) .catch(cachePartialPromises[searchKey].reject); return cachePartialPromises[searchKey]; }; /** * Load a template and call doRender on it. * * @method render * @private * @param {string} templateName - should consist of the component and the name of the template like this: * core/menu (lib/templates/menu.mustache) or * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache) * @param {Object} context - Could be array, string or simple value for the context of the template. * @param {string} themeName - Name of the current theme. * @return {Promise} JQuery promise object resolved when the template has been rendered. */ Renderer.prototype.render = function(templateName, context, themeName) { if (typeof (themeName) === "undefined") { // System context by default. themeName = config.theme; } this.currentThemeName = themeName; // Preload the module to do the icon rendering based on the theme iconsystem. var modulename = config.iconsystemmodule; var ready = $.Deferred(); require([modulename], function(System) { var system = new System(); if (!(system instanceof IconSystem)) { ready.reject('Invalid icon system specified' + config.iconsystem); } else { iconSystem = system; system.init().then(ready.resolve).catch(notification.exception); } }); return ready.then(function() { return this.cachePartials(templateName); }.bind(this)).then(function(templateSource) { return this.doRender(templateSource, context, themeName); }.bind(this)); }; /** * Prepend some HTML to a node and trigger events and fire javascript. * * @method domPrepend * @private * @param {jQuery|String} element - Element or selector to prepend HTML to * @param {String} html - HTML to prepend * @param {String} js - Javascript to run after we prepend the html * @return {Array} The list of new DOM Nodes * @fires event:filterContentUpdated */ var domPrepend = function(element, html, js) { var node = $(element); if (node.length) { // Prepend the html. var newContent = $(html); node.prepend(newContent); // Run any javascript associated with the new HTML. runTemplateJS(js); // Notify all filters about the new content. filterEvents.notifyFilterContentUpdated(node); return newContent.get(); } return []; }; /** * Append some HTML to a node and trigger events and fire javascript. * * @method domAppend * @private * @param {jQuery|String} element - Element or selector to append HTML to * @param {String} html - HTML to append * @param {String} js - Javascript to run after we append the html * @return {Array} The list of new DOM Nodes * @fires event:filterContentUpdated */ var domAppend = function(element, html, js) { var node = $(element); if (node.length) { // Append the html. var newContent = $(html); node.append(newContent); // Run any javascript associated with the new HTML. runTemplateJS(js); // Notify all filters about the new content. filterEvents.notifyFilterContentUpdated(node); return newContent.get(); } return []; }; return /** @alias module:core/templates */ { // Public variables and functions. /** * Every call to render creates a new instance of the class and calls render on it. This * means each render call has it's own class variables. * * @method render * @private * @param {string} templateName - should consist of the component and the name of the template like this: * core/menu (lib/templates/menu.mustache) or * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache) * @param {Object} context - Could be array, string or simple value for the context of the template. * @param {string} themeName - Name of the current theme. * @return {Promise} JQuery promise object resolved when the template has been rendered. */ render: function(templateName, context, themeName) { var renderer = new Renderer(); return renderer.render(templateName, context, themeName); }, /** * Prefetch a set of templates without rendering them. * * @method getTemplate * @param {Array} templateNames The list of templates to fetch * @param {String} themeName * @returns {Promise} */ prefetchTemplates: function(templateNames, themeName) { var renderer = new Renderer(); if (typeof themeName === "undefined") { // System context by default. themeName = config.theme; } return renderer.prefetchTemplates(templateNames, themeName); }, /** * Every call to render creates a new instance of the class and calls render on it. This * means each render call has it's own class variables. * * This alernate to the standard .render() function returns the html and js in a single object suitable for a * native Promise. * * @method renderForPromise * @private * @param {string} templateName - should consist of the component and the name of the template like this: * core/menu (lib/templates/menu.mustache) or * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache) * @param {Object} context - Could be array, string or simple value for the context of the template. * @param {string} themeName - Name of the current theme. * @return {Promise} JQuery promise object resolved when the template has been rendered. */ renderForPromise: function(templateName, context, themeName) { var renderer = new Renderer(); return renderer.render(templateName, context, themeName) .then(function(html, js) { return { html: html, js: js, }; }); }, /** * Every call to renderIcon creates a new instance of the class and calls renderIcon on it. This * means each render call has it's own class variables. * * @method renderIcon * @public * @param {string} key - Icon key. * @param {string} component - Icon component * @param {string} title - Icon title * @return {Promise} JQuery promise object resolved when the pix has been rendered. */ renderPix: function(key, component, title) { var renderer = new Renderer(); return renderer.renderIcon( key, getNormalisedComponent(component), title ); }, /** * Execute a block of JS returned from a template. * Call this AFTER adding the template HTML into the DOM so the nodes can be found. * * @method runTemplateJS * @param {string} source - A block of javascript. */ runTemplateJS: runTemplateJS, /** * Replace a node in the page with some new HTML and run the JS. * * @method replaceNodeContents * @param {JQuery} element - Element or selector to replace. * @param {String} newHTML - HTML to insert / replace. * @param {String} newJS - Javascript to run after the insertion. * @return {Array} The list of new DOM Nodes */ replaceNodeContents: function(element, newHTML, newJS) { return domReplace(element, newHTML, newJS, true); }, /** * Insert a node in the page with some new HTML and run the JS. * * @method replaceNode * @param {JQuery} element - Element or selector to replace. * @param {String} newHTML - HTML to insert / replace. * @param {String} newJS - Javascript to run after the insertion. * @return {Array} The list of new DOM Nodes */ replaceNode: function(element, newHTML, newJS) { return domReplace(element, newHTML, newJS, false); }, /** * Prepend some HTML to a node and trigger events and fire javascript. * * @method prependNodeContents * @param {jQuery|String} element - Element or selector to prepend HTML to * @param {String} html - HTML to prepend * @param {String} js - Javascript to run after we prepend the html * @return {Array} The list of new DOM Nodes */ prependNodeContents: function(element, html, js) { return domPrepend(element, html, js); }, /** * Append some HTML to a node and trigger events and fire javascript. * * @method appendNodeContents * @param {jQuery|String} element - Element or selector to append HTML to * @param {String} html - HTML to append * @param {String} js - Javascript to run after we append the html * @return {Array} The list of new DOM Nodes */ appendNodeContents: function(element, html, js) { return domAppend(element, html, js); }, }; }); pubsub.js 0000644 00000004262 15152050146 0006405 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A simple Javascript PubSub implementation. * * @module core/pubsub * @copyright 2018 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Pending from 'core/pending'; const events = {}; /** * Subscribe to an event. * * @param {string} eventName The name of the event to subscribe to. * @param {function} callback The callback function to run when eventName occurs. */ export const subscribe = function(eventName, callback) { events[eventName] = events[eventName] || []; events[eventName].push(callback); }; /** * Unsubscribe from an event. * * @param {string} eventName The name of the event to unsubscribe from. * @param {function} callback The callback to unsubscribe. */ export const unsubscribe = function(eventName, callback) { if (events[eventName]) { for (var i = 0; i < events[eventName].length; i++) { if (events[eventName][i] === callback) { events[eventName].splice(i, 1); break; } } } }; /** * Publish an event to all subscribers. * * @param {string} eventName The name of the event to publish. * @param {any} data The data to provide to the subscribed callbacks. */ export const publish = function(eventName, data) { const pendingPromise = new Pending("Publishing " + eventName); if (events[eventName]) { events[eventName].forEach(function(callback) { callback(data); }); } pendingPromise.resolve(); }; sessionstorage.js 0000644 00000004335 15152050146 0010156 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Simple API for set/get to sessionstorage, with cacherev expiration. * * Session storage will only persist for as long as the browser window * stays open. * * See: * https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage * * @module core/sessionstorage * @copyright 2017 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['core/config', 'core/storagewrapper'], function(config, StorageWrapper) { // Private functions and variables. /** @var {Object} StorageWrapper - Wraps browsers sessionStorage object */ var storage = new StorageWrapper(window.sessionStorage); return /** @alias module:core/sessionstorage */ { /** * Get a value from session storage. Remember - all values must be strings. * * @method get * @param {string} key The cache key to check. * @return {boolean|string} False if the value is not in the cache, or some other error - a string otherwise. */ get: function(key) { return storage.get(key); }, /** * Set a value to session storage. Remember - all values must be strings. * * @method set * @param {string} key The cache key to set. * @param {string} value The value to set. * @return {boolean} False if the value can't be saved in the cache, or some other error - true otherwise. */ set: function(key, value) { return storage.set(key, value); } }; }); popover_region_controller.js 0000644 00000032047 15152050146 0012407 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Controls the popover region element. * * See template: core/popover_region * * @module core/popover_region_controller * @copyright 2015 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.2 */ define(['jquery', 'core/str', 'core/custom_interaction_events'], function($, str, customEvents) { var SELECTORS = { CONTENT: '.popover-region-content', CONTENT_CONTAINER: '.popover-region-content-container', MENU_CONTAINER: '.popover-region-container', MENU_TOGGLE: '.popover-region-toggle', CAN_RECEIVE_FOCUS: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]', }; /** * Constructor for the PopoverRegionController. * * @param {jQuery} element object root element of the popover */ var PopoverRegionController = function(element) { this.root = $(element); this.content = this.root.find(SELECTORS.CONTENT); this.contentContainer = this.root.find(SELECTORS.CONTENT_CONTAINER); this.menuContainer = this.root.find(SELECTORS.MENU_CONTAINER); this.menuToggle = this.root.find(SELECTORS.MENU_TOGGLE); this.isLoading = false; this.promises = { closeHandlers: $.Deferred(), navigationHandlers: $.Deferred(), }; // Core event listeners to open and close. this.registerBaseEventListeners(); }; /** * The collection of events triggered by this controller. * * @returns {object} */ PopoverRegionController.prototype.events = function() { return { menuOpened: 'popoverregion:menuopened', menuClosed: 'popoverregion:menuclosed', startLoading: 'popoverregion:startLoading', stopLoading: 'popoverregion:stopLoading', }; }; /** * Return the container element for the content element. * * @method getContentContainer * @return {jQuery} object */ PopoverRegionController.prototype.getContentContainer = function() { return this.contentContainer; }; /** * Return the content element. * * @method getContent * @return {jQuery} object */ PopoverRegionController.prototype.getContent = function() { return this.content; }; /** * Checks if the popover is displayed. * * @method isMenuOpen * @return {bool} */ PopoverRegionController.prototype.isMenuOpen = function() { return !this.root.hasClass('collapsed'); }; /** * Toggle the visibility of the popover. * * @method toggleMenu */ PopoverRegionController.prototype.toggleMenu = function() { if (this.isMenuOpen()) { this.closeMenu(); } else { this.openMenu(); } }; /** * Hide the popover. * * Note: This triggers the menuClosed event. * * @method closeMenu */ PopoverRegionController.prototype.closeMenu = function() { // We're already closed. if (!this.isMenuOpen()) { return; } this.root.addClass('collapsed'); this.menuContainer.attr('aria-expanded', 'false'); this.menuContainer.attr('aria-hidden', 'true'); this.updateButtonAriaLabel(); this.updateFocusItemTabIndex(); this.root.trigger(this.events().menuClosed); }; /** * Show the popover. * * Note: This triggers the menuOpened event. * * @method openMenu */ PopoverRegionController.prototype.openMenu = function() { // We're already open. if (this.isMenuOpen()) { return; } this.root.removeClass('collapsed'); this.menuContainer.attr('aria-expanded', 'true'); this.menuContainer.attr('aria-hidden', 'false'); this.updateButtonAriaLabel(); this.updateFocusItemTabIndex(); // Resolve the promises to allow the handlers to be added // to the DOM, if they have been requested. this.promises.closeHandlers.resolve(); this.promises.navigationHandlers.resolve(); this.root.trigger(this.events().menuOpened); }; /** * Set the appropriate aria label on the popover toggle. * * @method updateButtonAriaLabel */ PopoverRegionController.prototype.updateButtonAriaLabel = function() { if (this.isMenuOpen()) { str.get_string('hidepopoverwindow').done(function(string) { this.menuToggle.attr('aria-label', string); }.bind(this)); } else { str.get_string('showpopoverwindow').done(function(string) { this.menuToggle.attr('aria-label', string); }.bind(this)); } }; /** * Set the loading state on this popover. * * Note: This triggers the startLoading event. * * @method startLoading */ PopoverRegionController.prototype.startLoading = function() { this.isLoading = true; this.getContentContainer().addClass('loading'); this.getContentContainer().attr('aria-busy', 'true'); this.root.trigger(this.events().startLoading); }; /** * Undo the loading state on this popover. * * Note: This triggers the stopLoading event. * * @method stopLoading */ PopoverRegionController.prototype.stopLoading = function() { this.isLoading = false; this.getContentContainer().removeClass('loading'); this.getContentContainer().attr('aria-busy', 'false'); this.root.trigger(this.events().stopLoading); }; /** * Sets the focus on the menu toggle. * * @method focusMenuToggle */ PopoverRegionController.prototype.focusMenuToggle = function() { this.menuToggle.focus(); }; /** * Check if a content item has focus. * * @method contentItemHasFocus * @return {bool} */ PopoverRegionController.prototype.contentItemHasFocus = function() { return this.getContentItemWithFocus().length > 0; }; /** * Return the currently focused content item. * * @method getContentItemWithFocus * @return {jQuery} object */ PopoverRegionController.prototype.getContentItemWithFocus = function() { var currentFocus = $(document.activeElement); var items = this.getContent().children(); var currentItem = items.filter(currentFocus); if (!currentItem.length) { currentItem = items.has(currentFocus); } return currentItem; }; /** * Focus the given content item or the first focusable element within * the content item. * * @method focusContentItem * @param {object} item The content item jQuery element */ PopoverRegionController.prototype.focusContentItem = function(item) { if (item.is(SELECTORS.CAN_RECEIVE_FOCUS)) { item.focus(); } else { item.find(SELECTORS.CAN_RECEIVE_FOCUS).first().focus(); } }; /** * Set focus on the first content item in the list. * * @method focusFirstContentItem */ PopoverRegionController.prototype.focusFirstContentItem = function() { this.focusContentItem(this.getContent().children().first()); }; /** * Set focus on the last content item in the list. * * @method focusLastContentItem */ PopoverRegionController.prototype.focusLastContentItem = function() { this.focusContentItem(this.getContent().children().last()); }; /** * Set focus on the content item after the item that currently has focus * in the list. * * @method focusNextContentItem */ PopoverRegionController.prototype.focusNextContentItem = function() { var currentItem = this.getContentItemWithFocus(); if (currentItem.length && currentItem.next()) { this.focusContentItem(currentItem.next()); } }; /** * Set focus on the content item preceding the item that currently has focus * in the list. * * @method focusPreviousContentItem */ PopoverRegionController.prototype.focusPreviousContentItem = function() { var currentItem = this.getContentItemWithFocus(); if (currentItem.length && currentItem.prev()) { this.focusContentItem(currentItem.prev()); } }; /** * Register the minimal amount of listeners for the popover to function. * * @method registerBaseEventListeners */ PopoverRegionController.prototype.registerBaseEventListeners = function() { customEvents.define(this.root, [ customEvents.events.activate, customEvents.events.escape, ]); // Toggle the popover visibility on activation (click/enter/space) of the toggle button. this.root.on(customEvents.events.activate, SELECTORS.MENU_TOGGLE, function() { this.toggleMenu(); }.bind(this)); // Delay the binding of these handlers until the region has been opened. this.promises.closeHandlers.done(function() { // Close the popover if escape is pressed. this.root.on(customEvents.events.escape, function() { this.closeMenu(); this.focusMenuToggle(); }.bind(this)); // Close the popover if any other part of the page is clicked. $('html').click(function(e) { var target = $(e.target); if (!this.root.is(target) && !this.root.has(target).length) { this.closeMenu(); } }.bind(this)); customEvents.define(this.getContentContainer(), [ customEvents.events.scrollBottom ]); }.bind(this)); }; /** * Set up the event listeners for keyboard navigating a list of content items. * * @method registerListNavigationEventListeners */ PopoverRegionController.prototype.registerListNavigationEventListeners = function() { customEvents.define(this.root, [ customEvents.events.down ]); // If the down arrow is pressed then open the menu and focus the first content // item or focus the next content item if the menu is open. this.root.on(customEvents.events.down, function(e, data) { if (!this.isMenuOpen()) { this.openMenu(); this.focusFirstContentItem(); } else { if (this.contentItemHasFocus()) { this.focusNextContentItem(); } else { this.focusFirstContentItem(); } } data.originalEvent.preventDefault(); }.bind(this)); // Delay the binding of these handlers until the region has been opened. this.promises.navigationHandlers.done(function() { customEvents.define(this.root, [ customEvents.events.up, customEvents.events.home, customEvents.events.end, ]); // Shift focus to the previous content item if the up key is pressed. this.root.on(customEvents.events.up, function(e, data) { this.focusPreviousContentItem(); data.originalEvent.preventDefault(); }.bind(this)); // Jump focus to the first content item if the home key is pressed. this.root.on(customEvents.events.home, function(e, data) { this.focusFirstContentItem(); data.originalEvent.preventDefault(); }.bind(this)); // Jump focus to the last content item if the end key is pressed. this.root.on(customEvents.events.end, function(e, data) { this.focusLastContentItem(); data.originalEvent.preventDefault(); }.bind(this)); }.bind(this)); }; /** * Set the appropriate tabindex attribute on the popover toggle. * * @method updateFocusItemTabIndex */ PopoverRegionController.prototype.updateFocusItemTabIndex = function() { if (this.isMenuOpen()) { this.menuContainer.find(SELECTORS.CAN_RECEIVE_FOCUS).removeAttr('tabindex'); } else { this.menuContainer.find(SELECTORS.CAN_RECEIVE_FOCUS).attr('tabindex', '-1'); } }; return PopoverRegionController; }); modal.js 0000644 00000073534 15152050146 0006211 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Contain the logic for modals. * * @module core/modal * @class core/modal * @copyright 2016 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define([ 'jquery', 'core/templates', 'core/notification', 'core/key_codes', 'core/custom_interaction_events', 'core/modal_backdrop', 'core_filters/events', 'core/modal_events', 'core/local/aria/focuslock', 'core/pending', 'core/aria', 'core/fullscreen' ], function( $, Templates, Notification, KeyCodes, CustomEvents, ModalBackdrop, FilterEvents, ModalEvents, FocusLock, Pending, Aria, Fullscreen ) { var SELECTORS = { CONTAINER: '[data-region="modal-container"]', MODAL: '[data-region="modal"]', HEADER: '[data-region="header"]', TITLE: '[data-region="title"]', BODY: '[data-region="body"]', FOOTER: '[data-region="footer"]', HIDE: '[data-action="hide"]', DIALOG: '[role=dialog]', FORM: 'form', MENU_BAR: '[role=menubar]', HAS_Z_INDEX: '.moodle-has-zindex', CAN_RECEIVE_FOCUS: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]', }; var TEMPLATES = { LOADING: 'core/loading', BACKDROP: 'core/modal_backdrop', }; /** * Module singleton for the backdrop to be reused by all Modal instances. */ var backdropPromise; /** * A counter that gets incremented for each modal created. This can be * used to generate unique values for the modals. */ var modalCounter = 0; /** * Constructor for the Modal. * * @param {object} root The root jQuery element for the modal */ var Modal = function(root) { this.root = $(root); this.modal = this.root.find(SELECTORS.MODAL); this.header = this.modal.find(SELECTORS.HEADER); this.headerPromise = $.Deferred(); this.title = this.header.find(SELECTORS.TITLE); this.titlePromise = $.Deferred(); this.body = this.modal.find(SELECTORS.BODY); this.bodyPromise = $.Deferred(); this.footer = this.modal.find(SELECTORS.FOOTER); this.footerPromise = $.Deferred(); this.hiddenSiblings = []; this.isAttached = false; this.bodyJS = null; this.footerJS = null; this.modalCount = modalCounter++; this.attachmentPoint = document.createElement('div'); document.body.append(this.attachmentPoint); this.focusOnClose = null; if (!this.root.is(SELECTORS.CONTAINER)) { Notification.exception({message: 'Element is not a modal container'}); } if (!this.modal.length) { Notification.exception({message: 'Container does not contain a modal'}); } if (!this.header.length) { Notification.exception({message: 'Modal is missing a header region'}); } if (!this.title.length) { Notification.exception({message: 'Modal header is missing a title region'}); } if (!this.body.length) { Notification.exception({message: 'Modal is missing a body region'}); } if (!this.footer.length) { Notification.exception({message: 'Modal is missing a footer region'}); } this.registerEventListeners(); }; /** * Attach the modal to the correct part of the page. * * If it hasn't already been added it runs any * javascript that has been cached until now. * * @method attachToDOM */ Modal.prototype.attachToDOM = function() { this.getAttachmentPoint().append(this.root); if (this.isAttached) { return; } FocusLock.trapFocus(this.root[0]); // If we'd cached any JS then we can run it how that the modal is // attached to the DOM. if (this.bodyJS) { Templates.runTemplateJS(this.bodyJS); this.bodyJS = null; } if (this.footerJS) { Templates.runTemplateJS(this.footerJS); this.footerJS = null; } this.isAttached = true; }; /** * Count the number of other visible modals (not including this one). * * @method countOtherVisibleModals * @return {int} */ Modal.prototype.countOtherVisibleModals = function() { var count = 0; $('body').find(SELECTORS.CONTAINER).each(function(index, element) { element = $(element); // If we haven't found ourself and the element is visible. if (!this.root.is(element) && element.hasClass('show')) { count++; } }.bind(this)); return count; }; /** * Get the modal backdrop. * * @method getBackdrop * @return {object} jQuery promise */ Modal.prototype.getBackdrop = function() { if (!backdropPromise) { backdropPromise = Templates.render(TEMPLATES.BACKDROP, {}) .then(function(html) { var element = $(html); return new ModalBackdrop(element); }) .fail(Notification.exception); } return backdropPromise; }; /** * Get the root element of this modal. * * @method getRoot * @return {object} jQuery object */ Modal.prototype.getRoot = function() { return this.root; }; /** * Get the modal element of this modal. * * @method getModal * @return {object} jQuery object */ Modal.prototype.getModal = function() { return this.modal; }; /** * Get the modal title element. * * @method getTitle * @return {object} jQuery object */ Modal.prototype.getTitle = function() { return this.title; }; /** * Get the modal body element. * * @method getBody * @return {object} jQuery object */ Modal.prototype.getBody = function() { return this.body; }; /** * Get the modal footer element. * * @method getFooter * @return {object} jQuery object */ Modal.prototype.getFooter = function() { return this.footer; }; /** * Get a promise resolving to the title region. * * @method getTitlePromise * @return {Promise} */ Modal.prototype.getTitlePromise = function() { return this.titlePromise; }; /** * Get a promise resolving to the body region. * * @method getBodyPromise * @return {object} jQuery object */ Modal.prototype.getBodyPromise = function() { return this.bodyPromise; }; /** * Get a promise resolving to the footer region. * * @method getFooterPromise * @return {object} jQuery object */ Modal.prototype.getFooterPromise = function() { return this.footerPromise; }; /** * Get the unique modal count. * * @method getModalCount * @return {int} */ Modal.prototype.getModalCount = function() { return this.modalCount; }; /** * Set the modal title element. * * This method is overloaded to take either a string value for the title or a jQuery promise that is resolved with * HTML most commonly from a Str.get_string call. * * @method setTitle * @param {(string|object)} value The title string or jQuery promise which resolves to the title. */ Modal.prototype.setTitle = function(value) { var title = this.getTitle(); this.titlePromise = $.Deferred(); this.asyncSet(value, title.html.bind(title)) .then(function() { this.titlePromise.resolve(title); }.bind(this)) .catch(Notification.exception); }; /** * Set the modal body element. * * This method is overloaded to take either a string value for the body or a jQuery promise that is resolved with * HTML and Javascript most commonly from a Templates.render call. * * @method setBody * @param {(string|object)} value The body string or jQuery promise which resolves to the body. * @fires event:filterContentUpdated */ Modal.prototype.setBody = function(value) { this.bodyPromise = $.Deferred(); var body = this.getBody(); if (typeof value === 'string') { // Just set the value if it's a string. body.html(value); FilterEvents.notifyFilterContentUpdated(body); this.getRoot().trigger(ModalEvents.bodyRendered, this); this.bodyPromise.resolve(body); } else { var jsPendingId = 'amd-modal-js-pending-id-' + this.getModalCount(); M.util.js_pending(jsPendingId); // Otherwise we assume it's a promise to be resolved with // html and javascript. var contentPromise = null; body.css('overflow', 'hidden'); // Ensure that the `value` is a jQuery Promise. value = $.when(value); if (value.state() == 'pending') { // We're still waiting for the body promise to resolve so // let's show a loading icon. var height = body.innerHeight(); if (height < 100) { height = 100; } body.animate({height: height + 'px'}, 150); body.html(''); contentPromise = Templates.render(TEMPLATES.LOADING, {}) .then(function(html) { var loadingIcon = $(html).hide(); body.html(loadingIcon); loadingIcon.fadeIn(150); // We only want the loading icon to fade out // when the content for the body has finished // loading. return $.when(loadingIcon.promise(), value); }) .then(function(loadingIcon) { // Once the content has finished loading and // the loading icon has been shown then we can // fade the icon away to reveal the content. return loadingIcon.fadeOut(100).promise(); }) .then(function() { return value; }); } else { // The content is already loaded so let's just display // it to the user. No need for a loading icon. contentPromise = value; } // Now we can actually display the content. contentPromise.then(function(html, js) { var result = null; if (this.isVisible()) { // If the modal is visible then we should display // the content gracefully for the user. body.css('opacity', 0); var currentHeight = body.innerHeight(); body.html(html); // We need to clear any height values we've set here // in order to measure the height of the content being // added. This then allows us to animate the height // transition. body.css('height', ''); var newHeight = body.innerHeight(); body.css('height', currentHeight + 'px'); result = body.animate( {height: newHeight + 'px', opacity: 1}, {duration: 150, queue: false} ).promise(); } else { // Since the modal isn't visible we can just immediately // set the content. No need to animate it. body.html(html); } if (js) { if (this.isAttached) { // If we're in the DOM then run the JS immediately. Templates.runTemplateJS(js); } else { // Otherwise cache it to be run when we're attached. this.bodyJS = js; } } return result; }.bind(this)) .then(function(result) { FilterEvents.notifyFilterContentUpdated(body); this.getRoot().trigger(ModalEvents.bodyRendered, this); return result; }.bind(this)) .then(function() { this.bodyPromise.resolve(body); return; }.bind(this)) .fail(Notification.exception) .always(function() { // When we're done displaying all of the content we need // to clear the custom values we've set here. body.css('height', ''); body.css('overflow', ''); body.css('opacity', ''); M.util.js_complete(jsPendingId); return; }) .fail(Notification.exception); } }; /** * Alternative to setBody() that can be used from non-Jquery modules * * @param {Promise} promise promise that returns {html, js} object * @return {Promise} */ Modal.prototype.setBodyContent = function(promise) { // Call the leegacy API for now and pass it a jQuery Promise. // This is a non-spec feature of jQuery and cannot be produced with spec promises. // We can encourage people to migrate to this approach, and in future we can swap // it so that setBody() calls setBodyPromise(). return promise.then(({html, js}) => this.setBody($.when(html, js))) .catch(exception => { this.hide(); throw exception; }); }; /** * Set the modal footer element. The footer element is made visible, if it * isn't already. * * This method is overloaded to take either a string * value for the body or a jQuery promise that is resolved with HTML and Javascript * most commonly from a Templates.render call. * * @method setFooter * @param {(string|object)} value The footer string or jQuery promise */ Modal.prototype.setFooter = function(value) { // Make sure the footer is visible. this.showFooter(); this.footerPromise = $.Deferred(); var footer = this.getFooter(); if (typeof value === 'string') { // Just set the value if it's a string. footer.html(value); this.footerPromise.resolve(footer); } else { // Otherwise we assume it's a promise to be resolved with // html and javascript. Templates.render(TEMPLATES.LOADING, {}) .then(function(html) { footer.html(html); return value; }) .then(function(html, js) { footer.html(html); if (js) { if (this.isAttached) { // If we're in the DOM then run the JS immediately. Templates.runTemplateJS(js); } else { // Otherwise cache it to be run when we're attached. this.footerJS = js; } } return footer; }.bind(this)) .then(function(footer) { this.footerPromise.resolve(footer); return; }.bind(this)) .catch(Notification.exception); } }; /** * Check if the footer has any content in it. * * @method hasFooterContent * @return {bool} */ Modal.prototype.hasFooterContent = function() { return this.getFooter().children().length ? true : false; }; /** * Hide the footer element. * * @method hideFooter */ Modal.prototype.hideFooter = function() { this.getFooter().addClass('hidden'); }; /** * Show the footer element. * * @method showFooter */ Modal.prototype.showFooter = function() { this.getFooter().removeClass('hidden'); }; /** * Mark the modal as a large modal. * * @method setLarge */ Modal.prototype.setLarge = function() { if (this.isLarge()) { return; } this.getModal().addClass('modal-lg'); }; /** * Check if the modal is a large modal. * * @method isLarge * @return {bool} */ Modal.prototype.isLarge = function() { return this.getModal().hasClass('modal-lg'); }; /** * Mark the modal as a small modal. * * @method setSmall */ Modal.prototype.setSmall = function() { if (this.isSmall()) { return; } this.getModal().removeClass('modal-lg'); }; /** * Check if the modal is a small modal. * * @method isSmall * @return {bool} */ Modal.prototype.isSmall = function() { return !this.getModal().hasClass('modal-lg'); }; /** * Set this modal to be scrollable or not. * * @method setScrollable * @param {bool} value Whether the modal is scrollable or not */ Modal.prototype.setScrollable = function(value) { if (!value) { this.getModal()[0].classList.remove('modal-dialog-scrollable'); return; } this.getModal()[0].classList.add('modal-dialog-scrollable'); }; /** * Determine the highest z-index value currently on the page. * * @method calculateZIndex * @return {int} */ Modal.prototype.calculateZIndex = function() { var items = $(SELECTORS.DIALOG + ', ' + SELECTORS.MENU_BAR + ', ' + SELECTORS.HAS_Z_INDEX); var zIndex = parseInt(this.root.css('z-index')); items.each(function(index, item) { item = $(item); // Note that webkit browsers won't return the z-index value from the CSS stylesheet // if the element doesn't have a position specified. Instead it'll return "auto". var itemZIndex = item.css('z-index') ? parseInt(item.css('z-index')) : 0; if (itemZIndex > zIndex) { zIndex = itemZIndex; } }); return zIndex; }; /** * Check if this modal is visible. * * @method isVisible * @return {bool} */ Modal.prototype.isVisible = function() { return this.root.hasClass('show'); }; /** * Check if this modal has focus. * * @method hasFocus * @return {bool} */ Modal.prototype.hasFocus = function() { var target = $(document.activeElement); return this.root.is(target) || this.root.has(target).length; }; /** * Check if this modal has CSS transitions applied. * * @method hasTransitions * @return {bool} */ Modal.prototype.hasTransitions = function() { return this.getRoot().hasClass('fade'); }; /** * Gets the jQuery wrapped node that the Modal should be attached to. * * @returns {jQuery} */ Modal.prototype.getAttachmentPoint = function() { return $(Fullscreen.getElement() || this.attachmentPoint); }; /** * Display this modal. The modal will be attached to the DOM if it hasn't * already been. * * @method show * @returns {Promise} */ Modal.prototype.show = function() { if (this.isVisible()) { return $.Deferred().resolve(); } var pendingPromise = new Pending('core/modal:show'); if (this.hasFooterContent()) { this.showFooter(); } else { this.hideFooter(); } this.attachToDOM(); // If the focusOnClose was not set. Set the focus back to triggered element. if (!this.focusOnClose && document.activeElement) { this.focusOnClose = document.activeElement; } return this.getBackdrop() .then(function(backdrop) { var currentIndex = this.calculateZIndex(); var newIndex = currentIndex + 2; var newBackdropIndex = newIndex - 1; this.root.css('z-index', newIndex); backdrop.setZIndex(newBackdropIndex); backdrop.show(); this.root.removeClass('hide').addClass('show'); this.accessibilityShow(); this.getModal().focus(); $('body').addClass('modal-open'); this.root.trigger(ModalEvents.shown, this); return; }.bind(this)) .then(pendingPromise.resolve); }; /** * Hide this modal if it does not contain a form. * * @method hideIfNotForm */ Modal.prototype.hideIfNotForm = function() { var formElement = this.modal.find(SELECTORS.FORM); if (formElement.length == 0) { this.hide(); } }; /** * Hide this modal. * * @method hide */ Modal.prototype.hide = function() { this.getBackdrop().done(function(backdrop) { FocusLock.untrapFocus(); if (!this.countOtherVisibleModals()) { // Hide the backdrop if we're the last open modal. backdrop.hide(); $('body').removeClass('modal-open'); } var currentIndex = parseInt(this.root.css('z-index')); this.root.css('z-index', ''); backdrop.setZIndex(currentIndex - 3); this.accessibilityHide(); if (this.hasTransitions()) { // Wait for CSS transitions to complete before hiding the element. this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', function() { this.getRoot().removeClass('show').addClass('hide'); }.bind(this)); } else { this.getRoot().removeClass('show').addClass('hide'); } // Ensure the modal is moved onto the body node if it is still attached to the DOM. if ($(document.body).find(this.getRoot()).length) { $(document.body).append(this.getRoot()); } this.root.trigger(ModalEvents.hidden, this); }.bind(this)); }; /** * Remove this modal from the DOM. * * @method destroy */ Modal.prototype.destroy = function() { this.hide(); this.root.remove(); this.root.trigger(ModalEvents.destroyed, this); this.attachmentPoint.remove(); }; /** * Sets the appropriate aria attributes on this dialogue and the other * elements in the DOM to ensure that screen readers are able to navigate * the dialogue popup correctly. * * @method accessibilityShow */ Modal.prototype.accessibilityShow = function() { // Make us visible to screen readers. Aria.unhide(this.root.get()); // Hide siblings. Aria.hideSiblings(this.root.get()[0]); }; /** * Restores the aria visibility on the DOM elements changed when displaying * the dialogue popup and makes the dialogue aria hidden to allow screen * readers to navigate the main page correctly when the dialogue is closed. * * @method accessibilityHide */ Modal.prototype.accessibilityHide = function() { // Unhide siblings. Aria.unhideSiblings(this.root.get()[0]); // Hide this modal. Aria.hide(this.root.get()); }; /** * Set up all of the event handling for the modal. * * @method registerEventListeners */ Modal.prototype.registerEventListeners = function() { this.getRoot().on('keydown', function(e) { if (!this.isVisible()) { return; } if (e.keyCode == KeyCodes.escape) { if (this.removeOnClose) { this.destroy(); } else { this.hide(); } } }.bind(this)); // Listen for clicks on the modal container. this.getRoot().click(function(e) { // If the click wasn't inside the modal element then we should // hide the modal. if (!$(e.target).closest(SELECTORS.MODAL).length) { // The check above fails to detect the click was inside the modal when the DOM tree is already changed. // So, we check if we can still find the container element or not. If not, then the DOM tree is changed. // It's best not to hide the modal in that case. if ($(e.target).closest(SELECTORS.CONTAINER).length) { var outsideClickEvent = $.Event(ModalEvents.outsideClick); this.getRoot().trigger(outsideClickEvent, this); if (!outsideClickEvent.isDefaultPrevented()) { this.hideIfNotForm(); } } } }.bind(this)); CustomEvents.define(this.getModal(), [CustomEvents.events.activate]); this.getModal().on(CustomEvents.events.activate, SELECTORS.HIDE, function(e, data) { if (this.removeOnClose) { this.destroy(); } else { this.hide(); } data.originalEvent.preventDefault(); }.bind(this)); this.getRoot().on(ModalEvents.hidden, () => { if (this.focusOnClose) { // Focus on the element that actually triggers the modal. this.focusOnClose.focus(); } }); }; /** * Register a listener to close the dialogue when the cancel button is pressed. * * @method registerCloseOnCancel */ Modal.prototype.registerCloseOnCancel = function() { // Handle the clicking of the Cancel button. this.getModal().on(CustomEvents.events.activate, this.getActionSelector('cancel'), function(e, data) { var cancelEvent = $.Event(ModalEvents.cancel); this.getRoot().trigger(cancelEvent, this); if (!cancelEvent.isDefaultPrevented()) { data.originalEvent.preventDefault(); if (this.removeOnClose) { this.destroy(); } else { this.hide(); } } }.bind(this)); }; /** * Register a listener to close the dialogue when the save button is pressed. * * @method registerCloseOnSave */ Modal.prototype.registerCloseOnSave = function() { // Handle the clicking of the Cancel button. this.getModal().on(CustomEvents.events.activate, this.getActionSelector('save'), function(e, data) { var saveEvent = $.Event(ModalEvents.save); this.getRoot().trigger(saveEvent, this); if (!saveEvent.isDefaultPrevented()) { data.originalEvent.preventDefault(); if (this.removeOnClose) { this.destroy(); } else { this.hide(); } } }.bind(this)); }; /** * Set or resolve and set the value using the function. * * @method asyncSet * @param {(string|object)} value The string or jQuery promise. * @param {function} setFunction The setter * @return {Promise} */ Modal.prototype.asyncSet = function(value, setFunction) { var p = value; if (typeof value !== 'object' || !value.hasOwnProperty('then')) { p = $.Deferred(); p.resolve(value); } p.then(function(content) { setFunction(content); return; }) .fail(Notification.exception); return p; }; /** * Set the title text of a button. * * This method is overloaded to take either a string value for the button title or a jQuery promise that is resolved with * text most commonly from a Str.get_string call. * * @param {DOMString} action The action of the button * @param {(String|object)} value The button text, or a promise which will resolve to it * @returns {Promise} */ Modal.prototype.setButtonText = function(action, value) { const button = this.getFooter().find(this.getActionSelector(action)); if (!button) { throw new Error("Unable to find the '" + action + "' button"); } return this.asyncSet(value, button.text.bind(button)); }; /** * Get the Selector for an action. * * @param {String} action * @returns {DOMString} */ Modal.prototype.getActionSelector = function(action) { return "[data-action='" + action + "']"; }; /** * Set the flag to remove the modal from the DOM on close. * * @param {Boolean} remove */ Modal.prototype.setRemoveOnClose = function(remove) { this.removeOnClose = remove; }; /** * Set the return element for the modal. * * @param {Element|jQuery} element Element to focus when the modal is closed */ Modal.prototype.setReturnElement = function(element) { this.focusOnClose = element; }; return Modal; }); custom_interaction_events.js 0000644 00000054273 15152050146 0012411 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * This module provides a wrapper to encapsulate a lot of the common combinations of * user interaction we use in Moodle. * * @module core/custom_interaction_events * @copyright 2016 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.2 */ define(['jquery', 'core/key_codes'], function($, keyCodes) { // The list of events provided by this module. Namespaced to avoid clashes. var events = { activate: 'cie:activate', keyboardActivate: 'cie:keyboardactivate', escape: 'cie:escape', down: 'cie:down', up: 'cie:up', home: 'cie:home', end: 'cie:end', next: 'cie:next', previous: 'cie:previous', asterix: 'cie:asterix', scrollLock: 'cie:scrollLock', scrollTop: 'cie:scrollTop', scrollBottom: 'cie:scrollBottom', ctrlPageUp: 'cie:ctrlPageUp', ctrlPageDown: 'cie:ctrlPageDown', enter: 'cie:enter', accessibleChange: 'cie:accessibleChange', }; // Static cache of jQuery events that have been handled. This should // only be populated by JavaScript generated events (which will keep it // fairly small). var triggeredEvents = {}; /** * Check if the caller has asked for the given event type to be * registered. * * @method shouldAddEvent * @private * @param {string} eventType name of the event (see events above) * @param {array} include the list of events to be added * @return {bool} true if the event should be added, false otherwise. */ var shouldAddEvent = function(eventType, include) { include = include || []; if (include.length && include.indexOf(eventType) !== -1) { return true; } return false; }; /** * Check if any of the modifier keys have been pressed on the event. * * @method isModifierPressed * @private * @param {event} e jQuery event * @return {bool} true if shift, meta (command on Mac), alt or ctrl are pressed */ var isModifierPressed = function(e) { return (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey); }; /** * Trigger the custom event for the given jQuery event. * * This function will only fire the custom event if one hasn't already been * fired for the jQuery event. * * This is to prevent multiple custom event handlers triggering multiple * custom events for a single jQuery event as it bubbles up the stack. * * @param {string} eventName The name of the custom event * @param {event} e The jQuery event * @return {void} */ var triggerEvent = function(eventName, e) { var eventTypeKey = ""; if (!e.hasOwnProperty('originalEvent')) { // This is a jQuery event generated from JavaScript not a browser event so // we need to build the cache key for the event. eventTypeKey = "" + eventName + e.type + e.timeStamp; if (!triggeredEvents.hasOwnProperty(eventTypeKey)) { // If we haven't seen this jQuery event before then fire a custom // event for it and remember the event for later. triggeredEvents[eventTypeKey] = true; $(e.target).trigger(eventName, [{originalEvent: e}]); } return; } eventTypeKey = "triggeredCustom_" + eventName; if (!e.originalEvent.hasOwnProperty(eventTypeKey)) { // If this is a jQuery event generated by the browser then set a // property on the original event to track that we've seen it before. // The property is set on the original event because it's the only part // of the jQuery event that is maintained through multiple event handlers. e.originalEvent[eventTypeKey] = true; $(e.target).trigger(eventName, [{originalEvent: e}]); return; } }; /** * Register a keyboard event that ignores modifier keys. * * @method addKeyboardEvent * @private * @param {object} element A jQuery object of the element to bind events to * @param {string} event The custom interaction event name * @param {int} keyCode The key code. */ var addKeyboardEvent = function(element, event, keyCode) { element.off('keydown.' + event).on('keydown.' + event, function(e) { if (!isModifierPressed(e)) { if (e.keyCode == keyCode) { triggerEvent(event, e); } } }); }; /** * Trigger the activate event on the given element if it is clicked or the enter * or space key are pressed without a modifier key. * * @method addActivateListener * @private * @param {object} element jQuery object to add event listeners to */ var addActivateListener = function(element) { element.off('click.cie.activate').on('click.cie.activate', function(e) { triggerEvent(events.activate, e); }); element.off('keydown.cie.activate').on('keydown.cie.activate', function(e) { if (!isModifierPressed(e)) { if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) { triggerEvent(events.activate, e); } } }); }; /** * Trigger the keyboard activate event on the given element if the enter * or space key are pressed without a modifier key. * * @method addKeyboardActivateListener * @private * @param {object} element jQuery object to add event listeners to */ var addKeyboardActivateListener = function(element) { element.off('keydown.cie.keyboardactivate').on('keydown.cie.keyboardactivate', function(e) { if (!isModifierPressed(e)) { if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) { triggerEvent(events.keyboardActivate, e); } } }); }; /** * Trigger the escape event on the given element if the escape key is pressed * without a modifier key. * * @method addEscapeListener * @private * @param {object} element jQuery object to add event listeners to */ var addEscapeListener = function(element) { addKeyboardEvent(element, events.escape, keyCodes.escape); }; /** * Trigger the down event on the given element if the down arrow key is pressed * without a modifier key. * * @method addDownListener * @private * @param {object} element jQuery object to add event listeners to */ var addDownListener = function(element) { addKeyboardEvent(element, events.down, keyCodes.arrowDown); }; /** * Trigger the up event on the given element if the up arrow key is pressed * without a modifier key. * * @method addUpListener * @private * @param {object} element jQuery object to add event listeners to */ var addUpListener = function(element) { addKeyboardEvent(element, events.up, keyCodes.arrowUp); }; /** * Trigger the home event on the given element if the home key is pressed * without a modifier key. * * @method addHomeListener * @private * @param {object} element jQuery object to add event listeners to */ var addHomeListener = function(element) { addKeyboardEvent(element, events.home, keyCodes.home); }; /** * Trigger the end event on the given element if the end key is pressed * without a modifier key. * * @method addEndListener * @private * @param {object} element jQuery object to add event listeners to */ var addEndListener = function(element) { addKeyboardEvent(element, events.end, keyCodes.end); }; /** * Trigger the next event on the given element if the right arrow key is pressed * without a modifier key in LTR mode or left arrow key in RTL mode. * * @method addNextListener * @private * @param {object} element jQuery object to add event listeners to */ var addNextListener = function(element) { // Left and right are flipped in RTL mode. var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowLeft : keyCodes.arrowRight; addKeyboardEvent(element, events.next, keyCode); }; /** * Trigger the previous event on the given element if the left arrow key is pressed * without a modifier key in LTR mode or right arrow key in RTL mode. * * @method addPreviousListener * @private * @param {object} element jQuery object to add event listeners to */ var addPreviousListener = function(element) { // Left and right are flipped in RTL mode. var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowRight : keyCodes.arrowLeft; addKeyboardEvent(element, events.previous, keyCode); }; /** * Trigger the asterix event on the given element if the asterix key is pressed * without a modifier key. * * @method addAsterixListener * @private * @param {object} element jQuery object to add event listeners to */ var addAsterixListener = function(element) { addKeyboardEvent(element, events.asterix, keyCodes.asterix); }; /** * Trigger the scrollTop event on the given element if the user scrolls to * the top of the given element. * * @method addScrollTopListener * @private * @param {object} element jQuery object to add event listeners to */ var addScrollTopListener = function(element) { element.off('scroll.cie.scrollTop').on('scroll.cie.scrollTop', function(e) { var scrollTop = element.scrollTop(); if (scrollTop === 0) { triggerEvent(events.scrollTop, e); } }); }; /** * Trigger the scrollBottom event on the given element if the user scrolls to * the bottom of the given element. * * @method addScrollBottomListener * @private * @param {object} element jQuery object to add event listeners to */ var addScrollBottomListener = function(element) { element.off('scroll.cie.scrollBottom').on('scroll.cie.scrollBottom', function(e) { var scrollTop = element.scrollTop(); var innerHeight = element.innerHeight(); var scrollHeight = element[0].scrollHeight; if (scrollTop + innerHeight >= scrollHeight) { triggerEvent(events.scrollBottom, e); } }); }; /** * Trigger the scrollLock event on the given element if the user scrolls to * the bottom or top of the given element. * * @method addScrollLockListener * @private * @param {jQuery} element jQuery object to add event listeners to */ var addScrollLockListener = function(element) { // Lock mousewheel scrolling within the element to stop the annoying window scroll. element.off('DOMMouseScroll.cie.DOMMouseScrollLock mousewheel.cie.mousewheelLock') .on('DOMMouseScroll.cie.DOMMouseScrollLock mousewheel.cie.mousewheelLock', function(e) { var scrollTop = element.scrollTop(); var scrollHeight = element[0].scrollHeight; var height = element.height(); var delta = (e.type == 'DOMMouseScroll' ? e.originalEvent.detail * -40 : e.originalEvent.wheelDelta); var up = delta > 0; if (!up && -delta > scrollHeight - height - scrollTop) { // Scrolling down past the bottom. element.scrollTop(scrollHeight); e.stopPropagation(); e.preventDefault(); e.returnValue = false; // Fire the scroll lock event. triggerEvent(events.scrollLock, e); return false; } else if (up && delta > scrollTop) { // Scrolling up past the top. element.scrollTop(0); e.stopPropagation(); e.preventDefault(); e.returnValue = false; // Fire the scroll lock event. triggerEvent(events.scrollLock, e); return false; } return true; }); }; /** * Trigger the ctrlPageUp event on the given element if the user presses the * control and page up key. * * @method addCtrlPageUpListener * @private * @param {object} element jQuery object to add event listeners to */ var addCtrlPageUpListener = function(element) { element.off('keydown.cie.ctrlpageup').on('keydown.cie.ctrlpageup', function(e) { if (e.ctrlKey) { if (e.keyCode == keyCodes.pageUp) { triggerEvent(events.ctrlPageUp, e); } } }); }; /** * Trigger the ctrlPageDown event on the given element if the user presses the * control and page down key. * * @method addCtrlPageDownListener * @private * @param {object} element jQuery object to add event listeners to */ var addCtrlPageDownListener = function(element) { element.off('keydown.cie.ctrlpagedown').on('keydown.cie.ctrlpagedown', function(e) { if (e.ctrlKey) { if (e.keyCode == keyCodes.pageDown) { triggerEvent(events.ctrlPageDown, e); } } }); }; /** * Trigger the enter event on the given element if the enter key is pressed * without a modifier key. * * @method addEnterListener * @private * @param {object} element jQuery object to add event listeners to */ var addEnterListener = function(element) { addKeyboardEvent(element, events.enter, keyCodes.enter); }; /** * Trigger the AccessibleChange event on the given element if the value of the element is changed. * * @method addAccessibleChangeListener * @private * @param {object} element jQuery object to add event listeners to */ var addAccessibleChangeListener = function(element) { var onMac = navigator.userAgent.indexOf('Macintosh') !== -1; var touchEnabled = ('ontouchstart' in window) || (('msMaxTouchPoints' in navigator) && (navigator.msMaxTouchPoints > 0)); if (onMac || touchEnabled) { // On Mac devices, and touch-enabled devices, the change event seems to be handled correctly and // consistently at this time. element.on('change', function(e) { triggerEvent(events.accessibleChange, e); }); } else { // Some browsers have non-normalised behaviour for handling the selection of values in a <select> element. // When using Chrome on Linux (and possibly others), a 'change' event is fired when pressing the Escape key. // When using Firefox on Linux (and possibly others), a 'change' event is fired when navigating through the // list with a keyboard. // // To normalise these behaviours: // - the initial value is stored in a data attribute when focusing the element // - the current value is checked against the stored initial value when and the accessibleChange event fired when: // --- blurring the element // --- the 'Enter' key is pressed // --- the element is clicked // --- the 'change' event is fired, except where it is from a keyboard interaction // // To facilitate the change event keyboard interaction check, the 'keyDown' handler sets a flag to ignore // the change event handler which is unset on the 'keyUp' event. // // Unfortunately we cannot control this entirely as some browsers (Chrome) trigger a change event when // pressign the Escape key, and this is considered to be the correct behaviour. // Chrome https://bugs.chromium.org/p/chromium/issues/detail?id=839717 // // Our longer-term solution to this should be to switch away from using <select> boxes as a single-select, // and make use of a dropdown of action links like the Bootstrap Dropdown menu. var setInitialValue = function(target) { target.dataset.initValue = target.value; }; var resetToInitialValue = function(target) { if ('initValue' in target.dataset) { target.value = target.dataset.initValue; } }; var checkAndTriggerAccessibleChange = function(e) { if (!('initValue' in e.target.dataset)) { // Some browsers trigger click before focus, therefore it is possible that initValue is undefined. // In this case it's likely that it's being focused for the first time and we should therefore not submit. return; } if (e.target.value !== e.target.dataset.initValue) { // Update the initValue when the event is triggered. // This means that if the click handler fires before the focus handler on a subsequent interaction // with the element, the currently dispalyed value will be the best guess current value. e.target.dataset.initValue = e.target.value; triggerEvent(events.accessibleChange, e); } }; var nativeElement = element.get()[0]; // The `focus` and `blur` events do not support bubbling. Use Event Capture instead. nativeElement.addEventListener('focus', function(e) { setInitialValue(e.target); }, true); nativeElement.addEventListener('blur', function(e) { checkAndTriggerAccessibleChange(e); }, true); element.on('keydown', function(e) { if ((e.which === keyCodes.enter)) { checkAndTriggerAccessibleChange(e); } else if (e.which === keyCodes.escape) { resetToInitialValue(e.target); e.target.dataset.ignoreChange = true; } else { // Firefox triggers a change event when using the keyboard to scroll through the selection. // Set a data- attribute that the change listener can use to ignore the change event where it was // generated from a keyboard change such as typing to complete a value, or using arrow keys. e.target.dataset.ignoreChange = true; } }); element.on('change', function(e) { if (e.target.dataset.ignoreChange) { // This change event was triggered from a keyboard change which is not yet complete. // Do not trigger the accessibleChange event until the selection is completed using the [return] // key. return; } checkAndTriggerAccessibleChange(e); }); element.on('keyup', function(e) { // The key has been lifted. Stop ignoring the change event. delete e.target.dataset.ignoreChange; }); element.on('click', function(e) { checkAndTriggerAccessibleChange(e); }); } }; /** * Get the list of events and their handlers. * * @method getHandlers * @private * @return {object} object key of event names and value of handler functions */ var getHandlers = function() { var handlers = {}; handlers[events.activate] = addActivateListener; handlers[events.keyboardActivate] = addKeyboardActivateListener; handlers[events.escape] = addEscapeListener; handlers[events.down] = addDownListener; handlers[events.up] = addUpListener; handlers[events.home] = addHomeListener; handlers[events.end] = addEndListener; handlers[events.next] = addNextListener; handlers[events.previous] = addPreviousListener; handlers[events.asterix] = addAsterixListener; handlers[events.scrollLock] = addScrollLockListener; handlers[events.scrollTop] = addScrollTopListener; handlers[events.scrollBottom] = addScrollBottomListener; handlers[events.ctrlPageUp] = addCtrlPageUpListener; handlers[events.ctrlPageDown] = addCtrlPageDownListener; handlers[events.enter] = addEnterListener; handlers[events.accessibleChange] = addAccessibleChangeListener; return handlers; }; /** * Add all of the listeners on the given element for the requested events. * * @method define * @public * @param {object} element the DOM element to register event listeners on * @param {array} include the array of events to be triggered */ var define = function(element, include) { element = $(element); include = include || []; if (!element.length || !include.length) { return; } $.each(getHandlers(), function(eventType, handler) { if (shouldAddEvent(eventType, include)) { handler(element); } }); }; return { define: define, events: events, }; }); paged_content_paging_dropdown.js 0000644 00000016561 15152050146 0013165 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Javascript to manage the paging dropdown control. * * @module core/paged_content_paging_dropdown * @copyright 2018 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define( [ 'jquery', 'core/custom_interaction_events', 'core/paged_content_events', 'core/pubsub' ], function( $, CustomEvents, PagedContentEvents, PubSub ) { var SELECTORS = { ROOT: '[data-region="paging-dropdown-container"]', DROPDOWN_ITEM: '[data-region="dropdown-item"]', DROPDOWN_TOGGLE: '[data-region="dropdown-toggle"]', ACTIVE_DROPDOWN_ITEM: '[data-region="dropdown-item"].active', CARET: '[data-region="caret"]' }; /** * Get the page number. * * @param {jquery} item The dropdown item. * @returns {Number} */ var getPageNumber = function(item) { return parseInt(item.attr('data-page-number'), 10); }; /** * Get all paging dropdown items. * * @param {jquery} root The root element. * @returns {jquery} A jquery object with all items. */ var getAllItems = function(root) { return root.find(SELECTORS.DROPDOWN_ITEM); }; /** * Get all paging dropdown items with lower page numbers than the given * dropdown item. * * @param {jquery} root The root element. * @param {jquery} item The dropdown item. * @returns {jquery} A jquery object with all items. */ var getPreviousItems = function(root, item) { var pageNumber = getPageNumber(item); return getAllItems(root).filter(function(index, element) { return getPageNumber($(element)) < pageNumber; }); }; /** * Get the number of items to be loaded for the dropdown item. * * @param {jquery} item The dropdown item. * @returns {Number} */ var getLimit = function(item) { return parseInt(item.attr('data-item-count'), 10); }; /** * Get the offset of items from the start of the itemset for the given * dropdown item. * * @param {jquery} root The root element. * @param {jquery} item The dropdown item. * @returns {Number} */ var getOffset = function(root, item) { if (item.attr('data-offset') != undefined) { return parseInt(item.attr('data-offset'), 10); } var offset = 0; getPreviousItems(root, item).each(function(index, prevItem) { prevItem = $(prevItem); offset += getLimit(prevItem); }); item.attr('data-offset', offset); return offset; }; /** * Get the active dropdown item. * * @param {jquery} root The root element. * @returns {jquery} The active dropdown item. */ var getActiveItem = function(root) { return root.find(SELECTORS.ACTIVE_DROPDOWN_ITEM); }; /** * Create the event payload for the list of dropdown items. The event payload * is an array of objects with one object per dropdown item. * * Each payload object contains the page number, limit, and offset for the * corresponding dropdown item. * * For example: If we had 3 dropdown items with incrementing page numbers loading * 25 items per page then the generated payload would look like: * [ * { * pageNumber: 1, * limit: 25, * offset: 0 * }, * { * pageNumber: 2, * limit: 25, * offset: 25 * }, * { * pageNumber: 3, * limit: 25, * offset: 50 * } * ] * * @param {jquery} root The root element. * @param {jquery} items The dropdown items. * @returns {object[]} The payload for the event. */ var generateEventPayload = function(root, items) { return items.map(function(index, item) { item = $(item); return { pageNumber: getPageNumber(item), limit: getLimit(item), offset: getOffset(root, item), }; }).get(); }; /** * Add page number attributes to each of the given items. The page numbers * start at 1 and increment by 1 for each item, e.g. 1, 2, 3 etc. * * @param {jquery} items The dropdown items. */ var generatePageNumbers = function(items) { items.each(function(index, item) { item = $(item); item.attr('data-page-number', index + 1); }); }; /** * Make the given item active by setting the active class on it and firing * the SHOW_PAGES event for the paged content to show the appropriate * pages. * * @param {jquery} root The root element. * @param {jquery} item The dropdown item. * @param {string} id A unique id for this instance. */ var setActiveItem = function(root, item, id) { var prevItems = getPreviousItems(root, item); var allItems = prevItems.add(item); var eventPayload = generateEventPayload(root, allItems); var toggle = root.find(SELECTORS.DROPDOWN_TOGGLE); var caret = toggle.find(SELECTORS.CARET); getActiveItem(root).removeClass('active'); item.addClass('active'); // Update the dropdown toggle to show which item is selected. toggle.html(item.text()); // Bootstrap 2 compatibility. toggle.append(caret); // Fire the event to tell the content to update. PubSub.publish(id + PagedContentEvents.SHOW_PAGES, eventPayload); }; /** * Initialise the module by firing the SHOW_PAGES event for an existing * active page found and setting up the event listener for the user to select * new pages. * * @param {object} root The root element. * @param {string} id A unique id for this instance. */ var init = function(root, id) { root = $(root); var items = getAllItems(root); generatePageNumbers(items); var activeItem = getActiveItem(root); if (activeItem.length) { // Fire the first event for the content to make sure it's visible. setActiveItem(root, activeItem, id); } CustomEvents.define(root, [ CustomEvents.events.activate ]); root.on(CustomEvents.events.activate, SELECTORS.DROPDOWN_ITEM, function(e, data) { var item = $(e.target).closest(SELECTORS.DROPDOWN_ITEM); setActiveItem(root, item, id); data.originalEvent.preventDefault(); }); }; return { init: init, rootSelector: SELECTORS.ROOT, }; }); tag.js 0000644 00000047540 15152050146 0005666 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * AJAX helper for the tag management page. * * @module core/tag * @copyright 2015 Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.0 */ define([ 'jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/pending', ], function( $, ajax, templates, notification, str, ModalFactory, ModalEvents, Pending ) { return /** @alias module:core/tag */ { /** * Initialises tag index page. * * @method initTagindexPage */ initTagindexPage: function() { // Click handler for changing tag type. $('body').delegate('.tagarea[data-ta] a[data-quickload=1]', 'click', function(e) { var pendingPromise = new Pending('core/tag:initTagindexPage'); e.preventDefault(); var target = $(this); var query = target[0].search.replace(/^\?/, ''); var tagarea = target.closest('.tagarea[data-ta]'); var args = query.split('&').reduce(function(s, c) { var t = c.split('='); s[t[0]] = decodeURIComponent(t[1]); return s; }, {}); ajax.call([{ methodname: 'core_tag_get_tagindex', args: {tagindex: args} }])[0] .then(function(data) { return templates.render('core_tag/index', data); }) .then(function(html, js) { templates.replaceNode(tagarea, html, js); return; }) .always(pendingPromise.resolve) .catch(notification.exception); }); }, /** * Initialises tag management page. * * @method initManagePage */ initManagePage: function() { // Set cell 'time modified' to 'now' when any of the element is updated in this row. $('body').on('updated', '[data-inplaceeditable]', function(e) { var pendingPromise = new Pending('core/tag:initManagePage'); str.get_strings([ { key: 'selecttag', component: 'core_tag', }, { key: 'now', component: 'core', }, ]) .then(function(result) { $('label[for="tagselect' + e.ajaxreturn.itemid + '"]').html(result[0]); $(e.target).closest('tr').find('td.col-timemodified').html(result[1]); return; }) .always(pendingPromise.resolve) .catch(notification.exception); if (e.ajaxreturn.itemtype === 'tagflag') { var row = $(e.target).closest('tr'); if (e.ajaxreturn.value === '0') { row.removeClass('table-warning'); } else { row.addClass('table-warning'); } } }); // Confirmation for single tag delete link. $('.tag-management-table').delegate('a.tagdelete', 'click', function(e) { var pendingPromise = new Pending('core/tag:tagdelete'); e.preventDefault(); var href = $(this).attr('href'); str.get_strings([ {key: 'delete', component: 'core'}, {key: 'confirmdeletetag', component: 'tag'}, {key: 'yes', component: 'core'}, {key: 'no', component: 'core'}, ]) .then(function(s) { return notification.confirm(s[0], s[1], s[2], s[3], function() { window.location.href = href; }); }) .always(pendingPromise.resolve) .catch(notification.exception); }); // Confirmation for bulk tag delete button. $("#tag-management-delete").click(function(e) { var form = $(this).closest('form').get(0); var cnt = $(form).find("input[data-togglegroup='tags-manage'][data-toggle='slave']:checked").length; if (!cnt) { return; } var pendingPromise = new Pending('core/tag:tag-management-delete'); var tempElement = $("<input type='hidden'/>").attr('name', this.name); e.preventDefault(); str.get_strings([ {key: 'delete', component: 'core'}, {key: 'confirmdeletetags', component: 'tag'}, {key: 'yes', component: 'core'}, {key: 'no', component: 'core'}, ]) .then(function(s) { return notification.confirm(s[0], s[1], s[2], s[3], function() { tempElement.appendTo(form); form.submit(); }); }) .always(pendingPromise.resolve) .catch(notification.exception); }); // Confirmation for bulk tag combine button. $("#tag-management-combine").click(function(e) { var pendingPromise = new Pending('core/tag:tag-management-combine'); e.preventDefault(); var form = $(this).closest('form').get(0); var tags = $(form).find("input[data-togglegroup='tags-manage'][data-toggle='slave']:checked"); if (tags.length <= 1) { str.get_strings([ {key: 'combineselected', component: 'tag'}, {key: 'selectmultipletags', component: 'tag'}, {key: 'ok'}, ]) .then(function(s) { return notification.alert(s[0], s[1], s[2]); }) .always(pendingPromise.resolve) .catch(notification.exception); return; } var tempElement = $("<input type='hidden'/>").attr('name', this.name); var saveButtonText = ''; var tagOptions = []; tags.each(function() { var tagid = $(this).val(), tagname = $('.inplaceeditable[data-itemtype=tagname][data-itemid=' + tagid + ']').attr('data-value'); tagOptions.push({ id: tagid, name: tagname }); }); str.get_strings([ {key: 'combineselected', component: 'tag'}, {key: 'continue', component: 'core'} ]) .then(function(langStrings) { var modalTitle = langStrings[0]; saveButtonText = langStrings[1]; var templateContext = { tags: tagOptions }; return ModalFactory.create({ title: modalTitle, body: templates.render('core_tag/combine_tags', templateContext), type: ModalFactory.types.SAVE_CANCEL }); }) .then(function(modal) { modal.setSaveButtonText(saveButtonText); return modal; }) .then(function(modal) { // Handle save event. modal.getRoot().on(ModalEvents.save, function(e) { e.preventDefault(); // Append this temp element in the form in the tags list, not the form in the modal. Confusing, right?!? tempElement.appendTo(form); // Get the selected tag from the modal. var maintag = $('input[name=maintag]:checked', '#combinetags_form').val(); // Append this in the tags list form. $("<input type='hidden'/>").attr('name', 'maintag').attr('value', maintag).appendTo(form); // Submit the tags list form. form.submit(); }); // Handle hidden event. modal.getRoot().on(ModalEvents.hidden, function() { // Destroy when hidden. modal.destroy(); }); modal.show(); // Tick the first option. $('#combinetags_form input[type=radio]').first().focus().prop('checked', true); return; }) .always(pendingPromise.resolve) .catch(notification.exception); }); // When user changes tag name to some name that already exists suggest to combine the tags. $('body').on('updatefailed', '[data-inplaceeditable][data-itemtype=tagname]', function(e) { var exception = e.exception; // The exception object returned by the callback. var newvalue = e.newvalue; // The value that user tried to udpated the element to. var tagid = $(e.target).attr('data-itemid'); if (exception.errorcode === 'namesalreadybeeingused') { var pendingPromise = new Pending('core/tag:updatefailed'); e.preventDefault(); // This will prevent default error dialogue. str.get_strings([ {key: 'confirm', component: 'core'}, {key: 'nameuseddocombine', component: 'tag'}, {key: 'yes', component: 'core'}, {key: 'cancel', component: 'core'}, ]) .then(function(s) { return notification.confirm(s[0], s[1], s[2], s[3], function() { window.location.href = window.location.href + "&newname=" + encodeURIComponent(newvalue) + "&tagid=" + encodeURIComponent(tagid) + '&action=renamecombine&sesskey=' + M.cfg.sesskey; }); }) .always(pendingPromise.resolve) .catch(notification.exception); } }); // Form for adding standard tags. $('body').on('click', 'a[data-action=addstandardtag]', function(e) { var pendingPromise = new Pending('core/tag:addstandardtag'); e.preventDefault(); return ModalFactory.create({ title: str.get_string('addotags', 'tag'), body: templates.render('core_tag/add_tags', { actionurl: window.location.href, sesskey: M.cfg.sesskey }), type: ModalFactory.types.SAVE_CANCEL }) .then(function(modal) { modal.setSaveButtonText(str.get_string('continue', 'core')); // Handle save event. modal.getRoot().on(ModalEvents.save, function(e) { var tagsInput = $(e.currentTarget).find('#id_tagslist'); var name = tagsInput.val().trim(); // Set the text field's value to the trimmed value. tagsInput.val(name); // Add submit event listener to the form. var tagsForm = $('#addtags_form'); tagsForm.on('submit', function(e) { // Validate the form. var form = $('#addtags_form'); if (form[0].checkValidity() === false) { e.preventDefault(); e.stopPropagation(); } form.addClass('was-validated'); // BS2 compatibility. $('[data-region="tagslistinput"]').addClass('error'); var errorMessage = $('#id_tagslist_error_message'); errorMessage.removeAttr('hidden'); errorMessage.addClass('help-block'); }); // Try to submit the form. tagsForm.submit(); return false; }); // Handle hidden event. modal.getRoot().on(ModalEvents.hidden, function() { // Destroy when hidden. modal.destroy(); }); modal.show(); return; }) .always(pendingPromise.resolve) .catch(notification.exception); }); }, /** * Initialises tag collection management page. * * @method initManageCollectionsPage */ initManageCollectionsPage: function() { $('body').on('updated', '[data-inplaceeditable]', function(e) { var pendingPromise = new Pending('core/tag:initManageCollectionsPage-updated'); var ajaxreturn = e.ajaxreturn, areaid, collid, isenabled; if (ajaxreturn.component === 'core_tag' && ajaxreturn.itemtype === 'tagareaenable') { areaid = $(this).attr('data-itemid'); $(".tag-collections-table ul[data-collectionid] li[data-areaid=" + areaid + "]").hide(); isenabled = ajaxreturn.value; if (isenabled === '1') { $(this).closest('tr').removeClass('dimmed_text'); collid = $(this).closest('tr').find('[data-itemtype="tagareacollection"]').attr("data-value"); $(".tag-collections-table ul[data-collectionid=" + collid + "] li[data-areaid=" + areaid + "]").show(); } else { $(this).closest('tr').addClass('dimmed_text'); } } if (ajaxreturn.component === 'core_tag' && ajaxreturn.itemtype === 'tagareacollection') { areaid = $(this).attr('data-itemid'); $(".tag-collections-table ul[data-collectionid] li[data-areaid=" + areaid + "]").hide(); collid = $(this).attr('data-value'); isenabled = $(this).closest('tr').find('[data-itemtype="tagareaenable"]').attr("data-value"); if (isenabled === "1") { $(".tag-collections-table ul[data-collectionid=" + collid + "] li[data-areaid=" + areaid + "]").show(); } } pendingPromise.resolve(); }); $('body').on('click', '.addtagcoll > a', function(e) { var pendingPromise = new Pending('core/tag:initManageCollectionsPage-addtagcoll'); e.preventDefault(); var keys = [ { key: 'addtagcoll', component: 'tag' }, { key: 'create', component: 'core' } ]; var href = $(this).attr('data-url'); var saveButtonText = ''; str.get_strings(keys) .then(function(langStrings) { var modalTitle = langStrings[0]; saveButtonText = langStrings[1]; var templateContext = { actionurl: href, sesskey: M.cfg.sesskey }; return ModalFactory.create({ title: modalTitle, body: templates.render('core_tag/add_tag_collection', templateContext), type: ModalFactory.types.SAVE_CANCEL }); }) .then(function(modal) { modal.setSaveButtonText(saveButtonText); // Handle save event. modal.getRoot().on(ModalEvents.save, function(e) { var collectionInput = $(e.currentTarget).find('#addtagcoll_name'); var name = collectionInput.val().trim(); // Set the text field's value to the trimmed value. collectionInput.val(name); // Add submit event listener to the form. var form = $('#addtagcoll_form'); form.on('submit', function(e) { // Validate the form. if (form[0].checkValidity() === false) { e.preventDefault(); e.stopPropagation(); } form.addClass('was-validated'); // BS2 compatibility. $('[data-region="addtagcoll_nameinput"]').addClass('error'); var errorMessage = $('#id_addtagcoll_name_error_message'); errorMessage.removeAttr('hidden'); errorMessage.addClass('help-block'); }); // Try to submit the form. form.submit(); return false; }); // Handle hidden event. modal.getRoot().on(ModalEvents.hidden, function() { // Destroy when hidden. modal.destroy(); }); modal.show(); return modal; }) .always(pendingPromise.resolve) .catch(notification.exception); }); $('body').on('click', '.tag-collections-table .action_delete', function(e) { var pendingPromise = new Pending('core/tag:initManageCollectionsPage-action_delete'); e.preventDefault(); var href = $(this).attr('data-url') + '&sesskey=' + M.cfg.sesskey; str.get_strings([ {key: 'delete'}, {key: 'suredeletecoll', component: 'tag', param: $(this).attr('data-collname')}, {key: 'yes'}, {key: 'no'}, ]) .then(function(s) { return notification.confirm(s[0], s[1], s[2], s[3], function() { window.location.href = href; }); }) .always(pendingPromise.resolve) .catch(notification.exception); }); } }; }); icon_system_standard.js 0000644 00000004464 15152050146 0011325 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Competency rule points module. * * @module core/icon_system_standard * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['core/icon_system', 'core/url', 'core/mustache'], function(IconSystem, CoreUrl, Mustache) { /** * IconSystemStandard * * @class core/icon_system_standard */ var IconSystemStandard = function() { IconSystem.apply(this, arguments); }; IconSystemStandard.prototype = Object.create(IconSystem.prototype); /** * Render an icon. * * @method renderIcon * @param {String} key * @param {String} component * @param {String} title * @param {String} template * @return {String} */ IconSystemStandard.prototype.renderIcon = function(key, component, title, template) { var url = CoreUrl.imageUrl(key, component); var templatecontext = { attributes: [ {name: 'src', value: url}, {name: 'alt', value: title}, {name: 'title', value: title} ] }; if (typeof title === "undefined" || title == "") { templatecontext.attributes.push({name: 'aria-hidden', value: 'true'}); } var result = Mustache.render(template, templatecontext); return result.trim(); }; /** * Get the name of the template to pre-cache for this icon system. * * @return {String} * @method getTemplateName */ IconSystemStandard.prototype.getTemplateName = function() { return 'core/pix_icon'; }; return IconSystemStandard; }); backoff_timer.js 0000644 00000011760 15152050146 0007701 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A timer that will execute a callback with decreasing frequency. Useful for * doing polling on the server without overwhelming it with requests. * * @module core/backoff_timer * @copyright 2016 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(function() { /** * Constructor for the back off timer. * * @class * @param {function} callback The function to execute after each tick * @param {function} backoffFunction The function to determine what the next timeout value should be */ var BackoffTimer = function(callback, backoffFunction) { this.callback = callback; this.backOffFunction = backoffFunction; }; /** * @property {function} callback The function to execute after each tick */ BackoffTimer.prototype.callback = null; /** * @property {function} backoffFunction The function to determine what the next timeout value should be */ BackoffTimer.prototype.backOffFunction = null; /** * @property {int} time The timeout value to use */ BackoffTimer.prototype.time = null; /** * @property {numeric} timeout The timeout identifier */ BackoffTimer.prototype.timeout = null; /** * Generate the next timeout in the back off time sequence * for the timer. * * The back off function is called to calculate the next value. * It is given the current value and an array of all previous values. * * @return {int} The new timeout value (in milliseconds) */ BackoffTimer.prototype.generateNextTime = function() { var newTime = this.backOffFunction(this.time); this.time = newTime; return newTime; }; /** * Stop the current timer and clear the previous time values * * @return {object} this */ BackoffTimer.prototype.reset = function() { this.time = null; this.stop(); return this; }; /** * Clear the current timeout, if one is set. * * @return {object} this */ BackoffTimer.prototype.stop = function() { if (this.timeout) { window.clearTimeout(this.timeout); this.timeout = null; } return this; }; /** * Start the current timer by generating the new timeout value and * starting the ticks. * * This function recurses after each tick with a new timeout value * generated each time. * * The callback function is called after each tick. * * @return {object} this */ BackoffTimer.prototype.start = function() { // If we haven't already started. if (!this.timeout) { var time = this.generateNextTime(); this.timeout = window.setTimeout(function() { this.callback(); // Clear the existing timer. this.stop(); // Start the next timer. this.start(); }.bind(this), time); } return this; }; /** * Reset the timer and start it again from the initial timeout * values * * @return {object} this */ BackoffTimer.prototype.restart = function() { return this.reset().start(); }; /** * Returns an incremental function for the timer. * * @param {int} minamount The minimum amount of time we wait before checking * @param {int} incrementamount The amount to increment the timer by * @param {int} maxamount The max amount to ever increment to * @param {int} timeoutamount The timeout to use once we reach the max amount * @return {function} */ BackoffTimer.getIncrementalCallback = function(minamount, incrementamount, maxamount, timeoutamount) { /** * An incremental function for the timer. * * @param {(int|null)} time The current timeout value or null if none set * @return {int} The new timeout value */ return function(time) { if (!time) { return minamount; } // Don't go over the max amount. if (time + incrementamount > maxamount) { return timeoutamount; } return time + incrementamount; }; }; return BackoffTimer; }); aria.js 0000644 00000001763 15152050146 0006024 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Helpers to perform ARIA compliance changes to the DOM. * * @module core/aria * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ export { hide, unhide, hideSiblings, unhideSiblings, } from './local/aria/aria-hidden'; localstorage.js 0000644 00000004123 15152050146 0007560 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Simple API for set/get to localstorage, with cacherev expiration. * * @module core/localstorage * @class localstorage * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 2.9 */ define(['core/config', 'core/storagewrapper'], function(config, StorageWrapper) { // Private functions and variables. /** @var {Object} StorageWrapper - Wraps browsers localStorage object */ var storage = new StorageWrapper(window.localStorage); return /** @alias module:core/localstorage */ { /** * Get a value from local storage. Remember - all values must be strings. * * @method get * @param {string} key The cache key to check. * @return {boolean|string} False if the value is not in the cache, or some other error - a string otherwise. */ get: function(key) { return storage.get(key); }, /** * Set a value to local storage. Remember - all values must be strings. * * @method set * @param {string} key The cache key to set. * @param {string} value The value to set. * @return {boolean} False if the value can't be saved in the cache, or some other error - true otherwise. */ set: function(key, value) { return storage.set(key, value); } }; }); prefetch.js 0000644 00000013544 15152050146 0006710 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Prefetch module to help lazily load content for use on the current page. * * @module core/prefetch * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * * @example <caption>Pre-fetching a set of strings to use later</caption> * * import prefetch from 'core/prefetch'; * * // A single string prefetch. * prefetch.prefetchString('error', 'cannotfindteacher'); * * // Prefetch multiple strings in the same component. * prefetch.prefetchStrings('core', [ * 'yes', * 'no', * ]); * * // Use the strings. * import {get_string as getString, get_strings as getStrings} from 'core/str'; * getString('cannotfindteacher', 'error') * .then(str => { * window.console.log(str); // Cannot find teacher * }) * .catch(); * getStrings([ * { * key: 'cannotfindteacher', * component: 'error', * }, * { * key: 'yes', * component: 'core', * }, * { * key: 'no', * component: 'core', * }, * ]) * .then((cannotFindTeacher, yes, no) => { * window.console.log(cannotFindTeacher); // Cannot find teacher * window.console.log(yes); // Yes * window.console.log(no); // No * }) * .catch(); */ import Config from 'core/config'; // Keep track of whether the initial prefetch has occurred. let initialPrefetchComplete = false; // Prefetch templates. let templateList = []; // Prefetch strings. let stringList = {}; let prefetchTimer; /** * Fetch all queued items in the queue. * * Should only be called via processQueue. * @private */ const fetchQueue = () => { // Prefetch templates. if (templateList) { const templatesToLoad = templateList.slice(); templateList = []; import('core/templates') .then(Templates => Templates.prefetchTemplates(templatesToLoad)) .catch(); } // Prefetch strings. const mappedStringsToFetch = stringList; stringList = {}; const stringsToFetch = []; Object.keys(mappedStringsToFetch).forEach(component => { stringsToFetch.push(...mappedStringsToFetch[component].map(key => { return {component, key}; })); }); if (stringsToFetch) { import('core/str') .then(Str => Str.get_strings(stringsToFetch)) .catch(); } }; /** * Process the prefetch queues as required. * * The initial call will queue the first fetch after a delay. * Subsequent fetches are immediate. * * @private */ const processQueue = () => { if (prefetchTimer) { // There is a live prefetch timer. The initial prefetch has been scheduled but is not complete. return; } // The initial prefetch has compelted. Just queue as normal. if (initialPrefetchComplete) { fetchQueue(); return; } // Queue the initial prefetch in a short while. prefetchTimer = setTimeout(() => { initialPrefetchComplete = true; prefetchTimer = null; // Ensure that the icon system is loaded. // This can be quite slow and delay UI interactions if it is loaded on demand. import(Config.iconsystemmodule) .then(IconSystem => { const iconSystem = new IconSystem(); prefetchTemplate(iconSystem.getTemplateName()); return iconSystem; }) .then(iconSystem => { fetchQueue(); iconSystem.init(); return; }) .catch(); }, 500); }; /** * Add a set of templates to the prefetch queue. * * @param {Array} templatesNames A list of the template names to fetch * @static */ const prefetchTemplates = templatesNames => { templateList = templateList.concat(templatesNames); processQueue(); }; /** * Add a single template to the prefetch queue. * * @param {String} templateName The template names to fetch * @static */ const prefetchTemplate = templateName => { prefetchTemplates([templateName]); }; /** * Add a set of strings from the same component to the prefetch queue. * * @param {String} component The component that all of the strings belongs to * @param {String[]} keys An array of string identifiers. * @static */ const prefetchStrings = (component, keys) => { if (!stringList[component]) { stringList[component] = []; } stringList[component] = stringList[component].concat(keys); processQueue(); }; /** * Add a single string to the prefetch queue. * * @param {String} component The component that the string belongs to * @param {String} key The string identifier * @static */ const prefetchString = (component, key) => { if (!stringList[component]) { stringList[component] = []; } stringList[component].push(key); processQueue(); }; // Prefetch some commonly-used templates. prefetchTemplates([].concat( ['core/loading'], ['core/modal'], ['core/modal_backdrop'], )); // And some commonly used strings. prefetchStrings('core', [ 'cancel', 'closebuttontitle', 'loading', 'savechanges', ]); prefetchStrings('core_form', [ 'showless', 'showmore', ]); export default { prefetchTemplate, prefetchTemplates, prefetchString, prefetchStrings, }; modal_cancel.js 0000644 00000002765 15152050146 0007514 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Contain the logic for the cancel modal. * * @module core/modal_cancel * @copyright 2016 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Modal from 'core/modal'; import Notification from 'core/notification'; /** * @class * @extends module:core/modal */ export default class extends Modal { constructor(root) { super(root); if (!this.getFooter().find(this.getActionSelector('cancel')).length) { Notification.exception({message: 'No cancel button found'}); } } /** * Register all event listeners. */ registerEventListeners() { // Call the parent registration. super.registerEventListeners(); // Register to close on cancel. this.registerCloseOnCancel(); } } paged_content_factory.js 0000644 00000051670 15152050146 0011453 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Factory to create a paged content widget. * * @module core/paged_content_factory * @copyright 2018 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define( [ 'jquery', 'core/templates', 'core/notification', 'core/paged_content', 'core/paged_content_events', 'core/pubsub', 'core/ajax' ], function( $, Templates, Notification, PagedContent, PagedContentEvents, PubSub, Ajax ) { var TEMPLATES = { PAGED_CONTENT: 'core/paged_content' }; var DEFAULT = { ITEMS_PER_PAGE_SINGLE: 25, ITEMS_PER_PAGE_ARRAY: [25, 50, 100, 0], MAX_PAGES: 3 }; /** * Get the default context to render the paged content mustache * template. * * @return {object} */ var getDefaultTemplateContext = function() { return { pagingbar: false, pagingdropdown: false, skipjs: true, ignorecontrolwhileloading: true, controlplacementbottom: false }; }; /** * Get the default context to render the paging bar mustache template. * * @return {object} */ var getDefaultPagingBarTemplateContext = function() { return { showitemsperpageselector: false, itemsperpage: [{value: 35, active: true}], previous: true, next: true, activepagenumber: 1, hidecontrolonsinglepage: true, pages: [] }; }; /** * Calculate the number of pages required for the given number of items and * how many of each item should appear on a page. * * @param {Number} numberOfItems How many items in total. * @param {Number} itemsPerPage How many items will be shown per page. * @return {Number} The number of pages required. */ var calculateNumberOfPages = function(numberOfItems, itemsPerPage) { var numberOfPages = 1; if (numberOfItems > 0) { var partial = numberOfItems % itemsPerPage; if (partial) { numberOfItems -= partial; numberOfPages = (numberOfItems / itemsPerPage) + 1; } else { numberOfPages = numberOfItems / itemsPerPage; } } return numberOfPages; }; /** * Build the context for the paging bar template when we have a known number * of items. * * @param {Number} numberOfItems How many items in total. * @param {Number} itemsPerPage How many items will be shown per page. * @return {object} Mustache template */ var buildPagingBarTemplateContextKnownLength = function(numberOfItems, itemsPerPage) { if (itemsPerPage === null) { itemsPerPage = DEFAULT.ITEMS_PER_PAGE_SINGLE; } if ($.isArray(itemsPerPage)) { // If we're given a total number of pages then we don't support a variable // set of items per page so just use the first one. itemsPerPage = itemsPerPage[0]; } var context = getDefaultPagingBarTemplateContext(); context.itemsperpage = buildItemsPerPagePagingBarContext(itemsPerPage); var numberOfPages = calculateNumberOfPages(numberOfItems, itemsPerPage); for (var i = 1; i <= numberOfPages; i++) { var page = { number: i, page: "" + i, }; // Make the first page active by default. if (i === 1) { page.active = true; } context.pages.push(page); } context.barsize = 10; return context; }; /** * Convert the itemsPerPage value into a format applicable for the mustache template. * The given value can be either a single integer or an array of integers / objects. * * E.g. * In: [5, 10] * out: [{value: 5, active: true}, {value: 10, active: false}] * * In: [5, {value: 10, active: true}] * Out: [{value: 5, active: false}, {value: 10, active: true}] * * In: [{value: 5, active: false}, {value: 10, active: true}] * Out: [{value: 5, active: false}, {value: 10, active: true}] * * @param {int|int[]} itemsPerPage Options for number of items per page. * @return {int|array} */ var buildItemsPerPagePagingBarContext = function(itemsPerPage) { var context = []; if ($.isArray(itemsPerPage)) { // Convert the array into a format accepted by the template. context = itemsPerPage.map(function(num) { if (typeof num === 'number') { // If the item is just a plain number then convert it into // an object with value and active keys. return { value: num, active: false }; } else { // Otherwise we assume the caller has specified things correctly. return num; } }); var activeItems = context.filter(function(item) { return item.active; }); // Default the first item to active if one hasn't been specified. if (!activeItems.length) { context[0].active = true; } } else { // Convert the integer into a format accepted by the template. context = [{value: itemsPerPage, active: true}]; } return context; }; /** * Build the context for the paging bar template when we have an unknown * number of items. * * @param {Number} itemsPerPage How many items will be shown per page. * @return {object} Mustache template */ var buildPagingBarTemplateContextUnknownLength = function(itemsPerPage) { if (itemsPerPage === null) { itemsPerPage = DEFAULT.ITEMS_PER_PAGE_ARRAY; } var context = getDefaultPagingBarTemplateContext(); context.itemsperpage = buildItemsPerPagePagingBarContext(itemsPerPage); // Only display the items per page selector if there is more than one to choose from. context.showitemsperpageselector = $.isArray(itemsPerPage) && itemsPerPage.length > 1; return context; }; /** * Build the context to render the paging bar template with based on the number * of pages to show. * * @param {int|null} numberOfItems How many items are there total. * @param {int|null} itemsPerPage How many items will be shown per page. * @return {object} The template context. */ var buildPagingBarTemplateContext = function(numberOfItems, itemsPerPage) { if (numberOfItems) { return buildPagingBarTemplateContextKnownLength(numberOfItems, itemsPerPage); } else { return buildPagingBarTemplateContextUnknownLength(itemsPerPage); } }; /** * Build the context to render the paging dropdown template based on the number * of pages to show and items per page. * * This control is rendered with a gradual increase of the items per page to * limit the number of pages in the dropdown. Each page will show twice as much * as the previous page (except for the first two pages). * * By default there will only be 4 pages shown (including the "All" option) unless * a different number of pages is defined using the maxPages config value. * * For example: * Items per page = 25 * Would render a dropdown will 4 options: * 25 * 50 * 100 * All * * @param {Number} itemsPerPage How many items will be shown per page. * @param {object} config Configuration options provided by the client. * @return {object} The template context. */ var buildPagingDropdownTemplateContext = function(itemsPerPage, config) { if (itemsPerPage === null) { itemsPerPage = DEFAULT.ITEMS_PER_PAGE_SINGLE; } if ($.isArray(itemsPerPage)) { // If we're given an array for the items per page, rather than a number, // then just use that as the options for the dropdown. return { options: itemsPerPage }; } var context = { options: [] }; var totalItems = 0; var lastIncrease = 0; var maxPages = DEFAULT.MAX_PAGES; if (config.hasOwnProperty('maxPages')) { maxPages = config.maxPages; } for (var i = 1; i <= maxPages; i++) { var itemCount = 0; if (i <= 2) { itemCount = itemsPerPage; lastIncrease = itemsPerPage; } else { lastIncrease = lastIncrease * 2; itemCount = lastIncrease; } totalItems += itemCount; var option = { itemcount: itemCount, content: totalItems }; // Make the first option active by default. if (i === 1) { option.active = true; } context.options.push(option); } return context; }; /** * Build the context to render the paged content template with based on the number * of pages to show, items per page, and configuration option. * * By default the code will render a paging bar for the paging controls unless * otherwise specified in the provided config. * * @param {int|null} numberOfItems Total number of items. * @param {int|null|array} itemsPerPage How many items will be shown per page. * @param {object} config Configuration options provided by the client. * @return {object} The template context. */ var buildTemplateContext = function(numberOfItems, itemsPerPage, config) { var context = getDefaultTemplateContext(); if (config.hasOwnProperty('ignoreControlWhileLoading')) { context.ignorecontrolwhileloading = config.ignoreControlWhileLoading; } if (config.hasOwnProperty('controlPlacementBottom')) { context.controlplacementbottom = config.controlPlacementBottom; } if (config.hasOwnProperty('hideControlOnSinglePage')) { context.hidecontrolonsinglepage = config.hideControlOnSinglePage; } if (config.hasOwnProperty('ariaLabels')) { context.arialabels = config.ariaLabels; } if (config.hasOwnProperty('dropdown') && config.dropdown) { context.pagingdropdown = buildPagingDropdownTemplateContext(itemsPerPage, config); } else { context.pagingbar = buildPagingBarTemplateContext(numberOfItems, itemsPerPage); if (config.hasOwnProperty('showFirstLast') && config.showFirstLast) { context.pagingbar.first = true; context.pagingbar.last = true; } } return context; }; /** * Create a paged content widget where the complete list of items is not loaded * up front but will instead be loaded by an ajax request (or similar). * * The client code must provide a callback function which loads and renders the * items for each page. See PagedContent.init for more details. * * The function will return a deferred that is resolved with a jQuery object * for the HTML content and a string for the JavaScript. * * The current list of configuration options available are: * dropdown {bool} True to render the page control as a dropdown (paging bar is default). * maxPages {Number} The maximum number of pages to show in the dropdown (only works with dropdown option) * ignoreControlWhileLoading {bool} Disable the pagination controls while loading a page (default to true) * controlPlacementBottom {bool} Render controls under paged content (default to false) * * @param {function} renderPagesContentCallback Callback for loading and rendering the items. * @param {object} config Configuration options provided by the client. * @return {promise} Resolved with jQuery HTML and string JS. */ var create = function(renderPagesContentCallback, config) { return createWithTotalAndLimit(null, null, renderPagesContentCallback, config); }; /** * Create a paged content widget where the complete list of items is not loaded * up front but will instead be loaded by an ajax request (or similar). * * The client code must provide a callback function which loads and renders the * items for each page. See PagedContent.init for more details. * * The function will return a deferred that is resolved with a jQuery object * for the HTML content and a string for the JavaScript. * * The current list of configuration options available are: * dropdown {bool} True to render the page control as a dropdown (paging bar is default). * maxPages {Number} The maximum number of pages to show in the dropdown (only works with dropdown option) * ignoreControlWhileLoading {bool} Disable the pagination controls while loading a page (default to true) * controlPlacementBottom {bool} Render controls under paged content (default to false) * * @param {int|array|null} itemsPerPage How many items will be shown per page. * @param {function} renderPagesContentCallback Callback for loading and rendering the items. * @param {object} config Configuration options provided by the client. * @return {promise} Resolved with jQuery HTML and string JS. */ var createWithLimit = function(itemsPerPage, renderPagesContentCallback, config) { return createWithTotalAndLimit(null, itemsPerPage, renderPagesContentCallback, config); }; /** * Create a paged content widget where the complete list of items is not loaded * up front but will instead be loaded by an ajax request (or similar). * * The client code must provide a callback function which loads and renders the * items for each page. See PagedContent.init for more details. * * The function will return a deferred that is resolved with a jQuery object * for the HTML content and a string for the JavaScript. * * The current list of configuration options available are: * dropdown {bool} True to render the page control as a dropdown (paging bar is default). * maxPages {Number} The maximum number of pages to show in the dropdown (only works with dropdown option) * ignoreControlWhileLoading {bool} Disable the pagination controls while loading a page (default to true) * controlPlacementBottom {bool} Render controls under paged content (default to false) * * @param {int|null} numberOfItems How many items are there in total. * @param {int|array|null} itemsPerPage How many items will be shown per page. * @param {function} renderPagesContentCallback Callback for loading and rendering the items. * @param {object} config Configuration options provided by the client. * @return {promise} Resolved with jQuery HTML and string JS. */ var createWithTotalAndLimit = function(numberOfItems, itemsPerPage, renderPagesContentCallback, config) { config = config || {}; var deferred = $.Deferred(); var templateContext = buildTemplateContext(numberOfItems, itemsPerPage, config); Templates.render(TEMPLATES.PAGED_CONTENT, templateContext) .then(function(html, js) { html = $(html); var id = html.attr('id'); // Set the id to the custom namespace provided if (config.hasOwnProperty('eventNamespace')) { id = config.eventNamespace; } var container = html; PagedContent.init(container, renderPagesContentCallback, id); registerEvents(id, config); deferred.resolve(html, js); return; }) .fail(function(exception) { deferred.reject(exception); }) .fail(Notification.exception); return deferred.promise(); }; /** * Create a paged content widget where the complete list of items is loaded * up front. * * The client code must provide a callback function which renders the * items for each page. The callback will be provided with an array where each * value in the array is a the list of items to render for the page. * * The function will return a deferred that is resolved with a jQuery object * for the HTML content and a string for the JavaScript. * * The current list of configuration options available are: * dropdown {bool} True to render the page control as a dropdown (paging bar is default). * maxPages {Number} The maximum number of pages to show in the dropdown (only works with dropdown option) * ignoreControlWhileLoading {bool} Disable the pagination controls while loading a page (default to true) * controlPlacementBottom {bool} Render controls under paged content (default to false) * * @param {array} contentItems The list of items to paginate. * @param {Number} itemsPerPage How many items will be shown per page. * @param {function} renderContentCallback Callback for rendering the items for the page. * @param {object} config Configuration options provided by the client. * @return {promise} Resolved with jQuery HTML and string JS. */ var createFromStaticList = function(contentItems, itemsPerPage, renderContentCallback, config) { if (typeof config == 'undefined') { config = {}; } var numberOfItems = contentItems.length; return createWithTotalAndLimit(numberOfItems, itemsPerPage, function(pagesData) { var contentToRender = []; pagesData.forEach(function(pageData) { var begin = pageData.offset; var end = pageData.limit ? begin + pageData.limit : numberOfItems; var items = contentItems.slice(begin, end); contentToRender.push(items); }); return renderContentCallback(contentToRender); }, config); }; /** * Reset the last page number for the generated paged-content * This is used when we need a way to update the last page number outside of the getters callback * * @param {String} id ID of the paged content container * @param {Int} lastPageNumber The last page number */ var resetLastPageNumber = function(id, lastPageNumber) { PubSub.publish(id + PagedContentEvents.ALL_ITEMS_LOADED, lastPageNumber); }; /** * Generate the callback handler for the page limit persistence functionality * * @param {String} persistentLimitKey * @return {callback} */ var generateLimitHandler = function(persistentLimitKey) { var callback = function(limit) { var args = { preferences: [ { type: persistentLimitKey, value: limit } ] }; var request = { methodname: 'core_user_update_user_preferences', args: args }; Ajax.call([request]); }; return callback; }; /** * Set up any events based on config key values * * @param {string} namespace The namespace for this component * @param {object} config Config options passed to the factory */ var registerEvents = function(namespace, config) { if (config.hasOwnProperty('persistentLimitKey')) { PubSub.subscribe(namespace + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT, generateLimitHandler(config.persistentLimitKey)); } }; return { create: create, createWithLimit: createWithLimit, createWithTotalAndLimit: createWithTotalAndLimit, createFromStaticList: createFromStaticList, // Backwards compatibility just in case anyone was using this. createFromAjax: createWithTotalAndLimit, resetLastPageNumber: resetLastPageNumber }; }); drawer.js 0000644 00000006772 15152050146 0006401 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Controls the drawer. * * @module core/drawer * @copyright 2019 Jun Pataleta <jun@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import $ from 'jquery'; import * as PubSub from 'core/pubsub'; import * as Aria from 'core/aria'; import DrawerEvents from 'core/drawer_events'; /** * Show the drawer. * * @param {Object} root The drawer container. */ const show = root => { // Ensure that it is a jQuery. root = $(root); Aria.unhide(root.get()); root.removeClass('hidden'); root.attr('aria-expanded', true); root.focus(); PubSub.publish(DrawerEvents.DRAWER_SHOWN, root); }; /** * Hide the drawer. * * @param {Object} root The drawer container. */ const hide = root => { // Ensure that it is a jQuery. root = $(root); root.addClass('hidden'); root.attr('aria-expanded', false); Aria.hide(root.get()); PubSub.publish(DrawerEvents.DRAWER_HIDDEN, root); }; /** * Check if the drawer is visible. * * @param {Object} root The drawer container. * @return {boolean} */ const isVisible = (root) => { let isHidden = root.hasClass('hidden'); return !isHidden; }; /** * Toggle the drawer visibility. * * @param {Object} root The drawer container. */ const toggle = (root) => { if (isVisible(root)) { hide(root); } else { show(root); } }; /** * Add event listeners to toggle the drawer. * * @param {Object} root The drawer container. * @param {Object} toggleElements The toggle elements. */ const registerToggles = (root, toggleElements) => { let openTrigger = null; toggleElements.attr('aria-expanded', isVisible(root)); toggleElements.on('click', (e) => { e.preventDefault(); const wasVisible = isVisible(root); toggle(root); toggleElements.attr('aria-expanded', !wasVisible); if (!wasVisible) { // Remember which trigger element opened the drawer. openTrigger = toggleElements.filter((index, element) => { return element == e.target || element.contains(e.target); }); } else if (openTrigger) { // The drawer has gone from open to close so we need to set the focus back // to the element that openend it. openTrigger.focus(); openTrigger = null; } }); }; /** * Find the root element of the drawer based on the using the drawer content root's ID. * * @param {Object} contentRoot The drawer content's root element. * @returns {*|jQuery} */ const getDrawerRoot = (contentRoot) => { contentRoot = $(contentRoot); return contentRoot.closest('[data-region="right-hand-drawer"]'); }; export default { hide: hide, show: show, isVisible: isVisible, toggle: toggle, registerToggles: registerToggles, getDrawerRoot: getDrawerRoot }; inplace_editable.js 0000644 00000042374 15152050146 0010357 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * AJAX helper for the inline editing a value. * * This script is automatically included from template core/inplace_editable * It registers a click-listener on [data-inplaceeditablelink] link (the "inplace edit" icon), * then replaces the displayed value with an input field. On "Enter" it sends a request * to web service core_update_inplace_editable, which invokes the specified callback. * Any exception thrown by the web service (or callback) is displayed as an error popup. * * @module core/inplace_editable * @copyright 2016 Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.1 */ define( ['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str', 'core/config', 'core/url', 'core/form-autocomplete', 'core/pending', 'core/local/inplace_editable/events', ], function($, ajax, templates, notification, str, cfg, url, autocomplete, Pending, Events) { const removeSpinner = function(element) { element.removeClass('updating'); element.find('img.spinner').hide(); }; /** * Update an inplace editable value. * * @param {Jquery} mainelement the element to update * @param {string} value the new value * @param {bool} silent if true the change won't alter the current page focus * @fires event:core/inplace_editable:updated * @fires event:core/inplace_editable:updateFailed */ const updateValue = function(mainelement, value, silent) { var pendingId = [ mainelement.attr('data-itemid'), mainelement.attr('data-component'), mainelement.attr('data-itemtype'), ].join('-'); var pendingPromise = new Pending(pendingId); addSpinner(mainelement); ajax.call([{ methodname: 'core_update_inplace_editable', args: { itemid: mainelement.attr('data-itemid'), component: mainelement.attr('data-component'), itemtype: mainelement.attr('data-itemtype'), value: value, }, }])[0] .then(function(data) { return templates.render('core/inplace_editable', data) .then(function(html, js) { var oldvalue = mainelement.attr('data-value'); var newelement = $(html); templates.replaceNode(mainelement, newelement, js); if (!silent) { newelement.find('[data-inplaceeditablelink]').focus(); } // Trigger updated event on the DOM element. Events.notifyElementUpdated(newelement.get(0), data, oldvalue); return; }); }) .then(function() { return pendingPromise.resolve(); }) .fail(function(ex) { removeSpinner(mainelement); M.util.js_complete(pendingId); // Trigger update failed event on the DOM element. let updateFailedEvent = Events.notifyElementUpdateFailed(mainelement.get(0), ex, value); if (!updateFailedEvent.defaultPrevented) { notification.exception(ex); } }); }; const addSpinner = function(element) { element.addClass('updating'); var spinner = element.find('img.spinner'); if (spinner.length) { spinner.show(); } else { spinner = $('<img/>') .attr('src', url.imageUrl('i/loading_small')) .addClass('spinner').addClass('smallicon') ; element.append(spinner); } }; $('body').on('click keypress', '[data-inplaceeditable] [data-inplaceeditablelink]', function(e) { if (e.type === 'keypress' && e.keyCode !== 13) { return; } var editingEnabledPromise = new Pending('autocomplete-start-editing'); e.stopImmediatePropagation(); e.preventDefault(); var target = $(this), mainelement = target.closest('[data-inplaceeditable]'); var turnEditingOff = function(el) { el.find('input').off(); el.find('select').off(); el.html(el.attr('data-oldcontent')); el.removeAttr('data-oldcontent'); el.removeClass('inplaceeditingon'); el.find('[data-inplaceeditablelink]').focus(); // Re-enable any parent draggable attribute. el.parents(`[data-inplace-in-draggable="true"]`) .attr('draggable', true) .attr('data-inplace-in-draggable', false); }; var turnEditingOffEverywhere = function() { // Re-enable any disabled draggable attribute. $(`[data-inplace-in-draggable="true"]`) .attr('draggable', true) .attr('data-inplace-in-draggable', false); $('span.inplaceeditable.inplaceeditingon').each(function() { turnEditingOff($(this)); }); }; var uniqueId = function(prefix, idlength) { var uniqid = prefix, i; for (i = 0; i < idlength; i++) { uniqid += String(Math.floor(Math.random() * 10)); } // Make sure this ID is not already taken by an existing element. if ($("#" + uniqid).length === 0) { return uniqid; } return uniqueId(prefix, idlength); }; var turnEditingOnText = function(el) { str.get_string('edittitleinstructions').done(function(s) { var instr = $('<span class="editinstructions">' + s + '</span>'). attr('id', uniqueId('id_editinstructions_', 20)), inputelement = $('<input type="text"/>'). attr('id', uniqueId('id_inplacevalue_', 20)). attr('value', el.attr('data-value')). attr('aria-describedby', instr.attr('id')). addClass('ignoredirty'). addClass('form-control'), lbl = $('<label class="accesshide">' + mainelement.attr('data-editlabel') + '</label>'). attr('for', inputelement.attr('id')); el.html('').append(instr).append(lbl).append(inputelement); inputelement.focus(); inputelement.select(); inputelement.on('keyup keypress focusout', function(e) { if (cfg.behatsiterunning && e.type === 'focusout') { // Behat triggers focusout too often. return; } if (e.type === 'keypress' && e.keyCode === 13) { // We need 'keypress' event for Enter because keyup/keydown would catch Enter that was // pressed in other fields. var val = inputelement.val(); turnEditingOff(el); updateValue(el, val); } if ((e.type === 'keyup' && e.keyCode === 27) || e.type === 'focusout') { // We need 'keyup' event for Escape because keypress does not work with Escape. turnEditingOff(el); } }); }); }; var turnEditingOnToggle = function(el, newvalue) { turnEditingOff(el); updateValue(el, newvalue); }; var turnEditingOnSelect = function(el, options) { var i, inputelement = $('<select></select>'). attr('id', uniqueId('id_inplacevalue_', 20)). addClass('custom-select'), lbl = $('<label class="accesshide">' + mainelement.attr('data-editlabel') + '</label>') .attr('for', inputelement.attr('id')); for (i in options) { inputelement .append($('<option>') .attr('value', options[i].key) .html(options[i].value)); } inputelement.val(el.attr('data-value')); el.html('') .append(lbl) .append(inputelement); inputelement.focus(); inputelement.select(); inputelement.on('keyup change focusout', function(e) { if (cfg.behatsiterunning && e.type === 'focusout') { // Behat triggers focusout too often. return; } if (e.type === 'change') { var val = inputelement.val(); turnEditingOff(el); updateValue(el, val); } if ((e.type === 'keyup' && e.keyCode === 27) || e.type === 'focusout') { // We need 'keyup' event for Escape because keypress does not work with Escape. turnEditingOff(el); } }); }; var turnEditingOnAutocomplete = function(el, args) { var i, inputelement = $('<select></select>'). attr('id', uniqueId('id_inplacevalue_', 20)). addClass('form-autocomplete-original-select'). addClass('custom-select'), lbl = $('<label class="accesshide">' + mainelement.attr('data-editlabel') + '</label>') .attr('for', inputelement.attr('id')), options = args.options, attributes = args.attributes, saveelement = $('<a href="#"></a>'), cancelelement = $('<a href="#"></a>'); for (i in options) { inputelement .append($('<option>') .attr('value', options[i].key) .html(options[i].value)); } if (attributes.multiple) { inputelement.attr('multiple', 'true'); } inputelement.val(JSON.parse(el.attr('data-value'))); str.get_string('savechanges', 'core').then(function(s) { return templates.renderPix('e/save', 'core', s); }).then(function(html) { saveelement.append(html); return; }).fail(notification.exception); str.get_string('cancel', 'core').then(function(s) { return templates.renderPix('e/cancel', 'core', s); }).then(function(html) { cancelelement.append(html); return; }).fail(notification.exception); el.html('') .append(lbl) .append(inputelement) .append(saveelement) .append(cancelelement); inputelement.focus(); inputelement.select(); autocomplete.enhance(inputelement, attributes.tags, attributes.ajax, attributes.placeholder, attributes.caseSensitive, attributes.showSuggestions, attributes.noSelectionString) .then(function() { // Focus on the enhanced combobox. el.find('[role=combobox]').focus(); // Stop eslint nagging. return; }).fail(notification.exception); inputelement.on('keyup', function(e) { if ((e.type === 'keyup' && e.keyCode === 27) || e.type === 'focusout') { // We need 'keyup' event for Escape because keypress does not work with Escape. turnEditingOff(el); } }); saveelement.on('click', function(e) { var val = JSON.stringify(inputelement.val()); // We need to empty the node to destroy all event handlers etc. inputelement.empty(); turnEditingOff(el); updateValue(el, val); e.preventDefault(); }); cancelelement.on('click', function(e) { // We need to empty the node to destroy all event handlers etc. inputelement.empty(); turnEditingOff(el); e.preventDefault(); }); }; var turnEditingOn = function(el) { el.addClass('inplaceeditingon'); el.attr('data-oldcontent', el.html()); var type = el.attr('data-type'); var options = el.attr('data-options'); // Input text inside draggable elements disable text selection in some browsers. // To prevent this we temporally disable any parent draggables. el.parents('[draggable="true"]') .attr('data-inplace-in-draggable', true) .attr('draggable', false); if (type === 'toggle') { turnEditingOnToggle(el, options); } else if (type === 'select') { turnEditingOnSelect(el, $.parseJSON(options)); } else if (type === 'autocomplete') { turnEditingOnAutocomplete(el, $.parseJSON(options)); } else { turnEditingOnText(el); } }; // Turn editing on for the current element and register handler for Enter/Esc keys. turnEditingOffEverywhere(); turnEditingOn(mainelement); editingEnabledPromise.resolve(); }); return { /** * Return an object to interact with the current inplace editables at a frontend level. * * @param {Element} parent the parent element containing a inplace editable * @returns {Object|undefined} an object to interact with the inplace element, or undefined * if no inplace editable is found. */ getInplaceEditable: function(parent) { const element = parent.querySelector(`[data-inplaceeditable]`); if (!element) { return undefined; } // Return an object to interact with the inplace editable. return { element, /** * Get the value from the inplace editable. * * @returns {string} the current inplace value */ getValue: function() { return this.element.dataset.value; }, /** * Force a value change. * * @param {string} newvalue the new value * @fires event:core/inplace_editable:updated * @fires event:core/inplace_editable:updateFailed */ setValue: function(newvalue) { updateValue($(this.element), newvalue, true); }, /** * Return the inplace editable itemid. * * @returns {string} the current itemid */ getItemId: function() { return this.element.dataset.itemid; }, }; } }; }); chartjs-lazy.js 0000644 00001447645 15152050146 0007541 0 ustar 00 /*! * Chart.js v3.8.0 * https://www.chartjs.org * (c) 2022 Chart.js Contributors * Released under the MIT License */ /** * Description of import into Moodle: * * - Download Chartjs source code (zip) file from https://github.com/chartjs/Chart.js/releases/latest. * - You must build Chart.js to generate the dist files (https://www.chartjs.org/docs/latest/developers/contributing.html#building-and-testing). * - Copy /dist/chart.js content to lib/amd/src/chartjs-lazy.js. * - Convert line endings to LF-Unix format. * - Keep these instructions to the file. * - Visit lib/tests/other/chartjstestpage.php to see if the library still works after the update. */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Chart = factory()); })(this, (function () { 'use strict'; function fontString(pixelSize, fontStyle, fontFamily) { return fontStyle + ' ' + pixelSize + 'px ' + fontFamily; } const requestAnimFrame = (function() { if (typeof window === 'undefined') { return function(callback) { return callback(); }; } return window.requestAnimationFrame; }()); function throttled(fn, thisArg, updateFn) { const updateArgs = updateFn || ((args) => Array.prototype.slice.call(args)); let ticking = false; let args = []; return function(...rest) { args = updateArgs(rest); if (!ticking) { ticking = true; requestAnimFrame.call(window, () => { ticking = false; fn.apply(thisArg, args); }); } }; } function debounce(fn, delay) { let timeout; return function(...args) { if (delay) { clearTimeout(timeout); timeout = setTimeout(fn, delay, args); } else { fn.apply(this, args); } return delay; }; } const _toLeftRightCenter = (align) => align === 'start' ? 'left' : align === 'end' ? 'right' : 'center'; const _alignStartEnd = (align, start, end) => align === 'start' ? start : align === 'end' ? end : (start + end) / 2; const _textX = (align, left, right, rtl) => { const check = rtl ? 'left' : 'right'; return align === check ? right : align === 'center' ? (left + right) / 2 : left; }; class Animator { constructor() { this._request = null; this._charts = new Map(); this._running = false; this._lastDate = undefined; } _notify(chart, anims, date, type) { const callbacks = anims.listeners[type]; const numSteps = anims.duration; callbacks.forEach(fn => fn({ chart, initial: anims.initial, numSteps, currentStep: Math.min(date - anims.start, numSteps) })); } _refresh() { if (this._request) { return; } this._running = true; this._request = requestAnimFrame.call(window, () => { this._update(); this._request = null; if (this._running) { this._refresh(); } }); } _update(date = Date.now()) { let remaining = 0; this._charts.forEach((anims, chart) => { if (!anims.running || !anims.items.length) { return; } const items = anims.items; let i = items.length - 1; let draw = false; let item; for (; i >= 0; --i) { item = items[i]; if (item._active) { if (item._total > anims.duration) { anims.duration = item._total; } item.tick(date); draw = true; } else { items[i] = items[items.length - 1]; items.pop(); } } if (draw) { chart.draw(); this._notify(chart, anims, date, 'progress'); } if (!items.length) { anims.running = false; this._notify(chart, anims, date, 'complete'); anims.initial = false; } remaining += items.length; }); this._lastDate = date; if (remaining === 0) { this._running = false; } } _getAnims(chart) { const charts = this._charts; let anims = charts.get(chart); if (!anims) { anims = { running: false, initial: true, items: [], listeners: { complete: [], progress: [] } }; charts.set(chart, anims); } return anims; } listen(chart, event, cb) { this._getAnims(chart).listeners[event].push(cb); } add(chart, items) { if (!items || !items.length) { return; } this._getAnims(chart).items.push(...items); } has(chart) { return this._getAnims(chart).items.length > 0; } start(chart) { const anims = this._charts.get(chart); if (!anims) { return; } anims.running = true; anims.start = Date.now(); anims.duration = anims.items.reduce((acc, cur) => Math.max(acc, cur._duration), 0); this._refresh(); } running(chart) { if (!this._running) { return false; } const anims = this._charts.get(chart); if (!anims || !anims.running || !anims.items.length) { return false; } return true; } stop(chart) { const anims = this._charts.get(chart); if (!anims || !anims.items.length) { return; } const items = anims.items; let i = items.length - 1; for (; i >= 0; --i) { items[i].cancel(); } anims.items = []; this._notify(chart, anims, Date.now(), 'complete'); } remove(chart) { return this._charts.delete(chart); } } var animator = new Animator(); /*! * @kurkle/color v0.2.1 * https://github.com/kurkle/color#readme * (c) 2022 Jukka Kurkela * Released under the MIT License */ function round(v) { return v + 0.5 | 0; } const lim = (v, l, h) => Math.max(Math.min(v, h), l); function p2b(v) { return lim(round(v * 2.55), 0, 255); } function n2b(v) { return lim(round(v * 255), 0, 255); } function b2n(v) { return lim(round(v / 2.55) / 100, 0, 1); } function n2p(v) { return lim(round(v * 100), 0, 100); } const map$1 = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, A: 10, B: 11, C: 12, D: 13, E: 14, F: 15, a: 10, b: 11, c: 12, d: 13, e: 14, f: 15}; const hex = [...'0123456789ABCDEF']; const h1 = b => hex[b & 0xF]; const h2 = b => hex[(b & 0xF0) >> 4] + hex[b & 0xF]; const eq = b => ((b & 0xF0) >> 4) === (b & 0xF); const isShort = v => eq(v.r) && eq(v.g) && eq(v.b) && eq(v.a); function hexParse(str) { var len = str.length; var ret; if (str[0] === '#') { if (len === 4 || len === 5) { ret = { r: 255 & map$1[str[1]] * 17, g: 255 & map$1[str[2]] * 17, b: 255 & map$1[str[3]] * 17, a: len === 5 ? map$1[str[4]] * 17 : 255 }; } else if (len === 7 || len === 9) { ret = { r: map$1[str[1]] << 4 | map$1[str[2]], g: map$1[str[3]] << 4 | map$1[str[4]], b: map$1[str[5]] << 4 | map$1[str[6]], a: len === 9 ? (map$1[str[7]] << 4 | map$1[str[8]]) : 255 }; } } return ret; } const alpha = (a, f) => a < 255 ? f(a) : ''; function hexString(v) { var f = isShort(v) ? h1 : h2; return v ? '#' + f(v.r) + f(v.g) + f(v.b) + alpha(v.a, f) : undefined; } const HUE_RE = /^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/; function hsl2rgbn(h, s, l) { const a = s * Math.min(l, 1 - l); const f = (n, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); return [f(0), f(8), f(4)]; } function hsv2rgbn(h, s, v) { const f = (n, k = (n + h / 60) % 6) => v - v * s * Math.max(Math.min(k, 4 - k, 1), 0); return [f(5), f(3), f(1)]; } function hwb2rgbn(h, w, b) { const rgb = hsl2rgbn(h, 1, 0.5); let i; if (w + b > 1) { i = 1 / (w + b); w *= i; b *= i; } for (i = 0; i < 3; i++) { rgb[i] *= 1 - w - b; rgb[i] += w; } return rgb; } function hueValue(r, g, b, d, max) { if (r === max) { return ((g - b) / d) + (g < b ? 6 : 0); } if (g === max) { return (b - r) / d + 2; } return (r - g) / d + 4; } function rgb2hsl(v) { const range = 255; const r = v.r / range; const g = v.g / range; const b = v.b / range; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const l = (max + min) / 2; let h, s, d; if (max !== min) { d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); h = hueValue(r, g, b, d, max); h = h * 60 + 0.5; } return [h | 0, s || 0, l]; } function calln(f, a, b, c) { return ( Array.isArray(a) ? f(a[0], a[1], a[2]) : f(a, b, c) ).map(n2b); } function hsl2rgb(h, s, l) { return calln(hsl2rgbn, h, s, l); } function hwb2rgb(h, w, b) { return calln(hwb2rgbn, h, w, b); } function hsv2rgb(h, s, v) { return calln(hsv2rgbn, h, s, v); } function hue(h) { return (h % 360 + 360) % 360; } function hueParse(str) { const m = HUE_RE.exec(str); let a = 255; let v; if (!m) { return; } if (m[5] !== v) { a = m[6] ? p2b(+m[5]) : n2b(+m[5]); } const h = hue(+m[2]); const p1 = +m[3] / 100; const p2 = +m[4] / 100; if (m[1] === 'hwb') { v = hwb2rgb(h, p1, p2); } else if (m[1] === 'hsv') { v = hsv2rgb(h, p1, p2); } else { v = hsl2rgb(h, p1, p2); } return { r: v[0], g: v[1], b: v[2], a: a }; } function rotate(v, deg) { var h = rgb2hsl(v); h[0] = hue(h[0] + deg); h = hsl2rgb(h); v.r = h[0]; v.g = h[1]; v.b = h[2]; } function hslString(v) { if (!v) { return; } const a = rgb2hsl(v); const h = a[0]; const s = n2p(a[1]); const l = n2p(a[2]); return v.a < 255 ? `hsla(${h}, ${s}%, ${l}%, ${b2n(v.a)})` : `hsl(${h}, ${s}%, ${l}%)`; } const map$2 = { x: 'dark', Z: 'light', Y: 're', X: 'blu', W: 'gr', V: 'medium', U: 'slate', A: 'ee', T: 'ol', S: 'or', B: 'ra', C: 'lateg', D: 'ights', R: 'in', Q: 'turquois', E: 'hi', P: 'ro', O: 'al', N: 'le', M: 'de', L: 'yello', F: 'en', K: 'ch', G: 'arks', H: 'ea', I: 'ightg', J: 'wh' }; const names$1 = { OiceXe: 'f0f8ff', antiquewEte: 'faebd7', aqua: 'ffff', aquamarRe: '7fffd4', azuY: 'f0ffff', beige: 'f5f5dc', bisque: 'ffe4c4', black: '0', blanKedOmond: 'ffebcd', Xe: 'ff', XeviTet: '8a2be2', bPwn: 'a52a2a', burlywood: 'deb887', caMtXe: '5f9ea0', KartYuse: '7fff00', KocTate: 'd2691e', cSO: 'ff7f50', cSnflowerXe: '6495ed', cSnsilk: 'fff8dc', crimson: 'dc143c', cyan: 'ffff', xXe: '8b', xcyan: '8b8b', xgTMnPd: 'b8860b', xWay: 'a9a9a9', xgYF: '6400', xgYy: 'a9a9a9', xkhaki: 'bdb76b', xmagFta: '8b008b', xTivegYF: '556b2f', xSange: 'ff8c00', xScEd: '9932cc', xYd: '8b0000', xsOmon: 'e9967a', xsHgYF: '8fbc8f', xUXe: '483d8b', xUWay: '2f4f4f', xUgYy: '2f4f4f', xQe: 'ced1', xviTet: '9400d3', dAppRk: 'ff1493', dApskyXe: 'bfff', dimWay: '696969', dimgYy: '696969', dodgerXe: '1e90ff', fiYbrick: 'b22222', flSOwEte: 'fffaf0', foYstWAn: '228b22', fuKsia: 'ff00ff', gaRsbSo: 'dcdcdc', ghostwEte: 'f8f8ff', gTd: 'ffd700', gTMnPd: 'daa520', Way: '808080', gYF: '8000', gYFLw: 'adff2f', gYy: '808080', honeyMw: 'f0fff0', hotpRk: 'ff69b4', RdianYd: 'cd5c5c', Rdigo: '4b0082', ivSy: 'fffff0', khaki: 'f0e68c', lavFMr: 'e6e6fa', lavFMrXsh: 'fff0f5', lawngYF: '7cfc00', NmoncEffon: 'fffacd', ZXe: 'add8e6', ZcSO: 'f08080', Zcyan: 'e0ffff', ZgTMnPdLw: 'fafad2', ZWay: 'd3d3d3', ZgYF: '90ee90', ZgYy: 'd3d3d3', ZpRk: 'ffb6c1', ZsOmon: 'ffa07a', ZsHgYF: '20b2aa', ZskyXe: '87cefa', ZUWay: '778899', ZUgYy: '778899', ZstAlXe: 'b0c4de', ZLw: 'ffffe0', lime: 'ff00', limegYF: '32cd32', lRF: 'faf0e6', magFta: 'ff00ff', maPon: '800000', VaquamarRe: '66cdaa', VXe: 'cd', VScEd: 'ba55d3', VpurpN: '9370db', VsHgYF: '3cb371', VUXe: '7b68ee', VsprRggYF: 'fa9a', VQe: '48d1cc', VviTetYd: 'c71585', midnightXe: '191970', mRtcYam: 'f5fffa', mistyPse: 'ffe4e1', moccasR: 'ffe4b5', navajowEte: 'ffdead', navy: '80', Tdlace: 'fdf5e6', Tive: '808000', TivedBb: '6b8e23', Sange: 'ffa500', SangeYd: 'ff4500', ScEd: 'da70d6', pOegTMnPd: 'eee8aa', pOegYF: '98fb98', pOeQe: 'afeeee', pOeviTetYd: 'db7093', papayawEp: 'ffefd5', pHKpuff: 'ffdab9', peru: 'cd853f', pRk: 'ffc0cb', plum: 'dda0dd', powMrXe: 'b0e0e6', purpN: '800080', YbeccapurpN: '663399', Yd: 'ff0000', Psybrown: 'bc8f8f', PyOXe: '4169e1', saddNbPwn: '8b4513', sOmon: 'fa8072', sandybPwn: 'f4a460', sHgYF: '2e8b57', sHshell: 'fff5ee', siFna: 'a0522d', silver: 'c0c0c0', skyXe: '87ceeb', UXe: '6a5acd', UWay: '708090', UgYy: '708090', snow: 'fffafa', sprRggYF: 'ff7f', stAlXe: '4682b4', tan: 'd2b48c', teO: '8080', tEstN: 'd8bfd8', tomato: 'ff6347', Qe: '40e0d0', viTet: 'ee82ee', JHt: 'f5deb3', wEte: 'ffffff', wEtesmoke: 'f5f5f5', Lw: 'ffff00', LwgYF: '9acd32' }; function unpack() { const unpacked = {}; const keys = Object.keys(names$1); const tkeys = Object.keys(map$2); let i, j, k, ok, nk; for (i = 0; i < keys.length; i++) { ok = nk = keys[i]; for (j = 0; j < tkeys.length; j++) { k = tkeys[j]; nk = nk.replace(k, map$2[k]); } k = parseInt(names$1[ok], 16); unpacked[nk] = [k >> 16 & 0xFF, k >> 8 & 0xFF, k & 0xFF]; } return unpacked; } let names; function nameParse(str) { if (!names) { names = unpack(); names.transparent = [0, 0, 0, 0]; } const a = names[str.toLowerCase()]; return a && { r: a[0], g: a[1], b: a[2], a: a.length === 4 ? a[3] : 255 }; } const RGB_RE = /^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/; function rgbParse(str) { const m = RGB_RE.exec(str); let a = 255; let r, g, b; if (!m) { return; } if (m[7] !== r) { const v = +m[7]; a = m[8] ? p2b(v) : lim(v * 255, 0, 255); } r = +m[1]; g = +m[3]; b = +m[5]; r = 255 & (m[2] ? p2b(r) : lim(r, 0, 255)); g = 255 & (m[4] ? p2b(g) : lim(g, 0, 255)); b = 255 & (m[6] ? p2b(b) : lim(b, 0, 255)); return { r: r, g: g, b: b, a: a }; } function rgbString(v) { return v && ( v.a < 255 ? `rgba(${v.r}, ${v.g}, ${v.b}, ${b2n(v.a)})` : `rgb(${v.r}, ${v.g}, ${v.b})` ); } const to = v => v <= 0.0031308 ? v * 12.92 : Math.pow(v, 1.0 / 2.4) * 1.055 - 0.055; const from = v => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); function interpolate$1(rgb1, rgb2, t) { const r = from(b2n(rgb1.r)); const g = from(b2n(rgb1.g)); const b = from(b2n(rgb1.b)); return { r: n2b(to(r + t * (from(b2n(rgb2.r)) - r))), g: n2b(to(g + t * (from(b2n(rgb2.g)) - g))), b: n2b(to(b + t * (from(b2n(rgb2.b)) - b))), a: rgb1.a + t * (rgb2.a - rgb1.a) }; } function modHSL(v, i, ratio) { if (v) { let tmp = rgb2hsl(v); tmp[i] = Math.max(0, Math.min(tmp[i] + tmp[i] * ratio, i === 0 ? 360 : 1)); tmp = hsl2rgb(tmp); v.r = tmp[0]; v.g = tmp[1]; v.b = tmp[2]; } } function clone$1(v, proto) { return v ? Object.assign(proto || {}, v) : v; } function fromObject(input) { var v = {r: 0, g: 0, b: 0, a: 255}; if (Array.isArray(input)) { if (input.length >= 3) { v = {r: input[0], g: input[1], b: input[2], a: 255}; if (input.length > 3) { v.a = n2b(input[3]); } } } else { v = clone$1(input, {r: 0, g: 0, b: 0, a: 1}); v.a = n2b(v.a); } return v; } function functionParse(str) { if (str.charAt(0) === 'r') { return rgbParse(str); } return hueParse(str); } class Color { constructor(input) { if (input instanceof Color) { return input; } const type = typeof input; let v; if (type === 'object') { v = fromObject(input); } else if (type === 'string') { v = hexParse(input) || nameParse(input) || functionParse(input); } this._rgb = v; this._valid = !!v; } get valid() { return this._valid; } get rgb() { var v = clone$1(this._rgb); if (v) { v.a = b2n(v.a); } return v; } set rgb(obj) { this._rgb = fromObject(obj); } rgbString() { return this._valid ? rgbString(this._rgb) : undefined; } hexString() { return this._valid ? hexString(this._rgb) : undefined; } hslString() { return this._valid ? hslString(this._rgb) : undefined; } mix(color, weight) { if (color) { const c1 = this.rgb; const c2 = color.rgb; let w2; const p = weight === w2 ? 0.5 : weight; const w = 2 * p - 1; const a = c1.a - c2.a; const w1 = ((w * a === -1 ? w : (w + a) / (1 + w * a)) + 1) / 2.0; w2 = 1 - w1; c1.r = 0xFF & w1 * c1.r + w2 * c2.r + 0.5; c1.g = 0xFF & w1 * c1.g + w2 * c2.g + 0.5; c1.b = 0xFF & w1 * c1.b + w2 * c2.b + 0.5; c1.a = p * c1.a + (1 - p) * c2.a; this.rgb = c1; } return this; } interpolate(color, t) { if (color) { this._rgb = interpolate$1(this._rgb, color._rgb, t); } return this; } clone() { return new Color(this.rgb); } alpha(a) { this._rgb.a = n2b(a); return this; } clearer(ratio) { const rgb = this._rgb; rgb.a *= 1 - ratio; return this; } greyscale() { const rgb = this._rgb; const val = round(rgb.r * 0.3 + rgb.g * 0.59 + rgb.b * 0.11); rgb.r = rgb.g = rgb.b = val; return this; } opaquer(ratio) { const rgb = this._rgb; rgb.a *= 1 + ratio; return this; } negate() { const v = this._rgb; v.r = 255 - v.r; v.g = 255 - v.g; v.b = 255 - v.b; return this; } lighten(ratio) { modHSL(this._rgb, 2, ratio); return this; } darken(ratio) { modHSL(this._rgb, 2, -ratio); return this; } saturate(ratio) { modHSL(this._rgb, 1, ratio); return this; } desaturate(ratio) { modHSL(this._rgb, 1, -ratio); return this; } rotate(deg) { rotate(this._rgb, deg); return this; } } function index_esm(input) { return new Color(input); } function isPatternOrGradient(value) { if (value && typeof value === 'object') { const type = value.toString(); return type === '[object CanvasPattern]' || type === '[object CanvasGradient]'; } return false; } function color(value) { return isPatternOrGradient(value) ? value : index_esm(value); } function getHoverColor(value) { return isPatternOrGradient(value) ? value : index_esm(value).saturate(0.5).darken(0.1).hexString(); } function noop() {} const uid = (function() { let id = 0; return function() { return id++; }; }()); function isNullOrUndef(value) { return value === null || typeof value === 'undefined'; } function isArray(value) { if (Array.isArray && Array.isArray(value)) { return true; } const type = Object.prototype.toString.call(value); if (type.slice(0, 7) === '[object' && type.slice(-6) === 'Array]') { return true; } return false; } function isObject(value) { return value !== null && Object.prototype.toString.call(value) === '[object Object]'; } const isNumberFinite = (value) => (typeof value === 'number' || value instanceof Number) && isFinite(+value); function finiteOrDefault(value, defaultValue) { return isNumberFinite(value) ? value : defaultValue; } function valueOrDefault(value, defaultValue) { return typeof value === 'undefined' ? defaultValue : value; } const toPercentage = (value, dimension) => typeof value === 'string' && value.endsWith('%') ? parseFloat(value) / 100 : value / dimension; const toDimension = (value, dimension) => typeof value === 'string' && value.endsWith('%') ? parseFloat(value) / 100 * dimension : +value; function callback(fn, args, thisArg) { if (fn && typeof fn.call === 'function') { return fn.apply(thisArg, args); } } function each(loopable, fn, thisArg, reverse) { let i, len, keys; if (isArray(loopable)) { len = loopable.length; if (reverse) { for (i = len - 1; i >= 0; i--) { fn.call(thisArg, loopable[i], i); } } else { for (i = 0; i < len; i++) { fn.call(thisArg, loopable[i], i); } } } else if (isObject(loopable)) { keys = Object.keys(loopable); len = keys.length; for (i = 0; i < len; i++) { fn.call(thisArg, loopable[keys[i]], keys[i]); } } } function _elementsEqual(a0, a1) { let i, ilen, v0, v1; if (!a0 || !a1 || a0.length !== a1.length) { return false; } for (i = 0, ilen = a0.length; i < ilen; ++i) { v0 = a0[i]; v1 = a1[i]; if (v0.datasetIndex !== v1.datasetIndex || v0.index !== v1.index) { return false; } } return true; } function clone(source) { if (isArray(source)) { return source.map(clone); } if (isObject(source)) { const target = Object.create(null); const keys = Object.keys(source); const klen = keys.length; let k = 0; for (; k < klen; ++k) { target[keys[k]] = clone(source[keys[k]]); } return target; } return source; } function isValidKey(key) { return ['__proto__', 'prototype', 'constructor'].indexOf(key) === -1; } function _merger(key, target, source, options) { if (!isValidKey(key)) { return; } const tval = target[key]; const sval = source[key]; if (isObject(tval) && isObject(sval)) { merge(tval, sval, options); } else { target[key] = clone(sval); } } function merge(target, source, options) { const sources = isArray(source) ? source : [source]; const ilen = sources.length; if (!isObject(target)) { return target; } options = options || {}; const merger = options.merger || _merger; for (let i = 0; i < ilen; ++i) { source = sources[i]; if (!isObject(source)) { continue; } const keys = Object.keys(source); for (let k = 0, klen = keys.length; k < klen; ++k) { merger(keys[k], target, source, options); } } return target; } function mergeIf(target, source) { return merge(target, source, {merger: _mergerIf}); } function _mergerIf(key, target, source) { if (!isValidKey(key)) { return; } const tval = target[key]; const sval = source[key]; if (isObject(tval) && isObject(sval)) { mergeIf(tval, sval); } else if (!Object.prototype.hasOwnProperty.call(target, key)) { target[key] = clone(sval); } } function _deprecated(scope, value, previous, current) { if (value !== undefined) { console.warn(scope + ': "' + previous + '" is deprecated. Please use "' + current + '" instead'); } } const emptyString = ''; const dot = '.'; function indexOfDotOrLength(key, start) { const idx = key.indexOf(dot, start); return idx === -1 ? key.length : idx; } function resolveObjectKey(obj, key) { if (key === emptyString) { return obj; } let pos = 0; let idx = indexOfDotOrLength(key, pos); while (obj && idx > pos) { obj = obj[key.slice(pos, idx)]; pos = idx + 1; idx = indexOfDotOrLength(key, pos); } return obj; } function _capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); } const defined = (value) => typeof value !== 'undefined'; const isFunction = (value) => typeof value === 'function'; const setsEqual = (a, b) => { if (a.size !== b.size) { return false; } for (const item of a) { if (!b.has(item)) { return false; } } return true; }; function _isClickEvent(e) { return e.type === 'mouseup' || e.type === 'click' || e.type === 'contextmenu'; } const overrides = Object.create(null); const descriptors = Object.create(null); function getScope$1(node, key) { if (!key) { return node; } const keys = key.split('.'); for (let i = 0, n = keys.length; i < n; ++i) { const k = keys[i]; node = node[k] || (node[k] = Object.create(null)); } return node; } function set(root, scope, values) { if (typeof scope === 'string') { return merge(getScope$1(root, scope), values); } return merge(getScope$1(root, ''), scope); } class Defaults { constructor(_descriptors) { this.animation = undefined; this.backgroundColor = 'rgba(0,0,0,0.1)'; this.borderColor = 'rgba(0,0,0,0.1)'; this.color = '#666'; this.datasets = {}; this.devicePixelRatio = (context) => context.chart.platform.getDevicePixelRatio(); this.elements = {}; this.events = [ 'mousemove', 'mouseout', 'click', 'touchstart', 'touchmove' ]; this.font = { family: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif", size: 12, style: 'normal', lineHeight: 1.2, weight: null }; this.hover = {}; this.hoverBackgroundColor = (ctx, options) => getHoverColor(options.backgroundColor); this.hoverBorderColor = (ctx, options) => getHoverColor(options.borderColor); this.hoverColor = (ctx, options) => getHoverColor(options.color); this.indexAxis = 'x'; this.interaction = { mode: 'nearest', intersect: true, includeInvisible: false }; this.maintainAspectRatio = true; this.onHover = null; this.onClick = null; this.parsing = true; this.plugins = {}; this.responsive = true; this.scale = undefined; this.scales = {}; this.showLine = true; this.drawActiveElementsOnTop = true; this.describe(_descriptors); } set(scope, values) { return set(this, scope, values); } get(scope) { return getScope$1(this, scope); } describe(scope, values) { return set(descriptors, scope, values); } override(scope, values) { return set(overrides, scope, values); } route(scope, name, targetScope, targetName) { const scopeObject = getScope$1(this, scope); const targetScopeObject = getScope$1(this, targetScope); const privateName = '_' + name; Object.defineProperties(scopeObject, { [privateName]: { value: scopeObject[name], writable: true }, [name]: { enumerable: true, get() { const local = this[privateName]; const target = targetScopeObject[targetName]; if (isObject(local)) { return Object.assign({}, target, local); } return valueOrDefault(local, target); }, set(value) { this[privateName] = value; } } }); } } var defaults = new Defaults({ _scriptable: (name) => !name.startsWith('on'), _indexable: (name) => name !== 'events', hover: { _fallback: 'interaction' }, interaction: { _scriptable: false, _indexable: false, } }); function _lookup(table, value, cmp) { cmp = cmp || ((index) => table[index] < value); let hi = table.length - 1; let lo = 0; let mid; while (hi - lo > 1) { mid = (lo + hi) >> 1; if (cmp(mid)) { lo = mid; } else { hi = mid; } } return {lo, hi}; } const _lookupByKey = (table, key, value) => _lookup(table, value, index => table[index][key] < value); const _rlookupByKey = (table, key, value) => _lookup(table, value, index => table[index][key] >= value); function _filterBetween(values, min, max) { let start = 0; let end = values.length; while (start < end && values[start] < min) { start++; } while (end > start && values[end - 1] > max) { end--; } return start > 0 || end < values.length ? values.slice(start, end) : values; } const arrayEvents = ['push', 'pop', 'shift', 'splice', 'unshift']; function listenArrayEvents(array, listener) { if (array._chartjs) { array._chartjs.listeners.push(listener); return; } Object.defineProperty(array, '_chartjs', { configurable: true, enumerable: false, value: { listeners: [listener] } }); arrayEvents.forEach((key) => { const method = '_onData' + _capitalize(key); const base = array[key]; Object.defineProperty(array, key, { configurable: true, enumerable: false, value(...args) { const res = base.apply(this, args); array._chartjs.listeners.forEach((object) => { if (typeof object[method] === 'function') { object[method](...args); } }); return res; } }); }); } function unlistenArrayEvents(array, listener) { const stub = array._chartjs; if (!stub) { return; } const listeners = stub.listeners; const index = listeners.indexOf(listener); if (index !== -1) { listeners.splice(index, 1); } if (listeners.length > 0) { return; } arrayEvents.forEach((key) => { delete array[key]; }); delete array._chartjs; } function _arrayUnique(items) { const set = new Set(); let i, ilen; for (i = 0, ilen = items.length; i < ilen; ++i) { set.add(items[i]); } if (set.size === ilen) { return items; } return Array.from(set); } const PI = Math.PI; const TAU = 2 * PI; const PITAU = TAU + PI; const INFINITY = Number.POSITIVE_INFINITY; const RAD_PER_DEG = PI / 180; const HALF_PI = PI / 2; const QUARTER_PI = PI / 4; const TWO_THIRDS_PI = PI * 2 / 3; const log10 = Math.log10; const sign = Math.sign; function niceNum(range) { const roundedRange = Math.round(range); range = almostEquals(range, roundedRange, range / 1000) ? roundedRange : range; const niceRange = Math.pow(10, Math.floor(log10(range))); const fraction = range / niceRange; const niceFraction = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10; return niceFraction * niceRange; } function _factorize(value) { const result = []; const sqrt = Math.sqrt(value); let i; for (i = 1; i < sqrt; i++) { if (value % i === 0) { result.push(i); result.push(value / i); } } if (sqrt === (sqrt | 0)) { result.push(sqrt); } result.sort((a, b) => a - b).pop(); return result; } function isNumber(n) { return !isNaN(parseFloat(n)) && isFinite(n); } function almostEquals(x, y, epsilon) { return Math.abs(x - y) < epsilon; } function almostWhole(x, epsilon) { const rounded = Math.round(x); return ((rounded - epsilon) <= x) && ((rounded + epsilon) >= x); } function _setMinAndMaxByKey(array, target, property) { let i, ilen, value; for (i = 0, ilen = array.length; i < ilen; i++) { value = array[i][property]; if (!isNaN(value)) { target.min = Math.min(target.min, value); target.max = Math.max(target.max, value); } } } function toRadians(degrees) { return degrees * (PI / 180); } function toDegrees(radians) { return radians * (180 / PI); } function _decimalPlaces(x) { if (!isNumberFinite(x)) { return; } let e = 1; let p = 0; while (Math.round(x * e) / e !== x) { e *= 10; p++; } return p; } function getAngleFromPoint(centrePoint, anglePoint) { const distanceFromXCenter = anglePoint.x - centrePoint.x; const distanceFromYCenter = anglePoint.y - centrePoint.y; const radialDistanceFromCenter = Math.sqrt(distanceFromXCenter * distanceFromXCenter + distanceFromYCenter * distanceFromYCenter); let angle = Math.atan2(distanceFromYCenter, distanceFromXCenter); if (angle < (-0.5 * PI)) { angle += TAU; } return { angle, distance: radialDistanceFromCenter }; } function distanceBetweenPoints(pt1, pt2) { return Math.sqrt(Math.pow(pt2.x - pt1.x, 2) + Math.pow(pt2.y - pt1.y, 2)); } function _angleDiff(a, b) { return (a - b + PITAU) % TAU - PI; } function _normalizeAngle(a) { return (a % TAU + TAU) % TAU; } function _angleBetween(angle, start, end, sameAngleIsFullCircle) { const a = _normalizeAngle(angle); const s = _normalizeAngle(start); const e = _normalizeAngle(end); const angleToStart = _normalizeAngle(s - a); const angleToEnd = _normalizeAngle(e - a); const startToAngle = _normalizeAngle(a - s); const endToAngle = _normalizeAngle(a - e); return a === s || a === e || (sameAngleIsFullCircle && s === e) || (angleToStart > angleToEnd && startToAngle < endToAngle); } function _limitValue(value, min, max) { return Math.max(min, Math.min(max, value)); } function _int16Range(value) { return _limitValue(value, -32768, 32767); } function _isBetween(value, start, end, epsilon = 1e-6) { return value >= Math.min(start, end) - epsilon && value <= Math.max(start, end) + epsilon; } function _isDomSupported() { return typeof window !== 'undefined' && typeof document !== 'undefined'; } function _getParentNode(domNode) { let parent = domNode.parentNode; if (parent && parent.toString() === '[object ShadowRoot]') { parent = parent.host; } return parent; } function parseMaxStyle(styleValue, node, parentProperty) { let valueInPixels; if (typeof styleValue === 'string') { valueInPixels = parseInt(styleValue, 10); if (styleValue.indexOf('%') !== -1) { valueInPixels = valueInPixels / 100 * node.parentNode[parentProperty]; } } else { valueInPixels = styleValue; } return valueInPixels; } const getComputedStyle = (element) => window.getComputedStyle(element, null); function getStyle(el, property) { return getComputedStyle(el).getPropertyValue(property); } const positions = ['top', 'right', 'bottom', 'left']; function getPositionedStyle(styles, style, suffix) { const result = {}; suffix = suffix ? '-' + suffix : ''; for (let i = 0; i < 4; i++) { const pos = positions[i]; result[pos] = parseFloat(styles[style + '-' + pos + suffix]) || 0; } result.width = result.left + result.right; result.height = result.top + result.bottom; return result; } const useOffsetPos = (x, y, target) => (x > 0 || y > 0) && (!target || !target.shadowRoot); function getCanvasPosition(e, canvas) { const touches = e.touches; const source = touches && touches.length ? touches[0] : e; const {offsetX, offsetY} = source; let box = false; let x, y; if (useOffsetPos(offsetX, offsetY, e.target)) { x = offsetX; y = offsetY; } else { const rect = canvas.getBoundingClientRect(); x = source.clientX - rect.left; y = source.clientY - rect.top; box = true; } return {x, y, box}; } function getRelativePosition(evt, chart) { if ('native' in evt) { return evt; } const {canvas, currentDevicePixelRatio} = chart; const style = getComputedStyle(canvas); const borderBox = style.boxSizing === 'border-box'; const paddings = getPositionedStyle(style, 'padding'); const borders = getPositionedStyle(style, 'border', 'width'); const {x, y, box} = getCanvasPosition(evt, canvas); const xOffset = paddings.left + (box && borders.left); const yOffset = paddings.top + (box && borders.top); let {width, height} = chart; if (borderBox) { width -= paddings.width + borders.width; height -= paddings.height + borders.height; } return { x: Math.round((x - xOffset) / width * canvas.width / currentDevicePixelRatio), y: Math.round((y - yOffset) / height * canvas.height / currentDevicePixelRatio) }; } function getContainerSize(canvas, width, height) { let maxWidth, maxHeight; if (width === undefined || height === undefined) { const container = _getParentNode(canvas); if (!container) { width = canvas.clientWidth; height = canvas.clientHeight; } else { const rect = container.getBoundingClientRect(); const containerStyle = getComputedStyle(container); const containerBorder = getPositionedStyle(containerStyle, 'border', 'width'); const containerPadding = getPositionedStyle(containerStyle, 'padding'); width = rect.width - containerPadding.width - containerBorder.width; height = rect.height - containerPadding.height - containerBorder.height; maxWidth = parseMaxStyle(containerStyle.maxWidth, container, 'clientWidth'); maxHeight = parseMaxStyle(containerStyle.maxHeight, container, 'clientHeight'); } } return { width, height, maxWidth: maxWidth || INFINITY, maxHeight: maxHeight || INFINITY }; } const round1 = v => Math.round(v * 10) / 10; function getMaximumSize(canvas, bbWidth, bbHeight, aspectRatio) { const style = getComputedStyle(canvas); const margins = getPositionedStyle(style, 'margin'); const maxWidth = parseMaxStyle(style.maxWidth, canvas, 'clientWidth') || INFINITY; const maxHeight = parseMaxStyle(style.maxHeight, canvas, 'clientHeight') || INFINITY; const containerSize = getContainerSize(canvas, bbWidth, bbHeight); let {width, height} = containerSize; if (style.boxSizing === 'content-box') { const borders = getPositionedStyle(style, 'border', 'width'); const paddings = getPositionedStyle(style, 'padding'); width -= paddings.width + borders.width; height -= paddings.height + borders.height; } width = Math.max(0, width - margins.width); height = Math.max(0, aspectRatio ? Math.floor(width / aspectRatio) : height - margins.height); width = round1(Math.min(width, maxWidth, containerSize.maxWidth)); height = round1(Math.min(height, maxHeight, containerSize.maxHeight)); if (width && !height) { height = round1(width / 2); } return { width, height }; } function retinaScale(chart, forceRatio, forceStyle) { const pixelRatio = forceRatio || 1; const deviceHeight = Math.floor(chart.height * pixelRatio); const deviceWidth = Math.floor(chart.width * pixelRatio); chart.height = deviceHeight / pixelRatio; chart.width = deviceWidth / pixelRatio; const canvas = chart.canvas; if (canvas.style && (forceStyle || (!canvas.style.height && !canvas.style.width))) { canvas.style.height = `${chart.height}px`; canvas.style.width = `${chart.width}px`; } if (chart.currentDevicePixelRatio !== pixelRatio || canvas.height !== deviceHeight || canvas.width !== deviceWidth) { chart.currentDevicePixelRatio = pixelRatio; canvas.height = deviceHeight; canvas.width = deviceWidth; chart.ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); return true; } return false; } const supportsEventListenerOptions = (function() { let passiveSupported = false; try { const options = { get passive() { passiveSupported = true; return false; } }; window.addEventListener('test', null, options); window.removeEventListener('test', null, options); } catch (e) { } return passiveSupported; }()); function readUsedSize(element, property) { const value = getStyle(element, property); const matches = value && value.match(/^(\d+)(\.\d+)?px$/); return matches ? +matches[1] : undefined; } function toFontString(font) { if (!font || isNullOrUndef(font.size) || isNullOrUndef(font.family)) { return null; } return (font.style ? font.style + ' ' : '') + (font.weight ? font.weight + ' ' : '') + font.size + 'px ' + font.family; } function _measureText(ctx, data, gc, longest, string) { let textWidth = data[string]; if (!textWidth) { textWidth = data[string] = ctx.measureText(string).width; gc.push(string); } if (textWidth > longest) { longest = textWidth; } return longest; } function _longestText(ctx, font, arrayOfThings, cache) { cache = cache || {}; let data = cache.data = cache.data || {}; let gc = cache.garbageCollect = cache.garbageCollect || []; if (cache.font !== font) { data = cache.data = {}; gc = cache.garbageCollect = []; cache.font = font; } ctx.save(); ctx.font = font; let longest = 0; const ilen = arrayOfThings.length; let i, j, jlen, thing, nestedThing; for (i = 0; i < ilen; i++) { thing = arrayOfThings[i]; if (thing !== undefined && thing !== null && isArray(thing) !== true) { longest = _measureText(ctx, data, gc, longest, thing); } else if (isArray(thing)) { for (j = 0, jlen = thing.length; j < jlen; j++) { nestedThing = thing[j]; if (nestedThing !== undefined && nestedThing !== null && !isArray(nestedThing)) { longest = _measureText(ctx, data, gc, longest, nestedThing); } } } } ctx.restore(); const gcLen = gc.length / 2; if (gcLen > arrayOfThings.length) { for (i = 0; i < gcLen; i++) { delete data[gc[i]]; } gc.splice(0, gcLen); } return longest; } function _alignPixel(chart, pixel, width) { const devicePixelRatio = chart.currentDevicePixelRatio; const halfWidth = width !== 0 ? Math.max(width / 2, 0.5) : 0; return Math.round((pixel - halfWidth) * devicePixelRatio) / devicePixelRatio + halfWidth; } function clearCanvas(canvas, ctx) { ctx = ctx || canvas.getContext('2d'); ctx.save(); ctx.resetTransform(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.restore(); } function drawPoint(ctx, options, x, y) { let type, xOffset, yOffset, size, cornerRadius; const style = options.pointStyle; const rotation = options.rotation; const radius = options.radius; let rad = (rotation || 0) * RAD_PER_DEG; if (style && typeof style === 'object') { type = style.toString(); if (type === '[object HTMLImageElement]' || type === '[object HTMLCanvasElement]') { ctx.save(); ctx.translate(x, y); ctx.rotate(rad); ctx.drawImage(style, -style.width / 2, -style.height / 2, style.width, style.height); ctx.restore(); return; } } if (isNaN(radius) || radius <= 0) { return; } ctx.beginPath(); switch (style) { default: ctx.arc(x, y, radius, 0, TAU); ctx.closePath(); break; case 'triangle': ctx.moveTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius); rad += TWO_THIRDS_PI; ctx.lineTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius); rad += TWO_THIRDS_PI; ctx.lineTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius); ctx.closePath(); break; case 'rectRounded': cornerRadius = radius * 0.516; size = radius - cornerRadius; xOffset = Math.cos(rad + QUARTER_PI) * size; yOffset = Math.sin(rad + QUARTER_PI) * size; ctx.arc(x - xOffset, y - yOffset, cornerRadius, rad - PI, rad - HALF_PI); ctx.arc(x + yOffset, y - xOffset, cornerRadius, rad - HALF_PI, rad); ctx.arc(x + xOffset, y + yOffset, cornerRadius, rad, rad + HALF_PI); ctx.arc(x - yOffset, y + xOffset, cornerRadius, rad + HALF_PI, rad + PI); ctx.closePath(); break; case 'rect': if (!rotation) { size = Math.SQRT1_2 * radius; ctx.rect(x - size, y - size, 2 * size, 2 * size); break; } rad += QUARTER_PI; case 'rectRot': xOffset = Math.cos(rad) * radius; yOffset = Math.sin(rad) * radius; ctx.moveTo(x - xOffset, y - yOffset); ctx.lineTo(x + yOffset, y - xOffset); ctx.lineTo(x + xOffset, y + yOffset); ctx.lineTo(x - yOffset, y + xOffset); ctx.closePath(); break; case 'crossRot': rad += QUARTER_PI; case 'cross': xOffset = Math.cos(rad) * radius; yOffset = Math.sin(rad) * radius; ctx.moveTo(x - xOffset, y - yOffset); ctx.lineTo(x + xOffset, y + yOffset); ctx.moveTo(x + yOffset, y - xOffset); ctx.lineTo(x - yOffset, y + xOffset); break; case 'star': xOffset = Math.cos(rad) * radius; yOffset = Math.sin(rad) * radius; ctx.moveTo(x - xOffset, y - yOffset); ctx.lineTo(x + xOffset, y + yOffset); ctx.moveTo(x + yOffset, y - xOffset); ctx.lineTo(x - yOffset, y + xOffset); rad += QUARTER_PI; xOffset = Math.cos(rad) * radius; yOffset = Math.sin(rad) * radius; ctx.moveTo(x - xOffset, y - yOffset); ctx.lineTo(x + xOffset, y + yOffset); ctx.moveTo(x + yOffset, y - xOffset); ctx.lineTo(x - yOffset, y + xOffset); break; case 'line': xOffset = Math.cos(rad) * radius; yOffset = Math.sin(rad) * radius; ctx.moveTo(x - xOffset, y - yOffset); ctx.lineTo(x + xOffset, y + yOffset); break; case 'dash': ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(rad) * radius, y + Math.sin(rad) * radius); break; } ctx.fill(); if (options.borderWidth > 0) { ctx.stroke(); } } function _isPointInArea(point, area, margin) { margin = margin || 0.5; return !area || (point && point.x > area.left - margin && point.x < area.right + margin && point.y > area.top - margin && point.y < area.bottom + margin); } function clipArea(ctx, area) { ctx.save(); ctx.beginPath(); ctx.rect(area.left, area.top, area.right - area.left, area.bottom - area.top); ctx.clip(); } function unclipArea(ctx) { ctx.restore(); } function _steppedLineTo(ctx, previous, target, flip, mode) { if (!previous) { return ctx.lineTo(target.x, target.y); } if (mode === 'middle') { const midpoint = (previous.x + target.x) / 2.0; ctx.lineTo(midpoint, previous.y); ctx.lineTo(midpoint, target.y); } else if (mode === 'after' !== !!flip) { ctx.lineTo(previous.x, target.y); } else { ctx.lineTo(target.x, previous.y); } ctx.lineTo(target.x, target.y); } function _bezierCurveTo(ctx, previous, target, flip) { if (!previous) { return ctx.lineTo(target.x, target.y); } ctx.bezierCurveTo( flip ? previous.cp1x : previous.cp2x, flip ? previous.cp1y : previous.cp2y, flip ? target.cp2x : target.cp1x, flip ? target.cp2y : target.cp1y, target.x, target.y); } function renderText(ctx, text, x, y, font, opts = {}) { const lines = isArray(text) ? text : [text]; const stroke = opts.strokeWidth > 0 && opts.strokeColor !== ''; let i, line; ctx.save(); ctx.font = font.string; setRenderOpts(ctx, opts); for (i = 0; i < lines.length; ++i) { line = lines[i]; if (stroke) { if (opts.strokeColor) { ctx.strokeStyle = opts.strokeColor; } if (!isNullOrUndef(opts.strokeWidth)) { ctx.lineWidth = opts.strokeWidth; } ctx.strokeText(line, x, y, opts.maxWidth); } ctx.fillText(line, x, y, opts.maxWidth); decorateText(ctx, x, y, line, opts); y += font.lineHeight; } ctx.restore(); } function setRenderOpts(ctx, opts) { if (opts.translation) { ctx.translate(opts.translation[0], opts.translation[1]); } if (!isNullOrUndef(opts.rotation)) { ctx.rotate(opts.rotation); } if (opts.color) { ctx.fillStyle = opts.color; } if (opts.textAlign) { ctx.textAlign = opts.textAlign; } if (opts.textBaseline) { ctx.textBaseline = opts.textBaseline; } } function decorateText(ctx, x, y, line, opts) { if (opts.strikethrough || opts.underline) { const metrics = ctx.measureText(line); const left = x - metrics.actualBoundingBoxLeft; const right = x + metrics.actualBoundingBoxRight; const top = y - metrics.actualBoundingBoxAscent; const bottom = y + metrics.actualBoundingBoxDescent; const yDecoration = opts.strikethrough ? (top + bottom) / 2 : bottom; ctx.strokeStyle = ctx.fillStyle; ctx.beginPath(); ctx.lineWidth = opts.decorationWidth || 2; ctx.moveTo(left, yDecoration); ctx.lineTo(right, yDecoration); ctx.stroke(); } } function addRoundedRectPath(ctx, rect) { const {x, y, w, h, radius} = rect; ctx.arc(x + radius.topLeft, y + radius.topLeft, radius.topLeft, -HALF_PI, PI, true); ctx.lineTo(x, y + h - radius.bottomLeft); ctx.arc(x + radius.bottomLeft, y + h - radius.bottomLeft, radius.bottomLeft, PI, HALF_PI, true); ctx.lineTo(x + w - radius.bottomRight, y + h); ctx.arc(x + w - radius.bottomRight, y + h - radius.bottomRight, radius.bottomRight, HALF_PI, 0, true); ctx.lineTo(x + w, y + radius.topRight); ctx.arc(x + w - radius.topRight, y + radius.topRight, radius.topRight, 0, -HALF_PI, true); ctx.lineTo(x + radius.topLeft, y); } function _createResolver(scopes, prefixes = [''], rootScopes = scopes, fallback, getTarget = () => scopes[0]) { if (!defined(fallback)) { fallback = _resolve('_fallback', scopes); } const cache = { [Symbol.toStringTag]: 'Object', _cacheable: true, _scopes: scopes, _rootScopes: rootScopes, _fallback: fallback, _getTarget: getTarget, override: (scope) => _createResolver([scope, ...scopes], prefixes, rootScopes, fallback), }; return new Proxy(cache, { deleteProperty(target, prop) { delete target[prop]; delete target._keys; delete scopes[0][prop]; return true; }, get(target, prop) { return _cached(target, prop, () => _resolveWithPrefixes(prop, prefixes, scopes, target)); }, getOwnPropertyDescriptor(target, prop) { return Reflect.getOwnPropertyDescriptor(target._scopes[0], prop); }, getPrototypeOf() { return Reflect.getPrototypeOf(scopes[0]); }, has(target, prop) { return getKeysFromAllScopes(target).includes(prop); }, ownKeys(target) { return getKeysFromAllScopes(target); }, set(target, prop, value) { const storage = target._storage || (target._storage = getTarget()); target[prop] = storage[prop] = value; delete target._keys; return true; } }); } function _attachContext(proxy, context, subProxy, descriptorDefaults) { const cache = { _cacheable: false, _proxy: proxy, _context: context, _subProxy: subProxy, _stack: new Set(), _descriptors: _descriptors(proxy, descriptorDefaults), setContext: (ctx) => _attachContext(proxy, ctx, subProxy, descriptorDefaults), override: (scope) => _attachContext(proxy.override(scope), context, subProxy, descriptorDefaults) }; return new Proxy(cache, { deleteProperty(target, prop) { delete target[prop]; delete proxy[prop]; return true; }, get(target, prop, receiver) { return _cached(target, prop, () => _resolveWithContext(target, prop, receiver)); }, getOwnPropertyDescriptor(target, prop) { return target._descriptors.allKeys ? Reflect.has(proxy, prop) ? {enumerable: true, configurable: true} : undefined : Reflect.getOwnPropertyDescriptor(proxy, prop); }, getPrototypeOf() { return Reflect.getPrototypeOf(proxy); }, has(target, prop) { return Reflect.has(proxy, prop); }, ownKeys() { return Reflect.ownKeys(proxy); }, set(target, prop, value) { proxy[prop] = value; delete target[prop]; return true; } }); } function _descriptors(proxy, defaults = {scriptable: true, indexable: true}) { const {_scriptable = defaults.scriptable, _indexable = defaults.indexable, _allKeys = defaults.allKeys} = proxy; return { allKeys: _allKeys, scriptable: _scriptable, indexable: _indexable, isScriptable: isFunction(_scriptable) ? _scriptable : () => _scriptable, isIndexable: isFunction(_indexable) ? _indexable : () => _indexable }; } const readKey = (prefix, name) => prefix ? prefix + _capitalize(name) : name; const needsSubResolver = (prop, value) => isObject(value) && prop !== 'adapters' && (Object.getPrototypeOf(value) === null || value.constructor === Object); function _cached(target, prop, resolve) { if (Object.prototype.hasOwnProperty.call(target, prop)) { return target[prop]; } const value = resolve(); target[prop] = value; return value; } function _resolveWithContext(target, prop, receiver) { const {_proxy, _context, _subProxy, _descriptors: descriptors} = target; let value = _proxy[prop]; if (isFunction(value) && descriptors.isScriptable(prop)) { value = _resolveScriptable(prop, value, target, receiver); } if (isArray(value) && value.length) { value = _resolveArray(prop, value, target, descriptors.isIndexable); } if (needsSubResolver(prop, value)) { value = _attachContext(value, _context, _subProxy && _subProxy[prop], descriptors); } return value; } function _resolveScriptable(prop, value, target, receiver) { const {_proxy, _context, _subProxy, _stack} = target; if (_stack.has(prop)) { throw new Error('Recursion detected: ' + Array.from(_stack).join('->') + '->' + prop); } _stack.add(prop); value = value(_context, _subProxy || receiver); _stack.delete(prop); if (needsSubResolver(prop, value)) { value = createSubResolver(_proxy._scopes, _proxy, prop, value); } return value; } function _resolveArray(prop, value, target, isIndexable) { const {_proxy, _context, _subProxy, _descriptors: descriptors} = target; if (defined(_context.index) && isIndexable(prop)) { value = value[_context.index % value.length]; } else if (isObject(value[0])) { const arr = value; const scopes = _proxy._scopes.filter(s => s !== arr); value = []; for (const item of arr) { const resolver = createSubResolver(scopes, _proxy, prop, item); value.push(_attachContext(resolver, _context, _subProxy && _subProxy[prop], descriptors)); } } return value; } function resolveFallback(fallback, prop, value) { return isFunction(fallback) ? fallback(prop, value) : fallback; } const getScope = (key, parent) => key === true ? parent : typeof key === 'string' ? resolveObjectKey(parent, key) : undefined; function addScopes(set, parentScopes, key, parentFallback, value) { for (const parent of parentScopes) { const scope = getScope(key, parent); if (scope) { set.add(scope); const fallback = resolveFallback(scope._fallback, key, value); if (defined(fallback) && fallback !== key && fallback !== parentFallback) { return fallback; } } else if (scope === false && defined(parentFallback) && key !== parentFallback) { return null; } } return false; } function createSubResolver(parentScopes, resolver, prop, value) { const rootScopes = resolver._rootScopes; const fallback = resolveFallback(resolver._fallback, prop, value); const allScopes = [...parentScopes, ...rootScopes]; const set = new Set(); set.add(value); let key = addScopesFromKey(set, allScopes, prop, fallback || prop, value); if (key === null) { return false; } if (defined(fallback) && fallback !== prop) { key = addScopesFromKey(set, allScopes, fallback, key, value); if (key === null) { return false; } } return _createResolver(Array.from(set), [''], rootScopes, fallback, () => subGetTarget(resolver, prop, value)); } function addScopesFromKey(set, allScopes, key, fallback, item) { while (key) { key = addScopes(set, allScopes, key, fallback, item); } return key; } function subGetTarget(resolver, prop, value) { const parent = resolver._getTarget(); if (!(prop in parent)) { parent[prop] = {}; } const target = parent[prop]; if (isArray(target) && isObject(value)) { return value; } return target; } function _resolveWithPrefixes(prop, prefixes, scopes, proxy) { let value; for (const prefix of prefixes) { value = _resolve(readKey(prefix, prop), scopes); if (defined(value)) { return needsSubResolver(prop, value) ? createSubResolver(scopes, proxy, prop, value) : value; } } } function _resolve(key, scopes) { for (const scope of scopes) { if (!scope) { continue; } const value = scope[key]; if (defined(value)) { return value; } } } function getKeysFromAllScopes(target) { let keys = target._keys; if (!keys) { keys = target._keys = resolveKeysFromAllScopes(target._scopes); } return keys; } function resolveKeysFromAllScopes(scopes) { const set = new Set(); for (const scope of scopes) { for (const key of Object.keys(scope).filter(k => !k.startsWith('_'))) { set.add(key); } } return Array.from(set); } function _parseObjectDataRadialScale(meta, data, start, count) { const {iScale} = meta; const {key = 'r'} = this._parsing; const parsed = new Array(count); let i, ilen, index, item; for (i = 0, ilen = count; i < ilen; ++i) { index = i + start; item = data[index]; parsed[i] = { r: iScale.parse(resolveObjectKey(item, key), index) }; } return parsed; } const EPSILON = Number.EPSILON || 1e-14; const getPoint = (points, i) => i < points.length && !points[i].skip && points[i]; const getValueAxis = (indexAxis) => indexAxis === 'x' ? 'y' : 'x'; function splineCurve(firstPoint, middlePoint, afterPoint, t) { const previous = firstPoint.skip ? middlePoint : firstPoint; const current = middlePoint; const next = afterPoint.skip ? middlePoint : afterPoint; const d01 = distanceBetweenPoints(current, previous); const d12 = distanceBetweenPoints(next, current); let s01 = d01 / (d01 + d12); let s12 = d12 / (d01 + d12); s01 = isNaN(s01) ? 0 : s01; s12 = isNaN(s12) ? 0 : s12; const fa = t * s01; const fb = t * s12; return { previous: { x: current.x - fa * (next.x - previous.x), y: current.y - fa * (next.y - previous.y) }, next: { x: current.x + fb * (next.x - previous.x), y: current.y + fb * (next.y - previous.y) } }; } function monotoneAdjust(points, deltaK, mK) { const pointsLen = points.length; let alphaK, betaK, tauK, squaredMagnitude, pointCurrent; let pointAfter = getPoint(points, 0); for (let i = 0; i < pointsLen - 1; ++i) { pointCurrent = pointAfter; pointAfter = getPoint(points, i + 1); if (!pointCurrent || !pointAfter) { continue; } if (almostEquals(deltaK[i], 0, EPSILON)) { mK[i] = mK[i + 1] = 0; continue; } alphaK = mK[i] / deltaK[i]; betaK = mK[i + 1] / deltaK[i]; squaredMagnitude = Math.pow(alphaK, 2) + Math.pow(betaK, 2); if (squaredMagnitude <= 9) { continue; } tauK = 3 / Math.sqrt(squaredMagnitude); mK[i] = alphaK * tauK * deltaK[i]; mK[i + 1] = betaK * tauK * deltaK[i]; } } function monotoneCompute(points, mK, indexAxis = 'x') { const valueAxis = getValueAxis(indexAxis); const pointsLen = points.length; let delta, pointBefore, pointCurrent; let pointAfter = getPoint(points, 0); for (let i = 0; i < pointsLen; ++i) { pointBefore = pointCurrent; pointCurrent = pointAfter; pointAfter = getPoint(points, i + 1); if (!pointCurrent) { continue; } const iPixel = pointCurrent[indexAxis]; const vPixel = pointCurrent[valueAxis]; if (pointBefore) { delta = (iPixel - pointBefore[indexAxis]) / 3; pointCurrent[`cp1${indexAxis}`] = iPixel - delta; pointCurrent[`cp1${valueAxis}`] = vPixel - delta * mK[i]; } if (pointAfter) { delta = (pointAfter[indexAxis] - iPixel) / 3; pointCurrent[`cp2${indexAxis}`] = iPixel + delta; pointCurrent[`cp2${valueAxis}`] = vPixel + delta * mK[i]; } } } function splineCurveMonotone(points, indexAxis = 'x') { const valueAxis = getValueAxis(indexAxis); const pointsLen = points.length; const deltaK = Array(pointsLen).fill(0); const mK = Array(pointsLen); let i, pointBefore, pointCurrent; let pointAfter = getPoint(points, 0); for (i = 0; i < pointsLen; ++i) { pointBefore = pointCurrent; pointCurrent = pointAfter; pointAfter = getPoint(points, i + 1); if (!pointCurrent) { continue; } if (pointAfter) { const slopeDelta = pointAfter[indexAxis] - pointCurrent[indexAxis]; deltaK[i] = slopeDelta !== 0 ? (pointAfter[valueAxis] - pointCurrent[valueAxis]) / slopeDelta : 0; } mK[i] = !pointBefore ? deltaK[i] : !pointAfter ? deltaK[i - 1] : (sign(deltaK[i - 1]) !== sign(deltaK[i])) ? 0 : (deltaK[i - 1] + deltaK[i]) / 2; } monotoneAdjust(points, deltaK, mK); monotoneCompute(points, mK, indexAxis); } function capControlPoint(pt, min, max) { return Math.max(Math.min(pt, max), min); } function capBezierPoints(points, area) { let i, ilen, point, inArea, inAreaPrev; let inAreaNext = _isPointInArea(points[0], area); for (i = 0, ilen = points.length; i < ilen; ++i) { inAreaPrev = inArea; inArea = inAreaNext; inAreaNext = i < ilen - 1 && _isPointInArea(points[i + 1], area); if (!inArea) { continue; } point = points[i]; if (inAreaPrev) { point.cp1x = capControlPoint(point.cp1x, area.left, area.right); point.cp1y = capControlPoint(point.cp1y, area.top, area.bottom); } if (inAreaNext) { point.cp2x = capControlPoint(point.cp2x, area.left, area.right); point.cp2y = capControlPoint(point.cp2y, area.top, area.bottom); } } } function _updateBezierControlPoints(points, options, area, loop, indexAxis) { let i, ilen, point, controlPoints; if (options.spanGaps) { points = points.filter((pt) => !pt.skip); } if (options.cubicInterpolationMode === 'monotone') { splineCurveMonotone(points, indexAxis); } else { let prev = loop ? points[points.length - 1] : points[0]; for (i = 0, ilen = points.length; i < ilen; ++i) { point = points[i]; controlPoints = splineCurve( prev, point, points[Math.min(i + 1, ilen - (loop ? 0 : 1)) % ilen], options.tension ); point.cp1x = controlPoints.previous.x; point.cp1y = controlPoints.previous.y; point.cp2x = controlPoints.next.x; point.cp2y = controlPoints.next.y; prev = point; } } if (options.capBezierPoints) { capBezierPoints(points, area); } } const atEdge = (t) => t === 0 || t === 1; const elasticIn = (t, s, p) => -(Math.pow(2, 10 * (t -= 1)) * Math.sin((t - s) * TAU / p)); const elasticOut = (t, s, p) => Math.pow(2, -10 * t) * Math.sin((t - s) * TAU / p) + 1; const effects = { linear: t => t, easeInQuad: t => t * t, easeOutQuad: t => -t * (t - 2), easeInOutQuad: t => ((t /= 0.5) < 1) ? 0.5 * t * t : -0.5 * ((--t) * (t - 2) - 1), easeInCubic: t => t * t * t, easeOutCubic: t => (t -= 1) * t * t + 1, easeInOutCubic: t => ((t /= 0.5) < 1) ? 0.5 * t * t * t : 0.5 * ((t -= 2) * t * t + 2), easeInQuart: t => t * t * t * t, easeOutQuart: t => -((t -= 1) * t * t * t - 1), easeInOutQuart: t => ((t /= 0.5) < 1) ? 0.5 * t * t * t * t : -0.5 * ((t -= 2) * t * t * t - 2), easeInQuint: t => t * t * t * t * t, easeOutQuint: t => (t -= 1) * t * t * t * t + 1, easeInOutQuint: t => ((t /= 0.5) < 1) ? 0.5 * t * t * t * t * t : 0.5 * ((t -= 2) * t * t * t * t + 2), easeInSine: t => -Math.cos(t * HALF_PI) + 1, easeOutSine: t => Math.sin(t * HALF_PI), easeInOutSine: t => -0.5 * (Math.cos(PI * t) - 1), easeInExpo: t => (t === 0) ? 0 : Math.pow(2, 10 * (t - 1)), easeOutExpo: t => (t === 1) ? 1 : -Math.pow(2, -10 * t) + 1, easeInOutExpo: t => atEdge(t) ? t : t < 0.5 ? 0.5 * Math.pow(2, 10 * (t * 2 - 1)) : 0.5 * (-Math.pow(2, -10 * (t * 2 - 1)) + 2), easeInCirc: t => (t >= 1) ? t : -(Math.sqrt(1 - t * t) - 1), easeOutCirc: t => Math.sqrt(1 - (t -= 1) * t), easeInOutCirc: t => ((t /= 0.5) < 1) ? -0.5 * (Math.sqrt(1 - t * t) - 1) : 0.5 * (Math.sqrt(1 - (t -= 2) * t) + 1), easeInElastic: t => atEdge(t) ? t : elasticIn(t, 0.075, 0.3), easeOutElastic: t => atEdge(t) ? t : elasticOut(t, 0.075, 0.3), easeInOutElastic(t) { const s = 0.1125; const p = 0.45; return atEdge(t) ? t : t < 0.5 ? 0.5 * elasticIn(t * 2, s, p) : 0.5 + 0.5 * elasticOut(t * 2 - 1, s, p); }, easeInBack(t) { const s = 1.70158; return t * t * ((s + 1) * t - s); }, easeOutBack(t) { const s = 1.70158; return (t -= 1) * t * ((s + 1) * t + s) + 1; }, easeInOutBack(t) { let s = 1.70158; if ((t /= 0.5) < 1) { return 0.5 * (t * t * (((s *= (1.525)) + 1) * t - s)); } return 0.5 * ((t -= 2) * t * (((s *= (1.525)) + 1) * t + s) + 2); }, easeInBounce: t => 1 - effects.easeOutBounce(1 - t), easeOutBounce(t) { const m = 7.5625; const d = 2.75; if (t < (1 / d)) { return m * t * t; } if (t < (2 / d)) { return m * (t -= (1.5 / d)) * t + 0.75; } if (t < (2.5 / d)) { return m * (t -= (2.25 / d)) * t + 0.9375; } return m * (t -= (2.625 / d)) * t + 0.984375; }, easeInOutBounce: t => (t < 0.5) ? effects.easeInBounce(t * 2) * 0.5 : effects.easeOutBounce(t * 2 - 1) * 0.5 + 0.5, }; function _pointInLine(p1, p2, t, mode) { return { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) }; } function _steppedInterpolation(p1, p2, t, mode) { return { x: p1.x + t * (p2.x - p1.x), y: mode === 'middle' ? t < 0.5 ? p1.y : p2.y : mode === 'after' ? t < 1 ? p1.y : p2.y : t > 0 ? p2.y : p1.y }; } function _bezierInterpolation(p1, p2, t, mode) { const cp1 = {x: p1.cp2x, y: p1.cp2y}; const cp2 = {x: p2.cp1x, y: p2.cp1y}; const a = _pointInLine(p1, cp1, t); const b = _pointInLine(cp1, cp2, t); const c = _pointInLine(cp2, p2, t); const d = _pointInLine(a, b, t); const e = _pointInLine(b, c, t); return _pointInLine(d, e, t); } const intlCache = new Map(); function getNumberFormat(locale, options) { options = options || {}; const cacheKey = locale + JSON.stringify(options); let formatter = intlCache.get(cacheKey); if (!formatter) { formatter = new Intl.NumberFormat(locale, options); intlCache.set(cacheKey, formatter); } return formatter; } function formatNumber(num, locale, options) { return getNumberFormat(locale, options).format(num); } const LINE_HEIGHT = new RegExp(/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/); const FONT_STYLE = new RegExp(/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/); function toLineHeight(value, size) { const matches = ('' + value).match(LINE_HEIGHT); if (!matches || matches[1] === 'normal') { return size * 1.2; } value = +matches[2]; switch (matches[3]) { case 'px': return value; case '%': value /= 100; break; } return size * value; } const numberOrZero = v => +v || 0; function _readValueToProps(value, props) { const ret = {}; const objProps = isObject(props); const keys = objProps ? Object.keys(props) : props; const read = isObject(value) ? objProps ? prop => valueOrDefault(value[prop], value[props[prop]]) : prop => value[prop] : () => value; for (const prop of keys) { ret[prop] = numberOrZero(read(prop)); } return ret; } function toTRBL(value) { return _readValueToProps(value, {top: 'y', right: 'x', bottom: 'y', left: 'x'}); } function toTRBLCorners(value) { return _readValueToProps(value, ['topLeft', 'topRight', 'bottomLeft', 'bottomRight']); } function toPadding(value) { const obj = toTRBL(value); obj.width = obj.left + obj.right; obj.height = obj.top + obj.bottom; return obj; } function toFont(options, fallback) { options = options || {}; fallback = fallback || defaults.font; let size = valueOrDefault(options.size, fallback.size); if (typeof size === 'string') { size = parseInt(size, 10); } let style = valueOrDefault(options.style, fallback.style); if (style && !('' + style).match(FONT_STYLE)) { console.warn('Invalid font style specified: "' + style + '"'); style = ''; } const font = { family: valueOrDefault(options.family, fallback.family), lineHeight: toLineHeight(valueOrDefault(options.lineHeight, fallback.lineHeight), size), size, style, weight: valueOrDefault(options.weight, fallback.weight), string: '' }; font.string = toFontString(font); return font; } function resolve(inputs, context, index, info) { let cacheable = true; let i, ilen, value; for (i = 0, ilen = inputs.length; i < ilen; ++i) { value = inputs[i]; if (value === undefined) { continue; } if (context !== undefined && typeof value === 'function') { value = value(context); cacheable = false; } if (index !== undefined && isArray(value)) { value = value[index % value.length]; cacheable = false; } if (value !== undefined) { if (info && !cacheable) { info.cacheable = false; } return value; } } } function _addGrace(minmax, grace, beginAtZero) { const {min, max} = minmax; const change = toDimension(grace, (max - min) / 2); const keepZero = (value, add) => beginAtZero && value === 0 ? 0 : value + add; return { min: keepZero(min, -Math.abs(change)), max: keepZero(max, change) }; } function createContext(parentContext, context) { return Object.assign(Object.create(parentContext), context); } const getRightToLeftAdapter = function(rectX, width) { return { x(x) { return rectX + rectX + width - x; }, setWidth(w) { width = w; }, textAlign(align) { if (align === 'center') { return align; } return align === 'right' ? 'left' : 'right'; }, xPlus(x, value) { return x - value; }, leftForLtr(x, itemWidth) { return x - itemWidth; }, }; }; const getLeftToRightAdapter = function() { return { x(x) { return x; }, setWidth(w) { }, textAlign(align) { return align; }, xPlus(x, value) { return x + value; }, leftForLtr(x, _itemWidth) { return x; }, }; }; function getRtlAdapter(rtl, rectX, width) { return rtl ? getRightToLeftAdapter(rectX, width) : getLeftToRightAdapter(); } function overrideTextDirection(ctx, direction) { let style, original; if (direction === 'ltr' || direction === 'rtl') { style = ctx.canvas.style; original = [ style.getPropertyValue('direction'), style.getPropertyPriority('direction'), ]; style.setProperty('direction', direction, 'important'); ctx.prevTextDirection = original; } } function restoreTextDirection(ctx, original) { if (original !== undefined) { delete ctx.prevTextDirection; ctx.canvas.style.setProperty('direction', original[0], original[1]); } } function propertyFn(property) { if (property === 'angle') { return { between: _angleBetween, compare: _angleDiff, normalize: _normalizeAngle, }; } return { between: _isBetween, compare: (a, b) => a - b, normalize: x => x }; } function normalizeSegment({start, end, count, loop, style}) { return { start: start % count, end: end % count, loop: loop && (end - start + 1) % count === 0, style }; } function getSegment(segment, points, bounds) { const {property, start: startBound, end: endBound} = bounds; const {between, normalize} = propertyFn(property); const count = points.length; let {start, end, loop} = segment; let i, ilen; if (loop) { start += count; end += count; for (i = 0, ilen = count; i < ilen; ++i) { if (!between(normalize(points[start % count][property]), startBound, endBound)) { break; } start--; end--; } start %= count; end %= count; } if (end < start) { end += count; } return {start, end, loop, style: segment.style}; } function _boundSegment(segment, points, bounds) { if (!bounds) { return [segment]; } const {property, start: startBound, end: endBound} = bounds; const count = points.length; const {compare, between, normalize} = propertyFn(property); const {start, end, loop, style} = getSegment(segment, points, bounds); const result = []; let inside = false; let subStart = null; let value, point, prevValue; const startIsBefore = () => between(startBound, prevValue, value) && compare(startBound, prevValue) !== 0; const endIsBefore = () => compare(endBound, value) === 0 || between(endBound, prevValue, value); const shouldStart = () => inside || startIsBefore(); const shouldStop = () => !inside || endIsBefore(); for (let i = start, prev = start; i <= end; ++i) { point = points[i % count]; if (point.skip) { continue; } value = normalize(point[property]); if (value === prevValue) { continue; } inside = between(value, startBound, endBound); if (subStart === null && shouldStart()) { subStart = compare(value, startBound) === 0 ? i : prev; } if (subStart !== null && shouldStop()) { result.push(normalizeSegment({start: subStart, end: i, loop, count, style})); subStart = null; } prev = i; prevValue = value; } if (subStart !== null) { result.push(normalizeSegment({start: subStart, end, loop, count, style})); } return result; } function _boundSegments(line, bounds) { const result = []; const segments = line.segments; for (let i = 0; i < segments.length; i++) { const sub = _boundSegment(segments[i], line.points, bounds); if (sub.length) { result.push(...sub); } } return result; } function findStartAndEnd(points, count, loop, spanGaps) { let start = 0; let end = count - 1; if (loop && !spanGaps) { while (start < count && !points[start].skip) { start++; } } while (start < count && points[start].skip) { start++; } start %= count; if (loop) { end += start; } while (end > start && points[end % count].skip) { end--; } end %= count; return {start, end}; } function solidSegments(points, start, max, loop) { const count = points.length; const result = []; let last = start; let prev = points[start]; let end; for (end = start + 1; end <= max; ++end) { const cur = points[end % count]; if (cur.skip || cur.stop) { if (!prev.skip) { loop = false; result.push({start: start % count, end: (end - 1) % count, loop}); start = last = cur.stop ? end : null; } } else { last = end; if (prev.skip) { start = end; } } prev = cur; } if (last !== null) { result.push({start: start % count, end: last % count, loop}); } return result; } function _computeSegments(line, segmentOptions) { const points = line.points; const spanGaps = line.options.spanGaps; const count = points.length; if (!count) { return []; } const loop = !!line._loop; const {start, end} = findStartAndEnd(points, count, loop, spanGaps); if (spanGaps === true) { return splitByStyles(line, [{start, end, loop}], points, segmentOptions); } const max = end < start ? end + count : end; const completeLoop = !!line._fullLoop && start === 0 && end === count - 1; return splitByStyles(line, solidSegments(points, start, max, completeLoop), points, segmentOptions); } function splitByStyles(line, segments, points, segmentOptions) { if (!segmentOptions || !segmentOptions.setContext || !points) { return segments; } return doSplitByStyles(line, segments, points, segmentOptions); } function doSplitByStyles(line, segments, points, segmentOptions) { const chartContext = line._chart.getContext(); const baseStyle = readStyle(line.options); const {_datasetIndex: datasetIndex, options: {spanGaps}} = line; const count = points.length; const result = []; let prevStyle = baseStyle; let start = segments[0].start; let i = start; function addStyle(s, e, l, st) { const dir = spanGaps ? -1 : 1; if (s === e) { return; } s += count; while (points[s % count].skip) { s -= dir; } while (points[e % count].skip) { e += dir; } if (s % count !== e % count) { result.push({start: s % count, end: e % count, loop: l, style: st}); prevStyle = st; start = e % count; } } for (const segment of segments) { start = spanGaps ? start : segment.start; let prev = points[start % count]; let style; for (i = start + 1; i <= segment.end; i++) { const pt = points[i % count]; style = readStyle(segmentOptions.setContext(createContext(chartContext, { type: 'segment', p0: prev, p1: pt, p0DataIndex: (i - 1) % count, p1DataIndex: i % count, datasetIndex }))); if (styleChanged(style, prevStyle)) { addStyle(start, i - 1, segment.loop, prevStyle); } prev = pt; prevStyle = style; } if (start < i - 1) { addStyle(start, i - 1, segment.loop, prevStyle); } } return result; } function readStyle(options) { return { backgroundColor: options.backgroundColor, borderCapStyle: options.borderCapStyle, borderDash: options.borderDash, borderDashOffset: options.borderDashOffset, borderJoinStyle: options.borderJoinStyle, borderWidth: options.borderWidth, borderColor: options.borderColor }; } function styleChanged(style, prevStyle) { return prevStyle && JSON.stringify(style) !== JSON.stringify(prevStyle); } var helpers = /*#__PURE__*/Object.freeze({ __proto__: null, easingEffects: effects, isPatternOrGradient: isPatternOrGradient, color: color, getHoverColor: getHoverColor, noop: noop, uid: uid, isNullOrUndef: isNullOrUndef, isArray: isArray, isObject: isObject, isFinite: isNumberFinite, finiteOrDefault: finiteOrDefault, valueOrDefault: valueOrDefault, toPercentage: toPercentage, toDimension: toDimension, callback: callback, each: each, _elementsEqual: _elementsEqual, clone: clone, _merger: _merger, merge: merge, mergeIf: mergeIf, _mergerIf: _mergerIf, _deprecated: _deprecated, resolveObjectKey: resolveObjectKey, _capitalize: _capitalize, defined: defined, isFunction: isFunction, setsEqual: setsEqual, _isClickEvent: _isClickEvent, toFontString: toFontString, _measureText: _measureText, _longestText: _longestText, _alignPixel: _alignPixel, clearCanvas: clearCanvas, drawPoint: drawPoint, _isPointInArea: _isPointInArea, clipArea: clipArea, unclipArea: unclipArea, _steppedLineTo: _steppedLineTo, _bezierCurveTo: _bezierCurveTo, renderText: renderText, addRoundedRectPath: addRoundedRectPath, _lookup: _lookup, _lookupByKey: _lookupByKey, _rlookupByKey: _rlookupByKey, _filterBetween: _filterBetween, listenArrayEvents: listenArrayEvents, unlistenArrayEvents: unlistenArrayEvents, _arrayUnique: _arrayUnique, _createResolver: _createResolver, _attachContext: _attachContext, _descriptors: _descriptors, _parseObjectDataRadialScale: _parseObjectDataRadialScale, splineCurve: splineCurve, splineCurveMonotone: splineCurveMonotone, _updateBezierControlPoints: _updateBezierControlPoints, _isDomSupported: _isDomSupported, _getParentNode: _getParentNode, getStyle: getStyle, getRelativePosition: getRelativePosition, getMaximumSize: getMaximumSize, retinaScale: retinaScale, supportsEventListenerOptions: supportsEventListenerOptions, readUsedSize: readUsedSize, fontString: fontString, requestAnimFrame: requestAnimFrame, throttled: throttled, debounce: debounce, _toLeftRightCenter: _toLeftRightCenter, _alignStartEnd: _alignStartEnd, _textX: _textX, _pointInLine: _pointInLine, _steppedInterpolation: _steppedInterpolation, _bezierInterpolation: _bezierInterpolation, formatNumber: formatNumber, toLineHeight: toLineHeight, _readValueToProps: _readValueToProps, toTRBL: toTRBL, toTRBLCorners: toTRBLCorners, toPadding: toPadding, toFont: toFont, resolve: resolve, _addGrace: _addGrace, createContext: createContext, PI: PI, TAU: TAU, PITAU: PITAU, INFINITY: INFINITY, RAD_PER_DEG: RAD_PER_DEG, HALF_PI: HALF_PI, QUARTER_PI: QUARTER_PI, TWO_THIRDS_PI: TWO_THIRDS_PI, log10: log10, sign: sign, niceNum: niceNum, _factorize: _factorize, isNumber: isNumber, almostEquals: almostEquals, almostWhole: almostWhole, _setMinAndMaxByKey: _setMinAndMaxByKey, toRadians: toRadians, toDegrees: toDegrees, _decimalPlaces: _decimalPlaces, getAngleFromPoint: getAngleFromPoint, distanceBetweenPoints: distanceBetweenPoints, _angleDiff: _angleDiff, _normalizeAngle: _normalizeAngle, _angleBetween: _angleBetween, _limitValue: _limitValue, _int16Range: _int16Range, _isBetween: _isBetween, getRtlAdapter: getRtlAdapter, overrideTextDirection: overrideTextDirection, restoreTextDirection: restoreTextDirection, _boundSegment: _boundSegment, _boundSegments: _boundSegments, _computeSegments: _computeSegments }); function binarySearch(metaset, axis, value, intersect) { const {controller, data, _sorted} = metaset; const iScale = controller._cachedMeta.iScale; if (iScale && axis === iScale.axis && axis !== 'r' && _sorted && data.length) { const lookupMethod = iScale._reversePixels ? _rlookupByKey : _lookupByKey; if (!intersect) { return lookupMethod(data, axis, value); } else if (controller._sharedOptions) { const el = data[0]; const range = typeof el.getRange === 'function' && el.getRange(axis); if (range) { const start = lookupMethod(data, axis, value - range); const end = lookupMethod(data, axis, value + range); return {lo: start.lo, hi: end.hi}; } } } return {lo: 0, hi: data.length - 1}; } function evaluateInteractionItems(chart, axis, position, handler, intersect) { const metasets = chart.getSortedVisibleDatasetMetas(); const value = position[axis]; for (let i = 0, ilen = metasets.length; i < ilen; ++i) { const {index, data} = metasets[i]; const {lo, hi} = binarySearch(metasets[i], axis, value, intersect); for (let j = lo; j <= hi; ++j) { const element = data[j]; if (!element.skip) { handler(element, index, j); } } } } function getDistanceMetricForAxis(axis) { const useX = axis.indexOf('x') !== -1; const useY = axis.indexOf('y') !== -1; return function(pt1, pt2) { const deltaX = useX ? Math.abs(pt1.x - pt2.x) : 0; const deltaY = useY ? Math.abs(pt1.y - pt2.y) : 0; return Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)); }; } function getIntersectItems(chart, position, axis, useFinalPosition, includeInvisible) { const items = []; if (!includeInvisible && !chart.isPointInArea(position)) { return items; } const evaluationFunc = function(element, datasetIndex, index) { if (!includeInvisible && !_isPointInArea(element, chart.chartArea, 0)) { return; } if (element.inRange(position.x, position.y, useFinalPosition)) { items.push({element, datasetIndex, index}); } }; evaluateInteractionItems(chart, axis, position, evaluationFunc, true); return items; } function getNearestRadialItems(chart, position, axis, useFinalPosition) { let items = []; function evaluationFunc(element, datasetIndex, index) { const {startAngle, endAngle} = element.getProps(['startAngle', 'endAngle'], useFinalPosition); const {angle} = getAngleFromPoint(element, {x: position.x, y: position.y}); if (_angleBetween(angle, startAngle, endAngle)) { items.push({element, datasetIndex, index}); } } evaluateInteractionItems(chart, axis, position, evaluationFunc); return items; } function getNearestCartesianItems(chart, position, axis, intersect, useFinalPosition, includeInvisible) { let items = []; const distanceMetric = getDistanceMetricForAxis(axis); let minDistance = Number.POSITIVE_INFINITY; function evaluationFunc(element, datasetIndex, index) { const inRange = element.inRange(position.x, position.y, useFinalPosition); if (intersect && !inRange) { return; } const center = element.getCenterPoint(useFinalPosition); const pointInArea = !!includeInvisible || chart.isPointInArea(center); if (!pointInArea && !inRange) { return; } const distance = distanceMetric(position, center); if (distance < minDistance) { items = [{element, datasetIndex, index}]; minDistance = distance; } else if (distance === minDistance) { items.push({element, datasetIndex, index}); } } evaluateInteractionItems(chart, axis, position, evaluationFunc); return items; } function getNearestItems(chart, position, axis, intersect, useFinalPosition, includeInvisible) { if (!includeInvisible && !chart.isPointInArea(position)) { return []; } return axis === 'r' && !intersect ? getNearestRadialItems(chart, position, axis, useFinalPosition) : getNearestCartesianItems(chart, position, axis, intersect, useFinalPosition, includeInvisible); } function getAxisItems(chart, position, axis, intersect, useFinalPosition) { const items = []; const rangeMethod = axis === 'x' ? 'inXRange' : 'inYRange'; let intersectsItem = false; evaluateInteractionItems(chart, axis, position, (element, datasetIndex, index) => { if (element[rangeMethod](position[axis], useFinalPosition)) { items.push({element, datasetIndex, index}); intersectsItem = intersectsItem || element.inRange(position.x, position.y, useFinalPosition); } }); if (intersect && !intersectsItem) { return []; } return items; } var Interaction = { evaluateInteractionItems, modes: { index(chart, e, options, useFinalPosition) { const position = getRelativePosition(e, chart); const axis = options.axis || 'x'; const includeInvisible = options.includeInvisible || false; const items = options.intersect ? getIntersectItems(chart, position, axis, useFinalPosition, includeInvisible) : getNearestItems(chart, position, axis, false, useFinalPosition, includeInvisible); const elements = []; if (!items.length) { return []; } chart.getSortedVisibleDatasetMetas().forEach((meta) => { const index = items[0].index; const element = meta.data[index]; if (element && !element.skip) { elements.push({element, datasetIndex: meta.index, index}); } }); return elements; }, dataset(chart, e, options, useFinalPosition) { const position = getRelativePosition(e, chart); const axis = options.axis || 'xy'; const includeInvisible = options.includeInvisible || false; let items = options.intersect ? getIntersectItems(chart, position, axis, useFinalPosition, includeInvisible) : getNearestItems(chart, position, axis, false, useFinalPosition, includeInvisible); if (items.length > 0) { const datasetIndex = items[0].datasetIndex; const data = chart.getDatasetMeta(datasetIndex).data; items = []; for (let i = 0; i < data.length; ++i) { items.push({element: data[i], datasetIndex, index: i}); } } return items; }, point(chart, e, options, useFinalPosition) { const position = getRelativePosition(e, chart); const axis = options.axis || 'xy'; const includeInvisible = options.includeInvisible || false; return getIntersectItems(chart, position, axis, useFinalPosition, includeInvisible); }, nearest(chart, e, options, useFinalPosition) { const position = getRelativePosition(e, chart); const axis = options.axis || 'xy'; const includeInvisible = options.includeInvisible || false; return getNearestItems(chart, position, axis, options.intersect, useFinalPosition, includeInvisible); }, x(chart, e, options, useFinalPosition) { const position = getRelativePosition(e, chart); return getAxisItems(chart, position, 'x', options.intersect, useFinalPosition); }, y(chart, e, options, useFinalPosition) { const position = getRelativePosition(e, chart); return getAxisItems(chart, position, 'y', options.intersect, useFinalPosition); } } }; const STATIC_POSITIONS = ['left', 'top', 'right', 'bottom']; function filterByPosition(array, position) { return array.filter(v => v.pos === position); } function filterDynamicPositionByAxis(array, axis) { return array.filter(v => STATIC_POSITIONS.indexOf(v.pos) === -1 && v.box.axis === axis); } function sortByWeight(array, reverse) { return array.sort((a, b) => { const v0 = reverse ? b : a; const v1 = reverse ? a : b; return v0.weight === v1.weight ? v0.index - v1.index : v0.weight - v1.weight; }); } function wrapBoxes(boxes) { const layoutBoxes = []; let i, ilen, box, pos, stack, stackWeight; for (i = 0, ilen = (boxes || []).length; i < ilen; ++i) { box = boxes[i]; ({position: pos, options: {stack, stackWeight = 1}} = box); layoutBoxes.push({ index: i, box, pos, horizontal: box.isHorizontal(), weight: box.weight, stack: stack && (pos + stack), stackWeight }); } return layoutBoxes; } function buildStacks(layouts) { const stacks = {}; for (const wrap of layouts) { const {stack, pos, stackWeight} = wrap; if (!stack || !STATIC_POSITIONS.includes(pos)) { continue; } const _stack = stacks[stack] || (stacks[stack] = {count: 0, placed: 0, weight: 0, size: 0}); _stack.count++; _stack.weight += stackWeight; } return stacks; } function setLayoutDims(layouts, params) { const stacks = buildStacks(layouts); const {vBoxMaxWidth, hBoxMaxHeight} = params; let i, ilen, layout; for (i = 0, ilen = layouts.length; i < ilen; ++i) { layout = layouts[i]; const {fullSize} = layout.box; const stack = stacks[layout.stack]; const factor = stack && layout.stackWeight / stack.weight; if (layout.horizontal) { layout.width = factor ? factor * vBoxMaxWidth : fullSize && params.availableWidth; layout.height = hBoxMaxHeight; } else { layout.width = vBoxMaxWidth; layout.height = factor ? factor * hBoxMaxHeight : fullSize && params.availableHeight; } } return stacks; } function buildLayoutBoxes(boxes) { const layoutBoxes = wrapBoxes(boxes); const fullSize = sortByWeight(layoutBoxes.filter(wrap => wrap.box.fullSize), true); const left = sortByWeight(filterByPosition(layoutBoxes, 'left'), true); const right = sortByWeight(filterByPosition(layoutBoxes, 'right')); const top = sortByWeight(filterByPosition(layoutBoxes, 'top'), true); const bottom = sortByWeight(filterByPosition(layoutBoxes, 'bottom')); const centerHorizontal = filterDynamicPositionByAxis(layoutBoxes, 'x'); const centerVertical = filterDynamicPositionByAxis(layoutBoxes, 'y'); return { fullSize, leftAndTop: left.concat(top), rightAndBottom: right.concat(centerVertical).concat(bottom).concat(centerHorizontal), chartArea: filterByPosition(layoutBoxes, 'chartArea'), vertical: left.concat(right).concat(centerVertical), horizontal: top.concat(bottom).concat(centerHorizontal) }; } function getCombinedMax(maxPadding, chartArea, a, b) { return Math.max(maxPadding[a], chartArea[a]) + Math.max(maxPadding[b], chartArea[b]); } function updateMaxPadding(maxPadding, boxPadding) { maxPadding.top = Math.max(maxPadding.top, boxPadding.top); maxPadding.left = Math.max(maxPadding.left, boxPadding.left); maxPadding.bottom = Math.max(maxPadding.bottom, boxPadding.bottom); maxPadding.right = Math.max(maxPadding.right, boxPadding.right); } function updateDims(chartArea, params, layout, stacks) { const {pos, box} = layout; const maxPadding = chartArea.maxPadding; if (!isObject(pos)) { if (layout.size) { chartArea[pos] -= layout.size; } const stack = stacks[layout.stack] || {size: 0, count: 1}; stack.size = Math.max(stack.size, layout.horizontal ? box.height : box.width); layout.size = stack.size / stack.count; chartArea[pos] += layout.size; } if (box.getPadding) { updateMaxPadding(maxPadding, box.getPadding()); } const newWidth = Math.max(0, params.outerWidth - getCombinedMax(maxPadding, chartArea, 'left', 'right')); const newHeight = Math.max(0, params.outerHeight - getCombinedMax(maxPadding, chartArea, 'top', 'bottom')); const widthChanged = newWidth !== chartArea.w; const heightChanged = newHeight !== chartArea.h; chartArea.w = newWidth; chartArea.h = newHeight; return layout.horizontal ? {same: widthChanged, other: heightChanged} : {same: heightChanged, other: widthChanged}; } function handleMaxPadding(chartArea) { const maxPadding = chartArea.maxPadding; function updatePos(pos) { const change = Math.max(maxPadding[pos] - chartArea[pos], 0); chartArea[pos] += change; return change; } chartArea.y += updatePos('top'); chartArea.x += updatePos('left'); updatePos('right'); updatePos('bottom'); } function getMargins(horizontal, chartArea) { const maxPadding = chartArea.maxPadding; function marginForPositions(positions) { const margin = {left: 0, top: 0, right: 0, bottom: 0}; positions.forEach((pos) => { margin[pos] = Math.max(chartArea[pos], maxPadding[pos]); }); return margin; } return horizontal ? marginForPositions(['left', 'right']) : marginForPositions(['top', 'bottom']); } function fitBoxes(boxes, chartArea, params, stacks) { const refitBoxes = []; let i, ilen, layout, box, refit, changed; for (i = 0, ilen = boxes.length, refit = 0; i < ilen; ++i) { layout = boxes[i]; box = layout.box; box.update( layout.width || chartArea.w, layout.height || chartArea.h, getMargins(layout.horizontal, chartArea) ); const {same, other} = updateDims(chartArea, params, layout, stacks); refit |= same && refitBoxes.length; changed = changed || other; if (!box.fullSize) { refitBoxes.push(layout); } } return refit && fitBoxes(refitBoxes, chartArea, params, stacks) || changed; } function setBoxDims(box, left, top, width, height) { box.top = top; box.left = left; box.right = left + width; box.bottom = top + height; box.width = width; box.height = height; } function placeBoxes(boxes, chartArea, params, stacks) { const userPadding = params.padding; let {x, y} = chartArea; for (const layout of boxes) { const box = layout.box; const stack = stacks[layout.stack] || {count: 1, placed: 0, weight: 1}; const weight = (layout.stackWeight / stack.weight) || 1; if (layout.horizontal) { const width = chartArea.w * weight; const height = stack.size || box.height; if (defined(stack.start)) { y = stack.start; } if (box.fullSize) { setBoxDims(box, userPadding.left, y, params.outerWidth - userPadding.right - userPadding.left, height); } else { setBoxDims(box, chartArea.left + stack.placed, y, width, height); } stack.start = y; stack.placed += width; y = box.bottom; } else { const height = chartArea.h * weight; const width = stack.size || box.width; if (defined(stack.start)) { x = stack.start; } if (box.fullSize) { setBoxDims(box, x, userPadding.top, width, params.outerHeight - userPadding.bottom - userPadding.top); } else { setBoxDims(box, x, chartArea.top + stack.placed, width, height); } stack.start = x; stack.placed += height; x = box.right; } } chartArea.x = x; chartArea.y = y; } defaults.set('layout', { autoPadding: true, padding: { top: 0, right: 0, bottom: 0, left: 0 } }); var layouts = { addBox(chart, item) { if (!chart.boxes) { chart.boxes = []; } item.fullSize = item.fullSize || false; item.position = item.position || 'top'; item.weight = item.weight || 0; item._layers = item._layers || function() { return [{ z: 0, draw(chartArea) { item.draw(chartArea); } }]; }; chart.boxes.push(item); }, removeBox(chart, layoutItem) { const index = chart.boxes ? chart.boxes.indexOf(layoutItem) : -1; if (index !== -1) { chart.boxes.splice(index, 1); } }, configure(chart, item, options) { item.fullSize = options.fullSize; item.position = options.position; item.weight = options.weight; }, update(chart, width, height, minPadding) { if (!chart) { return; } const padding = toPadding(chart.options.layout.padding); const availableWidth = Math.max(width - padding.width, 0); const availableHeight = Math.max(height - padding.height, 0); const boxes = buildLayoutBoxes(chart.boxes); const verticalBoxes = boxes.vertical; const horizontalBoxes = boxes.horizontal; each(chart.boxes, box => { if (typeof box.beforeLayout === 'function') { box.beforeLayout(); } }); const visibleVerticalBoxCount = verticalBoxes.reduce((total, wrap) => wrap.box.options && wrap.box.options.display === false ? total : total + 1, 0) || 1; const params = Object.freeze({ outerWidth: width, outerHeight: height, padding, availableWidth, availableHeight, vBoxMaxWidth: availableWidth / 2 / visibleVerticalBoxCount, hBoxMaxHeight: availableHeight / 2 }); const maxPadding = Object.assign({}, padding); updateMaxPadding(maxPadding, toPadding(minPadding)); const chartArea = Object.assign({ maxPadding, w: availableWidth, h: availableHeight, x: padding.left, y: padding.top }, padding); const stacks = setLayoutDims(verticalBoxes.concat(horizontalBoxes), params); fitBoxes(boxes.fullSize, chartArea, params, stacks); fitBoxes(verticalBoxes, chartArea, params, stacks); if (fitBoxes(horizontalBoxes, chartArea, params, stacks)) { fitBoxes(verticalBoxes, chartArea, params, stacks); } handleMaxPadding(chartArea); placeBoxes(boxes.leftAndTop, chartArea, params, stacks); chartArea.x += chartArea.w; chartArea.y += chartArea.h; placeBoxes(boxes.rightAndBottom, chartArea, params, stacks); chart.chartArea = { left: chartArea.left, top: chartArea.top, right: chartArea.left + chartArea.w, bottom: chartArea.top + chartArea.h, height: chartArea.h, width: chartArea.w, }; each(boxes.chartArea, (layout) => { const box = layout.box; Object.assign(box, chart.chartArea); box.update(chartArea.w, chartArea.h, {left: 0, top: 0, right: 0, bottom: 0}); }); } }; class BasePlatform { acquireContext(canvas, aspectRatio) {} releaseContext(context) { return false; } addEventListener(chart, type, listener) {} removeEventListener(chart, type, listener) {} getDevicePixelRatio() { return 1; } getMaximumSize(element, width, height, aspectRatio) { width = Math.max(0, width || element.width); height = height || element.height; return { width, height: Math.max(0, aspectRatio ? Math.floor(width / aspectRatio) : height) }; } isAttached(canvas) { return true; } updateConfig(config) { } } class BasicPlatform extends BasePlatform { acquireContext(item) { return item && item.getContext && item.getContext('2d') || null; } updateConfig(config) { config.options.animation = false; } } const EXPANDO_KEY = '$chartjs'; const EVENT_TYPES = { touchstart: 'mousedown', touchmove: 'mousemove', touchend: 'mouseup', pointerenter: 'mouseenter', pointerdown: 'mousedown', pointermove: 'mousemove', pointerup: 'mouseup', pointerleave: 'mouseout', pointerout: 'mouseout' }; const isNullOrEmpty = value => value === null || value === ''; function initCanvas(canvas, aspectRatio) { const style = canvas.style; const renderHeight = canvas.getAttribute('height'); const renderWidth = canvas.getAttribute('width'); canvas[EXPANDO_KEY] = { initial: { height: renderHeight, width: renderWidth, style: { display: style.display, height: style.height, width: style.width } } }; style.display = style.display || 'block'; style.boxSizing = style.boxSizing || 'border-box'; if (isNullOrEmpty(renderWidth)) { const displayWidth = readUsedSize(canvas, 'width'); if (displayWidth !== undefined) { canvas.width = displayWidth; } } if (isNullOrEmpty(renderHeight)) { if (canvas.style.height === '') { canvas.height = canvas.width / (aspectRatio || 2); } else { const displayHeight = readUsedSize(canvas, 'height'); if (displayHeight !== undefined) { canvas.height = displayHeight; } } } return canvas; } const eventListenerOptions = supportsEventListenerOptions ? {passive: true} : false; function addListener(node, type, listener) { node.addEventListener(type, listener, eventListenerOptions); } function removeListener(chart, type, listener) { chart.canvas.removeEventListener(type, listener, eventListenerOptions); } function fromNativeEvent(event, chart) { const type = EVENT_TYPES[event.type] || event.type; const {x, y} = getRelativePosition(event, chart); return { type, chart, native: event, x: x !== undefined ? x : null, y: y !== undefined ? y : null, }; } function nodeListContains(nodeList, canvas) { for (const node of nodeList) { if (node === canvas || node.contains(canvas)) { return true; } } } function createAttachObserver(chart, type, listener) { const canvas = chart.canvas; const observer = new MutationObserver(entries => { let trigger = false; for (const entry of entries) { trigger = trigger || nodeListContains(entry.addedNodes, canvas); trigger = trigger && !nodeListContains(entry.removedNodes, canvas); } if (trigger) { listener(); } }); observer.observe(document, {childList: true, subtree: true}); return observer; } function createDetachObserver(chart, type, listener) { const canvas = chart.canvas; const observer = new MutationObserver(entries => { let trigger = false; for (const entry of entries) { trigger = trigger || nodeListContains(entry.removedNodes, canvas); trigger = trigger && !nodeListContains(entry.addedNodes, canvas); } if (trigger) { listener(); } }); observer.observe(document, {childList: true, subtree: true}); return observer; } const drpListeningCharts = new Map(); let oldDevicePixelRatio = 0; function onWindowResize() { const dpr = window.devicePixelRatio; if (dpr === oldDevicePixelRatio) { return; } oldDevicePixelRatio = dpr; drpListeningCharts.forEach((resize, chart) => { if (chart.currentDevicePixelRatio !== dpr) { resize(); } }); } function listenDevicePixelRatioChanges(chart, resize) { if (!drpListeningCharts.size) { window.addEventListener('resize', onWindowResize); } drpListeningCharts.set(chart, resize); } function unlistenDevicePixelRatioChanges(chart) { drpListeningCharts.delete(chart); if (!drpListeningCharts.size) { window.removeEventListener('resize', onWindowResize); } } function createResizeObserver(chart, type, listener) { const canvas = chart.canvas; const container = canvas && _getParentNode(canvas); if (!container) { return; } const resize = throttled((width, height) => { const w = container.clientWidth; listener(width, height); if (w < container.clientWidth) { listener(); } }, window); const observer = new ResizeObserver(entries => { const entry = entries[0]; const width = entry.contentRect.width; const height = entry.contentRect.height; if (width === 0 && height === 0) { return; } resize(width, height); }); observer.observe(container); listenDevicePixelRatioChanges(chart, resize); return observer; } function releaseObserver(chart, type, observer) { if (observer) { observer.disconnect(); } if (type === 'resize') { unlistenDevicePixelRatioChanges(chart); } } function createProxyAndListen(chart, type, listener) { const canvas = chart.canvas; const proxy = throttled((event) => { if (chart.ctx !== null) { listener(fromNativeEvent(event, chart)); } }, chart, (args) => { const event = args[0]; return [event, event.offsetX, event.offsetY]; }); addListener(canvas, type, proxy); return proxy; } class DomPlatform extends BasePlatform { acquireContext(canvas, aspectRatio) { const context = canvas && canvas.getContext && canvas.getContext('2d'); if (context && context.canvas === canvas) { initCanvas(canvas, aspectRatio); return context; } return null; } releaseContext(context) { const canvas = context.canvas; if (!canvas[EXPANDO_KEY]) { return false; } const initial = canvas[EXPANDO_KEY].initial; ['height', 'width'].forEach((prop) => { const value = initial[prop]; if (isNullOrUndef(value)) { canvas.removeAttribute(prop); } else { canvas.setAttribute(prop, value); } }); const style = initial.style || {}; Object.keys(style).forEach((key) => { canvas.style[key] = style[key]; }); canvas.width = canvas.width; delete canvas[EXPANDO_KEY]; return true; } addEventListener(chart, type, listener) { this.removeEventListener(chart, type); const proxies = chart.$proxies || (chart.$proxies = {}); const handlers = { attach: createAttachObserver, detach: createDetachObserver, resize: createResizeObserver }; const handler = handlers[type] || createProxyAndListen; proxies[type] = handler(chart, type, listener); } removeEventListener(chart, type) { const proxies = chart.$proxies || (chart.$proxies = {}); const proxy = proxies[type]; if (!proxy) { return; } const handlers = { attach: releaseObserver, detach: releaseObserver, resize: releaseObserver }; const handler = handlers[type] || removeListener; handler(chart, type, proxy); proxies[type] = undefined; } getDevicePixelRatio() { return window.devicePixelRatio; } getMaximumSize(canvas, width, height, aspectRatio) { return getMaximumSize(canvas, width, height, aspectRatio); } isAttached(canvas) { const container = _getParentNode(canvas); return !!(container && container.isConnected); } } function _detectPlatform(canvas) { if (!_isDomSupported() || (typeof OffscreenCanvas !== 'undefined' && canvas instanceof OffscreenCanvas)) { return BasicPlatform; } return DomPlatform; } var platforms = /*#__PURE__*/Object.freeze({ __proto__: null, _detectPlatform: _detectPlatform, BasePlatform: BasePlatform, BasicPlatform: BasicPlatform, DomPlatform: DomPlatform }); const transparent = 'transparent'; const interpolators = { boolean(from, to, factor) { return factor > 0.5 ? to : from; }, color(from, to, factor) { const c0 = color(from || transparent); const c1 = c0.valid && color(to || transparent); return c1 && c1.valid ? c1.mix(c0, factor).hexString() : to; }, number(from, to, factor) { return from + (to - from) * factor; } }; class Animation { constructor(cfg, target, prop, to) { const currentValue = target[prop]; to = resolve([cfg.to, to, currentValue, cfg.from]); const from = resolve([cfg.from, currentValue, to]); this._active = true; this._fn = cfg.fn || interpolators[cfg.type || typeof from]; this._easing = effects[cfg.easing] || effects.linear; this._start = Math.floor(Date.now() + (cfg.delay || 0)); this._duration = this._total = Math.floor(cfg.duration); this._loop = !!cfg.loop; this._target = target; this._prop = prop; this._from = from; this._to = to; this._promises = undefined; } active() { return this._active; } update(cfg, to, date) { if (this._active) { this._notify(false); const currentValue = this._target[this._prop]; const elapsed = date - this._start; const remain = this._duration - elapsed; this._start = date; this._duration = Math.floor(Math.max(remain, cfg.duration)); this._total += elapsed; this._loop = !!cfg.loop; this._to = resolve([cfg.to, to, currentValue, cfg.from]); this._from = resolve([cfg.from, currentValue, to]); } } cancel() { if (this._active) { this.tick(Date.now()); this._active = false; this._notify(false); } } tick(date) { const elapsed = date - this._start; const duration = this._duration; const prop = this._prop; const from = this._from; const loop = this._loop; const to = this._to; let factor; this._active = from !== to && (loop || (elapsed < duration)); if (!this._active) { this._target[prop] = to; this._notify(true); return; } if (elapsed < 0) { this._target[prop] = from; return; } factor = (elapsed / duration) % 2; factor = loop && factor > 1 ? 2 - factor : factor; factor = this._easing(Math.min(1, Math.max(0, factor))); this._target[prop] = this._fn(from, to, factor); } wait() { const promises = this._promises || (this._promises = []); return new Promise((res, rej) => { promises.push({res, rej}); }); } _notify(resolved) { const method = resolved ? 'res' : 'rej'; const promises = this._promises || []; for (let i = 0; i < promises.length; i++) { promises[i][method](); } } } const numbers = ['x', 'y', 'borderWidth', 'radius', 'tension']; const colors = ['color', 'borderColor', 'backgroundColor']; defaults.set('animation', { delay: undefined, duration: 1000, easing: 'easeOutQuart', fn: undefined, from: undefined, loop: undefined, to: undefined, type: undefined, }); const animationOptions = Object.keys(defaults.animation); defaults.describe('animation', { _fallback: false, _indexable: false, _scriptable: (name) => name !== 'onProgress' && name !== 'onComplete' && name !== 'fn', }); defaults.set('animations', { colors: { type: 'color', properties: colors }, numbers: { type: 'number', properties: numbers }, }); defaults.describe('animations', { _fallback: 'animation', }); defaults.set('transitions', { active: { animation: { duration: 400 } }, resize: { animation: { duration: 0 } }, show: { animations: { colors: { from: 'transparent' }, visible: { type: 'boolean', duration: 0 }, } }, hide: { animations: { colors: { to: 'transparent' }, visible: { type: 'boolean', easing: 'linear', fn: v => v | 0 }, } } }); class Animations { constructor(chart, config) { this._chart = chart; this._properties = new Map(); this.configure(config); } configure(config) { if (!isObject(config)) { return; } const animatedProps = this._properties; Object.getOwnPropertyNames(config).forEach(key => { const cfg = config[key]; if (!isObject(cfg)) { return; } const resolved = {}; for (const option of animationOptions) { resolved[option] = cfg[option]; } (isArray(cfg.properties) && cfg.properties || [key]).forEach((prop) => { if (prop === key || !animatedProps.has(prop)) { animatedProps.set(prop, resolved); } }); }); } _animateOptions(target, values) { const newOptions = values.options; const options = resolveTargetOptions(target, newOptions); if (!options) { return []; } const animations = this._createAnimations(options, newOptions); if (newOptions.$shared) { awaitAll(target.options.$animations, newOptions).then(() => { target.options = newOptions; }, () => { }); } return animations; } _createAnimations(target, values) { const animatedProps = this._properties; const animations = []; const running = target.$animations || (target.$animations = {}); const props = Object.keys(values); const date = Date.now(); let i; for (i = props.length - 1; i >= 0; --i) { const prop = props[i]; if (prop.charAt(0) === '$') { continue; } if (prop === 'options') { animations.push(...this._animateOptions(target, values)); continue; } const value = values[prop]; let animation = running[prop]; const cfg = animatedProps.get(prop); if (animation) { if (cfg && animation.active()) { animation.update(cfg, value, date); continue; } else { animation.cancel(); } } if (!cfg || !cfg.duration) { target[prop] = value; continue; } running[prop] = animation = new Animation(cfg, target, prop, value); animations.push(animation); } return animations; } update(target, values) { if (this._properties.size === 0) { Object.assign(target, values); return; } const animations = this._createAnimations(target, values); if (animations.length) { animator.add(this._chart, animations); return true; } } } function awaitAll(animations, properties) { const running = []; const keys = Object.keys(properties); for (let i = 0; i < keys.length; i++) { const anim = animations[keys[i]]; if (anim && anim.active()) { running.push(anim.wait()); } } return Promise.all(running); } function resolveTargetOptions(target, newOptions) { if (!newOptions) { return; } let options = target.options; if (!options) { target.options = newOptions; return; } if (options.$shared) { target.options = options = Object.assign({}, options, {$shared: false, $animations: {}}); } return options; } function scaleClip(scale, allowedOverflow) { const opts = scale && scale.options || {}; const reverse = opts.reverse; const min = opts.min === undefined ? allowedOverflow : 0; const max = opts.max === undefined ? allowedOverflow : 0; return { start: reverse ? max : min, end: reverse ? min : max }; } function defaultClip(xScale, yScale, allowedOverflow) { if (allowedOverflow === false) { return false; } const x = scaleClip(xScale, allowedOverflow); const y = scaleClip(yScale, allowedOverflow); return { top: y.end, right: x.end, bottom: y.start, left: x.start }; } function toClip(value) { let t, r, b, l; if (isObject(value)) { t = value.top; r = value.right; b = value.bottom; l = value.left; } else { t = r = b = l = value; } return { top: t, right: r, bottom: b, left: l, disabled: value === false }; } function getSortedDatasetIndices(chart, filterVisible) { const keys = []; const metasets = chart._getSortedDatasetMetas(filterVisible); let i, ilen; for (i = 0, ilen = metasets.length; i < ilen; ++i) { keys.push(metasets[i].index); } return keys; } function applyStack(stack, value, dsIndex, options = {}) { const keys = stack.keys; const singleMode = options.mode === 'single'; let i, ilen, datasetIndex, otherValue; if (value === null) { return; } for (i = 0, ilen = keys.length; i < ilen; ++i) { datasetIndex = +keys[i]; if (datasetIndex === dsIndex) { if (options.all) { continue; } break; } otherValue = stack.values[datasetIndex]; if (isNumberFinite(otherValue) && (singleMode || (value === 0 || sign(value) === sign(otherValue)))) { value += otherValue; } } return value; } function convertObjectDataToArray(data) { const keys = Object.keys(data); const adata = new Array(keys.length); let i, ilen, key; for (i = 0, ilen = keys.length; i < ilen; ++i) { key = keys[i]; adata[i] = { x: key, y: data[key] }; } return adata; } function isStacked(scale, meta) { const stacked = scale && scale.options.stacked; return stacked || (stacked === undefined && meta.stack !== undefined); } function getStackKey(indexScale, valueScale, meta) { return `${indexScale.id}.${valueScale.id}.${meta.stack || meta.type}`; } function getUserBounds(scale) { const {min, max, minDefined, maxDefined} = scale.getUserBounds(); return { min: minDefined ? min : Number.NEGATIVE_INFINITY, max: maxDefined ? max : Number.POSITIVE_INFINITY }; } function getOrCreateStack(stacks, stackKey, indexValue) { const subStack = stacks[stackKey] || (stacks[stackKey] = {}); return subStack[indexValue] || (subStack[indexValue] = {}); } function getLastIndexInStack(stack, vScale, positive, type) { for (const meta of vScale.getMatchingVisibleMetas(type).reverse()) { const value = stack[meta.index]; if ((positive && value > 0) || (!positive && value < 0)) { return meta.index; } } return null; } function updateStacks(controller, parsed) { const {chart, _cachedMeta: meta} = controller; const stacks = chart._stacks || (chart._stacks = {}); const {iScale, vScale, index: datasetIndex} = meta; const iAxis = iScale.axis; const vAxis = vScale.axis; const key = getStackKey(iScale, vScale, meta); const ilen = parsed.length; let stack; for (let i = 0; i < ilen; ++i) { const item = parsed[i]; const {[iAxis]: index, [vAxis]: value} = item; const itemStacks = item._stacks || (item._stacks = {}); stack = itemStacks[vAxis] = getOrCreateStack(stacks, key, index); stack[datasetIndex] = value; stack._top = getLastIndexInStack(stack, vScale, true, meta.type); stack._bottom = getLastIndexInStack(stack, vScale, false, meta.type); } } function getFirstScaleId(chart, axis) { const scales = chart.scales; return Object.keys(scales).filter(key => scales[key].axis === axis).shift(); } function createDatasetContext(parent, index) { return createContext(parent, { active: false, dataset: undefined, datasetIndex: index, index, mode: 'default', type: 'dataset' } ); } function createDataContext(parent, index, element) { return createContext(parent, { active: false, dataIndex: index, parsed: undefined, raw: undefined, element, index, mode: 'default', type: 'data' }); } function clearStacks(meta, items) { const datasetIndex = meta.controller.index; const axis = meta.vScale && meta.vScale.axis; if (!axis) { return; } items = items || meta._parsed; for (const parsed of items) { const stacks = parsed._stacks; if (!stacks || stacks[axis] === undefined || stacks[axis][datasetIndex] === undefined) { return; } delete stacks[axis][datasetIndex]; } } const isDirectUpdateMode = (mode) => mode === 'reset' || mode === 'none'; const cloneIfNotShared = (cached, shared) => shared ? cached : Object.assign({}, cached); const createStack = (canStack, meta, chart) => canStack && !meta.hidden && meta._stacked && {keys: getSortedDatasetIndices(chart, true), values: null}; class DatasetController { constructor(chart, datasetIndex) { this.chart = chart; this._ctx = chart.ctx; this.index = datasetIndex; this._cachedDataOpts = {}; this._cachedMeta = this.getMeta(); this._type = this._cachedMeta.type; this.options = undefined; this._parsing = false; this._data = undefined; this._objectData = undefined; this._sharedOptions = undefined; this._drawStart = undefined; this._drawCount = undefined; this.enableOptionSharing = false; this.supportsDecimation = false; this.$context = undefined; this._syncList = []; this.initialize(); } initialize() { const meta = this._cachedMeta; this.configure(); this.linkScales(); meta._stacked = isStacked(meta.vScale, meta); this.addElements(); } updateIndex(datasetIndex) { if (this.index !== datasetIndex) { clearStacks(this._cachedMeta); } this.index = datasetIndex; } linkScales() { const chart = this.chart; const meta = this._cachedMeta; const dataset = this.getDataset(); const chooseId = (axis, x, y, r) => axis === 'x' ? x : axis === 'r' ? r : y; const xid = meta.xAxisID = valueOrDefault(dataset.xAxisID, getFirstScaleId(chart, 'x')); const yid = meta.yAxisID = valueOrDefault(dataset.yAxisID, getFirstScaleId(chart, 'y')); const rid = meta.rAxisID = valueOrDefault(dataset.rAxisID, getFirstScaleId(chart, 'r')); const indexAxis = meta.indexAxis; const iid = meta.iAxisID = chooseId(indexAxis, xid, yid, rid); const vid = meta.vAxisID = chooseId(indexAxis, yid, xid, rid); meta.xScale = this.getScaleForId(xid); meta.yScale = this.getScaleForId(yid); meta.rScale = this.getScaleForId(rid); meta.iScale = this.getScaleForId(iid); meta.vScale = this.getScaleForId(vid); } getDataset() { return this.chart.data.datasets[this.index]; } getMeta() { return this.chart.getDatasetMeta(this.index); } getScaleForId(scaleID) { return this.chart.scales[scaleID]; } _getOtherScale(scale) { const meta = this._cachedMeta; return scale === meta.iScale ? meta.vScale : meta.iScale; } reset() { this._update('reset'); } _destroy() { const meta = this._cachedMeta; if (this._data) { unlistenArrayEvents(this._data, this); } if (meta._stacked) { clearStacks(meta); } } _dataCheck() { const dataset = this.getDataset(); const data = dataset.data || (dataset.data = []); const _data = this._data; if (isObject(data)) { this._data = convertObjectDataToArray(data); } else if (_data !== data) { if (_data) { unlistenArrayEvents(_data, this); const meta = this._cachedMeta; clearStacks(meta); meta._parsed = []; } if (data && Object.isExtensible(data)) { listenArrayEvents(data, this); } this._syncList = []; this._data = data; } } addElements() { const meta = this._cachedMeta; this._dataCheck(); if (this.datasetElementType) { meta.dataset = new this.datasetElementType(); } } buildOrUpdateElements(resetNewElements) { const meta = this._cachedMeta; const dataset = this.getDataset(); let stackChanged = false; this._dataCheck(); const oldStacked = meta._stacked; meta._stacked = isStacked(meta.vScale, meta); if (meta.stack !== dataset.stack) { stackChanged = true; clearStacks(meta); meta.stack = dataset.stack; } this._resyncElements(resetNewElements); if (stackChanged || oldStacked !== meta._stacked) { updateStacks(this, meta._parsed); } } configure() { const config = this.chart.config; const scopeKeys = config.datasetScopeKeys(this._type); const scopes = config.getOptionScopes(this.getDataset(), scopeKeys, true); this.options = config.createResolver(scopes, this.getContext()); this._parsing = this.options.parsing; this._cachedDataOpts = {}; } parse(start, count) { const {_cachedMeta: meta, _data: data} = this; const {iScale, _stacked} = meta; const iAxis = iScale.axis; let sorted = start === 0 && count === data.length ? true : meta._sorted; let prev = start > 0 && meta._parsed[start - 1]; let i, cur, parsed; if (this._parsing === false) { meta._parsed = data; meta._sorted = true; parsed = data; } else { if (isArray(data[start])) { parsed = this.parseArrayData(meta, data, start, count); } else if (isObject(data[start])) { parsed = this.parseObjectData(meta, data, start, count); } else { parsed = this.parsePrimitiveData(meta, data, start, count); } const isNotInOrderComparedToPrev = () => cur[iAxis] === null || (prev && cur[iAxis] < prev[iAxis]); for (i = 0; i < count; ++i) { meta._parsed[i + start] = cur = parsed[i]; if (sorted) { if (isNotInOrderComparedToPrev()) { sorted = false; } prev = cur; } } meta._sorted = sorted; } if (_stacked) { updateStacks(this, parsed); } } parsePrimitiveData(meta, data, start, count) { const {iScale, vScale} = meta; const iAxis = iScale.axis; const vAxis = vScale.axis; const labels = iScale.getLabels(); const singleScale = iScale === vScale; const parsed = new Array(count); let i, ilen, index; for (i = 0, ilen = count; i < ilen; ++i) { index = i + start; parsed[i] = { [iAxis]: singleScale || iScale.parse(labels[index], index), [vAxis]: vScale.parse(data[index], index) }; } return parsed; } parseArrayData(meta, data, start, count) { const {xScale, yScale} = meta; const parsed = new Array(count); let i, ilen, index, item; for (i = 0, ilen = count; i < ilen; ++i) { index = i + start; item = data[index]; parsed[i] = { x: xScale.parse(item[0], index), y: yScale.parse(item[1], index) }; } return parsed; } parseObjectData(meta, data, start, count) { const {xScale, yScale} = meta; const {xAxisKey = 'x', yAxisKey = 'y'} = this._parsing; const parsed = new Array(count); let i, ilen, index, item; for (i = 0, ilen = count; i < ilen; ++i) { index = i + start; item = data[index]; parsed[i] = { x: xScale.parse(resolveObjectKey(item, xAxisKey), index), y: yScale.parse(resolveObjectKey(item, yAxisKey), index) }; } return parsed; } getParsed(index) { return this._cachedMeta._parsed[index]; } getDataElement(index) { return this._cachedMeta.data[index]; } applyStack(scale, parsed, mode) { const chart = this.chart; const meta = this._cachedMeta; const value = parsed[scale.axis]; const stack = { keys: getSortedDatasetIndices(chart, true), values: parsed._stacks[scale.axis] }; return applyStack(stack, value, meta.index, {mode}); } updateRangeFromParsed(range, scale, parsed, stack) { const parsedValue = parsed[scale.axis]; let value = parsedValue === null ? NaN : parsedValue; const values = stack && parsed._stacks[scale.axis]; if (stack && values) { stack.values = values; value = applyStack(stack, parsedValue, this._cachedMeta.index); } range.min = Math.min(range.min, value); range.max = Math.max(range.max, value); } getMinMax(scale, canStack) { const meta = this._cachedMeta; const _parsed = meta._parsed; const sorted = meta._sorted && scale === meta.iScale; const ilen = _parsed.length; const otherScale = this._getOtherScale(scale); const stack = createStack(canStack, meta, this.chart); const range = {min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY}; const {min: otherMin, max: otherMax} = getUserBounds(otherScale); let i, parsed; function _skip() { parsed = _parsed[i]; const otherValue = parsed[otherScale.axis]; return !isNumberFinite(parsed[scale.axis]) || otherMin > otherValue || otherMax < otherValue; } for (i = 0; i < ilen; ++i) { if (_skip()) { continue; } this.updateRangeFromParsed(range, scale, parsed, stack); if (sorted) { break; } } if (sorted) { for (i = ilen - 1; i >= 0; --i) { if (_skip()) { continue; } this.updateRangeFromParsed(range, scale, parsed, stack); break; } } return range; } getAllParsedValues(scale) { const parsed = this._cachedMeta._parsed; const values = []; let i, ilen, value; for (i = 0, ilen = parsed.length; i < ilen; ++i) { value = parsed[i][scale.axis]; if (isNumberFinite(value)) { values.push(value); } } return values; } getMaxOverflow() { return false; } getLabelAndValue(index) { const meta = this._cachedMeta; const iScale = meta.iScale; const vScale = meta.vScale; const parsed = this.getParsed(index); return { label: iScale ? '' + iScale.getLabelForValue(parsed[iScale.axis]) : '', value: vScale ? '' + vScale.getLabelForValue(parsed[vScale.axis]) : '' }; } _update(mode) { const meta = this._cachedMeta; this.update(mode || 'default'); meta._clip = toClip(valueOrDefault(this.options.clip, defaultClip(meta.xScale, meta.yScale, this.getMaxOverflow()))); } update(mode) {} draw() { const ctx = this._ctx; const chart = this.chart; const meta = this._cachedMeta; const elements = meta.data || []; const area = chart.chartArea; const active = []; const start = this._drawStart || 0; const count = this._drawCount || (elements.length - start); const drawActiveElementsOnTop = this.options.drawActiveElementsOnTop; let i; if (meta.dataset) { meta.dataset.draw(ctx, area, start, count); } for (i = start; i < start + count; ++i) { const element = elements[i]; if (element.hidden) { continue; } if (element.active && drawActiveElementsOnTop) { active.push(element); } else { element.draw(ctx, area); } } for (i = 0; i < active.length; ++i) { active[i].draw(ctx, area); } } getStyle(index, active) { const mode = active ? 'active' : 'default'; return index === undefined && this._cachedMeta.dataset ? this.resolveDatasetElementOptions(mode) : this.resolveDataElementOptions(index || 0, mode); } getContext(index, active, mode) { const dataset = this.getDataset(); let context; if (index >= 0 && index < this._cachedMeta.data.length) { const element = this._cachedMeta.data[index]; context = element.$context || (element.$context = createDataContext(this.getContext(), index, element)); context.parsed = this.getParsed(index); context.raw = dataset.data[index]; context.index = context.dataIndex = index; } else { context = this.$context || (this.$context = createDatasetContext(this.chart.getContext(), this.index)); context.dataset = dataset; context.index = context.datasetIndex = this.index; } context.active = !!active; context.mode = mode; return context; } resolveDatasetElementOptions(mode) { return this._resolveElementOptions(this.datasetElementType.id, mode); } resolveDataElementOptions(index, mode) { return this._resolveElementOptions(this.dataElementType.id, mode, index); } _resolveElementOptions(elementType, mode = 'default', index) { const active = mode === 'active'; const cache = this._cachedDataOpts; const cacheKey = elementType + '-' + mode; const cached = cache[cacheKey]; const sharing = this.enableOptionSharing && defined(index); if (cached) { return cloneIfNotShared(cached, sharing); } const config = this.chart.config; const scopeKeys = config.datasetElementScopeKeys(this._type, elementType); const prefixes = active ? [`${elementType}Hover`, 'hover', elementType, ''] : [elementType, '']; const scopes = config.getOptionScopes(this.getDataset(), scopeKeys); const names = Object.keys(defaults.elements[elementType]); const context = () => this.getContext(index, active); const values = config.resolveNamedOptions(scopes, names, context, prefixes); if (values.$shared) { values.$shared = sharing; cache[cacheKey] = Object.freeze(cloneIfNotShared(values, sharing)); } return values; } _resolveAnimations(index, transition, active) { const chart = this.chart; const cache = this._cachedDataOpts; const cacheKey = `animation-${transition}`; const cached = cache[cacheKey]; if (cached) { return cached; } let options; if (chart.options.animation !== false) { const config = this.chart.config; const scopeKeys = config.datasetAnimationScopeKeys(this._type, transition); const scopes = config.getOptionScopes(this.getDataset(), scopeKeys); options = config.createResolver(scopes, this.getContext(index, active, transition)); } const animations = new Animations(chart, options && options.animations); if (options && options._cacheable) { cache[cacheKey] = Object.freeze(animations); } return animations; } getSharedOptions(options) { if (!options.$shared) { return; } return this._sharedOptions || (this._sharedOptions = Object.assign({}, options)); } includeOptions(mode, sharedOptions) { return !sharedOptions || isDirectUpdateMode(mode) || this.chart._animationsDisabled; } updateElement(element, index, properties, mode) { if (isDirectUpdateMode(mode)) { Object.assign(element, properties); } else { this._resolveAnimations(index, mode).update(element, properties); } } updateSharedOptions(sharedOptions, mode, newOptions) { if (sharedOptions && !isDirectUpdateMode(mode)) { this._resolveAnimations(undefined, mode).update(sharedOptions, newOptions); } } _setStyle(element, index, mode, active) { element.active = active; const options = this.getStyle(index, active); this._resolveAnimations(index, mode, active).update(element, { options: (!active && this.getSharedOptions(options)) || options }); } removeHoverStyle(element, datasetIndex, index) { this._setStyle(element, index, 'active', false); } setHoverStyle(element, datasetIndex, index) { this._setStyle(element, index, 'active', true); } _removeDatasetHoverStyle() { const element = this._cachedMeta.dataset; if (element) { this._setStyle(element, undefined, 'active', false); } } _setDatasetHoverStyle() { const element = this._cachedMeta.dataset; if (element) { this._setStyle(element, undefined, 'active', true); } } _resyncElements(resetNewElements) { const data = this._data; const elements = this._cachedMeta.data; for (const [method, arg1, arg2] of this._syncList) { this[method](arg1, arg2); } this._syncList = []; const numMeta = elements.length; const numData = data.length; const count = Math.min(numData, numMeta); if (count) { this.parse(0, count); } if (numData > numMeta) { this._insertElements(numMeta, numData - numMeta, resetNewElements); } else if (numData < numMeta) { this._removeElements(numData, numMeta - numData); } } _insertElements(start, count, resetNewElements = true) { const meta = this._cachedMeta; const data = meta.data; const end = start + count; let i; const move = (arr) => { arr.length += count; for (i = arr.length - 1; i >= end; i--) { arr[i] = arr[i - count]; } }; move(data); for (i = start; i < end; ++i) { data[i] = new this.dataElementType(); } if (this._parsing) { move(meta._parsed); } this.parse(start, count); if (resetNewElements) { this.updateElements(data, start, count, 'reset'); } } updateElements(element, start, count, mode) {} _removeElements(start, count) { const meta = this._cachedMeta; if (this._parsing) { const removed = meta._parsed.splice(start, count); if (meta._stacked) { clearStacks(meta, removed); } } meta.data.splice(start, count); } _sync(args) { if (this._parsing) { this._syncList.push(args); } else { const [method, arg1, arg2] = args; this[method](arg1, arg2); } this.chart._dataChanges.push([this.index, ...args]); } _onDataPush() { const count = arguments.length; this._sync(['_insertElements', this.getDataset().data.length - count, count]); } _onDataPop() { this._sync(['_removeElements', this._cachedMeta.data.length - 1, 1]); } _onDataShift() { this._sync(['_removeElements', 0, 1]); } _onDataSplice(start, count) { if (count) { this._sync(['_removeElements', start, count]); } const newCount = arguments.length - 2; if (newCount) { this._sync(['_insertElements', start, newCount]); } } _onDataUnshift() { this._sync(['_insertElements', 0, arguments.length]); } } DatasetController.defaults = {}; DatasetController.prototype.datasetElementType = null; DatasetController.prototype.dataElementType = null; class Element { constructor() { this.x = undefined; this.y = undefined; this.active = false; this.options = undefined; this.$animations = undefined; } tooltipPosition(useFinalPosition) { const {x, y} = this.getProps(['x', 'y'], useFinalPosition); return {x, y}; } hasValue() { return isNumber(this.x) && isNumber(this.y); } getProps(props, final) { const anims = this.$animations; if (!final || !anims) { return this; } const ret = {}; props.forEach(prop => { ret[prop] = anims[prop] && anims[prop].active() ? anims[prop]._to : this[prop]; }); return ret; } } Element.defaults = {}; Element.defaultRoutes = undefined; const formatters = { values(value) { return isArray(value) ? value : '' + value; }, numeric(tickValue, index, ticks) { if (tickValue === 0) { return '0'; } const locale = this.chart.options.locale; let notation; let delta = tickValue; if (ticks.length > 1) { const maxTick = Math.max(Math.abs(ticks[0].value), Math.abs(ticks[ticks.length - 1].value)); if (maxTick < 1e-4 || maxTick > 1e+15) { notation = 'scientific'; } delta = calculateDelta(tickValue, ticks); } const logDelta = log10(Math.abs(delta)); const numDecimal = Math.max(Math.min(-1 * Math.floor(logDelta), 20), 0); const options = {notation, minimumFractionDigits: numDecimal, maximumFractionDigits: numDecimal}; Object.assign(options, this.options.ticks.format); return formatNumber(tickValue, locale, options); }, logarithmic(tickValue, index, ticks) { if (tickValue === 0) { return '0'; } const remain = tickValue / (Math.pow(10, Math.floor(log10(tickValue)))); if (remain === 1 || remain === 2 || remain === 5) { return formatters.numeric.call(this, tickValue, index, ticks); } return ''; } }; function calculateDelta(tickValue, ticks) { let delta = ticks.length > 3 ? ticks[2].value - ticks[1].value : ticks[1].value - ticks[0].value; if (Math.abs(delta) >= 1 && tickValue !== Math.floor(tickValue)) { delta = tickValue - Math.floor(tickValue); } return delta; } var Ticks = {formatters}; defaults.set('scale', { display: true, offset: false, reverse: false, beginAtZero: false, bounds: 'ticks', grace: 0, grid: { display: true, lineWidth: 1, drawBorder: true, drawOnChartArea: true, drawTicks: true, tickLength: 8, tickWidth: (_ctx, options) => options.lineWidth, tickColor: (_ctx, options) => options.color, offset: false, borderDash: [], borderDashOffset: 0.0, borderWidth: 1 }, title: { display: false, text: '', padding: { top: 4, bottom: 4 } }, ticks: { minRotation: 0, maxRotation: 50, mirror: false, textStrokeWidth: 0, textStrokeColor: '', padding: 3, display: true, autoSkip: true, autoSkipPadding: 3, labelOffset: 0, callback: Ticks.formatters.values, minor: {}, major: {}, align: 'center', crossAlign: 'near', showLabelBackdrop: false, backdropColor: 'rgba(255, 255, 255, 0.75)', backdropPadding: 2, } }); defaults.route('scale.ticks', 'color', '', 'color'); defaults.route('scale.grid', 'color', '', 'borderColor'); defaults.route('scale.grid', 'borderColor', '', 'borderColor'); defaults.route('scale.title', 'color', '', 'color'); defaults.describe('scale', { _fallback: false, _scriptable: (name) => !name.startsWith('before') && !name.startsWith('after') && name !== 'callback' && name !== 'parser', _indexable: (name) => name !== 'borderDash' && name !== 'tickBorderDash', }); defaults.describe('scales', { _fallback: 'scale', }); defaults.describe('scale.ticks', { _scriptable: (name) => name !== 'backdropPadding' && name !== 'callback', _indexable: (name) => name !== 'backdropPadding', }); function autoSkip(scale, ticks) { const tickOpts = scale.options.ticks; const ticksLimit = tickOpts.maxTicksLimit || determineMaxTicks(scale); const majorIndices = tickOpts.major.enabled ? getMajorIndices(ticks) : []; const numMajorIndices = majorIndices.length; const first = majorIndices[0]; const last = majorIndices[numMajorIndices - 1]; const newTicks = []; if (numMajorIndices > ticksLimit) { skipMajors(ticks, newTicks, majorIndices, numMajorIndices / ticksLimit); return newTicks; } const spacing = calculateSpacing(majorIndices, ticks, ticksLimit); if (numMajorIndices > 0) { let i, ilen; const avgMajorSpacing = numMajorIndices > 1 ? Math.round((last - first) / (numMajorIndices - 1)) : null; skip(ticks, newTicks, spacing, isNullOrUndef(avgMajorSpacing) ? 0 : first - avgMajorSpacing, first); for (i = 0, ilen = numMajorIndices - 1; i < ilen; i++) { skip(ticks, newTicks, spacing, majorIndices[i], majorIndices[i + 1]); } skip(ticks, newTicks, spacing, last, isNullOrUndef(avgMajorSpacing) ? ticks.length : last + avgMajorSpacing); return newTicks; } skip(ticks, newTicks, spacing); return newTicks; } function determineMaxTicks(scale) { const offset = scale.options.offset; const tickLength = scale._tickSize(); const maxScale = scale._length / tickLength + (offset ? 0 : 1); const maxChart = scale._maxLength / tickLength; return Math.floor(Math.min(maxScale, maxChart)); } function calculateSpacing(majorIndices, ticks, ticksLimit) { const evenMajorSpacing = getEvenSpacing(majorIndices); const spacing = ticks.length / ticksLimit; if (!evenMajorSpacing) { return Math.max(spacing, 1); } const factors = _factorize(evenMajorSpacing); for (let i = 0, ilen = factors.length - 1; i < ilen; i++) { const factor = factors[i]; if (factor > spacing) { return factor; } } return Math.max(spacing, 1); } function getMajorIndices(ticks) { const result = []; let i, ilen; for (i = 0, ilen = ticks.length; i < ilen; i++) { if (ticks[i].major) { result.push(i); } } return result; } function skipMajors(ticks, newTicks, majorIndices, spacing) { let count = 0; let next = majorIndices[0]; let i; spacing = Math.ceil(spacing); for (i = 0; i < ticks.length; i++) { if (i === next) { newTicks.push(ticks[i]); count++; next = majorIndices[count * spacing]; } } } function skip(ticks, newTicks, spacing, majorStart, majorEnd) { const start = valueOrDefault(majorStart, 0); const end = Math.min(valueOrDefault(majorEnd, ticks.length), ticks.length); let count = 0; let length, i, next; spacing = Math.ceil(spacing); if (majorEnd) { length = majorEnd - majorStart; spacing = length / Math.floor(length / spacing); } next = start; while (next < 0) { count++; next = Math.round(start + count * spacing); } for (i = Math.max(start, 0); i < end; i++) { if (i === next) { newTicks.push(ticks[i]); count++; next = Math.round(start + count * spacing); } } } function getEvenSpacing(arr) { const len = arr.length; let i, diff; if (len < 2) { return false; } for (diff = arr[0], i = 1; i < len; ++i) { if (arr[i] - arr[i - 1] !== diff) { return false; } } return diff; } const reverseAlign = (align) => align === 'left' ? 'right' : align === 'right' ? 'left' : align; const offsetFromEdge = (scale, edge, offset) => edge === 'top' || edge === 'left' ? scale[edge] + offset : scale[edge] - offset; function sample(arr, numItems) { const result = []; const increment = arr.length / numItems; const len = arr.length; let i = 0; for (; i < len; i += increment) { result.push(arr[Math.floor(i)]); } return result; } function getPixelForGridLine(scale, index, offsetGridLines) { const length = scale.ticks.length; const validIndex = Math.min(index, length - 1); const start = scale._startPixel; const end = scale._endPixel; const epsilon = 1e-6; let lineValue = scale.getPixelForTick(validIndex); let offset; if (offsetGridLines) { if (length === 1) { offset = Math.max(lineValue - start, end - lineValue); } else if (index === 0) { offset = (scale.getPixelForTick(1) - lineValue) / 2; } else { offset = (lineValue - scale.getPixelForTick(validIndex - 1)) / 2; } lineValue += validIndex < index ? offset : -offset; if (lineValue < start - epsilon || lineValue > end + epsilon) { return; } } return lineValue; } function garbageCollect(caches, length) { each(caches, (cache) => { const gc = cache.gc; const gcLen = gc.length / 2; let i; if (gcLen > length) { for (i = 0; i < gcLen; ++i) { delete cache.data[gc[i]]; } gc.splice(0, gcLen); } }); } function getTickMarkLength(options) { return options.drawTicks ? options.tickLength : 0; } function getTitleHeight(options, fallback) { if (!options.display) { return 0; } const font = toFont(options.font, fallback); const padding = toPadding(options.padding); const lines = isArray(options.text) ? options.text.length : 1; return (lines * font.lineHeight) + padding.height; } function createScaleContext(parent, scale) { return createContext(parent, { scale, type: 'scale' }); } function createTickContext(parent, index, tick) { return createContext(parent, { tick, index, type: 'tick' }); } function titleAlign(align, position, reverse) { let ret = _toLeftRightCenter(align); if ((reverse && position !== 'right') || (!reverse && position === 'right')) { ret = reverseAlign(ret); } return ret; } function titleArgs(scale, offset, position, align) { const {top, left, bottom, right, chart} = scale; const {chartArea, scales} = chart; let rotation = 0; let maxWidth, titleX, titleY; const height = bottom - top; const width = right - left; if (scale.isHorizontal()) { titleX = _alignStartEnd(align, left, right); if (isObject(position)) { const positionAxisID = Object.keys(position)[0]; const value = position[positionAxisID]; titleY = scales[positionAxisID].getPixelForValue(value) + height - offset; } else if (position === 'center') { titleY = (chartArea.bottom + chartArea.top) / 2 + height - offset; } else { titleY = offsetFromEdge(scale, position, offset); } maxWidth = right - left; } else { if (isObject(position)) { const positionAxisID = Object.keys(position)[0]; const value = position[positionAxisID]; titleX = scales[positionAxisID].getPixelForValue(value) - width + offset; } else if (position === 'center') { titleX = (chartArea.left + chartArea.right) / 2 - width + offset; } else { titleX = offsetFromEdge(scale, position, offset); } titleY = _alignStartEnd(align, bottom, top); rotation = position === 'left' ? -HALF_PI : HALF_PI; } return {titleX, titleY, maxWidth, rotation}; } class Scale extends Element { constructor(cfg) { super(); this.id = cfg.id; this.type = cfg.type; this.options = undefined; this.ctx = cfg.ctx; this.chart = cfg.chart; this.top = undefined; this.bottom = undefined; this.left = undefined; this.right = undefined; this.width = undefined; this.height = undefined; this._margins = { left: 0, right: 0, top: 0, bottom: 0 }; this.maxWidth = undefined; this.maxHeight = undefined; this.paddingTop = undefined; this.paddingBottom = undefined; this.paddingLeft = undefined; this.paddingRight = undefined; this.axis = undefined; this.labelRotation = undefined; this.min = undefined; this.max = undefined; this._range = undefined; this.ticks = []; this._gridLineItems = null; this._labelItems = null; this._labelSizes = null; this._length = 0; this._maxLength = 0; this._longestTextCache = {}; this._startPixel = undefined; this._endPixel = undefined; this._reversePixels = false; this._userMax = undefined; this._userMin = undefined; this._suggestedMax = undefined; this._suggestedMin = undefined; this._ticksLength = 0; this._borderValue = 0; this._cache = {}; this._dataLimitsCached = false; this.$context = undefined; } init(options) { this.options = options.setContext(this.getContext()); this.axis = options.axis; this._userMin = this.parse(options.min); this._userMax = this.parse(options.max); this._suggestedMin = this.parse(options.suggestedMin); this._suggestedMax = this.parse(options.suggestedMax); } parse(raw, index) { return raw; } getUserBounds() { let {_userMin, _userMax, _suggestedMin, _suggestedMax} = this; _userMin = finiteOrDefault(_userMin, Number.POSITIVE_INFINITY); _userMax = finiteOrDefault(_userMax, Number.NEGATIVE_INFINITY); _suggestedMin = finiteOrDefault(_suggestedMin, Number.POSITIVE_INFINITY); _suggestedMax = finiteOrDefault(_suggestedMax, Number.NEGATIVE_INFINITY); return { min: finiteOrDefault(_userMin, _suggestedMin), max: finiteOrDefault(_userMax, _suggestedMax), minDefined: isNumberFinite(_userMin), maxDefined: isNumberFinite(_userMax) }; } getMinMax(canStack) { let {min, max, minDefined, maxDefined} = this.getUserBounds(); let range; if (minDefined && maxDefined) { return {min, max}; } const metas = this.getMatchingVisibleMetas(); for (let i = 0, ilen = metas.length; i < ilen; ++i) { range = metas[i].controller.getMinMax(this, canStack); if (!minDefined) { min = Math.min(min, range.min); } if (!maxDefined) { max = Math.max(max, range.max); } } min = maxDefined && min > max ? max : min; max = minDefined && min > max ? min : max; return { min: finiteOrDefault(min, finiteOrDefault(max, min)), max: finiteOrDefault(max, finiteOrDefault(min, max)) }; } getPadding() { return { left: this.paddingLeft || 0, top: this.paddingTop || 0, right: this.paddingRight || 0, bottom: this.paddingBottom || 0 }; } getTicks() { return this.ticks; } getLabels() { const data = this.chart.data; return this.options.labels || (this.isHorizontal() ? data.xLabels : data.yLabels) || data.labels || []; } beforeLayout() { this._cache = {}; this._dataLimitsCached = false; } beforeUpdate() { callback(this.options.beforeUpdate, [this]); } update(maxWidth, maxHeight, margins) { const {beginAtZero, grace, ticks: tickOpts} = this.options; const sampleSize = tickOpts.sampleSize; this.beforeUpdate(); this.maxWidth = maxWidth; this.maxHeight = maxHeight; this._margins = margins = Object.assign({ left: 0, right: 0, top: 0, bottom: 0 }, margins); this.ticks = null; this._labelSizes = null; this._gridLineItems = null; this._labelItems = null; this.beforeSetDimensions(); this.setDimensions(); this.afterSetDimensions(); this._maxLength = this.isHorizontal() ? this.width + margins.left + margins.right : this.height + margins.top + margins.bottom; if (!this._dataLimitsCached) { this.beforeDataLimits(); this.determineDataLimits(); this.afterDataLimits(); this._range = _addGrace(this, grace, beginAtZero); this._dataLimitsCached = true; } this.beforeBuildTicks(); this.ticks = this.buildTicks() || []; this.afterBuildTicks(); const samplingEnabled = sampleSize < this.ticks.length; this._convertTicksToLabels(samplingEnabled ? sample(this.ticks, sampleSize) : this.ticks); this.configure(); this.beforeCalculateLabelRotation(); this.calculateLabelRotation(); this.afterCalculateLabelRotation(); if (tickOpts.display && (tickOpts.autoSkip || tickOpts.source === 'auto')) { this.ticks = autoSkip(this, this.ticks); this._labelSizes = null; this.afterAutoSkip(); } if (samplingEnabled) { this._convertTicksToLabels(this.ticks); } this.beforeFit(); this.fit(); this.afterFit(); this.afterUpdate(); } configure() { let reversePixels = this.options.reverse; let startPixel, endPixel; if (this.isHorizontal()) { startPixel = this.left; endPixel = this.right; } else { startPixel = this.top; endPixel = this.bottom; reversePixels = !reversePixels; } this._startPixel = startPixel; this._endPixel = endPixel; this._reversePixels = reversePixels; this._length = endPixel - startPixel; this._alignToPixels = this.options.alignToPixels; } afterUpdate() { callback(this.options.afterUpdate, [this]); } beforeSetDimensions() { callback(this.options.beforeSetDimensions, [this]); } setDimensions() { if (this.isHorizontal()) { this.width = this.maxWidth; this.left = 0; this.right = this.width; } else { this.height = this.maxHeight; this.top = 0; this.bottom = this.height; } this.paddingLeft = 0; this.paddingTop = 0; this.paddingRight = 0; this.paddingBottom = 0; } afterSetDimensions() { callback(this.options.afterSetDimensions, [this]); } _callHooks(name) { this.chart.notifyPlugins(name, this.getContext()); callback(this.options[name], [this]); } beforeDataLimits() { this._callHooks('beforeDataLimits'); } determineDataLimits() {} afterDataLimits() { this._callHooks('afterDataLimits'); } beforeBuildTicks() { this._callHooks('beforeBuildTicks'); } buildTicks() { return []; } afterBuildTicks() { this._callHooks('afterBuildTicks'); } beforeTickToLabelConversion() { callback(this.options.beforeTickToLabelConversion, [this]); } generateTickLabels(ticks) { const tickOpts = this.options.ticks; let i, ilen, tick; for (i = 0, ilen = ticks.length; i < ilen; i++) { tick = ticks[i]; tick.label = callback(tickOpts.callback, [tick.value, i, ticks], this); } } afterTickToLabelConversion() { callback(this.options.afterTickToLabelConversion, [this]); } beforeCalculateLabelRotation() { callback(this.options.beforeCalculateLabelRotation, [this]); } calculateLabelRotation() { const options = this.options; const tickOpts = options.ticks; const numTicks = this.ticks.length; const minRotation = tickOpts.minRotation || 0; const maxRotation = tickOpts.maxRotation; let labelRotation = minRotation; let tickWidth, maxHeight, maxLabelDiagonal; if (!this._isVisible() || !tickOpts.display || minRotation >= maxRotation || numTicks <= 1 || !this.isHorizontal()) { this.labelRotation = minRotation; return; } const labelSizes = this._getLabelSizes(); const maxLabelWidth = labelSizes.widest.width; const maxLabelHeight = labelSizes.highest.height; const maxWidth = _limitValue(this.chart.width - maxLabelWidth, 0, this.maxWidth); tickWidth = options.offset ? this.maxWidth / numTicks : maxWidth / (numTicks - 1); if (maxLabelWidth + 6 > tickWidth) { tickWidth = maxWidth / (numTicks - (options.offset ? 0.5 : 1)); maxHeight = this.maxHeight - getTickMarkLength(options.grid) - tickOpts.padding - getTitleHeight(options.title, this.chart.options.font); maxLabelDiagonal = Math.sqrt(maxLabelWidth * maxLabelWidth + maxLabelHeight * maxLabelHeight); labelRotation = toDegrees(Math.min( Math.asin(_limitValue((labelSizes.highest.height + 6) / tickWidth, -1, 1)), Math.asin(_limitValue(maxHeight / maxLabelDiagonal, -1, 1)) - Math.asin(_limitValue(maxLabelHeight / maxLabelDiagonal, -1, 1)) )); labelRotation = Math.max(minRotation, Math.min(maxRotation, labelRotation)); } this.labelRotation = labelRotation; } afterCalculateLabelRotation() { callback(this.options.afterCalculateLabelRotation, [this]); } afterAutoSkip() {} beforeFit() { callback(this.options.beforeFit, [this]); } fit() { const minSize = { width: 0, height: 0 }; const {chart, options: {ticks: tickOpts, title: titleOpts, grid: gridOpts}} = this; const display = this._isVisible(); const isHorizontal = this.isHorizontal(); if (display) { const titleHeight = getTitleHeight(titleOpts, chart.options.font); if (isHorizontal) { minSize.width = this.maxWidth; minSize.height = getTickMarkLength(gridOpts) + titleHeight; } else { minSize.height = this.maxHeight; minSize.width = getTickMarkLength(gridOpts) + titleHeight; } if (tickOpts.display && this.ticks.length) { const {first, last, widest, highest} = this._getLabelSizes(); const tickPadding = tickOpts.padding * 2; const angleRadians = toRadians(this.labelRotation); const cos = Math.cos(angleRadians); const sin = Math.sin(angleRadians); if (isHorizontal) { const labelHeight = tickOpts.mirror ? 0 : sin * widest.width + cos * highest.height; minSize.height = Math.min(this.maxHeight, minSize.height + labelHeight + tickPadding); } else { const labelWidth = tickOpts.mirror ? 0 : cos * widest.width + sin * highest.height; minSize.width = Math.min(this.maxWidth, minSize.width + labelWidth + tickPadding); } this._calculatePadding(first, last, sin, cos); } } this._handleMargins(); if (isHorizontal) { this.width = this._length = chart.width - this._margins.left - this._margins.right; this.height = minSize.height; } else { this.width = minSize.width; this.height = this._length = chart.height - this._margins.top - this._margins.bottom; } } _calculatePadding(first, last, sin, cos) { const {ticks: {align, padding}, position} = this.options; const isRotated = this.labelRotation !== 0; const labelsBelowTicks = position !== 'top' && this.axis === 'x'; if (this.isHorizontal()) { const offsetLeft = this.getPixelForTick(0) - this.left; const offsetRight = this.right - this.getPixelForTick(this.ticks.length - 1); let paddingLeft = 0; let paddingRight = 0; if (isRotated) { if (labelsBelowTicks) { paddingLeft = cos * first.width; paddingRight = sin * last.height; } else { paddingLeft = sin * first.height; paddingRight = cos * last.width; } } else if (align === 'start') { paddingRight = last.width; } else if (align === 'end') { paddingLeft = first.width; } else if (align !== 'inner') { paddingLeft = first.width / 2; paddingRight = last.width / 2; } this.paddingLeft = Math.max((paddingLeft - offsetLeft + padding) * this.width / (this.width - offsetLeft), 0); this.paddingRight = Math.max((paddingRight - offsetRight + padding) * this.width / (this.width - offsetRight), 0); } else { let paddingTop = last.height / 2; let paddingBottom = first.height / 2; if (align === 'start') { paddingTop = 0; paddingBottom = first.height; } else if (align === 'end') { paddingTop = last.height; paddingBottom = 0; } this.paddingTop = paddingTop + padding; this.paddingBottom = paddingBottom + padding; } } _handleMargins() { if (this._margins) { this._margins.left = Math.max(this.paddingLeft, this._margins.left); this._margins.top = Math.max(this.paddingTop, this._margins.top); this._margins.right = Math.max(this.paddingRight, this._margins.right); this._margins.bottom = Math.max(this.paddingBottom, this._margins.bottom); } } afterFit() { callback(this.options.afterFit, [this]); } isHorizontal() { const {axis, position} = this.options; return position === 'top' || position === 'bottom' || axis === 'x'; } isFullSize() { return this.options.fullSize; } _convertTicksToLabels(ticks) { this.beforeTickToLabelConversion(); this.generateTickLabels(ticks); let i, ilen; for (i = 0, ilen = ticks.length; i < ilen; i++) { if (isNullOrUndef(ticks[i].label)) { ticks.splice(i, 1); ilen--; i--; } } this.afterTickToLabelConversion(); } _getLabelSizes() { let labelSizes = this._labelSizes; if (!labelSizes) { const sampleSize = this.options.ticks.sampleSize; let ticks = this.ticks; if (sampleSize < ticks.length) { ticks = sample(ticks, sampleSize); } this._labelSizes = labelSizes = this._computeLabelSizes(ticks, ticks.length); } return labelSizes; } _computeLabelSizes(ticks, length) { const {ctx, _longestTextCache: caches} = this; const widths = []; const heights = []; let widestLabelSize = 0; let highestLabelSize = 0; let i, j, jlen, label, tickFont, fontString, cache, lineHeight, width, height, nestedLabel; for (i = 0; i < length; ++i) { label = ticks[i].label; tickFont = this._resolveTickFontOptions(i); ctx.font = fontString = tickFont.string; cache = caches[fontString] = caches[fontString] || {data: {}, gc: []}; lineHeight = tickFont.lineHeight; width = height = 0; if (!isNullOrUndef(label) && !isArray(label)) { width = _measureText(ctx, cache.data, cache.gc, width, label); height = lineHeight; } else if (isArray(label)) { for (j = 0, jlen = label.length; j < jlen; ++j) { nestedLabel = label[j]; if (!isNullOrUndef(nestedLabel) && !isArray(nestedLabel)) { width = _measureText(ctx, cache.data, cache.gc, width, nestedLabel); height += lineHeight; } } } widths.push(width); heights.push(height); widestLabelSize = Math.max(width, widestLabelSize); highestLabelSize = Math.max(height, highestLabelSize); } garbageCollect(caches, length); const widest = widths.indexOf(widestLabelSize); const highest = heights.indexOf(highestLabelSize); const valueAt = (idx) => ({width: widths[idx] || 0, height: heights[idx] || 0}); return { first: valueAt(0), last: valueAt(length - 1), widest: valueAt(widest), highest: valueAt(highest), widths, heights, }; } getLabelForValue(value) { return value; } getPixelForValue(value, index) { return NaN; } getValueForPixel(pixel) {} getPixelForTick(index) { const ticks = this.ticks; if (index < 0 || index > ticks.length - 1) { return null; } return this.getPixelForValue(ticks[index].value); } getPixelForDecimal(decimal) { if (this._reversePixels) { decimal = 1 - decimal; } const pixel = this._startPixel + decimal * this._length; return _int16Range(this._alignToPixels ? _alignPixel(this.chart, pixel, 0) : pixel); } getDecimalForPixel(pixel) { const decimal = (pixel - this._startPixel) / this._length; return this._reversePixels ? 1 - decimal : decimal; } getBasePixel() { return this.getPixelForValue(this.getBaseValue()); } getBaseValue() { const {min, max} = this; return min < 0 && max < 0 ? max : min > 0 && max > 0 ? min : 0; } getContext(index) { const ticks = this.ticks || []; if (index >= 0 && index < ticks.length) { const tick = ticks[index]; return tick.$context || (tick.$context = createTickContext(this.getContext(), index, tick)); } return this.$context || (this.$context = createScaleContext(this.chart.getContext(), this)); } _tickSize() { const optionTicks = this.options.ticks; const rot = toRadians(this.labelRotation); const cos = Math.abs(Math.cos(rot)); const sin = Math.abs(Math.sin(rot)); const labelSizes = this._getLabelSizes(); const padding = optionTicks.autoSkipPadding || 0; const w = labelSizes ? labelSizes.widest.width + padding : 0; const h = labelSizes ? labelSizes.highest.height + padding : 0; return this.isHorizontal() ? h * cos > w * sin ? w / cos : h / sin : h * sin < w * cos ? h / cos : w / sin; } _isVisible() { const display = this.options.display; if (display !== 'auto') { return !!display; } return this.getMatchingVisibleMetas().length > 0; } _computeGridLineItems(chartArea) { const axis = this.axis; const chart = this.chart; const options = this.options; const {grid, position} = options; const offset = grid.offset; const isHorizontal = this.isHorizontal(); const ticks = this.ticks; const ticksLength = ticks.length + (offset ? 1 : 0); const tl = getTickMarkLength(grid); const items = []; const borderOpts = grid.setContext(this.getContext()); const axisWidth = borderOpts.drawBorder ? borderOpts.borderWidth : 0; const axisHalfWidth = axisWidth / 2; const alignBorderValue = function(pixel) { return _alignPixel(chart, pixel, axisWidth); }; let borderValue, i, lineValue, alignedLineValue; let tx1, ty1, tx2, ty2, x1, y1, x2, y2; if (position === 'top') { borderValue = alignBorderValue(this.bottom); ty1 = this.bottom - tl; ty2 = borderValue - axisHalfWidth; y1 = alignBorderValue(chartArea.top) + axisHalfWidth; y2 = chartArea.bottom; } else if (position === 'bottom') { borderValue = alignBorderValue(this.top); y1 = chartArea.top; y2 = alignBorderValue(chartArea.bottom) - axisHalfWidth; ty1 = borderValue + axisHalfWidth; ty2 = this.top + tl; } else if (position === 'left') { borderValue = alignBorderValue(this.right); tx1 = this.right - tl; tx2 = borderValue - axisHalfWidth; x1 = alignBorderValue(chartArea.left) + axisHalfWidth; x2 = chartArea.right; } else if (position === 'right') { borderValue = alignBorderValue(this.left); x1 = chartArea.left; x2 = alignBorderValue(chartArea.right) - axisHalfWidth; tx1 = borderValue + axisHalfWidth; tx2 = this.left + tl; } else if (axis === 'x') { if (position === 'center') { borderValue = alignBorderValue((chartArea.top + chartArea.bottom) / 2 + 0.5); } else if (isObject(position)) { const positionAxisID = Object.keys(position)[0]; const value = position[positionAxisID]; borderValue = alignBorderValue(this.chart.scales[positionAxisID].getPixelForValue(value)); } y1 = chartArea.top; y2 = chartArea.bottom; ty1 = borderValue + axisHalfWidth; ty2 = ty1 + tl; } else if (axis === 'y') { if (position === 'center') { borderValue = alignBorderValue((chartArea.left + chartArea.right) / 2); } else if (isObject(position)) { const positionAxisID = Object.keys(position)[0]; const value = position[positionAxisID]; borderValue = alignBorderValue(this.chart.scales[positionAxisID].getPixelForValue(value)); } tx1 = borderValue - axisHalfWidth; tx2 = tx1 - tl; x1 = chartArea.left; x2 = chartArea.right; } const limit = valueOrDefault(options.ticks.maxTicksLimit, ticksLength); const step = Math.max(1, Math.ceil(ticksLength / limit)); for (i = 0; i < ticksLength; i += step) { const optsAtIndex = grid.setContext(this.getContext(i)); const lineWidth = optsAtIndex.lineWidth; const lineColor = optsAtIndex.color; const borderDash = grid.borderDash || []; const borderDashOffset = optsAtIndex.borderDashOffset; const tickWidth = optsAtIndex.tickWidth; const tickColor = optsAtIndex.tickColor; const tickBorderDash = optsAtIndex.tickBorderDash || []; const tickBorderDashOffset = optsAtIndex.tickBorderDashOffset; lineValue = getPixelForGridLine(this, i, offset); if (lineValue === undefined) { continue; } alignedLineValue = _alignPixel(chart, lineValue, lineWidth); if (isHorizontal) { tx1 = tx2 = x1 = x2 = alignedLineValue; } else { ty1 = ty2 = y1 = y2 = alignedLineValue; } items.push({ tx1, ty1, tx2, ty2, x1, y1, x2, y2, width: lineWidth, color: lineColor, borderDash, borderDashOffset, tickWidth, tickColor, tickBorderDash, tickBorderDashOffset, }); } this._ticksLength = ticksLength; this._borderValue = borderValue; return items; } _computeLabelItems(chartArea) { const axis = this.axis; const options = this.options; const {position, ticks: optionTicks} = options; const isHorizontal = this.isHorizontal(); const ticks = this.ticks; const {align, crossAlign, padding, mirror} = optionTicks; const tl = getTickMarkLength(options.grid); const tickAndPadding = tl + padding; const hTickAndPadding = mirror ? -padding : tickAndPadding; const rotation = -toRadians(this.labelRotation); const items = []; let i, ilen, tick, label, x, y, textAlign, pixel, font, lineHeight, lineCount, textOffset; let textBaseline = 'middle'; if (position === 'top') { y = this.bottom - hTickAndPadding; textAlign = this._getXAxisLabelAlignment(); } else if (position === 'bottom') { y = this.top + hTickAndPadding; textAlign = this._getXAxisLabelAlignment(); } else if (position === 'left') { const ret = this._getYAxisLabelAlignment(tl); textAlign = ret.textAlign; x = ret.x; } else if (position === 'right') { const ret = this._getYAxisLabelAlignment(tl); textAlign = ret.textAlign; x = ret.x; } else if (axis === 'x') { if (position === 'center') { y = ((chartArea.top + chartArea.bottom) / 2) + tickAndPadding; } else if (isObject(position)) { const positionAxisID = Object.keys(position)[0]; const value = position[positionAxisID]; y = this.chart.scales[positionAxisID].getPixelForValue(value) + tickAndPadding; } textAlign = this._getXAxisLabelAlignment(); } else if (axis === 'y') { if (position === 'center') { x = ((chartArea.left + chartArea.right) / 2) - tickAndPadding; } else if (isObject(position)) { const positionAxisID = Object.keys(position)[0]; const value = position[positionAxisID]; x = this.chart.scales[positionAxisID].getPixelForValue(value); } textAlign = this._getYAxisLabelAlignment(tl).textAlign; } if (axis === 'y') { if (align === 'start') { textBaseline = 'top'; } else if (align === 'end') { textBaseline = 'bottom'; } } const labelSizes = this._getLabelSizes(); for (i = 0, ilen = ticks.length; i < ilen; ++i) { tick = ticks[i]; label = tick.label; const optsAtIndex = optionTicks.setContext(this.getContext(i)); pixel = this.getPixelForTick(i) + optionTicks.labelOffset; font = this._resolveTickFontOptions(i); lineHeight = font.lineHeight; lineCount = isArray(label) ? label.length : 1; const halfCount = lineCount / 2; const color = optsAtIndex.color; const strokeColor = optsAtIndex.textStrokeColor; const strokeWidth = optsAtIndex.textStrokeWidth; let tickTextAlign = textAlign; if (isHorizontal) { x = pixel; if (textAlign === 'inner') { if (i === ilen - 1) { tickTextAlign = !this.options.reverse ? 'right' : 'left'; } else if (i === 0) { tickTextAlign = !this.options.reverse ? 'left' : 'right'; } else { tickTextAlign = 'center'; } } if (position === 'top') { if (crossAlign === 'near' || rotation !== 0) { textOffset = -lineCount * lineHeight + lineHeight / 2; } else if (crossAlign === 'center') { textOffset = -labelSizes.highest.height / 2 - halfCount * lineHeight + lineHeight; } else { textOffset = -labelSizes.highest.height + lineHeight / 2; } } else { if (crossAlign === 'near' || rotation !== 0) { textOffset = lineHeight / 2; } else if (crossAlign === 'center') { textOffset = labelSizes.highest.height / 2 - halfCount * lineHeight; } else { textOffset = labelSizes.highest.height - lineCount * lineHeight; } } if (mirror) { textOffset *= -1; } } else { y = pixel; textOffset = (1 - lineCount) * lineHeight / 2; } let backdrop; if (optsAtIndex.showLabelBackdrop) { const labelPadding = toPadding(optsAtIndex.backdropPadding); const height = labelSizes.heights[i]; const width = labelSizes.widths[i]; let top = y + textOffset - labelPadding.top; let left = x - labelPadding.left; switch (textBaseline) { case 'middle': top -= height / 2; break; case 'bottom': top -= height; break; } switch (textAlign) { case 'center': left -= width / 2; break; case 'right': left -= width; break; } backdrop = { left, top, width: width + labelPadding.width, height: height + labelPadding.height, color: optsAtIndex.backdropColor, }; } items.push({ rotation, label, font, color, strokeColor, strokeWidth, textOffset, textAlign: tickTextAlign, textBaseline, translation: [x, y], backdrop, }); } return items; } _getXAxisLabelAlignment() { const {position, ticks} = this.options; const rotation = -toRadians(this.labelRotation); if (rotation) { return position === 'top' ? 'left' : 'right'; } let align = 'center'; if (ticks.align === 'start') { align = 'left'; } else if (ticks.align === 'end') { align = 'right'; } else if (ticks.align === 'inner') { align = 'inner'; } return align; } _getYAxisLabelAlignment(tl) { const {position, ticks: {crossAlign, mirror, padding}} = this.options; const labelSizes = this._getLabelSizes(); const tickAndPadding = tl + padding; const widest = labelSizes.widest.width; let textAlign; let x; if (position === 'left') { if (mirror) { x = this.right + padding; if (crossAlign === 'near') { textAlign = 'left'; } else if (crossAlign === 'center') { textAlign = 'center'; x += (widest / 2); } else { textAlign = 'right'; x += widest; } } else { x = this.right - tickAndPadding; if (crossAlign === 'near') { textAlign = 'right'; } else if (crossAlign === 'center') { textAlign = 'center'; x -= (widest / 2); } else { textAlign = 'left'; x = this.left; } } } else if (position === 'right') { if (mirror) { x = this.left + padding; if (crossAlign === 'near') { textAlign = 'right'; } else if (crossAlign === 'center') { textAlign = 'center'; x -= (widest / 2); } else { textAlign = 'left'; x -= widest; } } else { x = this.left + tickAndPadding; if (crossAlign === 'near') { textAlign = 'left'; } else if (crossAlign === 'center') { textAlign = 'center'; x += widest / 2; } else { textAlign = 'right'; x = this.right; } } } else { textAlign = 'right'; } return {textAlign, x}; } _computeLabelArea() { if (this.options.ticks.mirror) { return; } const chart = this.chart; const position = this.options.position; if (position === 'left' || position === 'right') { return {top: 0, left: this.left, bottom: chart.height, right: this.right}; } if (position === 'top' || position === 'bottom') { return {top: this.top, left: 0, bottom: this.bottom, right: chart.width}; } } drawBackground() { const {ctx, options: {backgroundColor}, left, top, width, height} = this; if (backgroundColor) { ctx.save(); ctx.fillStyle = backgroundColor; ctx.fillRect(left, top, width, height); ctx.restore(); } } getLineWidthForValue(value) { const grid = this.options.grid; if (!this._isVisible() || !grid.display) { return 0; } const ticks = this.ticks; const index = ticks.findIndex(t => t.value === value); if (index >= 0) { const opts = grid.setContext(this.getContext(index)); return opts.lineWidth; } return 0; } drawGrid(chartArea) { const grid = this.options.grid; const ctx = this.ctx; const items = this._gridLineItems || (this._gridLineItems = this._computeGridLineItems(chartArea)); let i, ilen; const drawLine = (p1, p2, style) => { if (!style.width || !style.color) { return; } ctx.save(); ctx.lineWidth = style.width; ctx.strokeStyle = style.color; ctx.setLineDash(style.borderDash || []); ctx.lineDashOffset = style.borderDashOffset; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); ctx.restore(); }; if (grid.display) { for (i = 0, ilen = items.length; i < ilen; ++i) { const item = items[i]; if (grid.drawOnChartArea) { drawLine( {x: item.x1, y: item.y1}, {x: item.x2, y: item.y2}, item ); } if (grid.drawTicks) { drawLine( {x: item.tx1, y: item.ty1}, {x: item.tx2, y: item.ty2}, { color: item.tickColor, width: item.tickWidth, borderDash: item.tickBorderDash, borderDashOffset: item.tickBorderDashOffset } ); } } } } drawBorder() { const {chart, ctx, options: {grid}} = this; const borderOpts = grid.setContext(this.getContext()); const axisWidth = grid.drawBorder ? borderOpts.borderWidth : 0; if (!axisWidth) { return; } const lastLineWidth = grid.setContext(this.getContext(0)).lineWidth; const borderValue = this._borderValue; let x1, x2, y1, y2; if (this.isHorizontal()) { x1 = _alignPixel(chart, this.left, axisWidth) - axisWidth / 2; x2 = _alignPixel(chart, this.right, lastLineWidth) + lastLineWidth / 2; y1 = y2 = borderValue; } else { y1 = _alignPixel(chart, this.top, axisWidth) - axisWidth / 2; y2 = _alignPixel(chart, this.bottom, lastLineWidth) + lastLineWidth / 2; x1 = x2 = borderValue; } ctx.save(); ctx.lineWidth = borderOpts.borderWidth; ctx.strokeStyle = borderOpts.borderColor; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.restore(); } drawLabels(chartArea) { const optionTicks = this.options.ticks; if (!optionTicks.display) { return; } const ctx = this.ctx; const area = this._computeLabelArea(); if (area) { clipArea(ctx, area); } const items = this._labelItems || (this._labelItems = this._computeLabelItems(chartArea)); let i, ilen; for (i = 0, ilen = items.length; i < ilen; ++i) { const item = items[i]; const tickFont = item.font; const label = item.label; if (item.backdrop) { ctx.fillStyle = item.backdrop.color; ctx.fillRect(item.backdrop.left, item.backdrop.top, item.backdrop.width, item.backdrop.height); } let y = item.textOffset; renderText(ctx, label, 0, y, tickFont, item); } if (area) { unclipArea(ctx); } } drawTitle() { const {ctx, options: {position, title, reverse}} = this; if (!title.display) { return; } const font = toFont(title.font); const padding = toPadding(title.padding); const align = title.align; let offset = font.lineHeight / 2; if (position === 'bottom' || position === 'center' || isObject(position)) { offset += padding.bottom; if (isArray(title.text)) { offset += font.lineHeight * (title.text.length - 1); } } else { offset += padding.top; } const {titleX, titleY, maxWidth, rotation} = titleArgs(this, offset, position, align); renderText(ctx, title.text, 0, 0, font, { color: title.color, maxWidth, rotation, textAlign: titleAlign(align, position, reverse), textBaseline: 'middle', translation: [titleX, titleY], }); } draw(chartArea) { if (!this._isVisible()) { return; } this.drawBackground(); this.drawGrid(chartArea); this.drawBorder(); this.drawTitle(); this.drawLabels(chartArea); } _layers() { const opts = this.options; const tz = opts.ticks && opts.ticks.z || 0; const gz = valueOrDefault(opts.grid && opts.grid.z, -1); if (!this._isVisible() || this.draw !== Scale.prototype.draw) { return [{ z: tz, draw: (chartArea) => { this.draw(chartArea); } }]; } return [{ z: gz, draw: (chartArea) => { this.drawBackground(); this.drawGrid(chartArea); this.drawTitle(); } }, { z: gz + 1, draw: () => { this.drawBorder(); } }, { z: tz, draw: (chartArea) => { this.drawLabels(chartArea); } }]; } getMatchingVisibleMetas(type) { const metas = this.chart.getSortedVisibleDatasetMetas(); const axisID = this.axis + 'AxisID'; const result = []; let i, ilen; for (i = 0, ilen = metas.length; i < ilen; ++i) { const meta = metas[i]; if (meta[axisID] === this.id && (!type || meta.type === type)) { result.push(meta); } } return result; } _resolveTickFontOptions(index) { const opts = this.options.ticks.setContext(this.getContext(index)); return toFont(opts.font); } _maxDigits() { const fontSize = this._resolveTickFontOptions(0).lineHeight; return (this.isHorizontal() ? this.width : this.height) / fontSize; } } class TypedRegistry { constructor(type, scope, override) { this.type = type; this.scope = scope; this.override = override; this.items = Object.create(null); } isForType(type) { return Object.prototype.isPrototypeOf.call(this.type.prototype, type.prototype); } register(item) { const proto = Object.getPrototypeOf(item); let parentScope; if (isIChartComponent(proto)) { parentScope = this.register(proto); } const items = this.items; const id = item.id; const scope = this.scope + '.' + id; if (!id) { throw new Error('class does not have id: ' + item); } if (id in items) { return scope; } items[id] = item; registerDefaults(item, scope, parentScope); if (this.override) { defaults.override(item.id, item.overrides); } return scope; } get(id) { return this.items[id]; } unregister(item) { const items = this.items; const id = item.id; const scope = this.scope; if (id in items) { delete items[id]; } if (scope && id in defaults[scope]) { delete defaults[scope][id]; if (this.override) { delete overrides[id]; } } } } function registerDefaults(item, scope, parentScope) { const itemDefaults = merge(Object.create(null), [ parentScope ? defaults.get(parentScope) : {}, defaults.get(scope), item.defaults ]); defaults.set(scope, itemDefaults); if (item.defaultRoutes) { routeDefaults(scope, item.defaultRoutes); } if (item.descriptors) { defaults.describe(scope, item.descriptors); } } function routeDefaults(scope, routes) { Object.keys(routes).forEach(property => { const propertyParts = property.split('.'); const sourceName = propertyParts.pop(); const sourceScope = [scope].concat(propertyParts).join('.'); const parts = routes[property].split('.'); const targetName = parts.pop(); const targetScope = parts.join('.'); defaults.route(sourceScope, sourceName, targetScope, targetName); }); } function isIChartComponent(proto) { return 'id' in proto && 'defaults' in proto; } class Registry { constructor() { this.controllers = new TypedRegistry(DatasetController, 'datasets', true); this.elements = new TypedRegistry(Element, 'elements'); this.plugins = new TypedRegistry(Object, 'plugins'); this.scales = new TypedRegistry(Scale, 'scales'); this._typedRegistries = [this.controllers, this.scales, this.elements]; } add(...args) { this._each('register', args); } remove(...args) { this._each('unregister', args); } addControllers(...args) { this._each('register', args, this.controllers); } addElements(...args) { this._each('register', args, this.elements); } addPlugins(...args) { this._each('register', args, this.plugins); } addScales(...args) { this._each('register', args, this.scales); } getController(id) { return this._get(id, this.controllers, 'controller'); } getElement(id) { return this._get(id, this.elements, 'element'); } getPlugin(id) { return this._get(id, this.plugins, 'plugin'); } getScale(id) { return this._get(id, this.scales, 'scale'); } removeControllers(...args) { this._each('unregister', args, this.controllers); } removeElements(...args) { this._each('unregister', args, this.elements); } removePlugins(...args) { this._each('unregister', args, this.plugins); } removeScales(...args) { this._each('unregister', args, this.scales); } _each(method, args, typedRegistry) { [...args].forEach(arg => { const reg = typedRegistry || this._getRegistryForType(arg); if (typedRegistry || reg.isForType(arg) || (reg === this.plugins && arg.id)) { this._exec(method, reg, arg); } else { each(arg, item => { const itemReg = typedRegistry || this._getRegistryForType(item); this._exec(method, itemReg, item); }); } }); } _exec(method, registry, component) { const camelMethod = _capitalize(method); callback(component['before' + camelMethod], [], component); registry[method](component); callback(component['after' + camelMethod], [], component); } _getRegistryForType(type) { for (let i = 0; i < this._typedRegistries.length; i++) { const reg = this._typedRegistries[i]; if (reg.isForType(type)) { return reg; } } return this.plugins; } _get(id, typedRegistry, type) { const item = typedRegistry.get(id); if (item === undefined) { throw new Error('"' + id + '" is not a registered ' + type + '.'); } return item; } } var registry = new Registry(); class PluginService { constructor() { this._init = []; } notify(chart, hook, args, filter) { if (hook === 'beforeInit') { this._init = this._createDescriptors(chart, true); this._notify(this._init, chart, 'install'); } const descriptors = filter ? this._descriptors(chart).filter(filter) : this._descriptors(chart); const result = this._notify(descriptors, chart, hook, args); if (hook === 'afterDestroy') { this._notify(descriptors, chart, 'stop'); this._notify(this._init, chart, 'uninstall'); } return result; } _notify(descriptors, chart, hook, args) { args = args || {}; for (const descriptor of descriptors) { const plugin = descriptor.plugin; const method = plugin[hook]; const params = [chart, args, descriptor.options]; if (callback(method, params, plugin) === false && args.cancelable) { return false; } } return true; } invalidate() { if (!isNullOrUndef(this._cache)) { this._oldCache = this._cache; this._cache = undefined; } } _descriptors(chart) { if (this._cache) { return this._cache; } const descriptors = this._cache = this._createDescriptors(chart); this._notifyStateChanges(chart); return descriptors; } _createDescriptors(chart, all) { const config = chart && chart.config; const options = valueOrDefault(config.options && config.options.plugins, {}); const plugins = allPlugins(config); return options === false && !all ? [] : createDescriptors(chart, plugins, options, all); } _notifyStateChanges(chart) { const previousDescriptors = this._oldCache || []; const descriptors = this._cache; const diff = (a, b) => a.filter(x => !b.some(y => x.plugin.id === y.plugin.id)); this._notify(diff(previousDescriptors, descriptors), chart, 'stop'); this._notify(diff(descriptors, previousDescriptors), chart, 'start'); } } function allPlugins(config) { const plugins = []; const keys = Object.keys(registry.plugins.items); for (let i = 0; i < keys.length; i++) { plugins.push(registry.getPlugin(keys[i])); } const local = config.plugins || []; for (let i = 0; i < local.length; i++) { const plugin = local[i]; if (plugins.indexOf(plugin) === -1) { plugins.push(plugin); } } return plugins; } function getOpts(options, all) { if (!all && options === false) { return null; } if (options === true) { return {}; } return options; } function createDescriptors(chart, plugins, options, all) { const result = []; const context = chart.getContext(); for (let i = 0; i < plugins.length; i++) { const plugin = plugins[i]; const id = plugin.id; const opts = getOpts(options[id], all); if (opts === null) { continue; } result.push({ plugin, options: pluginOpts(chart.config, plugin, opts, context) }); } return result; } function pluginOpts(config, plugin, opts, context) { const keys = config.pluginScopeKeys(plugin); const scopes = config.getOptionScopes(opts, keys); return config.createResolver(scopes, context, [''], {scriptable: false, indexable: false, allKeys: true}); } function getIndexAxis(type, options) { const datasetDefaults = defaults.datasets[type] || {}; const datasetOptions = (options.datasets || {})[type] || {}; return datasetOptions.indexAxis || options.indexAxis || datasetDefaults.indexAxis || 'x'; } function getAxisFromDefaultScaleID(id, indexAxis) { let axis = id; if (id === '_index_') { axis = indexAxis; } else if (id === '_value_') { axis = indexAxis === 'x' ? 'y' : 'x'; } return axis; } function getDefaultScaleIDFromAxis(axis, indexAxis) { return axis === indexAxis ? '_index_' : '_value_'; } function axisFromPosition(position) { if (position === 'top' || position === 'bottom') { return 'x'; } if (position === 'left' || position === 'right') { return 'y'; } } function determineAxis(id, scaleOptions) { if (id === 'x' || id === 'y') { return id; } return scaleOptions.axis || axisFromPosition(scaleOptions.position) || id.charAt(0).toLowerCase(); } function mergeScaleConfig(config, options) { const chartDefaults = overrides[config.type] || {scales: {}}; const configScales = options.scales || {}; const chartIndexAxis = getIndexAxis(config.type, options); const firstIDs = Object.create(null); const scales = Object.create(null); Object.keys(configScales).forEach(id => { const scaleConf = configScales[id]; if (!isObject(scaleConf)) { return console.error(`Invalid scale configuration for scale: ${id}`); } if (scaleConf._proxy) { return console.warn(`Ignoring resolver passed as options for scale: ${id}`); } const axis = determineAxis(id, scaleConf); const defaultId = getDefaultScaleIDFromAxis(axis, chartIndexAxis); const defaultScaleOptions = chartDefaults.scales || {}; firstIDs[axis] = firstIDs[axis] || id; scales[id] = mergeIf(Object.create(null), [{axis}, scaleConf, defaultScaleOptions[axis], defaultScaleOptions[defaultId]]); }); config.data.datasets.forEach(dataset => { const type = dataset.type || config.type; const indexAxis = dataset.indexAxis || getIndexAxis(type, options); const datasetDefaults = overrides[type] || {}; const defaultScaleOptions = datasetDefaults.scales || {}; Object.keys(defaultScaleOptions).forEach(defaultID => { const axis = getAxisFromDefaultScaleID(defaultID, indexAxis); const id = dataset[axis + 'AxisID'] || firstIDs[axis] || axis; scales[id] = scales[id] || Object.create(null); mergeIf(scales[id], [{axis}, configScales[id], defaultScaleOptions[defaultID]]); }); }); Object.keys(scales).forEach(key => { const scale = scales[key]; mergeIf(scale, [defaults.scales[scale.type], defaults.scale]); }); return scales; } function initOptions(config) { const options = config.options || (config.options = {}); options.plugins = valueOrDefault(options.plugins, {}); options.scales = mergeScaleConfig(config, options); } function initData(data) { data = data || {}; data.datasets = data.datasets || []; data.labels = data.labels || []; return data; } function initConfig(config) { config = config || {}; config.data = initData(config.data); initOptions(config); return config; } const keyCache = new Map(); const keysCached = new Set(); function cachedKeys(cacheKey, generate) { let keys = keyCache.get(cacheKey); if (!keys) { keys = generate(); keyCache.set(cacheKey, keys); keysCached.add(keys); } return keys; } const addIfFound = (set, obj, key) => { const opts = resolveObjectKey(obj, key); if (opts !== undefined) { set.add(opts); } }; class Config { constructor(config) { this._config = initConfig(config); this._scopeCache = new Map(); this._resolverCache = new Map(); } get platform() { return this._config.platform; } get type() { return this._config.type; } set type(type) { this._config.type = type; } get data() { return this._config.data; } set data(data) { this._config.data = initData(data); } get options() { return this._config.options; } set options(options) { this._config.options = options; } get plugins() { return this._config.plugins; } update() { const config = this._config; this.clearCache(); initOptions(config); } clearCache() { this._scopeCache.clear(); this._resolverCache.clear(); } datasetScopeKeys(datasetType) { return cachedKeys(datasetType, () => [[ `datasets.${datasetType}`, '' ]]); } datasetAnimationScopeKeys(datasetType, transition) { return cachedKeys(`${datasetType}.transition.${transition}`, () => [ [ `datasets.${datasetType}.transitions.${transition}`, `transitions.${transition}`, ], [ `datasets.${datasetType}`, '' ] ]); } datasetElementScopeKeys(datasetType, elementType) { return cachedKeys(`${datasetType}-${elementType}`, () => [[ `datasets.${datasetType}.elements.${elementType}`, `datasets.${datasetType}`, `elements.${elementType}`, '' ]]); } pluginScopeKeys(plugin) { const id = plugin.id; const type = this.type; return cachedKeys(`${type}-plugin-${id}`, () => [[ `plugins.${id}`, ...plugin.additionalOptionScopes || [], ]]); } _cachedScopes(mainScope, resetCache) { const _scopeCache = this._scopeCache; let cache = _scopeCache.get(mainScope); if (!cache || resetCache) { cache = new Map(); _scopeCache.set(mainScope, cache); } return cache; } getOptionScopes(mainScope, keyLists, resetCache) { const {options, type} = this; const cache = this._cachedScopes(mainScope, resetCache); const cached = cache.get(keyLists); if (cached) { return cached; } const scopes = new Set(); keyLists.forEach(keys => { if (mainScope) { scopes.add(mainScope); keys.forEach(key => addIfFound(scopes, mainScope, key)); } keys.forEach(key => addIfFound(scopes, options, key)); keys.forEach(key => addIfFound(scopes, overrides[type] || {}, key)); keys.forEach(key => addIfFound(scopes, defaults, key)); keys.forEach(key => addIfFound(scopes, descriptors, key)); }); const array = Array.from(scopes); if (array.length === 0) { array.push(Object.create(null)); } if (keysCached.has(keyLists)) { cache.set(keyLists, array); } return array; } chartOptionScopes() { const {options, type} = this; return [ options, overrides[type] || {}, defaults.datasets[type] || {}, {type}, defaults, descriptors ]; } resolveNamedOptions(scopes, names, context, prefixes = ['']) { const result = {$shared: true}; const {resolver, subPrefixes} = getResolver(this._resolverCache, scopes, prefixes); let options = resolver; if (needContext(resolver, names)) { result.$shared = false; context = isFunction(context) ? context() : context; const subResolver = this.createResolver(scopes, context, subPrefixes); options = _attachContext(resolver, context, subResolver); } for (const prop of names) { result[prop] = options[prop]; } return result; } createResolver(scopes, context, prefixes = [''], descriptorDefaults) { const {resolver} = getResolver(this._resolverCache, scopes, prefixes); return isObject(context) ? _attachContext(resolver, context, undefined, descriptorDefaults) : resolver; } } function getResolver(resolverCache, scopes, prefixes) { let cache = resolverCache.get(scopes); if (!cache) { cache = new Map(); resolverCache.set(scopes, cache); } const cacheKey = prefixes.join(); let cached = cache.get(cacheKey); if (!cached) { const resolver = _createResolver(scopes, prefixes); cached = { resolver, subPrefixes: prefixes.filter(p => !p.toLowerCase().includes('hover')) }; cache.set(cacheKey, cached); } return cached; } const hasFunction = value => isObject(value) && Object.getOwnPropertyNames(value).reduce((acc, key) => acc || isFunction(value[key]), false); function needContext(proxy, names) { const {isScriptable, isIndexable} = _descriptors(proxy); for (const prop of names) { const scriptable = isScriptable(prop); const indexable = isIndexable(prop); const value = (indexable || scriptable) && proxy[prop]; if ((scriptable && (isFunction(value) || hasFunction(value))) || (indexable && isArray(value))) { return true; } } return false; } var version = "3.8.0"; const KNOWN_POSITIONS = ['top', 'bottom', 'left', 'right', 'chartArea']; function positionIsHorizontal(position, axis) { return position === 'top' || position === 'bottom' || (KNOWN_POSITIONS.indexOf(position) === -1 && axis === 'x'); } function compare2Level(l1, l2) { return function(a, b) { return a[l1] === b[l1] ? a[l2] - b[l2] : a[l1] - b[l1]; }; } function onAnimationsComplete(context) { const chart = context.chart; const animationOptions = chart.options.animation; chart.notifyPlugins('afterRender'); callback(animationOptions && animationOptions.onComplete, [context], chart); } function onAnimationProgress(context) { const chart = context.chart; const animationOptions = chart.options.animation; callback(animationOptions && animationOptions.onProgress, [context], chart); } function getCanvas(item) { if (_isDomSupported() && typeof item === 'string') { item = document.getElementById(item); } else if (item && item.length) { item = item[0]; } if (item && item.canvas) { item = item.canvas; } return item; } const instances = {}; const getChart = (key) => { const canvas = getCanvas(key); return Object.values(instances).filter((c) => c.canvas === canvas).pop(); }; function moveNumericKeys(obj, start, move) { const keys = Object.keys(obj); for (const key of keys) { const intKey = +key; if (intKey >= start) { const value = obj[key]; delete obj[key]; if (move > 0 || intKey > start) { obj[intKey + move] = value; } } } } function determineLastEvent(e, lastEvent, inChartArea, isClick) { if (!inChartArea || e.type === 'mouseout') { return null; } if (isClick) { return lastEvent; } return e; } class Chart { constructor(item, userConfig) { const config = this.config = new Config(userConfig); const initialCanvas = getCanvas(item); const existingChart = getChart(initialCanvas); if (existingChart) { throw new Error( 'Canvas is already in use. Chart with ID \'' + existingChart.id + '\'' + ' must be destroyed before the canvas can be reused.' ); } const options = config.createResolver(config.chartOptionScopes(), this.getContext()); this.platform = new (config.platform || _detectPlatform(initialCanvas))(); this.platform.updateConfig(config); const context = this.platform.acquireContext(initialCanvas, options.aspectRatio); const canvas = context && context.canvas; const height = canvas && canvas.height; const width = canvas && canvas.width; this.id = uid(); this.ctx = context; this.canvas = canvas; this.width = width; this.height = height; this._options = options; this._aspectRatio = this.aspectRatio; this._layers = []; this._metasets = []; this._stacks = undefined; this.boxes = []; this.currentDevicePixelRatio = undefined; this.chartArea = undefined; this._active = []; this._lastEvent = undefined; this._listeners = {}; this._responsiveListeners = undefined; this._sortedMetasets = []; this.scales = {}; this._plugins = new PluginService(); this.$proxies = {}; this._hiddenIndices = {}; this.attached = false; this._animationsDisabled = undefined; this.$context = undefined; this._doResize = debounce(mode => this.update(mode), options.resizeDelay || 0); this._dataChanges = []; instances[this.id] = this; if (!context || !canvas) { console.error("Failed to create chart: can't acquire context from the given item"); return; } animator.listen(this, 'complete', onAnimationsComplete); animator.listen(this, 'progress', onAnimationProgress); this._initialize(); if (this.attached) { this.update(); } } get aspectRatio() { const {options: {aspectRatio, maintainAspectRatio}, width, height, _aspectRatio} = this; if (!isNullOrUndef(aspectRatio)) { return aspectRatio; } if (maintainAspectRatio && _aspectRatio) { return _aspectRatio; } return height ? width / height : null; } get data() { return this.config.data; } set data(data) { this.config.data = data; } get options() { return this._options; } set options(options) { this.config.options = options; } _initialize() { this.notifyPlugins('beforeInit'); if (this.options.responsive) { this.resize(); } else { retinaScale(this, this.options.devicePixelRatio); } this.bindEvents(); this.notifyPlugins('afterInit'); return this; } clear() { clearCanvas(this.canvas, this.ctx); return this; } stop() { animator.stop(this); return this; } resize(width, height) { if (!animator.running(this)) { this._resize(width, height); } else { this._resizeBeforeDraw = {width, height}; } } _resize(width, height) { const options = this.options; const canvas = this.canvas; const aspectRatio = options.maintainAspectRatio && this.aspectRatio; const newSize = this.platform.getMaximumSize(canvas, width, height, aspectRatio); const newRatio = options.devicePixelRatio || this.platform.getDevicePixelRatio(); const mode = this.width ? 'resize' : 'attach'; this.width = newSize.width; this.height = newSize.height; this._aspectRatio = this.aspectRatio; if (!retinaScale(this, newRatio, true)) { return; } this.notifyPlugins('resize', {size: newSize}); callback(options.onResize, [this, newSize], this); if (this.attached) { if (this._doResize(mode)) { this.render(); } } } ensureScalesHaveIDs() { const options = this.options; const scalesOptions = options.scales || {}; each(scalesOptions, (axisOptions, axisID) => { axisOptions.id = axisID; }); } buildOrUpdateScales() { const options = this.options; const scaleOpts = options.scales; const scales = this.scales; const updated = Object.keys(scales).reduce((obj, id) => { obj[id] = false; return obj; }, {}); let items = []; if (scaleOpts) { items = items.concat( Object.keys(scaleOpts).map((id) => { const scaleOptions = scaleOpts[id]; const axis = determineAxis(id, scaleOptions); const isRadial = axis === 'r'; const isHorizontal = axis === 'x'; return { options: scaleOptions, dposition: isRadial ? 'chartArea' : isHorizontal ? 'bottom' : 'left', dtype: isRadial ? 'radialLinear' : isHorizontal ? 'category' : 'linear' }; }) ); } each(items, (item) => { const scaleOptions = item.options; const id = scaleOptions.id; const axis = determineAxis(id, scaleOptions); const scaleType = valueOrDefault(scaleOptions.type, item.dtype); if (scaleOptions.position === undefined || positionIsHorizontal(scaleOptions.position, axis) !== positionIsHorizontal(item.dposition)) { scaleOptions.position = item.dposition; } updated[id] = true; let scale = null; if (id in scales && scales[id].type === scaleType) { scale = scales[id]; } else { const scaleClass = registry.getScale(scaleType); scale = new scaleClass({ id, type: scaleType, ctx: this.ctx, chart: this }); scales[scale.id] = scale; } scale.init(scaleOptions, options); }); each(updated, (hasUpdated, id) => { if (!hasUpdated) { delete scales[id]; } }); each(scales, (scale) => { layouts.configure(this, scale, scale.options); layouts.addBox(this, scale); }); } _updateMetasets() { const metasets = this._metasets; const numData = this.data.datasets.length; const numMeta = metasets.length; metasets.sort((a, b) => a.index - b.index); if (numMeta > numData) { for (let i = numData; i < numMeta; ++i) { this._destroyDatasetMeta(i); } metasets.splice(numData, numMeta - numData); } this._sortedMetasets = metasets.slice(0).sort(compare2Level('order', 'index')); } _removeUnreferencedMetasets() { const {_metasets: metasets, data: {datasets}} = this; if (metasets.length > datasets.length) { delete this._stacks; } metasets.forEach((meta, index) => { if (datasets.filter(x => x === meta._dataset).length === 0) { this._destroyDatasetMeta(index); } }); } buildOrUpdateControllers() { const newControllers = []; const datasets = this.data.datasets; let i, ilen; this._removeUnreferencedMetasets(); for (i = 0, ilen = datasets.length; i < ilen; i++) { const dataset = datasets[i]; let meta = this.getDatasetMeta(i); const type = dataset.type || this.config.type; if (meta.type && meta.type !== type) { this._destroyDatasetMeta(i); meta = this.getDatasetMeta(i); } meta.type = type; meta.indexAxis = dataset.indexAxis || getIndexAxis(type, this.options); meta.order = dataset.order || 0; meta.index = i; meta.label = '' + dataset.label; meta.visible = this.isDatasetVisible(i); if (meta.controller) { meta.controller.updateIndex(i); meta.controller.linkScales(); } else { const ControllerClass = registry.getController(type); const {datasetElementType, dataElementType} = defaults.datasets[type]; Object.assign(ControllerClass.prototype, { dataElementType: registry.getElement(dataElementType), datasetElementType: datasetElementType && registry.getElement(datasetElementType) }); meta.controller = new ControllerClass(this, i); newControllers.push(meta.controller); } } this._updateMetasets(); return newControllers; } _resetElements() { each(this.data.datasets, (dataset, datasetIndex) => { this.getDatasetMeta(datasetIndex).controller.reset(); }, this); } reset() { this._resetElements(); this.notifyPlugins('reset'); } update(mode) { const config = this.config; config.update(); const options = this._options = config.createResolver(config.chartOptionScopes(), this.getContext()); const animsDisabled = this._animationsDisabled = !options.animation; this._updateScales(); this._checkEventBindings(); this._updateHiddenIndices(); this._plugins.invalidate(); if (this.notifyPlugins('beforeUpdate', {mode, cancelable: true}) === false) { return; } const newControllers = this.buildOrUpdateControllers(); this.notifyPlugins('beforeElementsUpdate'); let minPadding = 0; for (let i = 0, ilen = this.data.datasets.length; i < ilen; i++) { const {controller} = this.getDatasetMeta(i); const reset = !animsDisabled && newControllers.indexOf(controller) === -1; controller.buildOrUpdateElements(reset); minPadding = Math.max(+controller.getMaxOverflow(), minPadding); } minPadding = this._minPadding = options.layout.autoPadding ? minPadding : 0; this._updateLayout(minPadding); if (!animsDisabled) { each(newControllers, (controller) => { controller.reset(); }); } this._updateDatasets(mode); this.notifyPlugins('afterUpdate', {mode}); this._layers.sort(compare2Level('z', '_idx')); const {_active, _lastEvent} = this; if (_lastEvent) { this._eventHandler(_lastEvent, true); } else if (_active.length) { this._updateHoverStyles(_active, _active, true); } this.render(); } _updateScales() { each(this.scales, (scale) => { layouts.removeBox(this, scale); }); this.ensureScalesHaveIDs(); this.buildOrUpdateScales(); } _checkEventBindings() { const options = this.options; const existingEvents = new Set(Object.keys(this._listeners)); const newEvents = new Set(options.events); if (!setsEqual(existingEvents, newEvents) || !!this._responsiveListeners !== options.responsive) { this.unbindEvents(); this.bindEvents(); } } _updateHiddenIndices() { const {_hiddenIndices} = this; const changes = this._getUniformDataChanges() || []; for (const {method, start, count} of changes) { const move = method === '_removeElements' ? -count : count; moveNumericKeys(_hiddenIndices, start, move); } } _getUniformDataChanges() { const _dataChanges = this._dataChanges; if (!_dataChanges || !_dataChanges.length) { return; } this._dataChanges = []; const datasetCount = this.data.datasets.length; const makeSet = (idx) => new Set( _dataChanges .filter(c => c[0] === idx) .map((c, i) => i + ',' + c.splice(1).join(',')) ); const changeSet = makeSet(0); for (let i = 1; i < datasetCount; i++) { if (!setsEqual(changeSet, makeSet(i))) { return; } } return Array.from(changeSet) .map(c => c.split(',')) .map(a => ({method: a[1], start: +a[2], count: +a[3]})); } _updateLayout(minPadding) { if (this.notifyPlugins('beforeLayout', {cancelable: true}) === false) { return; } layouts.update(this, this.width, this.height, minPadding); const area = this.chartArea; const noArea = area.width <= 0 || area.height <= 0; this._layers = []; each(this.boxes, (box) => { if (noArea && box.position === 'chartArea') { return; } if (box.configure) { box.configure(); } this._layers.push(...box._layers()); }, this); this._layers.forEach((item, index) => { item._idx = index; }); this.notifyPlugins('afterLayout'); } _updateDatasets(mode) { if (this.notifyPlugins('beforeDatasetsUpdate', {mode, cancelable: true}) === false) { return; } for (let i = 0, ilen = this.data.datasets.length; i < ilen; ++i) { this.getDatasetMeta(i).controller.configure(); } for (let i = 0, ilen = this.data.datasets.length; i < ilen; ++i) { this._updateDataset(i, isFunction(mode) ? mode({datasetIndex: i}) : mode); } this.notifyPlugins('afterDatasetsUpdate', {mode}); } _updateDataset(index, mode) { const meta = this.getDatasetMeta(index); const args = {meta, index, mode, cancelable: true}; if (this.notifyPlugins('beforeDatasetUpdate', args) === false) { return; } meta.controller._update(mode); args.cancelable = false; this.notifyPlugins('afterDatasetUpdate', args); } render() { if (this.notifyPlugins('beforeRender', {cancelable: true}) === false) { return; } if (animator.has(this)) { if (this.attached && !animator.running(this)) { animator.start(this); } } else { this.draw(); onAnimationsComplete({chart: this}); } } draw() { let i; if (this._resizeBeforeDraw) { const {width, height} = this._resizeBeforeDraw; this._resize(width, height); this._resizeBeforeDraw = null; } this.clear(); if (this.width <= 0 || this.height <= 0) { return; } if (this.notifyPlugins('beforeDraw', {cancelable: true}) === false) { return; } const layers = this._layers; for (i = 0; i < layers.length && layers[i].z <= 0; ++i) { layers[i].draw(this.chartArea); } this._drawDatasets(); for (; i < layers.length; ++i) { layers[i].draw(this.chartArea); } this.notifyPlugins('afterDraw'); } _getSortedDatasetMetas(filterVisible) { const metasets = this._sortedMetasets; const result = []; let i, ilen; for (i = 0, ilen = metasets.length; i < ilen; ++i) { const meta = metasets[i]; if (!filterVisible || meta.visible) { result.push(meta); } } return result; } getSortedVisibleDatasetMetas() { return this._getSortedDatasetMetas(true); } _drawDatasets() { if (this.notifyPlugins('beforeDatasetsDraw', {cancelable: true}) === false) { return; } const metasets = this.getSortedVisibleDatasetMetas(); for (let i = metasets.length - 1; i >= 0; --i) { this._drawDataset(metasets[i]); } this.notifyPlugins('afterDatasetsDraw'); } _drawDataset(meta) { const ctx = this.ctx; const clip = meta._clip; const useClip = !clip.disabled; const area = this.chartArea; const args = { meta, index: meta.index, cancelable: true }; if (this.notifyPlugins('beforeDatasetDraw', args) === false) { return; } if (useClip) { clipArea(ctx, { left: clip.left === false ? 0 : area.left - clip.left, right: clip.right === false ? this.width : area.right + clip.right, top: clip.top === false ? 0 : area.top - clip.top, bottom: clip.bottom === false ? this.height : area.bottom + clip.bottom }); } meta.controller.draw(); if (useClip) { unclipArea(ctx); } args.cancelable = false; this.notifyPlugins('afterDatasetDraw', args); } isPointInArea(point) { return _isPointInArea(point, this.chartArea, this._minPadding); } getElementsAtEventForMode(e, mode, options, useFinalPosition) { const method = Interaction.modes[mode]; if (typeof method === 'function') { return method(this, e, options, useFinalPosition); } return []; } getDatasetMeta(datasetIndex) { const dataset = this.data.datasets[datasetIndex]; const metasets = this._metasets; let meta = metasets.filter(x => x && x._dataset === dataset).pop(); if (!meta) { meta = { type: null, data: [], dataset: null, controller: null, hidden: null, xAxisID: null, yAxisID: null, order: dataset && dataset.order || 0, index: datasetIndex, _dataset: dataset, _parsed: [], _sorted: false }; metasets.push(meta); } return meta; } getContext() { return this.$context || (this.$context = createContext(null, {chart: this, type: 'chart'})); } getVisibleDatasetCount() { return this.getSortedVisibleDatasetMetas().length; } isDatasetVisible(datasetIndex) { const dataset = this.data.datasets[datasetIndex]; if (!dataset) { return false; } const meta = this.getDatasetMeta(datasetIndex); return typeof meta.hidden === 'boolean' ? !meta.hidden : !dataset.hidden; } setDatasetVisibility(datasetIndex, visible) { const meta = this.getDatasetMeta(datasetIndex); meta.hidden = !visible; } toggleDataVisibility(index) { this._hiddenIndices[index] = !this._hiddenIndices[index]; } getDataVisibility(index) { return !this._hiddenIndices[index]; } _updateVisibility(datasetIndex, dataIndex, visible) { const mode = visible ? 'show' : 'hide'; const meta = this.getDatasetMeta(datasetIndex); const anims = meta.controller._resolveAnimations(undefined, mode); if (defined(dataIndex)) { meta.data[dataIndex].hidden = !visible; this.update(); } else { this.setDatasetVisibility(datasetIndex, visible); anims.update(meta, {visible}); this.update((ctx) => ctx.datasetIndex === datasetIndex ? mode : undefined); } } hide(datasetIndex, dataIndex) { this._updateVisibility(datasetIndex, dataIndex, false); } show(datasetIndex, dataIndex) { this._updateVisibility(datasetIndex, dataIndex, true); } _destroyDatasetMeta(datasetIndex) { const meta = this._metasets[datasetIndex]; if (meta && meta.controller) { meta.controller._destroy(); } delete this._metasets[datasetIndex]; } _stop() { let i, ilen; this.stop(); animator.remove(this); for (i = 0, ilen = this.data.datasets.length; i < ilen; ++i) { this._destroyDatasetMeta(i); } } destroy() { this.notifyPlugins('beforeDestroy'); const {canvas, ctx} = this; this._stop(); this.config.clearCache(); if (canvas) { this.unbindEvents(); clearCanvas(canvas, ctx); this.platform.releaseContext(ctx); this.canvas = null; this.ctx = null; } this.notifyPlugins('destroy'); delete instances[this.id]; this.notifyPlugins('afterDestroy'); } toBase64Image(...args) { return this.canvas.toDataURL(...args); } bindEvents() { this.bindUserEvents(); if (this.options.responsive) { this.bindResponsiveEvents(); } else { this.attached = true; } } bindUserEvents() { const listeners = this._listeners; const platform = this.platform; const _add = (type, listener) => { platform.addEventListener(this, type, listener); listeners[type] = listener; }; const listener = (e, x, y) => { e.offsetX = x; e.offsetY = y; this._eventHandler(e); }; each(this.options.events, (type) => _add(type, listener)); } bindResponsiveEvents() { if (!this._responsiveListeners) { this._responsiveListeners = {}; } const listeners = this._responsiveListeners; const platform = this.platform; const _add = (type, listener) => { platform.addEventListener(this, type, listener); listeners[type] = listener; }; const _remove = (type, listener) => { if (listeners[type]) { platform.removeEventListener(this, type, listener); delete listeners[type]; } }; const listener = (width, height) => { if (this.canvas) { this.resize(width, height); } }; let detached; const attached = () => { _remove('attach', attached); this.attached = true; this.resize(); _add('resize', listener); _add('detach', detached); }; detached = () => { this.attached = false; _remove('resize', listener); this._stop(); this._resize(0, 0); _add('attach', attached); }; if (platform.isAttached(this.canvas)) { attached(); } else { detached(); } } unbindEvents() { each(this._listeners, (listener, type) => { this.platform.removeEventListener(this, type, listener); }); this._listeners = {}; each(this._responsiveListeners, (listener, type) => { this.platform.removeEventListener(this, type, listener); }); this._responsiveListeners = undefined; } updateHoverStyle(items, mode, enabled) { const prefix = enabled ? 'set' : 'remove'; let meta, item, i, ilen; if (mode === 'dataset') { meta = this.getDatasetMeta(items[0].datasetIndex); meta.controller['_' + prefix + 'DatasetHoverStyle'](); } for (i = 0, ilen = items.length; i < ilen; ++i) { item = items[i]; const controller = item && this.getDatasetMeta(item.datasetIndex).controller; if (controller) { controller[prefix + 'HoverStyle'](item.element, item.datasetIndex, item.index); } } } getActiveElements() { return this._active || []; } setActiveElements(activeElements) { const lastActive = this._active || []; const active = activeElements.map(({datasetIndex, index}) => { const meta = this.getDatasetMeta(datasetIndex); if (!meta) { throw new Error('No dataset found at index ' + datasetIndex); } return { datasetIndex, element: meta.data[index], index, }; }); const changed = !_elementsEqual(active, lastActive); if (changed) { this._active = active; this._lastEvent = null; this._updateHoverStyles(active, lastActive); } } notifyPlugins(hook, args, filter) { return this._plugins.notify(this, hook, args, filter); } _updateHoverStyles(active, lastActive, replay) { const hoverOptions = this.options.hover; const diff = (a, b) => a.filter(x => !b.some(y => x.datasetIndex === y.datasetIndex && x.index === y.index)); const deactivated = diff(lastActive, active); const activated = replay ? active : diff(active, lastActive); if (deactivated.length) { this.updateHoverStyle(deactivated, hoverOptions.mode, false); } if (activated.length && hoverOptions.mode) { this.updateHoverStyle(activated, hoverOptions.mode, true); } } _eventHandler(e, replay) { const args = { event: e, replay, cancelable: true, inChartArea: this.isPointInArea(e) }; const eventFilter = (plugin) => (plugin.options.events || this.options.events).includes(e.native.type); if (this.notifyPlugins('beforeEvent', args, eventFilter) === false) { return; } const changed = this._handleEvent(e, replay, args.inChartArea); args.cancelable = false; this.notifyPlugins('afterEvent', args, eventFilter); if (changed || args.changed) { this.render(); } return this; } _handleEvent(e, replay, inChartArea) { const {_active: lastActive = [], options} = this; const useFinalPosition = replay; const active = this._getActiveElements(e, lastActive, inChartArea, useFinalPosition); const isClick = _isClickEvent(e); const lastEvent = determineLastEvent(e, this._lastEvent, inChartArea, isClick); if (inChartArea) { this._lastEvent = null; callback(options.onHover, [e, active, this], this); if (isClick) { callback(options.onClick, [e, active, this], this); } } const changed = !_elementsEqual(active, lastActive); if (changed || replay) { this._active = active; this._updateHoverStyles(active, lastActive, replay); } this._lastEvent = lastEvent; return changed; } _getActiveElements(e, lastActive, inChartArea, useFinalPosition) { if (e.type === 'mouseout') { return []; } if (!inChartArea) { return lastActive; } const hoverOptions = this.options.hover; return this.getElementsAtEventForMode(e, hoverOptions.mode, hoverOptions, useFinalPosition); } } const invalidatePlugins = () => each(Chart.instances, (chart) => chart._plugins.invalidate()); const enumerable = true; Object.defineProperties(Chart, { defaults: { enumerable, value: defaults }, instances: { enumerable, value: instances }, overrides: { enumerable, value: overrides }, registry: { enumerable, value: registry }, version: { enumerable, value: version }, getChart: { enumerable, value: getChart }, register: { enumerable, value: (...items) => { registry.add(...items); invalidatePlugins(); } }, unregister: { enumerable, value: (...items) => { registry.remove(...items); invalidatePlugins(); } } }); function abstract() { throw new Error('This method is not implemented: Check that a complete date adapter is provided.'); } class DateAdapter { constructor(options) { this.options = options || {}; } formats() { return abstract(); } parse(value, format) { return abstract(); } format(timestamp, format) { return abstract(); } add(timestamp, amount, unit) { return abstract(); } diff(a, b, unit) { return abstract(); } startOf(timestamp, unit, weekday) { return abstract(); } endOf(timestamp, unit) { return abstract(); } } DateAdapter.override = function(members) { Object.assign(DateAdapter.prototype, members); }; var _adapters = { _date: DateAdapter }; function getAllScaleValues(scale, type) { if (!scale._cache.$bar) { const visibleMetas = scale.getMatchingVisibleMetas(type); let values = []; for (let i = 0, ilen = visibleMetas.length; i < ilen; i++) { values = values.concat(visibleMetas[i].controller.getAllParsedValues(scale)); } scale._cache.$bar = _arrayUnique(values.sort((a, b) => a - b)); } return scale._cache.$bar; } function computeMinSampleSize(meta) { const scale = meta.iScale; const values = getAllScaleValues(scale, meta.type); let min = scale._length; let i, ilen, curr, prev; const updateMinAndPrev = () => { if (curr === 32767 || curr === -32768) { return; } if (defined(prev)) { min = Math.min(min, Math.abs(curr - prev) || min); } prev = curr; }; for (i = 0, ilen = values.length; i < ilen; ++i) { curr = scale.getPixelForValue(values[i]); updateMinAndPrev(); } prev = undefined; for (i = 0, ilen = scale.ticks.length; i < ilen; ++i) { curr = scale.getPixelForTick(i); updateMinAndPrev(); } return min; } function computeFitCategoryTraits(index, ruler, options, stackCount) { const thickness = options.barThickness; let size, ratio; if (isNullOrUndef(thickness)) { size = ruler.min * options.categoryPercentage; ratio = options.barPercentage; } else { size = thickness * stackCount; ratio = 1; } return { chunk: size / stackCount, ratio, start: ruler.pixels[index] - (size / 2) }; } function computeFlexCategoryTraits(index, ruler, options, stackCount) { const pixels = ruler.pixels; const curr = pixels[index]; let prev = index > 0 ? pixels[index - 1] : null; let next = index < pixels.length - 1 ? pixels[index + 1] : null; const percent = options.categoryPercentage; if (prev === null) { prev = curr - (next === null ? ruler.end - ruler.start : next - curr); } if (next === null) { next = curr + curr - prev; } const start = curr - (curr - Math.min(prev, next)) / 2 * percent; const size = Math.abs(next - prev) / 2 * percent; return { chunk: size / stackCount, ratio: options.barPercentage, start }; } function parseFloatBar(entry, item, vScale, i) { const startValue = vScale.parse(entry[0], i); const endValue = vScale.parse(entry[1], i); const min = Math.min(startValue, endValue); const max = Math.max(startValue, endValue); let barStart = min; let barEnd = max; if (Math.abs(min) > Math.abs(max)) { barStart = max; barEnd = min; } item[vScale.axis] = barEnd; item._custom = { barStart, barEnd, start: startValue, end: endValue, min, max }; } function parseValue(entry, item, vScale, i) { if (isArray(entry)) { parseFloatBar(entry, item, vScale, i); } else { item[vScale.axis] = vScale.parse(entry, i); } return item; } function parseArrayOrPrimitive(meta, data, start, count) { const iScale = meta.iScale; const vScale = meta.vScale; const labels = iScale.getLabels(); const singleScale = iScale === vScale; const parsed = []; let i, ilen, item, entry; for (i = start, ilen = start + count; i < ilen; ++i) { entry = data[i]; item = {}; item[iScale.axis] = singleScale || iScale.parse(labels[i], i); parsed.push(parseValue(entry, item, vScale, i)); } return parsed; } function isFloatBar(custom) { return custom && custom.barStart !== undefined && custom.barEnd !== undefined; } function barSign(size, vScale, actualBase) { if (size !== 0) { return sign(size); } return (vScale.isHorizontal() ? 1 : -1) * (vScale.min >= actualBase ? 1 : -1); } function borderProps(properties) { let reverse, start, end, top, bottom; if (properties.horizontal) { reverse = properties.base > properties.x; start = 'left'; end = 'right'; } else { reverse = properties.base < properties.y; start = 'bottom'; end = 'top'; } if (reverse) { top = 'end'; bottom = 'start'; } else { top = 'start'; bottom = 'end'; } return {start, end, reverse, top, bottom}; } function setBorderSkipped(properties, options, stack, index) { let edge = options.borderSkipped; const res = {}; if (!edge) { properties.borderSkipped = res; return; } const {start, end, reverse, top, bottom} = borderProps(properties); if (edge === 'middle' && stack) { properties.enableBorderRadius = true; if ((stack._top || 0) === index) { edge = top; } else if ((stack._bottom || 0) === index) { edge = bottom; } else { res[parseEdge(bottom, start, end, reverse)] = true; edge = top; } } res[parseEdge(edge, start, end, reverse)] = true; properties.borderSkipped = res; } function parseEdge(edge, a, b, reverse) { if (reverse) { edge = swap(edge, a, b); edge = startEnd(edge, b, a); } else { edge = startEnd(edge, a, b); } return edge; } function swap(orig, v1, v2) { return orig === v1 ? v2 : orig === v2 ? v1 : orig; } function startEnd(v, start, end) { return v === 'start' ? start : v === 'end' ? end : v; } function setInflateAmount(properties, {inflateAmount}, ratio) { properties.inflateAmount = inflateAmount === 'auto' ? ratio === 1 ? 0.33 : 0 : inflateAmount; } class BarController extends DatasetController { parsePrimitiveData(meta, data, start, count) { return parseArrayOrPrimitive(meta, data, start, count); } parseArrayData(meta, data, start, count) { return parseArrayOrPrimitive(meta, data, start, count); } parseObjectData(meta, data, start, count) { const {iScale, vScale} = meta; const {xAxisKey = 'x', yAxisKey = 'y'} = this._parsing; const iAxisKey = iScale.axis === 'x' ? xAxisKey : yAxisKey; const vAxisKey = vScale.axis === 'x' ? xAxisKey : yAxisKey; const parsed = []; let i, ilen, item, obj; for (i = start, ilen = start + count; i < ilen; ++i) { obj = data[i]; item = {}; item[iScale.axis] = iScale.parse(resolveObjectKey(obj, iAxisKey), i); parsed.push(parseValue(resolveObjectKey(obj, vAxisKey), item, vScale, i)); } return parsed; } updateRangeFromParsed(range, scale, parsed, stack) { super.updateRangeFromParsed(range, scale, parsed, stack); const custom = parsed._custom; if (custom && scale === this._cachedMeta.vScale) { range.min = Math.min(range.min, custom.min); range.max = Math.max(range.max, custom.max); } } getMaxOverflow() { return 0; } getLabelAndValue(index) { const meta = this._cachedMeta; const {iScale, vScale} = meta; const parsed = this.getParsed(index); const custom = parsed._custom; const value = isFloatBar(custom) ? '[' + custom.start + ', ' + custom.end + ']' : '' + vScale.getLabelForValue(parsed[vScale.axis]); return { label: '' + iScale.getLabelForValue(parsed[iScale.axis]), value }; } initialize() { this.enableOptionSharing = true; super.initialize(); const meta = this._cachedMeta; meta.stack = this.getDataset().stack; } update(mode) { const meta = this._cachedMeta; this.updateElements(meta.data, 0, meta.data.length, mode); } updateElements(bars, start, count, mode) { const reset = mode === 'reset'; const {index, _cachedMeta: {vScale}} = this; const base = vScale.getBasePixel(); const horizontal = vScale.isHorizontal(); const ruler = this._getRuler(); const firstOpts = this.resolveDataElementOptions(start, mode); const sharedOptions = this.getSharedOptions(firstOpts); const includeOptions = this.includeOptions(mode, sharedOptions); this.updateSharedOptions(sharedOptions, mode, firstOpts); for (let i = start; i < start + count; i++) { const parsed = this.getParsed(i); const vpixels = reset || isNullOrUndef(parsed[vScale.axis]) ? {base, head: base} : this._calculateBarValuePixels(i); const ipixels = this._calculateBarIndexPixels(i, ruler); const stack = (parsed._stacks || {})[vScale.axis]; const properties = { horizontal, base: vpixels.base, enableBorderRadius: !stack || isFloatBar(parsed._custom) || (index === stack._top || index === stack._bottom), x: horizontal ? vpixels.head : ipixels.center, y: horizontal ? ipixels.center : vpixels.head, height: horizontal ? ipixels.size : Math.abs(vpixels.size), width: horizontal ? Math.abs(vpixels.size) : ipixels.size }; if (includeOptions) { properties.options = sharedOptions || this.resolveDataElementOptions(i, bars[i].active ? 'active' : mode); } const options = properties.options || bars[i].options; setBorderSkipped(properties, options, stack, index); setInflateAmount(properties, options, ruler.ratio); this.updateElement(bars[i], i, properties, mode); } } _getStacks(last, dataIndex) { const meta = this._cachedMeta; const iScale = meta.iScale; const metasets = iScale.getMatchingVisibleMetas(this._type); const stacked = iScale.options.stacked; const ilen = metasets.length; const stacks = []; let i, item; for (i = 0; i < ilen; ++i) { item = metasets[i]; if (!item.controller.options.grouped) { continue; } if (typeof dataIndex !== 'undefined') { const val = item.controller.getParsed(dataIndex)[ item.controller._cachedMeta.vScale.axis ]; if (isNullOrUndef(val) || isNaN(val)) { continue; } } if (stacked === false || stacks.indexOf(item.stack) === -1 || (stacked === undefined && item.stack === undefined)) { stacks.push(item.stack); } if (item.index === last) { break; } } if (!stacks.length) { stacks.push(undefined); } return stacks; } _getStackCount(index) { return this._getStacks(undefined, index).length; } _getStackIndex(datasetIndex, name, dataIndex) { const stacks = this._getStacks(datasetIndex, dataIndex); const index = (name !== undefined) ? stacks.indexOf(name) : -1; return (index === -1) ? stacks.length - 1 : index; } _getRuler() { const opts = this.options; const meta = this._cachedMeta; const iScale = meta.iScale; const pixels = []; let i, ilen; for (i = 0, ilen = meta.data.length; i < ilen; ++i) { pixels.push(iScale.getPixelForValue(this.getParsed(i)[iScale.axis], i)); } const barThickness = opts.barThickness; const min = barThickness || computeMinSampleSize(meta); return { min, pixels, start: iScale._startPixel, end: iScale._endPixel, stackCount: this._getStackCount(), scale: iScale, grouped: opts.grouped, ratio: barThickness ? 1 : opts.categoryPercentage * opts.barPercentage }; } _calculateBarValuePixels(index) { const {_cachedMeta: {vScale, _stacked}, options: {base: baseValue, minBarLength}} = this; const actualBase = baseValue || 0; const parsed = this.getParsed(index); const custom = parsed._custom; const floating = isFloatBar(custom); let value = parsed[vScale.axis]; let start = 0; let length = _stacked ? this.applyStack(vScale, parsed, _stacked) : value; let head, size; if (length !== value) { start = length - value; length = value; } if (floating) { value = custom.barStart; length = custom.barEnd - custom.barStart; if (value !== 0 && sign(value) !== sign(custom.barEnd)) { start = 0; } start += value; } const startValue = !isNullOrUndef(baseValue) && !floating ? baseValue : start; let base = vScale.getPixelForValue(startValue); if (this.chart.getDataVisibility(index)) { head = vScale.getPixelForValue(start + length); } else { head = base; } size = head - base; if (Math.abs(size) < minBarLength) { size = barSign(size, vScale, actualBase) * minBarLength; if (value === actualBase) { base -= size / 2; } const startPixel = vScale.getPixelForDecimal(0); const endPixel = vScale.getPixelForDecimal(1); const min = Math.min(startPixel, endPixel); const max = Math.max(startPixel, endPixel); base = Math.max(Math.min(base, max), min); head = base + size; } if (base === vScale.getPixelForValue(actualBase)) { const halfGrid = sign(size) * vScale.getLineWidthForValue(actualBase) / 2; base += halfGrid; size -= halfGrid; } return { size, base, head, center: head + size / 2 }; } _calculateBarIndexPixels(index, ruler) { const scale = ruler.scale; const options = this.options; const skipNull = options.skipNull; const maxBarThickness = valueOrDefault(options.maxBarThickness, Infinity); let center, size; if (ruler.grouped) { const stackCount = skipNull ? this._getStackCount(index) : ruler.stackCount; const range = options.barThickness === 'flex' ? computeFlexCategoryTraits(index, ruler, options, stackCount) : computeFitCategoryTraits(index, ruler, options, stackCount); const stackIndex = this._getStackIndex(this.index, this._cachedMeta.stack, skipNull ? index : undefined); center = range.start + (range.chunk * stackIndex) + (range.chunk / 2); size = Math.min(maxBarThickness, range.chunk * range.ratio); } else { center = scale.getPixelForValue(this.getParsed(index)[scale.axis], index); size = Math.min(maxBarThickness, ruler.min * ruler.ratio); } return { base: center - size / 2, head: center + size / 2, center, size }; } draw() { const meta = this._cachedMeta; const vScale = meta.vScale; const rects = meta.data; const ilen = rects.length; let i = 0; for (; i < ilen; ++i) { if (this.getParsed(i)[vScale.axis] !== null) { rects[i].draw(this._ctx); } } } } BarController.id = 'bar'; BarController.defaults = { datasetElementType: false, dataElementType: 'bar', categoryPercentage: 0.8, barPercentage: 0.9, grouped: true, animations: { numbers: { type: 'number', properties: ['x', 'y', 'base', 'width', 'height'] } } }; BarController.overrides = { scales: { _index_: { type: 'category', offset: true, grid: { offset: true } }, _value_: { type: 'linear', beginAtZero: true, } } }; class BubbleController extends DatasetController { initialize() { this.enableOptionSharing = true; super.initialize(); } parsePrimitiveData(meta, data, start, count) { const parsed = super.parsePrimitiveData(meta, data, start, count); for (let i = 0; i < parsed.length; i++) { parsed[i]._custom = this.resolveDataElementOptions(i + start).radius; } return parsed; } parseArrayData(meta, data, start, count) { const parsed = super.parseArrayData(meta, data, start, count); for (let i = 0; i < parsed.length; i++) { const item = data[start + i]; parsed[i]._custom = valueOrDefault(item[2], this.resolveDataElementOptions(i + start).radius); } return parsed; } parseObjectData(meta, data, start, count) { const parsed = super.parseObjectData(meta, data, start, count); for (let i = 0; i < parsed.length; i++) { const item = data[start + i]; parsed[i]._custom = valueOrDefault(item && item.r && +item.r, this.resolveDataElementOptions(i + start).radius); } return parsed; } getMaxOverflow() { const data = this._cachedMeta.data; let max = 0; for (let i = data.length - 1; i >= 0; --i) { max = Math.max(max, data[i].size(this.resolveDataElementOptions(i)) / 2); } return max > 0 && max; } getLabelAndValue(index) { const meta = this._cachedMeta; const {xScale, yScale} = meta; const parsed = this.getParsed(index); const x = xScale.getLabelForValue(parsed.x); const y = yScale.getLabelForValue(parsed.y); const r = parsed._custom; return { label: meta.label, value: '(' + x + ', ' + y + (r ? ', ' + r : '') + ')' }; } update(mode) { const points = this._cachedMeta.data; this.updateElements(points, 0, points.length, mode); } updateElements(points, start, count, mode) { const reset = mode === 'reset'; const {iScale, vScale} = this._cachedMeta; const firstOpts = this.resolveDataElementOptions(start, mode); const sharedOptions = this.getSharedOptions(firstOpts); const includeOptions = this.includeOptions(mode, sharedOptions); const iAxis = iScale.axis; const vAxis = vScale.axis; for (let i = start; i < start + count; i++) { const point = points[i]; const parsed = !reset && this.getParsed(i); const properties = {}; const iPixel = properties[iAxis] = reset ? iScale.getPixelForDecimal(0.5) : iScale.getPixelForValue(parsed[iAxis]); const vPixel = properties[vAxis] = reset ? vScale.getBasePixel() : vScale.getPixelForValue(parsed[vAxis]); properties.skip = isNaN(iPixel) || isNaN(vPixel); if (includeOptions) { properties.options = this.resolveDataElementOptions(i, point.active ? 'active' : mode); if (reset) { properties.options.radius = 0; } } this.updateElement(point, i, properties, mode); } this.updateSharedOptions(sharedOptions, mode, firstOpts); } resolveDataElementOptions(index, mode) { const parsed = this.getParsed(index); let values = super.resolveDataElementOptions(index, mode); if (values.$shared) { values = Object.assign({}, values, {$shared: false}); } const radius = values.radius; if (mode !== 'active') { values.radius = 0; } values.radius += valueOrDefault(parsed && parsed._custom, radius); return values; } } BubbleController.id = 'bubble'; BubbleController.defaults = { datasetElementType: false, dataElementType: 'point', animations: { numbers: { type: 'number', properties: ['x', 'y', 'borderWidth', 'radius'] } } }; BubbleController.overrides = { scales: { x: { type: 'linear' }, y: { type: 'linear' } }, plugins: { tooltip: { callbacks: { title() { return ''; } } } } }; function getRatioAndOffset(rotation, circumference, cutout) { let ratioX = 1; let ratioY = 1; let offsetX = 0; let offsetY = 0; if (circumference < TAU) { const startAngle = rotation; const endAngle = startAngle + circumference; const startX = Math.cos(startAngle); const startY = Math.sin(startAngle); const endX = Math.cos(endAngle); const endY = Math.sin(endAngle); const calcMax = (angle, a, b) => _angleBetween(angle, startAngle, endAngle, true) ? 1 : Math.max(a, a * cutout, b, b * cutout); const calcMin = (angle, a, b) => _angleBetween(angle, startAngle, endAngle, true) ? -1 : Math.min(a, a * cutout, b, b * cutout); const maxX = calcMax(0, startX, endX); const maxY = calcMax(HALF_PI, startY, endY); const minX = calcMin(PI, startX, endX); const minY = calcMin(PI + HALF_PI, startY, endY); ratioX = (maxX - minX) / 2; ratioY = (maxY - minY) / 2; offsetX = -(maxX + minX) / 2; offsetY = -(maxY + minY) / 2; } return {ratioX, ratioY, offsetX, offsetY}; } class DoughnutController extends DatasetController { constructor(chart, datasetIndex) { super(chart, datasetIndex); this.enableOptionSharing = true; this.innerRadius = undefined; this.outerRadius = undefined; this.offsetX = undefined; this.offsetY = undefined; } linkScales() {} parse(start, count) { const data = this.getDataset().data; const meta = this._cachedMeta; if (this._parsing === false) { meta._parsed = data; } else { let getter = (i) => +data[i]; if (isObject(data[start])) { const {key = 'value'} = this._parsing; getter = (i) => +resolveObjectKey(data[i], key); } let i, ilen; for (i = start, ilen = start + count; i < ilen; ++i) { meta._parsed[i] = getter(i); } } } _getRotation() { return toRadians(this.options.rotation - 90); } _getCircumference() { return toRadians(this.options.circumference); } _getRotationExtents() { let min = TAU; let max = -TAU; for (let i = 0; i < this.chart.data.datasets.length; ++i) { if (this.chart.isDatasetVisible(i)) { const controller = this.chart.getDatasetMeta(i).controller; const rotation = controller._getRotation(); const circumference = controller._getCircumference(); min = Math.min(min, rotation); max = Math.max(max, rotation + circumference); } } return { rotation: min, circumference: max - min, }; } update(mode) { const chart = this.chart; const {chartArea} = chart; const meta = this._cachedMeta; const arcs = meta.data; const spacing = this.getMaxBorderWidth() + this.getMaxOffset(arcs) + this.options.spacing; const maxSize = Math.max((Math.min(chartArea.width, chartArea.height) - spacing) / 2, 0); const cutout = Math.min(toPercentage(this.options.cutout, maxSize), 1); const chartWeight = this._getRingWeight(this.index); const {circumference, rotation} = this._getRotationExtents(); const {ratioX, ratioY, offsetX, offsetY} = getRatioAndOffset(rotation, circumference, cutout); const maxWidth = (chartArea.width - spacing) / ratioX; const maxHeight = (chartArea.height - spacing) / ratioY; const maxRadius = Math.max(Math.min(maxWidth, maxHeight) / 2, 0); const outerRadius = toDimension(this.options.radius, maxRadius); const innerRadius = Math.max(outerRadius * cutout, 0); const radiusLength = (outerRadius - innerRadius) / this._getVisibleDatasetWeightTotal(); this.offsetX = offsetX * outerRadius; this.offsetY = offsetY * outerRadius; meta.total = this.calculateTotal(); this.outerRadius = outerRadius - radiusLength * this._getRingWeightOffset(this.index); this.innerRadius = Math.max(this.outerRadius - radiusLength * chartWeight, 0); this.updateElements(arcs, 0, arcs.length, mode); } _circumference(i, reset) { const opts = this.options; const meta = this._cachedMeta; const circumference = this._getCircumference(); if ((reset && opts.animation.animateRotate) || !this.chart.getDataVisibility(i) || meta._parsed[i] === null || meta.data[i].hidden) { return 0; } return this.calculateCircumference(meta._parsed[i] * circumference / TAU); } updateElements(arcs, start, count, mode) { const reset = mode === 'reset'; const chart = this.chart; const chartArea = chart.chartArea; const opts = chart.options; const animationOpts = opts.animation; const centerX = (chartArea.left + chartArea.right) / 2; const centerY = (chartArea.top + chartArea.bottom) / 2; const animateScale = reset && animationOpts.animateScale; const innerRadius = animateScale ? 0 : this.innerRadius; const outerRadius = animateScale ? 0 : this.outerRadius; const firstOpts = this.resolveDataElementOptions(start, mode); const sharedOptions = this.getSharedOptions(firstOpts); const includeOptions = this.includeOptions(mode, sharedOptions); let startAngle = this._getRotation(); let i; for (i = 0; i < start; ++i) { startAngle += this._circumference(i, reset); } for (i = start; i < start + count; ++i) { const circumference = this._circumference(i, reset); const arc = arcs[i]; const properties = { x: centerX + this.offsetX, y: centerY + this.offsetY, startAngle, endAngle: startAngle + circumference, circumference, outerRadius, innerRadius }; if (includeOptions) { properties.options = sharedOptions || this.resolveDataElementOptions(i, arc.active ? 'active' : mode); } startAngle += circumference; this.updateElement(arc, i, properties, mode); } this.updateSharedOptions(sharedOptions, mode, firstOpts); } calculateTotal() { const meta = this._cachedMeta; const metaData = meta.data; let total = 0; let i; for (i = 0; i < metaData.length; i++) { const value = meta._parsed[i]; if (value !== null && !isNaN(value) && this.chart.getDataVisibility(i) && !metaData[i].hidden) { total += Math.abs(value); } } return total; } calculateCircumference(value) { const total = this._cachedMeta.total; if (total > 0 && !isNaN(value)) { return TAU * (Math.abs(value) / total); } return 0; } getLabelAndValue(index) { const meta = this._cachedMeta; const chart = this.chart; const labels = chart.data.labels || []; const value = formatNumber(meta._parsed[index], chart.options.locale); return { label: labels[index] || '', value, }; } getMaxBorderWidth(arcs) { let max = 0; const chart = this.chart; let i, ilen, meta, controller, options; if (!arcs) { for (i = 0, ilen = chart.data.datasets.length; i < ilen; ++i) { if (chart.isDatasetVisible(i)) { meta = chart.getDatasetMeta(i); arcs = meta.data; controller = meta.controller; break; } } } if (!arcs) { return 0; } for (i = 0, ilen = arcs.length; i < ilen; ++i) { options = controller.resolveDataElementOptions(i); if (options.borderAlign !== 'inner') { max = Math.max(max, options.borderWidth || 0, options.hoverBorderWidth || 0); } } return max; } getMaxOffset(arcs) { let max = 0; for (let i = 0, ilen = arcs.length; i < ilen; ++i) { const options = this.resolveDataElementOptions(i); max = Math.max(max, options.offset || 0, options.hoverOffset || 0); } return max; } _getRingWeightOffset(datasetIndex) { let ringWeightOffset = 0; for (let i = 0; i < datasetIndex; ++i) { if (this.chart.isDatasetVisible(i)) { ringWeightOffset += this._getRingWeight(i); } } return ringWeightOffset; } _getRingWeight(datasetIndex) { return Math.max(valueOrDefault(this.chart.data.datasets[datasetIndex].weight, 1), 0); } _getVisibleDatasetWeightTotal() { return this._getRingWeightOffset(this.chart.data.datasets.length) || 1; } } DoughnutController.id = 'doughnut'; DoughnutController.defaults = { datasetElementType: false, dataElementType: 'arc', animation: { animateRotate: true, animateScale: false }, animations: { numbers: { type: 'number', properties: ['circumference', 'endAngle', 'innerRadius', 'outerRadius', 'startAngle', 'x', 'y', 'offset', 'borderWidth', 'spacing'] }, }, cutout: '50%', rotation: 0, circumference: 360, radius: '100%', spacing: 0, indexAxis: 'r', }; DoughnutController.descriptors = { _scriptable: (name) => name !== 'spacing', _indexable: (name) => name !== 'spacing', }; DoughnutController.overrides = { aspectRatio: 1, plugins: { legend: { labels: { generateLabels(chart) { const data = chart.data; if (data.labels.length && data.datasets.length) { const {labels: {pointStyle}} = chart.legend.options; return data.labels.map((label, i) => { const meta = chart.getDatasetMeta(0); const style = meta.controller.getStyle(i); return { text: label, fillStyle: style.backgroundColor, strokeStyle: style.borderColor, lineWidth: style.borderWidth, pointStyle: pointStyle, hidden: !chart.getDataVisibility(i), index: i }; }); } return []; } }, onClick(e, legendItem, legend) { legend.chart.toggleDataVisibility(legendItem.index); legend.chart.update(); } }, tooltip: { callbacks: { title() { return ''; }, label(tooltipItem) { let dataLabel = tooltipItem.label; const value = ': ' + tooltipItem.formattedValue; if (isArray(dataLabel)) { dataLabel = dataLabel.slice(); dataLabel[0] += value; } else { dataLabel += value; } return dataLabel; } } } } }; class LineController extends DatasetController { initialize() { this.enableOptionSharing = true; this.supportsDecimation = true; super.initialize(); } update(mode) { const meta = this._cachedMeta; const {dataset: line, data: points = [], _dataset} = meta; const animationsDisabled = this.chart._animationsDisabled; let {start, count} = getStartAndCountOfVisiblePoints(meta, points, animationsDisabled); this._drawStart = start; this._drawCount = count; if (scaleRangesChanged(meta)) { start = 0; count = points.length; } line._chart = this.chart; line._datasetIndex = this.index; line._decimated = !!_dataset._decimated; line.points = points; const options = this.resolveDatasetElementOptions(mode); if (!this.options.showLine) { options.borderWidth = 0; } options.segment = this.options.segment; this.updateElement(line, undefined, { animated: !animationsDisabled, options }, mode); this.updateElements(points, start, count, mode); } updateElements(points, start, count, mode) { const reset = mode === 'reset'; const {iScale, vScale, _stacked, _dataset} = this._cachedMeta; const firstOpts = this.resolveDataElementOptions(start, mode); const sharedOptions = this.getSharedOptions(firstOpts); const includeOptions = this.includeOptions(mode, sharedOptions); const iAxis = iScale.axis; const vAxis = vScale.axis; const {spanGaps, segment} = this.options; const maxGapLength = isNumber(spanGaps) ? spanGaps : Number.POSITIVE_INFINITY; const directUpdate = this.chart._animationsDisabled || reset || mode === 'none'; let prevParsed = start > 0 && this.getParsed(start - 1); for (let i = start; i < start + count; ++i) { const point = points[i]; const parsed = this.getParsed(i); const properties = directUpdate ? point : {}; const nullData = isNullOrUndef(parsed[vAxis]); const iPixel = properties[iAxis] = iScale.getPixelForValue(parsed[iAxis], i); const vPixel = properties[vAxis] = reset || nullData ? vScale.getBasePixel() : vScale.getPixelForValue(_stacked ? this.applyStack(vScale, parsed, _stacked) : parsed[vAxis], i); properties.skip = isNaN(iPixel) || isNaN(vPixel) || nullData; properties.stop = i > 0 && (Math.abs(parsed[iAxis] - prevParsed[iAxis])) > maxGapLength; if (segment) { properties.parsed = parsed; properties.raw = _dataset.data[i]; } if (includeOptions) { properties.options = sharedOptions || this.resolveDataElementOptions(i, point.active ? 'active' : mode); } if (!directUpdate) { this.updateElement(point, i, properties, mode); } prevParsed = parsed; } this.updateSharedOptions(sharedOptions, mode, firstOpts); } getMaxOverflow() { const meta = this._cachedMeta; const dataset = meta.dataset; const border = dataset.options && dataset.options.borderWidth || 0; const data = meta.data || []; if (!data.length) { return border; } const firstPoint = data[0].size(this.resolveDataElementOptions(0)); const lastPoint = data[data.length - 1].size(this.resolveDataElementOptions(data.length - 1)); return Math.max(border, firstPoint, lastPoint) / 2; } draw() { const meta = this._cachedMeta; meta.dataset.updateControlPoints(this.chart.chartArea, meta.iScale.axis); super.draw(); } } LineController.id = 'line'; LineController.defaults = { datasetElementType: 'line', dataElementType: 'point', showLine: true, spanGaps: false, }; LineController.overrides = { scales: { _index_: { type: 'category', }, _value_: { type: 'linear', }, } }; function getStartAndCountOfVisiblePoints(meta, points, animationsDisabled) { const pointCount = points.length; let start = 0; let count = pointCount; if (meta._sorted) { const {iScale, _parsed} = meta; const axis = iScale.axis; const {min, max, minDefined, maxDefined} = iScale.getUserBounds(); if (minDefined) { start = _limitValue(Math.min( _lookupByKey(_parsed, iScale.axis, min).lo, animationsDisabled ? pointCount : _lookupByKey(points, axis, iScale.getPixelForValue(min)).lo), 0, pointCount - 1); } if (maxDefined) { count = _limitValue(Math.max( _lookupByKey(_parsed, iScale.axis, max).hi + 1, animationsDisabled ? 0 : _lookupByKey(points, axis, iScale.getPixelForValue(max)).hi + 1), start, pointCount) - start; } else { count = pointCount - start; } } return {start, count}; } function scaleRangesChanged(meta) { const {xScale, yScale, _scaleRanges} = meta; const newRanges = { xmin: xScale.min, xmax: xScale.max, ymin: yScale.min, ymax: yScale.max }; if (!_scaleRanges) { meta._scaleRanges = newRanges; return true; } const changed = _scaleRanges.xmin !== xScale.min || _scaleRanges.xmax !== xScale.max || _scaleRanges.ymin !== yScale.min || _scaleRanges.ymax !== yScale.max; Object.assign(_scaleRanges, newRanges); return changed; } class PolarAreaController extends DatasetController { constructor(chart, datasetIndex) { super(chart, datasetIndex); this.innerRadius = undefined; this.outerRadius = undefined; } getLabelAndValue(index) { const meta = this._cachedMeta; const chart = this.chart; const labels = chart.data.labels || []; const value = formatNumber(meta._parsed[index].r, chart.options.locale); return { label: labels[index] || '', value, }; } parseObjectData(meta, data, start, count) { return _parseObjectDataRadialScale.bind(this)(meta, data, start, count); } update(mode) { const arcs = this._cachedMeta.data; this._updateRadius(); this.updateElements(arcs, 0, arcs.length, mode); } getMinMax() { const meta = this._cachedMeta; const range = {min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY}; meta.data.forEach((element, index) => { const parsed = this.getParsed(index).r; if (!isNaN(parsed) && this.chart.getDataVisibility(index)) { if (parsed < range.min) { range.min = parsed; } if (parsed > range.max) { range.max = parsed; } } }); return range; } _updateRadius() { const chart = this.chart; const chartArea = chart.chartArea; const opts = chart.options; const minSize = Math.min(chartArea.right - chartArea.left, chartArea.bottom - chartArea.top); const outerRadius = Math.max(minSize / 2, 0); const innerRadius = Math.max(opts.cutoutPercentage ? (outerRadius / 100) * (opts.cutoutPercentage) : 1, 0); const radiusLength = (outerRadius - innerRadius) / chart.getVisibleDatasetCount(); this.outerRadius = outerRadius - (radiusLength * this.index); this.innerRadius = this.outerRadius - radiusLength; } updateElements(arcs, start, count, mode) { const reset = mode === 'reset'; const chart = this.chart; const opts = chart.options; const animationOpts = opts.animation; const scale = this._cachedMeta.rScale; const centerX = scale.xCenter; const centerY = scale.yCenter; const datasetStartAngle = scale.getIndexAngle(0) - 0.5 * PI; let angle = datasetStartAngle; let i; const defaultAngle = 360 / this.countVisibleElements(); for (i = 0; i < start; ++i) { angle += this._computeAngle(i, mode, defaultAngle); } for (i = start; i < start + count; i++) { const arc = arcs[i]; let startAngle = angle; let endAngle = angle + this._computeAngle(i, mode, defaultAngle); let outerRadius = chart.getDataVisibility(i) ? scale.getDistanceFromCenterForValue(this.getParsed(i).r) : 0; angle = endAngle; if (reset) { if (animationOpts.animateScale) { outerRadius = 0; } if (animationOpts.animateRotate) { startAngle = endAngle = datasetStartAngle; } } const properties = { x: centerX, y: centerY, innerRadius: 0, outerRadius, startAngle, endAngle, options: this.resolveDataElementOptions(i, arc.active ? 'active' : mode) }; this.updateElement(arc, i, properties, mode); } } countVisibleElements() { const meta = this._cachedMeta; let count = 0; meta.data.forEach((element, index) => { if (!isNaN(this.getParsed(index).r) && this.chart.getDataVisibility(index)) { count++; } }); return count; } _computeAngle(index, mode, defaultAngle) { return this.chart.getDataVisibility(index) ? toRadians(this.resolveDataElementOptions(index, mode).angle || defaultAngle) : 0; } } PolarAreaController.id = 'polarArea'; PolarAreaController.defaults = { dataElementType: 'arc', animation: { animateRotate: true, animateScale: true }, animations: { numbers: { type: 'number', properties: ['x', 'y', 'startAngle', 'endAngle', 'innerRadius', 'outerRadius'] }, }, indexAxis: 'r', startAngle: 0, }; PolarAreaController.overrides = { aspectRatio: 1, plugins: { legend: { labels: { generateLabels(chart) { const data = chart.data; if (data.labels.length && data.datasets.length) { const {labels: {pointStyle}} = chart.legend.options; return data.labels.map((label, i) => { const meta = chart.getDatasetMeta(0); const style = meta.controller.getStyle(i); return { text: label, fillStyle: style.backgroundColor, strokeStyle: style.borderColor, lineWidth: style.borderWidth, pointStyle: pointStyle, hidden: !chart.getDataVisibility(i), index: i }; }); } return []; } }, onClick(e, legendItem, legend) { legend.chart.toggleDataVisibility(legendItem.index); legend.chart.update(); } }, tooltip: { callbacks: { title() { return ''; }, label(context) { return context.chart.data.labels[context.dataIndex] + ': ' + context.formattedValue; } } } }, scales: { r: { type: 'radialLinear', angleLines: { display: false }, beginAtZero: true, grid: { circular: true }, pointLabels: { display: false }, startAngle: 0 } } }; class PieController extends DoughnutController { } PieController.id = 'pie'; PieController.defaults = { cutout: 0, rotation: 0, circumference: 360, radius: '100%' }; class RadarController extends DatasetController { getLabelAndValue(index) { const vScale = this._cachedMeta.vScale; const parsed = this.getParsed(index); return { label: vScale.getLabels()[index], value: '' + vScale.getLabelForValue(parsed[vScale.axis]) }; } parseObjectData(meta, data, start, count) { return _parseObjectDataRadialScale.bind(this)(meta, data, start, count); } update(mode) { const meta = this._cachedMeta; const line = meta.dataset; const points = meta.data || []; const labels = meta.iScale.getLabels(); line.points = points; if (mode !== 'resize') { const options = this.resolveDatasetElementOptions(mode); if (!this.options.showLine) { options.borderWidth = 0; } const properties = { _loop: true, _fullLoop: labels.length === points.length, options }; this.updateElement(line, undefined, properties, mode); } this.updateElements(points, 0, points.length, mode); } updateElements(points, start, count, mode) { const scale = this._cachedMeta.rScale; const reset = mode === 'reset'; for (let i = start; i < start + count; i++) { const point = points[i]; const options = this.resolveDataElementOptions(i, point.active ? 'active' : mode); const pointPosition = scale.getPointPositionForValue(i, this.getParsed(i).r); const x = reset ? scale.xCenter : pointPosition.x; const y = reset ? scale.yCenter : pointPosition.y; const properties = { x, y, angle: pointPosition.angle, skip: isNaN(x) || isNaN(y), options }; this.updateElement(point, i, properties, mode); } } } RadarController.id = 'radar'; RadarController.defaults = { datasetElementType: 'line', dataElementType: 'point', indexAxis: 'r', showLine: true, elements: { line: { fill: 'start' } }, }; RadarController.overrides = { aspectRatio: 1, scales: { r: { type: 'radialLinear', } } }; class ScatterController extends LineController { } ScatterController.id = 'scatter'; ScatterController.defaults = { showLine: false, fill: false }; ScatterController.overrides = { interaction: { mode: 'point' }, plugins: { tooltip: { callbacks: { title() { return ''; }, label(item) { return '(' + item.label + ', ' + item.formattedValue + ')'; } } } }, scales: { x: { type: 'linear' }, y: { type: 'linear' } } }; var controllers = /*#__PURE__*/Object.freeze({ __proto__: null, BarController: BarController, BubbleController: BubbleController, DoughnutController: DoughnutController, LineController: LineController, PolarAreaController: PolarAreaController, PieController: PieController, RadarController: RadarController, ScatterController: ScatterController }); function clipArc(ctx, element, endAngle) { const {startAngle, pixelMargin, x, y, outerRadius, innerRadius} = element; let angleMargin = pixelMargin / outerRadius; ctx.beginPath(); ctx.arc(x, y, outerRadius, startAngle - angleMargin, endAngle + angleMargin); if (innerRadius > pixelMargin) { angleMargin = pixelMargin / innerRadius; ctx.arc(x, y, innerRadius, endAngle + angleMargin, startAngle - angleMargin, true); } else { ctx.arc(x, y, pixelMargin, endAngle + HALF_PI, startAngle - HALF_PI); } ctx.closePath(); ctx.clip(); } function toRadiusCorners(value) { return _readValueToProps(value, ['outerStart', 'outerEnd', 'innerStart', 'innerEnd']); } function parseBorderRadius$1(arc, innerRadius, outerRadius, angleDelta) { const o = toRadiusCorners(arc.options.borderRadius); const halfThickness = (outerRadius - innerRadius) / 2; const innerLimit = Math.min(halfThickness, angleDelta * innerRadius / 2); const computeOuterLimit = (val) => { const outerArcLimit = (outerRadius - Math.min(halfThickness, val)) * angleDelta / 2; return _limitValue(val, 0, Math.min(halfThickness, outerArcLimit)); }; return { outerStart: computeOuterLimit(o.outerStart), outerEnd: computeOuterLimit(o.outerEnd), innerStart: _limitValue(o.innerStart, 0, innerLimit), innerEnd: _limitValue(o.innerEnd, 0, innerLimit), }; } function rThetaToXY(r, theta, x, y) { return { x: x + r * Math.cos(theta), y: y + r * Math.sin(theta), }; } function pathArc(ctx, element, offset, spacing, end) { const {x, y, startAngle: start, pixelMargin, innerRadius: innerR} = element; const outerRadius = Math.max(element.outerRadius + spacing + offset - pixelMargin, 0); const innerRadius = innerR > 0 ? innerR + spacing + offset + pixelMargin : 0; let spacingOffset = 0; const alpha = end - start; if (spacing) { const noSpacingInnerRadius = innerR > 0 ? innerR - spacing : 0; const noSpacingOuterRadius = outerRadius > 0 ? outerRadius - spacing : 0; const avNogSpacingRadius = (noSpacingInnerRadius + noSpacingOuterRadius) / 2; const adjustedAngle = avNogSpacingRadius !== 0 ? (alpha * avNogSpacingRadius) / (avNogSpacingRadius + spacing) : alpha; spacingOffset = (alpha - adjustedAngle) / 2; } const beta = Math.max(0.001, alpha * outerRadius - offset / PI) / outerRadius; const angleOffset = (alpha - beta) / 2; const startAngle = start + angleOffset + spacingOffset; const endAngle = end - angleOffset - spacingOffset; const {outerStart, outerEnd, innerStart, innerEnd} = parseBorderRadius$1(element, innerRadius, outerRadius, endAngle - startAngle); const outerStartAdjustedRadius = outerRadius - outerStart; const outerEndAdjustedRadius = outerRadius - outerEnd; const outerStartAdjustedAngle = startAngle + outerStart / outerStartAdjustedRadius; const outerEndAdjustedAngle = endAngle - outerEnd / outerEndAdjustedRadius; const innerStartAdjustedRadius = innerRadius + innerStart; const innerEndAdjustedRadius = innerRadius + innerEnd; const innerStartAdjustedAngle = startAngle + innerStart / innerStartAdjustedRadius; const innerEndAdjustedAngle = endAngle - innerEnd / innerEndAdjustedRadius; ctx.beginPath(); ctx.arc(x, y, outerRadius, outerStartAdjustedAngle, outerEndAdjustedAngle); if (outerEnd > 0) { const pCenter = rThetaToXY(outerEndAdjustedRadius, outerEndAdjustedAngle, x, y); ctx.arc(pCenter.x, pCenter.y, outerEnd, outerEndAdjustedAngle, endAngle + HALF_PI); } const p4 = rThetaToXY(innerEndAdjustedRadius, endAngle, x, y); ctx.lineTo(p4.x, p4.y); if (innerEnd > 0) { const pCenter = rThetaToXY(innerEndAdjustedRadius, innerEndAdjustedAngle, x, y); ctx.arc(pCenter.x, pCenter.y, innerEnd, endAngle + HALF_PI, innerEndAdjustedAngle + Math.PI); } ctx.arc(x, y, innerRadius, endAngle - (innerEnd / innerRadius), startAngle + (innerStart / innerRadius), true); if (innerStart > 0) { const pCenter = rThetaToXY(innerStartAdjustedRadius, innerStartAdjustedAngle, x, y); ctx.arc(pCenter.x, pCenter.y, innerStart, innerStartAdjustedAngle + Math.PI, startAngle - HALF_PI); } const p8 = rThetaToXY(outerStartAdjustedRadius, startAngle, x, y); ctx.lineTo(p8.x, p8.y); if (outerStart > 0) { const pCenter = rThetaToXY(outerStartAdjustedRadius, outerStartAdjustedAngle, x, y); ctx.arc(pCenter.x, pCenter.y, outerStart, startAngle - HALF_PI, outerStartAdjustedAngle); } ctx.closePath(); } function drawArc(ctx, element, offset, spacing) { const {fullCircles, startAngle, circumference} = element; let endAngle = element.endAngle; if (fullCircles) { pathArc(ctx, element, offset, spacing, startAngle + TAU); for (let i = 0; i < fullCircles; ++i) { ctx.fill(); } if (!isNaN(circumference)) { endAngle = startAngle + circumference % TAU; if (circumference % TAU === 0) { endAngle += TAU; } } } pathArc(ctx, element, offset, spacing, endAngle); ctx.fill(); return endAngle; } function drawFullCircleBorders(ctx, element, inner) { const {x, y, startAngle, pixelMargin, fullCircles} = element; const outerRadius = Math.max(element.outerRadius - pixelMargin, 0); const innerRadius = element.innerRadius + pixelMargin; let i; if (inner) { clipArc(ctx, element, startAngle + TAU); } ctx.beginPath(); ctx.arc(x, y, innerRadius, startAngle + TAU, startAngle, true); for (i = 0; i < fullCircles; ++i) { ctx.stroke(); } ctx.beginPath(); ctx.arc(x, y, outerRadius, startAngle, startAngle + TAU); for (i = 0; i < fullCircles; ++i) { ctx.stroke(); } } function drawBorder(ctx, element, offset, spacing, endAngle) { const {options} = element; const {borderWidth, borderJoinStyle} = options; const inner = options.borderAlign === 'inner'; if (!borderWidth) { return; } if (inner) { ctx.lineWidth = borderWidth * 2; ctx.lineJoin = borderJoinStyle || 'round'; } else { ctx.lineWidth = borderWidth; ctx.lineJoin = borderJoinStyle || 'bevel'; } if (element.fullCircles) { drawFullCircleBorders(ctx, element, inner); } if (inner) { clipArc(ctx, element, endAngle); } pathArc(ctx, element, offset, spacing, endAngle); ctx.stroke(); } class ArcElement extends Element { constructor(cfg) { super(); this.options = undefined; this.circumference = undefined; this.startAngle = undefined; this.endAngle = undefined; this.innerRadius = undefined; this.outerRadius = undefined; this.pixelMargin = 0; this.fullCircles = 0; if (cfg) { Object.assign(this, cfg); } } inRange(chartX, chartY, useFinalPosition) { const point = this.getProps(['x', 'y'], useFinalPosition); const {angle, distance} = getAngleFromPoint(point, {x: chartX, y: chartY}); const {startAngle, endAngle, innerRadius, outerRadius, circumference} = this.getProps([ 'startAngle', 'endAngle', 'innerRadius', 'outerRadius', 'circumference' ], useFinalPosition); const rAdjust = this.options.spacing / 2; const _circumference = valueOrDefault(circumference, endAngle - startAngle); const betweenAngles = _circumference >= TAU || _angleBetween(angle, startAngle, endAngle); const withinRadius = _isBetween(distance, innerRadius + rAdjust, outerRadius + rAdjust); return (betweenAngles && withinRadius); } getCenterPoint(useFinalPosition) { const {x, y, startAngle, endAngle, innerRadius, outerRadius} = this.getProps([ 'x', 'y', 'startAngle', 'endAngle', 'innerRadius', 'outerRadius', 'circumference', ], useFinalPosition); const {offset, spacing} = this.options; const halfAngle = (startAngle + endAngle) / 2; const halfRadius = (innerRadius + outerRadius + spacing + offset) / 2; return { x: x + Math.cos(halfAngle) * halfRadius, y: y + Math.sin(halfAngle) * halfRadius }; } tooltipPosition(useFinalPosition) { return this.getCenterPoint(useFinalPosition); } draw(ctx) { const {options, circumference} = this; const offset = (options.offset || 0) / 2; const spacing = (options.spacing || 0) / 2; this.pixelMargin = (options.borderAlign === 'inner') ? 0.33 : 0; this.fullCircles = circumference > TAU ? Math.floor(circumference / TAU) : 0; if (circumference === 0 || this.innerRadius < 0 || this.outerRadius < 0) { return; } ctx.save(); let radiusOffset = 0; if (offset) { radiusOffset = offset / 2; const halfAngle = (this.startAngle + this.endAngle) / 2; ctx.translate(Math.cos(halfAngle) * radiusOffset, Math.sin(halfAngle) * radiusOffset); if (this.circumference >= PI) { radiusOffset = offset; } } ctx.fillStyle = options.backgroundColor; ctx.strokeStyle = options.borderColor; const endAngle = drawArc(ctx, this, radiusOffset, spacing); drawBorder(ctx, this, radiusOffset, spacing, endAngle); ctx.restore(); } } ArcElement.id = 'arc'; ArcElement.defaults = { borderAlign: 'center', borderColor: '#fff', borderJoinStyle: undefined, borderRadius: 0, borderWidth: 2, offset: 0, spacing: 0, angle: undefined, }; ArcElement.defaultRoutes = { backgroundColor: 'backgroundColor' }; function setStyle(ctx, options, style = options) { ctx.lineCap = valueOrDefault(style.borderCapStyle, options.borderCapStyle); ctx.setLineDash(valueOrDefault(style.borderDash, options.borderDash)); ctx.lineDashOffset = valueOrDefault(style.borderDashOffset, options.borderDashOffset); ctx.lineJoin = valueOrDefault(style.borderJoinStyle, options.borderJoinStyle); ctx.lineWidth = valueOrDefault(style.borderWidth, options.borderWidth); ctx.strokeStyle = valueOrDefault(style.borderColor, options.borderColor); } function lineTo(ctx, previous, target) { ctx.lineTo(target.x, target.y); } function getLineMethod(options) { if (options.stepped) { return _steppedLineTo; } if (options.tension || options.cubicInterpolationMode === 'monotone') { return _bezierCurveTo; } return lineTo; } function pathVars(points, segment, params = {}) { const count = points.length; const {start: paramsStart = 0, end: paramsEnd = count - 1} = params; const {start: segmentStart, end: segmentEnd} = segment; const start = Math.max(paramsStart, segmentStart); const end = Math.min(paramsEnd, segmentEnd); const outside = paramsStart < segmentStart && paramsEnd < segmentStart || paramsStart > segmentEnd && paramsEnd > segmentEnd; return { count, start, loop: segment.loop, ilen: end < start && !outside ? count + end - start : end - start }; } function pathSegment(ctx, line, segment, params) { const {points, options} = line; const {count, start, loop, ilen} = pathVars(points, segment, params); const lineMethod = getLineMethod(options); let {move = true, reverse} = params || {}; let i, point, prev; for (i = 0; i <= ilen; ++i) { point = points[(start + (reverse ? ilen - i : i)) % count]; if (point.skip) { continue; } else if (move) { ctx.moveTo(point.x, point.y); move = false; } else { lineMethod(ctx, prev, point, reverse, options.stepped); } prev = point; } if (loop) { point = points[(start + (reverse ? ilen : 0)) % count]; lineMethod(ctx, prev, point, reverse, options.stepped); } return !!loop; } function fastPathSegment(ctx, line, segment, params) { const points = line.points; const {count, start, ilen} = pathVars(points, segment, params); const {move = true, reverse} = params || {}; let avgX = 0; let countX = 0; let i, point, prevX, minY, maxY, lastY; const pointIndex = (index) => (start + (reverse ? ilen - index : index)) % count; const drawX = () => { if (minY !== maxY) { ctx.lineTo(avgX, maxY); ctx.lineTo(avgX, minY); ctx.lineTo(avgX, lastY); } }; if (move) { point = points[pointIndex(0)]; ctx.moveTo(point.x, point.y); } for (i = 0; i <= ilen; ++i) { point = points[pointIndex(i)]; if (point.skip) { continue; } const x = point.x; const y = point.y; const truncX = x | 0; if (truncX === prevX) { if (y < minY) { minY = y; } else if (y > maxY) { maxY = y; } avgX = (countX * avgX + x) / ++countX; } else { drawX(); ctx.lineTo(x, y); prevX = truncX; countX = 0; minY = maxY = y; } lastY = y; } drawX(); } function _getSegmentMethod(line) { const opts = line.options; const borderDash = opts.borderDash && opts.borderDash.length; const useFastPath = !line._decimated && !line._loop && !opts.tension && opts.cubicInterpolationMode !== 'monotone' && !opts.stepped && !borderDash; return useFastPath ? fastPathSegment : pathSegment; } function _getInterpolationMethod(options) { if (options.stepped) { return _steppedInterpolation; } if (options.tension || options.cubicInterpolationMode === 'monotone') { return _bezierInterpolation; } return _pointInLine; } function strokePathWithCache(ctx, line, start, count) { let path = line._path; if (!path) { path = line._path = new Path2D(); if (line.path(path, start, count)) { path.closePath(); } } setStyle(ctx, line.options); ctx.stroke(path); } function strokePathDirect(ctx, line, start, count) { const {segments, options} = line; const segmentMethod = _getSegmentMethod(line); for (const segment of segments) { setStyle(ctx, options, segment.style); ctx.beginPath(); if (segmentMethod(ctx, line, segment, {start, end: start + count - 1})) { ctx.closePath(); } ctx.stroke(); } } const usePath2D = typeof Path2D === 'function'; function draw(ctx, line, start, count) { if (usePath2D && !line.options.segment) { strokePathWithCache(ctx, line, start, count); } else { strokePathDirect(ctx, line, start, count); } } class LineElement extends Element { constructor(cfg) { super(); this.animated = true; this.options = undefined; this._chart = undefined; this._loop = undefined; this._fullLoop = undefined; this._path = undefined; this._points = undefined; this._segments = undefined; this._decimated = false; this._pointsUpdated = false; this._datasetIndex = undefined; if (cfg) { Object.assign(this, cfg); } } updateControlPoints(chartArea, indexAxis) { const options = this.options; if ((options.tension || options.cubicInterpolationMode === 'monotone') && !options.stepped && !this._pointsUpdated) { const loop = options.spanGaps ? this._loop : this._fullLoop; _updateBezierControlPoints(this._points, options, chartArea, loop, indexAxis); this._pointsUpdated = true; } } set points(points) { this._points = points; delete this._segments; delete this._path; this._pointsUpdated = false; } get points() { return this._points; } get segments() { return this._segments || (this._segments = _computeSegments(this, this.options.segment)); } first() { const segments = this.segments; const points = this.points; return segments.length && points[segments[0].start]; } last() { const segments = this.segments; const points = this.points; const count = segments.length; return count && points[segments[count - 1].end]; } interpolate(point, property) { const options = this.options; const value = point[property]; const points = this.points; const segments = _boundSegments(this, {property, start: value, end: value}); if (!segments.length) { return; } const result = []; const _interpolate = _getInterpolationMethod(options); let i, ilen; for (i = 0, ilen = segments.length; i < ilen; ++i) { const {start, end} = segments[i]; const p1 = points[start]; const p2 = points[end]; if (p1 === p2) { result.push(p1); continue; } const t = Math.abs((value - p1[property]) / (p2[property] - p1[property])); const interpolated = _interpolate(p1, p2, t, options.stepped); interpolated[property] = point[property]; result.push(interpolated); } return result.length === 1 ? result[0] : result; } pathSegment(ctx, segment, params) { const segmentMethod = _getSegmentMethod(this); return segmentMethod(ctx, this, segment, params); } path(ctx, start, count) { const segments = this.segments; const segmentMethod = _getSegmentMethod(this); let loop = this._loop; start = start || 0; count = count || (this.points.length - start); for (const segment of segments) { loop &= segmentMethod(ctx, this, segment, {start, end: start + count - 1}); } return !!loop; } draw(ctx, chartArea, start, count) { const options = this.options || {}; const points = this.points || []; if (points.length && options.borderWidth) { ctx.save(); draw(ctx, this, start, count); ctx.restore(); } if (this.animated) { this._pointsUpdated = false; this._path = undefined; } } } LineElement.id = 'line'; LineElement.defaults = { borderCapStyle: 'butt', borderDash: [], borderDashOffset: 0, borderJoinStyle: 'miter', borderWidth: 3, capBezierPoints: true, cubicInterpolationMode: 'default', fill: false, spanGaps: false, stepped: false, tension: 0, }; LineElement.defaultRoutes = { backgroundColor: 'backgroundColor', borderColor: 'borderColor' }; LineElement.descriptors = { _scriptable: true, _indexable: (name) => name !== 'borderDash' && name !== 'fill', }; function inRange$1(el, pos, axis, useFinalPosition) { const options = el.options; const {[axis]: value} = el.getProps([axis], useFinalPosition); return (Math.abs(pos - value) < options.radius + options.hitRadius); } class PointElement extends Element { constructor(cfg) { super(); this.options = undefined; this.parsed = undefined; this.skip = undefined; this.stop = undefined; if (cfg) { Object.assign(this, cfg); } } inRange(mouseX, mouseY, useFinalPosition) { const options = this.options; const {x, y} = this.getProps(['x', 'y'], useFinalPosition); return ((Math.pow(mouseX - x, 2) + Math.pow(mouseY - y, 2)) < Math.pow(options.hitRadius + options.radius, 2)); } inXRange(mouseX, useFinalPosition) { return inRange$1(this, mouseX, 'x', useFinalPosition); } inYRange(mouseY, useFinalPosition) { return inRange$1(this, mouseY, 'y', useFinalPosition); } getCenterPoint(useFinalPosition) { const {x, y} = this.getProps(['x', 'y'], useFinalPosition); return {x, y}; } size(options) { options = options || this.options || {}; let radius = options.radius || 0; radius = Math.max(radius, radius && options.hoverRadius || 0); const borderWidth = radius && options.borderWidth || 0; return (radius + borderWidth) * 2; } draw(ctx, area) { const options = this.options; if (this.skip || options.radius < 0.1 || !_isPointInArea(this, area, this.size(options) / 2)) { return; } ctx.strokeStyle = options.borderColor; ctx.lineWidth = options.borderWidth; ctx.fillStyle = options.backgroundColor; drawPoint(ctx, options, this.x, this.y); } getRange() { const options = this.options || {}; return options.radius + options.hitRadius; } } PointElement.id = 'point'; PointElement.defaults = { borderWidth: 1, hitRadius: 1, hoverBorderWidth: 1, hoverRadius: 4, pointStyle: 'circle', radius: 3, rotation: 0 }; PointElement.defaultRoutes = { backgroundColor: 'backgroundColor', borderColor: 'borderColor' }; function getBarBounds(bar, useFinalPosition) { const {x, y, base, width, height} = bar.getProps(['x', 'y', 'base', 'width', 'height'], useFinalPosition); let left, right, top, bottom, half; if (bar.horizontal) { half = height / 2; left = Math.min(x, base); right = Math.max(x, base); top = y - half; bottom = y + half; } else { half = width / 2; left = x - half; right = x + half; top = Math.min(y, base); bottom = Math.max(y, base); } return {left, top, right, bottom}; } function skipOrLimit(skip, value, min, max) { return skip ? 0 : _limitValue(value, min, max); } function parseBorderWidth(bar, maxW, maxH) { const value = bar.options.borderWidth; const skip = bar.borderSkipped; const o = toTRBL(value); return { t: skipOrLimit(skip.top, o.top, 0, maxH), r: skipOrLimit(skip.right, o.right, 0, maxW), b: skipOrLimit(skip.bottom, o.bottom, 0, maxH), l: skipOrLimit(skip.left, o.left, 0, maxW) }; } function parseBorderRadius(bar, maxW, maxH) { const {enableBorderRadius} = bar.getProps(['enableBorderRadius']); const value = bar.options.borderRadius; const o = toTRBLCorners(value); const maxR = Math.min(maxW, maxH); const skip = bar.borderSkipped; const enableBorder = enableBorderRadius || isObject(value); return { topLeft: skipOrLimit(!enableBorder || skip.top || skip.left, o.topLeft, 0, maxR), topRight: skipOrLimit(!enableBorder || skip.top || skip.right, o.topRight, 0, maxR), bottomLeft: skipOrLimit(!enableBorder || skip.bottom || skip.left, o.bottomLeft, 0, maxR), bottomRight: skipOrLimit(!enableBorder || skip.bottom || skip.right, o.bottomRight, 0, maxR) }; } function boundingRects(bar) { const bounds = getBarBounds(bar); const width = bounds.right - bounds.left; const height = bounds.bottom - bounds.top; const border = parseBorderWidth(bar, width / 2, height / 2); const radius = parseBorderRadius(bar, width / 2, height / 2); return { outer: { x: bounds.left, y: bounds.top, w: width, h: height, radius }, inner: { x: bounds.left + border.l, y: bounds.top + border.t, w: width - border.l - border.r, h: height - border.t - border.b, radius: { topLeft: Math.max(0, radius.topLeft - Math.max(border.t, border.l)), topRight: Math.max(0, radius.topRight - Math.max(border.t, border.r)), bottomLeft: Math.max(0, radius.bottomLeft - Math.max(border.b, border.l)), bottomRight: Math.max(0, radius.bottomRight - Math.max(border.b, border.r)), } } }; } function inRange(bar, x, y, useFinalPosition) { const skipX = x === null; const skipY = y === null; const skipBoth = skipX && skipY; const bounds = bar && !skipBoth && getBarBounds(bar, useFinalPosition); return bounds && (skipX || _isBetween(x, bounds.left, bounds.right)) && (skipY || _isBetween(y, bounds.top, bounds.bottom)); } function hasRadius(radius) { return radius.topLeft || radius.topRight || radius.bottomLeft || radius.bottomRight; } function addNormalRectPath(ctx, rect) { ctx.rect(rect.x, rect.y, rect.w, rect.h); } function inflateRect(rect, amount, refRect = {}) { const x = rect.x !== refRect.x ? -amount : 0; const y = rect.y !== refRect.y ? -amount : 0; const w = (rect.x + rect.w !== refRect.x + refRect.w ? amount : 0) - x; const h = (rect.y + rect.h !== refRect.y + refRect.h ? amount : 0) - y; return { x: rect.x + x, y: rect.y + y, w: rect.w + w, h: rect.h + h, radius: rect.radius }; } class BarElement extends Element { constructor(cfg) { super(); this.options = undefined; this.horizontal = undefined; this.base = undefined; this.width = undefined; this.height = undefined; this.inflateAmount = undefined; if (cfg) { Object.assign(this, cfg); } } draw(ctx) { const {inflateAmount, options: {borderColor, backgroundColor}} = this; const {inner, outer} = boundingRects(this); const addRectPath = hasRadius(outer.radius) ? addRoundedRectPath : addNormalRectPath; ctx.save(); if (outer.w !== inner.w || outer.h !== inner.h) { ctx.beginPath(); addRectPath(ctx, inflateRect(outer, inflateAmount, inner)); ctx.clip(); addRectPath(ctx, inflateRect(inner, -inflateAmount, outer)); ctx.fillStyle = borderColor; ctx.fill('evenodd'); } ctx.beginPath(); addRectPath(ctx, inflateRect(inner, inflateAmount)); ctx.fillStyle = backgroundColor; ctx.fill(); ctx.restore(); } inRange(mouseX, mouseY, useFinalPosition) { return inRange(this, mouseX, mouseY, useFinalPosition); } inXRange(mouseX, useFinalPosition) { return inRange(this, mouseX, null, useFinalPosition); } inYRange(mouseY, useFinalPosition) { return inRange(this, null, mouseY, useFinalPosition); } getCenterPoint(useFinalPosition) { const {x, y, base, horizontal} = this.getProps(['x', 'y', 'base', 'horizontal'], useFinalPosition); return { x: horizontal ? (x + base) / 2 : x, y: horizontal ? y : (y + base) / 2 }; } getRange(axis) { return axis === 'x' ? this.width / 2 : this.height / 2; } } BarElement.id = 'bar'; BarElement.defaults = { borderSkipped: 'start', borderWidth: 0, borderRadius: 0, inflateAmount: 'auto', pointStyle: undefined }; BarElement.defaultRoutes = { backgroundColor: 'backgroundColor', borderColor: 'borderColor' }; var elements = /*#__PURE__*/Object.freeze({ __proto__: null, ArcElement: ArcElement, LineElement: LineElement, PointElement: PointElement, BarElement: BarElement }); function lttbDecimation(data, start, count, availableWidth, options) { const samples = options.samples || availableWidth; if (samples >= count) { return data.slice(start, start + count); } const decimated = []; const bucketWidth = (count - 2) / (samples - 2); let sampledIndex = 0; const endIndex = start + count - 1; let a = start; let i, maxAreaPoint, maxArea, area, nextA; decimated[sampledIndex++] = data[a]; for (i = 0; i < samples - 2; i++) { let avgX = 0; let avgY = 0; let j; const avgRangeStart = Math.floor((i + 1) * bucketWidth) + 1 + start; const avgRangeEnd = Math.min(Math.floor((i + 2) * bucketWidth) + 1, count) + start; const avgRangeLength = avgRangeEnd - avgRangeStart; for (j = avgRangeStart; j < avgRangeEnd; j++) { avgX += data[j].x; avgY += data[j].y; } avgX /= avgRangeLength; avgY /= avgRangeLength; const rangeOffs = Math.floor(i * bucketWidth) + 1 + start; const rangeTo = Math.min(Math.floor((i + 1) * bucketWidth) + 1, count) + start; const {x: pointAx, y: pointAy} = data[a]; maxArea = area = -1; for (j = rangeOffs; j < rangeTo; j++) { area = 0.5 * Math.abs( (pointAx - avgX) * (data[j].y - pointAy) - (pointAx - data[j].x) * (avgY - pointAy) ); if (area > maxArea) { maxArea = area; maxAreaPoint = data[j]; nextA = j; } } decimated[sampledIndex++] = maxAreaPoint; a = nextA; } decimated[sampledIndex++] = data[endIndex]; return decimated; } function minMaxDecimation(data, start, count, availableWidth) { let avgX = 0; let countX = 0; let i, point, x, y, prevX, minIndex, maxIndex, startIndex, minY, maxY; const decimated = []; const endIndex = start + count - 1; const xMin = data[start].x; const xMax = data[endIndex].x; const dx = xMax - xMin; for (i = start; i < start + count; ++i) { point = data[i]; x = (point.x - xMin) / dx * availableWidth; y = point.y; const truncX = x | 0; if (truncX === prevX) { if (y < minY) { minY = y; minIndex = i; } else if (y > maxY) { maxY = y; maxIndex = i; } avgX = (countX * avgX + point.x) / ++countX; } else { const lastIndex = i - 1; if (!isNullOrUndef(minIndex) && !isNullOrUndef(maxIndex)) { const intermediateIndex1 = Math.min(minIndex, maxIndex); const intermediateIndex2 = Math.max(minIndex, maxIndex); if (intermediateIndex1 !== startIndex && intermediateIndex1 !== lastIndex) { decimated.push({ ...data[intermediateIndex1], x: avgX, }); } if (intermediateIndex2 !== startIndex && intermediateIndex2 !== lastIndex) { decimated.push({ ...data[intermediateIndex2], x: avgX }); } } if (i > 0 && lastIndex !== startIndex) { decimated.push(data[lastIndex]); } decimated.push(point); prevX = truncX; countX = 0; minY = maxY = y; minIndex = maxIndex = startIndex = i; } } return decimated; } function cleanDecimatedDataset(dataset) { if (dataset._decimated) { const data = dataset._data; delete dataset._decimated; delete dataset._data; Object.defineProperty(dataset, 'data', {value: data}); } } function cleanDecimatedData(chart) { chart.data.datasets.forEach((dataset) => { cleanDecimatedDataset(dataset); }); } function getStartAndCountOfVisiblePointsSimplified(meta, points) { const pointCount = points.length; let start = 0; let count; const {iScale} = meta; const {min, max, minDefined, maxDefined} = iScale.getUserBounds(); if (minDefined) { start = _limitValue(_lookupByKey(points, iScale.axis, min).lo, 0, pointCount - 1); } if (maxDefined) { count = _limitValue(_lookupByKey(points, iScale.axis, max).hi + 1, start, pointCount) - start; } else { count = pointCount - start; } return {start, count}; } var plugin_decimation = { id: 'decimation', defaults: { algorithm: 'min-max', enabled: false, }, beforeElementsUpdate: (chart, args, options) => { if (!options.enabled) { cleanDecimatedData(chart); return; } const availableWidth = chart.width; chart.data.datasets.forEach((dataset, datasetIndex) => { const {_data, indexAxis} = dataset; const meta = chart.getDatasetMeta(datasetIndex); const data = _data || dataset.data; if (resolve([indexAxis, chart.options.indexAxis]) === 'y') { return; } if (!meta.controller.supportsDecimation) { return; } const xAxis = chart.scales[meta.xAxisID]; if (xAxis.type !== 'linear' && xAxis.type !== 'time') { return; } if (chart.options.parsing) { return; } let {start, count} = getStartAndCountOfVisiblePointsSimplified(meta, data); const threshold = options.threshold || 4 * availableWidth; if (count <= threshold) { cleanDecimatedDataset(dataset); return; } if (isNullOrUndef(_data)) { dataset._data = data; delete dataset.data; Object.defineProperty(dataset, 'data', { configurable: true, enumerable: true, get: function() { return this._decimated; }, set: function(d) { this._data = d; } }); } let decimated; switch (options.algorithm) { case 'lttb': decimated = lttbDecimation(data, start, count, availableWidth, options); break; case 'min-max': decimated = minMaxDecimation(data, start, count, availableWidth); break; default: throw new Error(`Unsupported decimation algorithm '${options.algorithm}'`); } dataset._decimated = decimated; }); }, destroy(chart) { cleanDecimatedData(chart); } }; function _segments(line, target, property) { const segments = line.segments; const points = line.points; const tpoints = target.points; const parts = []; for (const segment of segments) { let {start, end} = segment; end = _findSegmentEnd(start, end, points); const bounds = _getBounds(property, points[start], points[end], segment.loop); if (!target.segments) { parts.push({ source: segment, target: bounds, start: points[start], end: points[end] }); continue; } const targetSegments = _boundSegments(target, bounds); for (const tgt of targetSegments) { const subBounds = _getBounds(property, tpoints[tgt.start], tpoints[tgt.end], tgt.loop); const fillSources = _boundSegment(segment, points, subBounds); for (const fillSource of fillSources) { parts.push({ source: fillSource, target: tgt, start: { [property]: _getEdge(bounds, subBounds, 'start', Math.max) }, end: { [property]: _getEdge(bounds, subBounds, 'end', Math.min) } }); } } } return parts; } function _getBounds(property, first, last, loop) { if (loop) { return; } let start = first[property]; let end = last[property]; if (property === 'angle') { start = _normalizeAngle(start); end = _normalizeAngle(end); } return {property, start, end}; } function _pointsFromSegments(boundary, line) { const {x = null, y = null} = boundary || {}; const linePoints = line.points; const points = []; line.segments.forEach(({start, end}) => { end = _findSegmentEnd(start, end, linePoints); const first = linePoints[start]; const last = linePoints[end]; if (y !== null) { points.push({x: first.x, y}); points.push({x: last.x, y}); } else if (x !== null) { points.push({x, y: first.y}); points.push({x, y: last.y}); } }); return points; } function _findSegmentEnd(start, end, points) { for (;end > start; end--) { const point = points[end]; if (!isNaN(point.x) && !isNaN(point.y)) { break; } } return end; } function _getEdge(a, b, prop, fn) { if (a && b) { return fn(a[prop], b[prop]); } return a ? a[prop] : b ? b[prop] : 0; } function _createBoundaryLine(boundary, line) { let points = []; let _loop = false; if (isArray(boundary)) { _loop = true; points = boundary; } else { points = _pointsFromSegments(boundary, line); } return points.length ? new LineElement({ points, options: {tension: 0}, _loop, _fullLoop: _loop }) : null; } function _resolveTarget(sources, index, propagate) { const source = sources[index]; let fill = source.fill; const visited = [index]; let target; if (!propagate) { return fill; } while (fill !== false && visited.indexOf(fill) === -1) { if (!isNumberFinite(fill)) { return fill; } target = sources[fill]; if (!target) { return false; } if (target.visible) { return fill; } visited.push(fill); fill = target.fill; } return false; } function _decodeFill(line, index, count) { const fill = parseFillOption(line); if (isObject(fill)) { return isNaN(fill.value) ? false : fill; } let target = parseFloat(fill); if (isNumberFinite(target) && Math.floor(target) === target) { return decodeTargetIndex(fill[0], index, target, count); } return ['origin', 'start', 'end', 'stack', 'shape'].indexOf(fill) >= 0 && fill; } function decodeTargetIndex(firstCh, index, target, count) { if (firstCh === '-' || firstCh === '+') { target = index + target; } if (target === index || target < 0 || target >= count) { return false; } return target; } function _getTargetPixel(fill, scale) { let pixel = null; if (fill === 'start') { pixel = scale.bottom; } else if (fill === 'end') { pixel = scale.top; } else if (isObject(fill)) { pixel = scale.getPixelForValue(fill.value); } else if (scale.getBasePixel) { pixel = scale.getBasePixel(); } return pixel; } function _getTargetValue(fill, scale, startValue) { let value; if (fill === 'start') { value = startValue; } else if (fill === 'end') { value = scale.options.reverse ? scale.min : scale.max; } else if (isObject(fill)) { value = fill.value; } else { value = scale.getBaseValue(); } return value; } function parseFillOption(line) { const options = line.options; const fillOption = options.fill; let fill = valueOrDefault(fillOption && fillOption.target, fillOption); if (fill === undefined) { fill = !!options.backgroundColor; } if (fill === false || fill === null) { return false; } if (fill === true) { return 'origin'; } return fill; } function _buildStackLine(source) { const {scale, index, line} = source; const points = []; const segments = line.segments; const sourcePoints = line.points; const linesBelow = getLinesBelow(scale, index); linesBelow.push(_createBoundaryLine({x: null, y: scale.bottom}, line)); for (let i = 0; i < segments.length; i++) { const segment = segments[i]; for (let j = segment.start; j <= segment.end; j++) { addPointsBelow(points, sourcePoints[j], linesBelow); } } return new LineElement({points, options: {}}); } function getLinesBelow(scale, index) { const below = []; const metas = scale.getMatchingVisibleMetas('line'); for (let i = 0; i < metas.length; i++) { const meta = metas[i]; if (meta.index === index) { break; } if (!meta.hidden) { below.unshift(meta.dataset); } } return below; } function addPointsBelow(points, sourcePoint, linesBelow) { const postponed = []; for (let j = 0; j < linesBelow.length; j++) { const line = linesBelow[j]; const {first, last, point} = findPoint(line, sourcePoint, 'x'); if (!point || (first && last)) { continue; } if (first) { postponed.unshift(point); } else { points.push(point); if (!last) { break; } } } points.push(...postponed); } function findPoint(line, sourcePoint, property) { const point = line.interpolate(sourcePoint, property); if (!point) { return {}; } const pointValue = point[property]; const segments = line.segments; const linePoints = line.points; let first = false; let last = false; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; const firstValue = linePoints[segment.start][property]; const lastValue = linePoints[segment.end][property]; if (_isBetween(pointValue, firstValue, lastValue)) { first = pointValue === firstValue; last = pointValue === lastValue; break; } } return {first, last, point}; } class simpleArc { constructor(opts) { this.x = opts.x; this.y = opts.y; this.radius = opts.radius; } pathSegment(ctx, bounds, opts) { const {x, y, radius} = this; bounds = bounds || {start: 0, end: TAU}; ctx.arc(x, y, radius, bounds.end, bounds.start, true); return !opts.bounds; } interpolate(point) { const {x, y, radius} = this; const angle = point.angle; return { x: x + Math.cos(angle) * radius, y: y + Math.sin(angle) * radius, angle }; } } function _getTarget(source) { const {chart, fill, line} = source; if (isNumberFinite(fill)) { return getLineByIndex(chart, fill); } if (fill === 'stack') { return _buildStackLine(source); } if (fill === 'shape') { return true; } const boundary = computeBoundary(source); if (boundary instanceof simpleArc) { return boundary; } return _createBoundaryLine(boundary, line); } function getLineByIndex(chart, index) { const meta = chart.getDatasetMeta(index); const visible = meta && chart.isDatasetVisible(index); return visible ? meta.dataset : null; } function computeBoundary(source) { const scale = source.scale || {}; if (scale.getPointPositionForValue) { return computeCircularBoundary(source); } return computeLinearBoundary(source); } function computeLinearBoundary(source) { const {scale = {}, fill} = source; const pixel = _getTargetPixel(fill, scale); if (isNumberFinite(pixel)) { const horizontal = scale.isHorizontal(); return { x: horizontal ? pixel : null, y: horizontal ? null : pixel }; } return null; } function computeCircularBoundary(source) { const {scale, fill} = source; const options = scale.options; const length = scale.getLabels().length; const start = options.reverse ? scale.max : scale.min; const value = _getTargetValue(fill, scale, start); const target = []; if (options.grid.circular) { const center = scale.getPointPositionForValue(0, start); return new simpleArc({ x: center.x, y: center.y, radius: scale.getDistanceFromCenterForValue(value) }); } for (let i = 0; i < length; ++i) { target.push(scale.getPointPositionForValue(i, value)); } return target; } function _drawfill(ctx, source, area) { const target = _getTarget(source); const {line, scale, axis} = source; const lineOpts = line.options; const fillOption = lineOpts.fill; const color = lineOpts.backgroundColor; const {above = color, below = color} = fillOption || {}; if (target && line.points.length) { clipArea(ctx, area); doFill(ctx, {line, target, above, below, area, scale, axis}); unclipArea(ctx); } } function doFill(ctx, cfg) { const {line, target, above, below, area, scale} = cfg; const property = line._loop ? 'angle' : cfg.axis; ctx.save(); if (property === 'x' && below !== above) { clipVertical(ctx, target, area.top); fill(ctx, {line, target, color: above, scale, property}); ctx.restore(); ctx.save(); clipVertical(ctx, target, area.bottom); } fill(ctx, {line, target, color: below, scale, property}); ctx.restore(); } function clipVertical(ctx, target, clipY) { const {segments, points} = target; let first = true; let lineLoop = false; ctx.beginPath(); for (const segment of segments) { const {start, end} = segment; const firstPoint = points[start]; const lastPoint = points[_findSegmentEnd(start, end, points)]; if (first) { ctx.moveTo(firstPoint.x, firstPoint.y); first = false; } else { ctx.lineTo(firstPoint.x, clipY); ctx.lineTo(firstPoint.x, firstPoint.y); } lineLoop = !!target.pathSegment(ctx, segment, {move: lineLoop}); if (lineLoop) { ctx.closePath(); } else { ctx.lineTo(lastPoint.x, clipY); } } ctx.lineTo(target.first().x, clipY); ctx.closePath(); ctx.clip(); } function fill(ctx, cfg) { const {line, target, property, color, scale} = cfg; const segments = _segments(line, target, property); for (const {source: src, target: tgt, start, end} of segments) { const {style: {backgroundColor = color} = {}} = src; const notShape = target !== true; ctx.save(); ctx.fillStyle = backgroundColor; clipBounds(ctx, scale, notShape && _getBounds(property, start, end)); ctx.beginPath(); const lineLoop = !!line.pathSegment(ctx, src); let loop; if (notShape) { if (lineLoop) { ctx.closePath(); } else { interpolatedLineTo(ctx, target, end, property); } const targetLoop = !!target.pathSegment(ctx, tgt, {move: lineLoop, reverse: true}); loop = lineLoop && targetLoop; if (!loop) { interpolatedLineTo(ctx, target, start, property); } } ctx.closePath(); ctx.fill(loop ? 'evenodd' : 'nonzero'); ctx.restore(); } } function clipBounds(ctx, scale, bounds) { const {top, bottom} = scale.chart.chartArea; const {property, start, end} = bounds || {}; if (property === 'x') { ctx.beginPath(); ctx.rect(start, top, end - start, bottom - top); ctx.clip(); } } function interpolatedLineTo(ctx, target, point, property) { const interpolatedPoint = target.interpolate(point, property); if (interpolatedPoint) { ctx.lineTo(interpolatedPoint.x, interpolatedPoint.y); } } var index = { id: 'filler', afterDatasetsUpdate(chart, _args, options) { const count = (chart.data.datasets || []).length; const sources = []; let meta, i, line, source; for (i = 0; i < count; ++i) { meta = chart.getDatasetMeta(i); line = meta.dataset; source = null; if (line && line.options && line instanceof LineElement) { source = { visible: chart.isDatasetVisible(i), index: i, fill: _decodeFill(line, i, count), chart, axis: meta.controller.options.indexAxis, scale: meta.vScale, line, }; } meta.$filler = source; sources.push(source); } for (i = 0; i < count; ++i) { source = sources[i]; if (!source || source.fill === false) { continue; } source.fill = _resolveTarget(sources, i, options.propagate); } }, beforeDraw(chart, _args, options) { const draw = options.drawTime === 'beforeDraw'; const metasets = chart.getSortedVisibleDatasetMetas(); const area = chart.chartArea; for (let i = metasets.length - 1; i >= 0; --i) { const source = metasets[i].$filler; if (!source) { continue; } source.line.updateControlPoints(area, source.axis); if (draw) { _drawfill(chart.ctx, source, area); } } }, beforeDatasetsDraw(chart, _args, options) { if (options.drawTime !== 'beforeDatasetsDraw') { return; } const metasets = chart.getSortedVisibleDatasetMetas(); for (let i = metasets.length - 1; i >= 0; --i) { const source = metasets[i].$filler; if (source) { _drawfill(chart.ctx, source, chart.chartArea); } } }, beforeDatasetDraw(chart, args, options) { const source = args.meta.$filler; if (!source || source.fill === false || options.drawTime !== 'beforeDatasetDraw') { return; } _drawfill(chart.ctx, source, chart.chartArea); }, defaults: { propagate: true, drawTime: 'beforeDatasetDraw' } }; const getBoxSize = (labelOpts, fontSize) => { let {boxHeight = fontSize, boxWidth = fontSize} = labelOpts; if (labelOpts.usePointStyle) { boxHeight = Math.min(boxHeight, fontSize); boxWidth = Math.min(boxWidth, fontSize); } return { boxWidth, boxHeight, itemHeight: Math.max(fontSize, boxHeight) }; }; const itemsEqual = (a, b) => a !== null && b !== null && a.datasetIndex === b.datasetIndex && a.index === b.index; class Legend extends Element { constructor(config) { super(); this._added = false; this.legendHitBoxes = []; this._hoveredItem = null; this.doughnutMode = false; this.chart = config.chart; this.options = config.options; this.ctx = config.ctx; this.legendItems = undefined; this.columnSizes = undefined; this.lineWidths = undefined; this.maxHeight = undefined; this.maxWidth = undefined; this.top = undefined; this.bottom = undefined; this.left = undefined; this.right = undefined; this.height = undefined; this.width = undefined; this._margins = undefined; this.position = undefined; this.weight = undefined; this.fullSize = undefined; } update(maxWidth, maxHeight, margins) { this.maxWidth = maxWidth; this.maxHeight = maxHeight; this._margins = margins; this.setDimensions(); this.buildLabels(); this.fit(); } setDimensions() { if (this.isHorizontal()) { this.width = this.maxWidth; this.left = this._margins.left; this.right = this.width; } else { this.height = this.maxHeight; this.top = this._margins.top; this.bottom = this.height; } } buildLabels() { const labelOpts = this.options.labels || {}; let legendItems = callback(labelOpts.generateLabels, [this.chart], this) || []; if (labelOpts.filter) { legendItems = legendItems.filter((item) => labelOpts.filter(item, this.chart.data)); } if (labelOpts.sort) { legendItems = legendItems.sort((a, b) => labelOpts.sort(a, b, this.chart.data)); } if (this.options.reverse) { legendItems.reverse(); } this.legendItems = legendItems; } fit() { const {options, ctx} = this; if (!options.display) { this.width = this.height = 0; return; } const labelOpts = options.labels; const labelFont = toFont(labelOpts.font); const fontSize = labelFont.size; const titleHeight = this._computeTitleHeight(); const {boxWidth, itemHeight} = getBoxSize(labelOpts, fontSize); let width, height; ctx.font = labelFont.string; if (this.isHorizontal()) { width = this.maxWidth; height = this._fitRows(titleHeight, fontSize, boxWidth, itemHeight) + 10; } else { height = this.maxHeight; width = this._fitCols(titleHeight, fontSize, boxWidth, itemHeight) + 10; } this.width = Math.min(width, options.maxWidth || this.maxWidth); this.height = Math.min(height, options.maxHeight || this.maxHeight); } _fitRows(titleHeight, fontSize, boxWidth, itemHeight) { const {ctx, maxWidth, options: {labels: {padding}}} = this; const hitboxes = this.legendHitBoxes = []; const lineWidths = this.lineWidths = [0]; const lineHeight = itemHeight + padding; let totalHeight = titleHeight; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; let row = -1; let top = -lineHeight; this.legendItems.forEach((legendItem, i) => { const itemWidth = boxWidth + (fontSize / 2) + ctx.measureText(legendItem.text).width; if (i === 0 || lineWidths[lineWidths.length - 1] + itemWidth + 2 * padding > maxWidth) { totalHeight += lineHeight; lineWidths[lineWidths.length - (i > 0 ? 0 : 1)] = 0; top += lineHeight; row++; } hitboxes[i] = {left: 0, top, row, width: itemWidth, height: itemHeight}; lineWidths[lineWidths.length - 1] += itemWidth + padding; }); return totalHeight; } _fitCols(titleHeight, fontSize, boxWidth, itemHeight) { const {ctx, maxHeight, options: {labels: {padding}}} = this; const hitboxes = this.legendHitBoxes = []; const columnSizes = this.columnSizes = []; const heightLimit = maxHeight - titleHeight; let totalWidth = padding; let currentColWidth = 0; let currentColHeight = 0; let left = 0; let col = 0; this.legendItems.forEach((legendItem, i) => { const itemWidth = boxWidth + (fontSize / 2) + ctx.measureText(legendItem.text).width; if (i > 0 && currentColHeight + itemHeight + 2 * padding > heightLimit) { totalWidth += currentColWidth + padding; columnSizes.push({width: currentColWidth, height: currentColHeight}); left += currentColWidth + padding; col++; currentColWidth = currentColHeight = 0; } hitboxes[i] = {left, top: currentColHeight, col, width: itemWidth, height: itemHeight}; currentColWidth = Math.max(currentColWidth, itemWidth); currentColHeight += itemHeight + padding; }); totalWidth += currentColWidth; columnSizes.push({width: currentColWidth, height: currentColHeight}); return totalWidth; } adjustHitBoxes() { if (!this.options.display) { return; } const titleHeight = this._computeTitleHeight(); const {legendHitBoxes: hitboxes, options: {align, labels: {padding}, rtl}} = this; const rtlHelper = getRtlAdapter(rtl, this.left, this.width); if (this.isHorizontal()) { let row = 0; let left = _alignStartEnd(align, this.left + padding, this.right - this.lineWidths[row]); for (const hitbox of hitboxes) { if (row !== hitbox.row) { row = hitbox.row; left = _alignStartEnd(align, this.left + padding, this.right - this.lineWidths[row]); } hitbox.top += this.top + titleHeight + padding; hitbox.left = rtlHelper.leftForLtr(rtlHelper.x(left), hitbox.width); left += hitbox.width + padding; } } else { let col = 0; let top = _alignStartEnd(align, this.top + titleHeight + padding, this.bottom - this.columnSizes[col].height); for (const hitbox of hitboxes) { if (hitbox.col !== col) { col = hitbox.col; top = _alignStartEnd(align, this.top + titleHeight + padding, this.bottom - this.columnSizes[col].height); } hitbox.top = top; hitbox.left += this.left + padding; hitbox.left = rtlHelper.leftForLtr(rtlHelper.x(hitbox.left), hitbox.width); top += hitbox.height + padding; } } } isHorizontal() { return this.options.position === 'top' || this.options.position === 'bottom'; } draw() { if (this.options.display) { const ctx = this.ctx; clipArea(ctx, this); this._draw(); unclipArea(ctx); } } _draw() { const {options: opts, columnSizes, lineWidths, ctx} = this; const {align, labels: labelOpts} = opts; const defaultColor = defaults.color; const rtlHelper = getRtlAdapter(opts.rtl, this.left, this.width); const labelFont = toFont(labelOpts.font); const {color: fontColor, padding} = labelOpts; const fontSize = labelFont.size; const halfFontSize = fontSize / 2; let cursor; this.drawTitle(); ctx.textAlign = rtlHelper.textAlign('left'); ctx.textBaseline = 'middle'; ctx.lineWidth = 0.5; ctx.font = labelFont.string; const {boxWidth, boxHeight, itemHeight} = getBoxSize(labelOpts, fontSize); const drawLegendBox = function(x, y, legendItem) { if (isNaN(boxWidth) || boxWidth <= 0 || isNaN(boxHeight) || boxHeight < 0) { return; } ctx.save(); const lineWidth = valueOrDefault(legendItem.lineWidth, 1); ctx.fillStyle = valueOrDefault(legendItem.fillStyle, defaultColor); ctx.lineCap = valueOrDefault(legendItem.lineCap, 'butt'); ctx.lineDashOffset = valueOrDefault(legendItem.lineDashOffset, 0); ctx.lineJoin = valueOrDefault(legendItem.lineJoin, 'miter'); ctx.lineWidth = lineWidth; ctx.strokeStyle = valueOrDefault(legendItem.strokeStyle, defaultColor); ctx.setLineDash(valueOrDefault(legendItem.lineDash, [])); if (labelOpts.usePointStyle) { const drawOptions = { radius: boxWidth * Math.SQRT2 / 2, pointStyle: legendItem.pointStyle, rotation: legendItem.rotation, borderWidth: lineWidth }; const centerX = rtlHelper.xPlus(x, boxWidth / 2); const centerY = y + halfFontSize; drawPoint(ctx, drawOptions, centerX, centerY); } else { const yBoxTop = y + Math.max((fontSize - boxHeight) / 2, 0); const xBoxLeft = rtlHelper.leftForLtr(x, boxWidth); const borderRadius = toTRBLCorners(legendItem.borderRadius); ctx.beginPath(); if (Object.values(borderRadius).some(v => v !== 0)) { addRoundedRectPath(ctx, { x: xBoxLeft, y: yBoxTop, w: boxWidth, h: boxHeight, radius: borderRadius, }); } else { ctx.rect(xBoxLeft, yBoxTop, boxWidth, boxHeight); } ctx.fill(); if (lineWidth !== 0) { ctx.stroke(); } } ctx.restore(); }; const fillText = function(x, y, legendItem) { renderText(ctx, legendItem.text, x, y + (itemHeight / 2), labelFont, { strikethrough: legendItem.hidden, textAlign: rtlHelper.textAlign(legendItem.textAlign) }); }; const isHorizontal = this.isHorizontal(); const titleHeight = this._computeTitleHeight(); if (isHorizontal) { cursor = { x: _alignStartEnd(align, this.left + padding, this.right - lineWidths[0]), y: this.top + padding + titleHeight, line: 0 }; } else { cursor = { x: this.left + padding, y: _alignStartEnd(align, this.top + titleHeight + padding, this.bottom - columnSizes[0].height), line: 0 }; } overrideTextDirection(this.ctx, opts.textDirection); const lineHeight = itemHeight + padding; this.legendItems.forEach((legendItem, i) => { ctx.strokeStyle = legendItem.fontColor || fontColor; ctx.fillStyle = legendItem.fontColor || fontColor; const textWidth = ctx.measureText(legendItem.text).width; const textAlign = rtlHelper.textAlign(legendItem.textAlign || (legendItem.textAlign = labelOpts.textAlign)); const width = boxWidth + halfFontSize + textWidth; let x = cursor.x; let y = cursor.y; rtlHelper.setWidth(this.width); if (isHorizontal) { if (i > 0 && x + width + padding > this.right) { y = cursor.y += lineHeight; cursor.line++; x = cursor.x = _alignStartEnd(align, this.left + padding, this.right - lineWidths[cursor.line]); } } else if (i > 0 && y + lineHeight > this.bottom) { x = cursor.x = x + columnSizes[cursor.line].width + padding; cursor.line++; y = cursor.y = _alignStartEnd(align, this.top + titleHeight + padding, this.bottom - columnSizes[cursor.line].height); } const realX = rtlHelper.x(x); drawLegendBox(realX, y, legendItem); x = _textX(textAlign, x + boxWidth + halfFontSize, isHorizontal ? x + width : this.right, opts.rtl); fillText(rtlHelper.x(x), y, legendItem); if (isHorizontal) { cursor.x += width + padding; } else { cursor.y += lineHeight; } }); restoreTextDirection(this.ctx, opts.textDirection); } drawTitle() { const opts = this.options; const titleOpts = opts.title; const titleFont = toFont(titleOpts.font); const titlePadding = toPadding(titleOpts.padding); if (!titleOpts.display) { return; } const rtlHelper = getRtlAdapter(opts.rtl, this.left, this.width); const ctx = this.ctx; const position = titleOpts.position; const halfFontSize = titleFont.size / 2; const topPaddingPlusHalfFontSize = titlePadding.top + halfFontSize; let y; let left = this.left; let maxWidth = this.width; if (this.isHorizontal()) { maxWidth = Math.max(...this.lineWidths); y = this.top + topPaddingPlusHalfFontSize; left = _alignStartEnd(opts.align, left, this.right - maxWidth); } else { const maxHeight = this.columnSizes.reduce((acc, size) => Math.max(acc, size.height), 0); y = topPaddingPlusHalfFontSize + _alignStartEnd(opts.align, this.top, this.bottom - maxHeight - opts.labels.padding - this._computeTitleHeight()); } const x = _alignStartEnd(position, left, left + maxWidth); ctx.textAlign = rtlHelper.textAlign(_toLeftRightCenter(position)); ctx.textBaseline = 'middle'; ctx.strokeStyle = titleOpts.color; ctx.fillStyle = titleOpts.color; ctx.font = titleFont.string; renderText(ctx, titleOpts.text, x, y, titleFont); } _computeTitleHeight() { const titleOpts = this.options.title; const titleFont = toFont(titleOpts.font); const titlePadding = toPadding(titleOpts.padding); return titleOpts.display ? titleFont.lineHeight + titlePadding.height : 0; } _getLegendItemAt(x, y) { let i, hitBox, lh; if (_isBetween(x, this.left, this.right) && _isBetween(y, this.top, this.bottom)) { lh = this.legendHitBoxes; for (i = 0; i < lh.length; ++i) { hitBox = lh[i]; if (_isBetween(x, hitBox.left, hitBox.left + hitBox.width) && _isBetween(y, hitBox.top, hitBox.top + hitBox.height)) { return this.legendItems[i]; } } } return null; } handleEvent(e) { const opts = this.options; if (!isListened(e.type, opts)) { return; } const hoveredItem = this._getLegendItemAt(e.x, e.y); if (e.type === 'mousemove' || e.type === 'mouseout') { const previous = this._hoveredItem; const sameItem = itemsEqual(previous, hoveredItem); if (previous && !sameItem) { callback(opts.onLeave, [e, previous, this], this); } this._hoveredItem = hoveredItem; if (hoveredItem && !sameItem) { callback(opts.onHover, [e, hoveredItem, this], this); } } else if (hoveredItem) { callback(opts.onClick, [e, hoveredItem, this], this); } } } function isListened(type, opts) { if ((type === 'mousemove' || type === 'mouseout') && (opts.onHover || opts.onLeave)) { return true; } if (opts.onClick && (type === 'click' || type === 'mouseup')) { return true; } return false; } var plugin_legend = { id: 'legend', _element: Legend, start(chart, _args, options) { const legend = chart.legend = new Legend({ctx: chart.ctx, options, chart}); layouts.configure(chart, legend, options); layouts.addBox(chart, legend); }, stop(chart) { layouts.removeBox(chart, chart.legend); delete chart.legend; }, beforeUpdate(chart, _args, options) { const legend = chart.legend; layouts.configure(chart, legend, options); legend.options = options; }, afterUpdate(chart) { const legend = chart.legend; legend.buildLabels(); legend.adjustHitBoxes(); }, afterEvent(chart, args) { if (!args.replay) { chart.legend.handleEvent(args.event); } }, defaults: { display: true, position: 'top', align: 'center', fullSize: true, reverse: false, weight: 1000, onClick(e, legendItem, legend) { const index = legendItem.datasetIndex; const ci = legend.chart; if (ci.isDatasetVisible(index)) { ci.hide(index); legendItem.hidden = true; } else { ci.show(index); legendItem.hidden = false; } }, onHover: null, onLeave: null, labels: { color: (ctx) => ctx.chart.options.color, boxWidth: 40, padding: 10, generateLabels(chart) { const datasets = chart.data.datasets; const {labels: {usePointStyle, pointStyle, textAlign, color}} = chart.legend.options; return chart._getSortedDatasetMetas().map((meta) => { const style = meta.controller.getStyle(usePointStyle ? 0 : undefined); const borderWidth = toPadding(style.borderWidth); return { text: datasets[meta.index].label, fillStyle: style.backgroundColor, fontColor: color, hidden: !meta.visible, lineCap: style.borderCapStyle, lineDash: style.borderDash, lineDashOffset: style.borderDashOffset, lineJoin: style.borderJoinStyle, lineWidth: (borderWidth.width + borderWidth.height) / 4, strokeStyle: style.borderColor, pointStyle: pointStyle || style.pointStyle, rotation: style.rotation, textAlign: textAlign || style.textAlign, borderRadius: 0, datasetIndex: meta.index }; }, this); } }, title: { color: (ctx) => ctx.chart.options.color, display: false, position: 'center', text: '', } }, descriptors: { _scriptable: (name) => !name.startsWith('on'), labels: { _scriptable: (name) => !['generateLabels', 'filter', 'sort'].includes(name), } }, }; class Title extends Element { constructor(config) { super(); this.chart = config.chart; this.options = config.options; this.ctx = config.ctx; this._padding = undefined; this.top = undefined; this.bottom = undefined; this.left = undefined; this.right = undefined; this.width = undefined; this.height = undefined; this.position = undefined; this.weight = undefined; this.fullSize = undefined; } update(maxWidth, maxHeight) { const opts = this.options; this.left = 0; this.top = 0; if (!opts.display) { this.width = this.height = this.right = this.bottom = 0; return; } this.width = this.right = maxWidth; this.height = this.bottom = maxHeight; const lineCount = isArray(opts.text) ? opts.text.length : 1; this._padding = toPadding(opts.padding); const textSize = lineCount * toFont(opts.font).lineHeight + this._padding.height; if (this.isHorizontal()) { this.height = textSize; } else { this.width = textSize; } } isHorizontal() { const pos = this.options.position; return pos === 'top' || pos === 'bottom'; } _drawArgs(offset) { const {top, left, bottom, right, options} = this; const align = options.align; let rotation = 0; let maxWidth, titleX, titleY; if (this.isHorizontal()) { titleX = _alignStartEnd(align, left, right); titleY = top + offset; maxWidth = right - left; } else { if (options.position === 'left') { titleX = left + offset; titleY = _alignStartEnd(align, bottom, top); rotation = PI * -0.5; } else { titleX = right - offset; titleY = _alignStartEnd(align, top, bottom); rotation = PI * 0.5; } maxWidth = bottom - top; } return {titleX, titleY, maxWidth, rotation}; } draw() { const ctx = this.ctx; const opts = this.options; if (!opts.display) { return; } const fontOpts = toFont(opts.font); const lineHeight = fontOpts.lineHeight; const offset = lineHeight / 2 + this._padding.top; const {titleX, titleY, maxWidth, rotation} = this._drawArgs(offset); renderText(ctx, opts.text, 0, 0, fontOpts, { color: opts.color, maxWidth, rotation, textAlign: _toLeftRightCenter(opts.align), textBaseline: 'middle', translation: [titleX, titleY], }); } } function createTitle(chart, titleOpts) { const title = new Title({ ctx: chart.ctx, options: titleOpts, chart }); layouts.configure(chart, title, titleOpts); layouts.addBox(chart, title); chart.titleBlock = title; } var plugin_title = { id: 'title', _element: Title, start(chart, _args, options) { createTitle(chart, options); }, stop(chart) { const titleBlock = chart.titleBlock; layouts.removeBox(chart, titleBlock); delete chart.titleBlock; }, beforeUpdate(chart, _args, options) { const title = chart.titleBlock; layouts.configure(chart, title, options); title.options = options; }, defaults: { align: 'center', display: false, font: { weight: 'bold', }, fullSize: true, padding: 10, position: 'top', text: '', weight: 2000 }, defaultRoutes: { color: 'color' }, descriptors: { _scriptable: true, _indexable: false, }, }; const map = new WeakMap(); var plugin_subtitle = { id: 'subtitle', start(chart, _args, options) { const title = new Title({ ctx: chart.ctx, options, chart }); layouts.configure(chart, title, options); layouts.addBox(chart, title); map.set(chart, title); }, stop(chart) { layouts.removeBox(chart, map.get(chart)); map.delete(chart); }, beforeUpdate(chart, _args, options) { const title = map.get(chart); layouts.configure(chart, title, options); title.options = options; }, defaults: { align: 'center', display: false, font: { weight: 'normal', }, fullSize: true, padding: 0, position: 'top', text: '', weight: 1500 }, defaultRoutes: { color: 'color' }, descriptors: { _scriptable: true, _indexable: false, }, }; const positioners = { average(items) { if (!items.length) { return false; } let i, len; let x = 0; let y = 0; let count = 0; for (i = 0, len = items.length; i < len; ++i) { const el = items[i].element; if (el && el.hasValue()) { const pos = el.tooltipPosition(); x += pos.x; y += pos.y; ++count; } } return { x: x / count, y: y / count }; }, nearest(items, eventPosition) { if (!items.length) { return false; } let x = eventPosition.x; let y = eventPosition.y; let minDistance = Number.POSITIVE_INFINITY; let i, len, nearestElement; for (i = 0, len = items.length; i < len; ++i) { const el = items[i].element; if (el && el.hasValue()) { const center = el.getCenterPoint(); const d = distanceBetweenPoints(eventPosition, center); if (d < minDistance) { minDistance = d; nearestElement = el; } } } if (nearestElement) { const tp = nearestElement.tooltipPosition(); x = tp.x; y = tp.y; } return { x, y }; } }; function pushOrConcat(base, toPush) { if (toPush) { if (isArray(toPush)) { Array.prototype.push.apply(base, toPush); } else { base.push(toPush); } } return base; } function splitNewlines(str) { if ((typeof str === 'string' || str instanceof String) && str.indexOf('\n') > -1) { return str.split('\n'); } return str; } function createTooltipItem(chart, item) { const {element, datasetIndex, index} = item; const controller = chart.getDatasetMeta(datasetIndex).controller; const {label, value} = controller.getLabelAndValue(index); return { chart, label, parsed: controller.getParsed(index), raw: chart.data.datasets[datasetIndex].data[index], formattedValue: value, dataset: controller.getDataset(), dataIndex: index, datasetIndex, element }; } function getTooltipSize(tooltip, options) { const ctx = tooltip.chart.ctx; const {body, footer, title} = tooltip; const {boxWidth, boxHeight} = options; const bodyFont = toFont(options.bodyFont); const titleFont = toFont(options.titleFont); const footerFont = toFont(options.footerFont); const titleLineCount = title.length; const footerLineCount = footer.length; const bodyLineItemCount = body.length; const padding = toPadding(options.padding); let height = padding.height; let width = 0; let combinedBodyLength = body.reduce((count, bodyItem) => count + bodyItem.before.length + bodyItem.lines.length + bodyItem.after.length, 0); combinedBodyLength += tooltip.beforeBody.length + tooltip.afterBody.length; if (titleLineCount) { height += titleLineCount * titleFont.lineHeight + (titleLineCount - 1) * options.titleSpacing + options.titleMarginBottom; } if (combinedBodyLength) { const bodyLineHeight = options.displayColors ? Math.max(boxHeight, bodyFont.lineHeight) : bodyFont.lineHeight; height += bodyLineItemCount * bodyLineHeight + (combinedBodyLength - bodyLineItemCount) * bodyFont.lineHeight + (combinedBodyLength - 1) * options.bodySpacing; } if (footerLineCount) { height += options.footerMarginTop + footerLineCount * footerFont.lineHeight + (footerLineCount - 1) * options.footerSpacing; } let widthPadding = 0; const maxLineWidth = function(line) { width = Math.max(width, ctx.measureText(line).width + widthPadding); }; ctx.save(); ctx.font = titleFont.string; each(tooltip.title, maxLineWidth); ctx.font = bodyFont.string; each(tooltip.beforeBody.concat(tooltip.afterBody), maxLineWidth); widthPadding = options.displayColors ? (boxWidth + 2 + options.boxPadding) : 0; each(body, (bodyItem) => { each(bodyItem.before, maxLineWidth); each(bodyItem.lines, maxLineWidth); each(bodyItem.after, maxLineWidth); }); widthPadding = 0; ctx.font = footerFont.string; each(tooltip.footer, maxLineWidth); ctx.restore(); width += padding.width; return {width, height}; } function determineYAlign(chart, size) { const {y, height} = size; if (y < height / 2) { return 'top'; } else if (y > (chart.height - height / 2)) { return 'bottom'; } return 'center'; } function doesNotFitWithAlign(xAlign, chart, options, size) { const {x, width} = size; const caret = options.caretSize + options.caretPadding; if (xAlign === 'left' && x + width + caret > chart.width) { return true; } if (xAlign === 'right' && x - width - caret < 0) { return true; } } function determineXAlign(chart, options, size, yAlign) { const {x, width} = size; const {width: chartWidth, chartArea: {left, right}} = chart; let xAlign = 'center'; if (yAlign === 'center') { xAlign = x <= (left + right) / 2 ? 'left' : 'right'; } else if (x <= width / 2) { xAlign = 'left'; } else if (x >= chartWidth - width / 2) { xAlign = 'right'; } if (doesNotFitWithAlign(xAlign, chart, options, size)) { xAlign = 'center'; } return xAlign; } function determineAlignment(chart, options, size) { const yAlign = size.yAlign || options.yAlign || determineYAlign(chart, size); return { xAlign: size.xAlign || options.xAlign || determineXAlign(chart, options, size, yAlign), yAlign }; } function alignX(size, xAlign) { let {x, width} = size; if (xAlign === 'right') { x -= width; } else if (xAlign === 'center') { x -= (width / 2); } return x; } function alignY(size, yAlign, paddingAndSize) { let {y, height} = size; if (yAlign === 'top') { y += paddingAndSize; } else if (yAlign === 'bottom') { y -= height + paddingAndSize; } else { y -= (height / 2); } return y; } function getBackgroundPoint(options, size, alignment, chart) { const {caretSize, caretPadding, cornerRadius} = options; const {xAlign, yAlign} = alignment; const paddingAndSize = caretSize + caretPadding; const {topLeft, topRight, bottomLeft, bottomRight} = toTRBLCorners(cornerRadius); let x = alignX(size, xAlign); const y = alignY(size, yAlign, paddingAndSize); if (yAlign === 'center') { if (xAlign === 'left') { x += paddingAndSize; } else if (xAlign === 'right') { x -= paddingAndSize; } } else if (xAlign === 'left') { x -= Math.max(topLeft, bottomLeft) + caretSize; } else if (xAlign === 'right') { x += Math.max(topRight, bottomRight) + caretSize; } return { x: _limitValue(x, 0, chart.width - size.width), y: _limitValue(y, 0, chart.height - size.height) }; } function getAlignedX(tooltip, align, options) { const padding = toPadding(options.padding); return align === 'center' ? tooltip.x + tooltip.width / 2 : align === 'right' ? tooltip.x + tooltip.width - padding.right : tooltip.x + padding.left; } function getBeforeAfterBodyLines(callback) { return pushOrConcat([], splitNewlines(callback)); } function createTooltipContext(parent, tooltip, tooltipItems) { return createContext(parent, { tooltip, tooltipItems, type: 'tooltip' }); } function overrideCallbacks(callbacks, context) { const override = context && context.dataset && context.dataset.tooltip && context.dataset.tooltip.callbacks; return override ? callbacks.override(override) : callbacks; } class Tooltip extends Element { constructor(config) { super(); this.opacity = 0; this._active = []; this._eventPosition = undefined; this._size = undefined; this._cachedAnimations = undefined; this._tooltipItems = []; this.$animations = undefined; this.$context = undefined; this.chart = config.chart || config._chart; this._chart = this.chart; this.options = config.options; this.dataPoints = undefined; this.title = undefined; this.beforeBody = undefined; this.body = undefined; this.afterBody = undefined; this.footer = undefined; this.xAlign = undefined; this.yAlign = undefined; this.x = undefined; this.y = undefined; this.height = undefined; this.width = undefined; this.caretX = undefined; this.caretY = undefined; this.labelColors = undefined; this.labelPointStyles = undefined; this.labelTextColors = undefined; } initialize(options) { this.options = options; this._cachedAnimations = undefined; this.$context = undefined; } _resolveAnimations() { const cached = this._cachedAnimations; if (cached) { return cached; } const chart = this.chart; const options = this.options.setContext(this.getContext()); const opts = options.enabled && chart.options.animation && options.animations; const animations = new Animations(this.chart, opts); if (opts._cacheable) { this._cachedAnimations = Object.freeze(animations); } return animations; } getContext() { return this.$context || (this.$context = createTooltipContext(this.chart.getContext(), this, this._tooltipItems)); } getTitle(context, options) { const {callbacks} = options; const beforeTitle = callbacks.beforeTitle.apply(this, [context]); const title = callbacks.title.apply(this, [context]); const afterTitle = callbacks.afterTitle.apply(this, [context]); let lines = []; lines = pushOrConcat(lines, splitNewlines(beforeTitle)); lines = pushOrConcat(lines, splitNewlines(title)); lines = pushOrConcat(lines, splitNewlines(afterTitle)); return lines; } getBeforeBody(tooltipItems, options) { return getBeforeAfterBodyLines(options.callbacks.beforeBody.apply(this, [tooltipItems])); } getBody(tooltipItems, options) { const {callbacks} = options; const bodyItems = []; each(tooltipItems, (context) => { const bodyItem = { before: [], lines: [], after: [] }; const scoped = overrideCallbacks(callbacks, context); pushOrConcat(bodyItem.before, splitNewlines(scoped.beforeLabel.call(this, context))); pushOrConcat(bodyItem.lines, scoped.label.call(this, context)); pushOrConcat(bodyItem.after, splitNewlines(scoped.afterLabel.call(this, context))); bodyItems.push(bodyItem); }); return bodyItems; } getAfterBody(tooltipItems, options) { return getBeforeAfterBodyLines(options.callbacks.afterBody.apply(this, [tooltipItems])); } getFooter(tooltipItems, options) { const {callbacks} = options; const beforeFooter = callbacks.beforeFooter.apply(this, [tooltipItems]); const footer = callbacks.footer.apply(this, [tooltipItems]); const afterFooter = callbacks.afterFooter.apply(this, [tooltipItems]); let lines = []; lines = pushOrConcat(lines, splitNewlines(beforeFooter)); lines = pushOrConcat(lines, splitNewlines(footer)); lines = pushOrConcat(lines, splitNewlines(afterFooter)); return lines; } _createItems(options) { const active = this._active; const data = this.chart.data; const labelColors = []; const labelPointStyles = []; const labelTextColors = []; let tooltipItems = []; let i, len; for (i = 0, len = active.length; i < len; ++i) { tooltipItems.push(createTooltipItem(this.chart, active[i])); } if (options.filter) { tooltipItems = tooltipItems.filter((element, index, array) => options.filter(element, index, array, data)); } if (options.itemSort) { tooltipItems = tooltipItems.sort((a, b) => options.itemSort(a, b, data)); } each(tooltipItems, (context) => { const scoped = overrideCallbacks(options.callbacks, context); labelColors.push(scoped.labelColor.call(this, context)); labelPointStyles.push(scoped.labelPointStyle.call(this, context)); labelTextColors.push(scoped.labelTextColor.call(this, context)); }); this.labelColors = labelColors; this.labelPointStyles = labelPointStyles; this.labelTextColors = labelTextColors; this.dataPoints = tooltipItems; return tooltipItems; } update(changed, replay) { const options = this.options.setContext(this.getContext()); const active = this._active; let properties; let tooltipItems = []; if (!active.length) { if (this.opacity !== 0) { properties = { opacity: 0 }; } } else { const position = positioners[options.position].call(this, active, this._eventPosition); tooltipItems = this._createItems(options); this.title = this.getTitle(tooltipItems, options); this.beforeBody = this.getBeforeBody(tooltipItems, options); this.body = this.getBody(tooltipItems, options); this.afterBody = this.getAfterBody(tooltipItems, options); this.footer = this.getFooter(tooltipItems, options); const size = this._size = getTooltipSize(this, options); const positionAndSize = Object.assign({}, position, size); const alignment = determineAlignment(this.chart, options, positionAndSize); const backgroundPoint = getBackgroundPoint(options, positionAndSize, alignment, this.chart); this.xAlign = alignment.xAlign; this.yAlign = alignment.yAlign; properties = { opacity: 1, x: backgroundPoint.x, y: backgroundPoint.y, width: size.width, height: size.height, caretX: position.x, caretY: position.y }; } this._tooltipItems = tooltipItems; this.$context = undefined; if (properties) { this._resolveAnimations().update(this, properties); } if (changed && options.external) { options.external.call(this, {chart: this.chart, tooltip: this, replay}); } } drawCaret(tooltipPoint, ctx, size, options) { const caretPosition = this.getCaretPosition(tooltipPoint, size, options); ctx.lineTo(caretPosition.x1, caretPosition.y1); ctx.lineTo(caretPosition.x2, caretPosition.y2); ctx.lineTo(caretPosition.x3, caretPosition.y3); } getCaretPosition(tooltipPoint, size, options) { const {xAlign, yAlign} = this; const {caretSize, cornerRadius} = options; const {topLeft, topRight, bottomLeft, bottomRight} = toTRBLCorners(cornerRadius); const {x: ptX, y: ptY} = tooltipPoint; const {width, height} = size; let x1, x2, x3, y1, y2, y3; if (yAlign === 'center') { y2 = ptY + (height / 2); if (xAlign === 'left') { x1 = ptX; x2 = x1 - caretSize; y1 = y2 + caretSize; y3 = y2 - caretSize; } else { x1 = ptX + width; x2 = x1 + caretSize; y1 = y2 - caretSize; y3 = y2 + caretSize; } x3 = x1; } else { if (xAlign === 'left') { x2 = ptX + Math.max(topLeft, bottomLeft) + (caretSize); } else if (xAlign === 'right') { x2 = ptX + width - Math.max(topRight, bottomRight) - caretSize; } else { x2 = this.caretX; } if (yAlign === 'top') { y1 = ptY; y2 = y1 - caretSize; x1 = x2 - caretSize; x3 = x2 + caretSize; } else { y1 = ptY + height; y2 = y1 + caretSize; x1 = x2 + caretSize; x3 = x2 - caretSize; } y3 = y1; } return {x1, x2, x3, y1, y2, y3}; } drawTitle(pt, ctx, options) { const title = this.title; const length = title.length; let titleFont, titleSpacing, i; if (length) { const rtlHelper = getRtlAdapter(options.rtl, this.x, this.width); pt.x = getAlignedX(this, options.titleAlign, options); ctx.textAlign = rtlHelper.textAlign(options.titleAlign); ctx.textBaseline = 'middle'; titleFont = toFont(options.titleFont); titleSpacing = options.titleSpacing; ctx.fillStyle = options.titleColor; ctx.font = titleFont.string; for (i = 0; i < length; ++i) { ctx.fillText(title[i], rtlHelper.x(pt.x), pt.y + titleFont.lineHeight / 2); pt.y += titleFont.lineHeight + titleSpacing; if (i + 1 === length) { pt.y += options.titleMarginBottom - titleSpacing; } } } } _drawColorBox(ctx, pt, i, rtlHelper, options) { const labelColors = this.labelColors[i]; const labelPointStyle = this.labelPointStyles[i]; const {boxHeight, boxWidth, boxPadding} = options; const bodyFont = toFont(options.bodyFont); const colorX = getAlignedX(this, 'left', options); const rtlColorX = rtlHelper.x(colorX); const yOffSet = boxHeight < bodyFont.lineHeight ? (bodyFont.lineHeight - boxHeight) / 2 : 0; const colorY = pt.y + yOffSet; if (options.usePointStyle) { const drawOptions = { radius: Math.min(boxWidth, boxHeight) / 2, pointStyle: labelPointStyle.pointStyle, rotation: labelPointStyle.rotation, borderWidth: 1 }; const centerX = rtlHelper.leftForLtr(rtlColorX, boxWidth) + boxWidth / 2; const centerY = colorY + boxHeight / 2; ctx.strokeStyle = options.multiKeyBackground; ctx.fillStyle = options.multiKeyBackground; drawPoint(ctx, drawOptions, centerX, centerY); ctx.strokeStyle = labelColors.borderColor; ctx.fillStyle = labelColors.backgroundColor; drawPoint(ctx, drawOptions, centerX, centerY); } else { ctx.lineWidth = labelColors.borderWidth || 1; ctx.strokeStyle = labelColors.borderColor; ctx.setLineDash(labelColors.borderDash || []); ctx.lineDashOffset = labelColors.borderDashOffset || 0; const outerX = rtlHelper.leftForLtr(rtlColorX, boxWidth - boxPadding); const innerX = rtlHelper.leftForLtr(rtlHelper.xPlus(rtlColorX, 1), boxWidth - boxPadding - 2); const borderRadius = toTRBLCorners(labelColors.borderRadius); if (Object.values(borderRadius).some(v => v !== 0)) { ctx.beginPath(); ctx.fillStyle = options.multiKeyBackground; addRoundedRectPath(ctx, { x: outerX, y: colorY, w: boxWidth, h: boxHeight, radius: borderRadius, }); ctx.fill(); ctx.stroke(); ctx.fillStyle = labelColors.backgroundColor; ctx.beginPath(); addRoundedRectPath(ctx, { x: innerX, y: colorY + 1, w: boxWidth - 2, h: boxHeight - 2, radius: borderRadius, }); ctx.fill(); } else { ctx.fillStyle = options.multiKeyBackground; ctx.fillRect(outerX, colorY, boxWidth, boxHeight); ctx.strokeRect(outerX, colorY, boxWidth, boxHeight); ctx.fillStyle = labelColors.backgroundColor; ctx.fillRect(innerX, colorY + 1, boxWidth - 2, boxHeight - 2); } } ctx.fillStyle = this.labelTextColors[i]; } drawBody(pt, ctx, options) { const {body} = this; const {bodySpacing, bodyAlign, displayColors, boxHeight, boxWidth, boxPadding} = options; const bodyFont = toFont(options.bodyFont); let bodyLineHeight = bodyFont.lineHeight; let xLinePadding = 0; const rtlHelper = getRtlAdapter(options.rtl, this.x, this.width); const fillLineOfText = function(line) { ctx.fillText(line, rtlHelper.x(pt.x + xLinePadding), pt.y + bodyLineHeight / 2); pt.y += bodyLineHeight + bodySpacing; }; const bodyAlignForCalculation = rtlHelper.textAlign(bodyAlign); let bodyItem, textColor, lines, i, j, ilen, jlen; ctx.textAlign = bodyAlign; ctx.textBaseline = 'middle'; ctx.font = bodyFont.string; pt.x = getAlignedX(this, bodyAlignForCalculation, options); ctx.fillStyle = options.bodyColor; each(this.beforeBody, fillLineOfText); xLinePadding = displayColors && bodyAlignForCalculation !== 'right' ? bodyAlign === 'center' ? (boxWidth / 2 + boxPadding) : (boxWidth + 2 + boxPadding) : 0; for (i = 0, ilen = body.length; i < ilen; ++i) { bodyItem = body[i]; textColor = this.labelTextColors[i]; ctx.fillStyle = textColor; each(bodyItem.before, fillLineOfText); lines = bodyItem.lines; if (displayColors && lines.length) { this._drawColorBox(ctx, pt, i, rtlHelper, options); bodyLineHeight = Math.max(bodyFont.lineHeight, boxHeight); } for (j = 0, jlen = lines.length; j < jlen; ++j) { fillLineOfText(lines[j]); bodyLineHeight = bodyFont.lineHeight; } each(bodyItem.after, fillLineOfText); } xLinePadding = 0; bodyLineHeight = bodyFont.lineHeight; each(this.afterBody, fillLineOfText); pt.y -= bodySpacing; } drawFooter(pt, ctx, options) { const footer = this.footer; const length = footer.length; let footerFont, i; if (length) { const rtlHelper = getRtlAdapter(options.rtl, this.x, this.width); pt.x = getAlignedX(this, options.footerAlign, options); pt.y += options.footerMarginTop; ctx.textAlign = rtlHelper.textAlign(options.footerAlign); ctx.textBaseline = 'middle'; footerFont = toFont(options.footerFont); ctx.fillStyle = options.footerColor; ctx.font = footerFont.string; for (i = 0; i < length; ++i) { ctx.fillText(footer[i], rtlHelper.x(pt.x), pt.y + footerFont.lineHeight / 2); pt.y += footerFont.lineHeight + options.footerSpacing; } } } drawBackground(pt, ctx, tooltipSize, options) { const {xAlign, yAlign} = this; const {x, y} = pt; const {width, height} = tooltipSize; const {topLeft, topRight, bottomLeft, bottomRight} = toTRBLCorners(options.cornerRadius); ctx.fillStyle = options.backgroundColor; ctx.strokeStyle = options.borderColor; ctx.lineWidth = options.borderWidth; ctx.beginPath(); ctx.moveTo(x + topLeft, y); if (yAlign === 'top') { this.drawCaret(pt, ctx, tooltipSize, options); } ctx.lineTo(x + width - topRight, y); ctx.quadraticCurveTo(x + width, y, x + width, y + topRight); if (yAlign === 'center' && xAlign === 'right') { this.drawCaret(pt, ctx, tooltipSize, options); } ctx.lineTo(x + width, y + height - bottomRight); ctx.quadraticCurveTo(x + width, y + height, x + width - bottomRight, y + height); if (yAlign === 'bottom') { this.drawCaret(pt, ctx, tooltipSize, options); } ctx.lineTo(x + bottomLeft, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - bottomLeft); if (yAlign === 'center' && xAlign === 'left') { this.drawCaret(pt, ctx, tooltipSize, options); } ctx.lineTo(x, y + topLeft); ctx.quadraticCurveTo(x, y, x + topLeft, y); ctx.closePath(); ctx.fill(); if (options.borderWidth > 0) { ctx.stroke(); } } _updateAnimationTarget(options) { const chart = this.chart; const anims = this.$animations; const animX = anims && anims.x; const animY = anims && anims.y; if (animX || animY) { const position = positioners[options.position].call(this, this._active, this._eventPosition); if (!position) { return; } const size = this._size = getTooltipSize(this, options); const positionAndSize = Object.assign({}, position, this._size); const alignment = determineAlignment(chart, options, positionAndSize); const point = getBackgroundPoint(options, positionAndSize, alignment, chart); if (animX._to !== point.x || animY._to !== point.y) { this.xAlign = alignment.xAlign; this.yAlign = alignment.yAlign; this.width = size.width; this.height = size.height; this.caretX = position.x; this.caretY = position.y; this._resolveAnimations().update(this, point); } } } _willRender() { return !!this.opacity; } draw(ctx) { const options = this.options.setContext(this.getContext()); let opacity = this.opacity; if (!opacity) { return; } this._updateAnimationTarget(options); const tooltipSize = { width: this.width, height: this.height }; const pt = { x: this.x, y: this.y }; opacity = Math.abs(opacity) < 1e-3 ? 0 : opacity; const padding = toPadding(options.padding); const hasTooltipContent = this.title.length || this.beforeBody.length || this.body.length || this.afterBody.length || this.footer.length; if (options.enabled && hasTooltipContent) { ctx.save(); ctx.globalAlpha = opacity; this.drawBackground(pt, ctx, tooltipSize, options); overrideTextDirection(ctx, options.textDirection); pt.y += padding.top; this.drawTitle(pt, ctx, options); this.drawBody(pt, ctx, options); this.drawFooter(pt, ctx, options); restoreTextDirection(ctx, options.textDirection); ctx.restore(); } } getActiveElements() { return this._active || []; } setActiveElements(activeElements, eventPosition) { const lastActive = this._active; const active = activeElements.map(({datasetIndex, index}) => { const meta = this.chart.getDatasetMeta(datasetIndex); if (!meta) { throw new Error('Cannot find a dataset at index ' + datasetIndex); } return { datasetIndex, element: meta.data[index], index, }; }); const changed = !_elementsEqual(lastActive, active); const positionChanged = this._positionChanged(active, eventPosition); if (changed || positionChanged) { this._active = active; this._eventPosition = eventPosition; this._ignoreReplayEvents = true; this.update(true); } } handleEvent(e, replay, inChartArea = true) { if (replay && this._ignoreReplayEvents) { return false; } this._ignoreReplayEvents = false; const options = this.options; const lastActive = this._active || []; const active = this._getActiveElements(e, lastActive, replay, inChartArea); const positionChanged = this._positionChanged(active, e); const changed = replay || !_elementsEqual(active, lastActive) || positionChanged; if (changed) { this._active = active; if (options.enabled || options.external) { this._eventPosition = { x: e.x, y: e.y }; this.update(true, replay); } } return changed; } _getActiveElements(e, lastActive, replay, inChartArea) { const options = this.options; if (e.type === 'mouseout') { return []; } if (!inChartArea) { return lastActive; } const active = this.chart.getElementsAtEventForMode(e, options.mode, options, replay); if (options.reverse) { active.reverse(); } return active; } _positionChanged(active, e) { const {caretX, caretY, options} = this; const position = positioners[options.position].call(this, active, e); return position !== false && (caretX !== position.x || caretY !== position.y); } } Tooltip.positioners = positioners; var plugin_tooltip = { id: 'tooltip', _element: Tooltip, positioners, afterInit(chart, _args, options) { if (options) { chart.tooltip = new Tooltip({chart, options}); } }, beforeUpdate(chart, _args, options) { if (chart.tooltip) { chart.tooltip.initialize(options); } }, reset(chart, _args, options) { if (chart.tooltip) { chart.tooltip.initialize(options); } }, afterDraw(chart) { const tooltip = chart.tooltip; if (tooltip && tooltip._willRender()) { const args = { tooltip }; if (chart.notifyPlugins('beforeTooltipDraw', args) === false) { return; } tooltip.draw(chart.ctx); chart.notifyPlugins('afterTooltipDraw', args); } }, afterEvent(chart, args) { if (chart.tooltip) { const useFinalPosition = args.replay; if (chart.tooltip.handleEvent(args.event, useFinalPosition, args.inChartArea)) { args.changed = true; } } }, defaults: { enabled: true, external: null, position: 'average', backgroundColor: 'rgba(0,0,0,0.8)', titleColor: '#fff', titleFont: { weight: 'bold', }, titleSpacing: 2, titleMarginBottom: 6, titleAlign: 'left', bodyColor: '#fff', bodySpacing: 2, bodyFont: { }, bodyAlign: 'left', footerColor: '#fff', footerSpacing: 2, footerMarginTop: 6, footerFont: { weight: 'bold', }, footerAlign: 'left', padding: 6, caretPadding: 2, caretSize: 5, cornerRadius: 6, boxHeight: (ctx, opts) => opts.bodyFont.size, boxWidth: (ctx, opts) => opts.bodyFont.size, multiKeyBackground: '#fff', displayColors: true, boxPadding: 0, borderColor: 'rgba(0,0,0,0)', borderWidth: 0, animation: { duration: 400, easing: 'easeOutQuart', }, animations: { numbers: { type: 'number', properties: ['x', 'y', 'width', 'height', 'caretX', 'caretY'], }, opacity: { easing: 'linear', duration: 200 } }, callbacks: { beforeTitle: noop, title(tooltipItems) { if (tooltipItems.length > 0) { const item = tooltipItems[0]; const labels = item.chart.data.labels; const labelCount = labels ? labels.length : 0; if (this && this.options && this.options.mode === 'dataset') { return item.dataset.label || ''; } else if (item.label) { return item.label; } else if (labelCount > 0 && item.dataIndex < labelCount) { return labels[item.dataIndex]; } } return ''; }, afterTitle: noop, beforeBody: noop, beforeLabel: noop, label(tooltipItem) { if (this && this.options && this.options.mode === 'dataset') { return tooltipItem.label + ': ' + tooltipItem.formattedValue || tooltipItem.formattedValue; } let label = tooltipItem.dataset.label || ''; if (label) { label += ': '; } const value = tooltipItem.formattedValue; if (!isNullOrUndef(value)) { label += value; } return label; }, labelColor(tooltipItem) { const meta = tooltipItem.chart.getDatasetMeta(tooltipItem.datasetIndex); const options = meta.controller.getStyle(tooltipItem.dataIndex); return { borderColor: options.borderColor, backgroundColor: options.backgroundColor, borderWidth: options.borderWidth, borderDash: options.borderDash, borderDashOffset: options.borderDashOffset, borderRadius: 0, }; }, labelTextColor() { return this.options.bodyColor; }, labelPointStyle(tooltipItem) { const meta = tooltipItem.chart.getDatasetMeta(tooltipItem.datasetIndex); const options = meta.controller.getStyle(tooltipItem.dataIndex); return { pointStyle: options.pointStyle, rotation: options.rotation, }; }, afterLabel: noop, afterBody: noop, beforeFooter: noop, footer: noop, afterFooter: noop } }, defaultRoutes: { bodyFont: 'font', footerFont: 'font', titleFont: 'font' }, descriptors: { _scriptable: (name) => name !== 'filter' && name !== 'itemSort' && name !== 'external', _indexable: false, callbacks: { _scriptable: false, _indexable: false, }, animation: { _fallback: false }, animations: { _fallback: 'animation' } }, additionalOptionScopes: ['interaction'] }; var plugins = /*#__PURE__*/Object.freeze({ __proto__: null, Decimation: plugin_decimation, Filler: index, Legend: plugin_legend, SubTitle: plugin_subtitle, Title: plugin_title, Tooltip: plugin_tooltip }); const addIfString = (labels, raw, index, addedLabels) => { if (typeof raw === 'string') { index = labels.push(raw) - 1; addedLabels.unshift({index, label: raw}); } else if (isNaN(raw)) { index = null; } return index; }; function findOrAddLabel(labels, raw, index, addedLabels) { const first = labels.indexOf(raw); if (first === -1) { return addIfString(labels, raw, index, addedLabels); } const last = labels.lastIndexOf(raw); return first !== last ? index : first; } const validIndex = (index, max) => index === null ? null : _limitValue(Math.round(index), 0, max); class CategoryScale extends Scale { constructor(cfg) { super(cfg); this._startValue = undefined; this._valueRange = 0; this._addedLabels = []; } init(scaleOptions) { const added = this._addedLabels; if (added.length) { const labels = this.getLabels(); for (const {index, label} of added) { if (labels[index] === label) { labels.splice(index, 1); } } this._addedLabels = []; } super.init(scaleOptions); } parse(raw, index) { if (isNullOrUndef(raw)) { return null; } const labels = this.getLabels(); index = isFinite(index) && labels[index] === raw ? index : findOrAddLabel(labels, raw, valueOrDefault(index, raw), this._addedLabels); return validIndex(index, labels.length - 1); } determineDataLimits() { const {minDefined, maxDefined} = this.getUserBounds(); let {min, max} = this.getMinMax(true); if (this.options.bounds === 'ticks') { if (!minDefined) { min = 0; } if (!maxDefined) { max = this.getLabels().length - 1; } } this.min = min; this.max = max; } buildTicks() { const min = this.min; const max = this.max; const offset = this.options.offset; const ticks = []; let labels = this.getLabels(); labels = (min === 0 && max === labels.length - 1) ? labels : labels.slice(min, max + 1); this._valueRange = Math.max(labels.length - (offset ? 0 : 1), 1); this._startValue = this.min - (offset ? 0.5 : 0); for (let value = min; value <= max; value++) { ticks.push({value}); } return ticks; } getLabelForValue(value) { const labels = this.getLabels(); if (value >= 0 && value < labels.length) { return labels[value]; } return value; } configure() { super.configure(); if (!this.isHorizontal()) { this._reversePixels = !this._reversePixels; } } getPixelForValue(value) { if (typeof value !== 'number') { value = this.parse(value); } return value === null ? NaN : this.getPixelForDecimal((value - this._startValue) / this._valueRange); } getPixelForTick(index) { const ticks = this.ticks; if (index < 0 || index > ticks.length - 1) { return null; } return this.getPixelForValue(ticks[index].value); } getValueForPixel(pixel) { return Math.round(this._startValue + this.getDecimalForPixel(pixel) * this._valueRange); } getBasePixel() { return this.bottom; } } CategoryScale.id = 'category'; CategoryScale.defaults = { ticks: { callback: CategoryScale.prototype.getLabelForValue } }; function generateTicks$1(generationOptions, dataRange) { const ticks = []; const MIN_SPACING = 1e-14; const {bounds, step, min, max, precision, count, maxTicks, maxDigits, includeBounds} = generationOptions; const unit = step || 1; const maxSpaces = maxTicks - 1; const {min: rmin, max: rmax} = dataRange; const minDefined = !isNullOrUndef(min); const maxDefined = !isNullOrUndef(max); const countDefined = !isNullOrUndef(count); const minSpacing = (rmax - rmin) / (maxDigits + 1); let spacing = niceNum((rmax - rmin) / maxSpaces / unit) * unit; let factor, niceMin, niceMax, numSpaces; if (spacing < MIN_SPACING && !minDefined && !maxDefined) { return [{value: rmin}, {value: rmax}]; } numSpaces = Math.ceil(rmax / spacing) - Math.floor(rmin / spacing); if (numSpaces > maxSpaces) { spacing = niceNum(numSpaces * spacing / maxSpaces / unit) * unit; } if (!isNullOrUndef(precision)) { factor = Math.pow(10, precision); spacing = Math.ceil(spacing * factor) / factor; } if (bounds === 'ticks') { niceMin = Math.floor(rmin / spacing) * spacing; niceMax = Math.ceil(rmax / spacing) * spacing; } else { niceMin = rmin; niceMax = rmax; } if (minDefined && maxDefined && step && almostWhole((max - min) / step, spacing / 1000)) { numSpaces = Math.round(Math.min((max - min) / spacing, maxTicks)); spacing = (max - min) / numSpaces; niceMin = min; niceMax = max; } else if (countDefined) { niceMin = minDefined ? min : niceMin; niceMax = maxDefined ? max : niceMax; numSpaces = count - 1; spacing = (niceMax - niceMin) / numSpaces; } else { numSpaces = (niceMax - niceMin) / spacing; if (almostEquals(numSpaces, Math.round(numSpaces), spacing / 1000)) { numSpaces = Math.round(numSpaces); } else { numSpaces = Math.ceil(numSpaces); } } const decimalPlaces = Math.max( _decimalPlaces(spacing), _decimalPlaces(niceMin) ); factor = Math.pow(10, isNullOrUndef(precision) ? decimalPlaces : precision); niceMin = Math.round(niceMin * factor) / factor; niceMax = Math.round(niceMax * factor) / factor; let j = 0; if (minDefined) { if (includeBounds && niceMin !== min) { ticks.push({value: min}); if (niceMin < min) { j++; } if (almostEquals(Math.round((niceMin + j * spacing) * factor) / factor, min, relativeLabelSize(min, minSpacing, generationOptions))) { j++; } } else if (niceMin < min) { j++; } } for (; j < numSpaces; ++j) { ticks.push({value: Math.round((niceMin + j * spacing) * factor) / factor}); } if (maxDefined && includeBounds && niceMax !== max) { if (ticks.length && almostEquals(ticks[ticks.length - 1].value, max, relativeLabelSize(max, minSpacing, generationOptions))) { ticks[ticks.length - 1].value = max; } else { ticks.push({value: max}); } } else if (!maxDefined || niceMax === max) { ticks.push({value: niceMax}); } return ticks; } function relativeLabelSize(value, minSpacing, {horizontal, minRotation}) { const rad = toRadians(minRotation); const ratio = (horizontal ? Math.sin(rad) : Math.cos(rad)) || 0.001; const length = 0.75 * minSpacing * ('' + value).length; return Math.min(minSpacing / ratio, length); } class LinearScaleBase extends Scale { constructor(cfg) { super(cfg); this.start = undefined; this.end = undefined; this._startValue = undefined; this._endValue = undefined; this._valueRange = 0; } parse(raw, index) { if (isNullOrUndef(raw)) { return null; } if ((typeof raw === 'number' || raw instanceof Number) && !isFinite(+raw)) { return null; } return +raw; } handleTickRangeOptions() { const {beginAtZero} = this.options; const {minDefined, maxDefined} = this.getUserBounds(); let {min, max} = this; const setMin = v => (min = minDefined ? min : v); const setMax = v => (max = maxDefined ? max : v); if (beginAtZero) { const minSign = sign(min); const maxSign = sign(max); if (minSign < 0 && maxSign < 0) { setMax(0); } else if (minSign > 0 && maxSign > 0) { setMin(0); } } if (min === max) { let offset = 1; if (max >= Number.MAX_SAFE_INTEGER || min <= Number.MIN_SAFE_INTEGER) { offset = Math.abs(max * 0.05); } setMax(max + offset); if (!beginAtZero) { setMin(min - offset); } } this.min = min; this.max = max; } getTickLimit() { const tickOpts = this.options.ticks; let {maxTicksLimit, stepSize} = tickOpts; let maxTicks; if (stepSize) { maxTicks = Math.ceil(this.max / stepSize) - Math.floor(this.min / stepSize) + 1; if (maxTicks > 1000) { console.warn(`scales.${this.id}.ticks.stepSize: ${stepSize} would result generating up to ${maxTicks} ticks. Limiting to 1000.`); maxTicks = 1000; } } else { maxTicks = this.computeTickLimit(); maxTicksLimit = maxTicksLimit || 11; } if (maxTicksLimit) { maxTicks = Math.min(maxTicksLimit, maxTicks); } return maxTicks; } computeTickLimit() { return Number.POSITIVE_INFINITY; } buildTicks() { const opts = this.options; const tickOpts = opts.ticks; let maxTicks = this.getTickLimit(); maxTicks = Math.max(2, maxTicks); const numericGeneratorOptions = { maxTicks, bounds: opts.bounds, min: opts.min, max: opts.max, precision: tickOpts.precision, step: tickOpts.stepSize, count: tickOpts.count, maxDigits: this._maxDigits(), horizontal: this.isHorizontal(), minRotation: tickOpts.minRotation || 0, includeBounds: tickOpts.includeBounds !== false }; const dataRange = this._range || this; const ticks = generateTicks$1(numericGeneratorOptions, dataRange); if (opts.bounds === 'ticks') { _setMinAndMaxByKey(ticks, this, 'value'); } if (opts.reverse) { ticks.reverse(); this.start = this.max; this.end = this.min; } else { this.start = this.min; this.end = this.max; } return ticks; } configure() { const ticks = this.ticks; let start = this.min; let end = this.max; super.configure(); if (this.options.offset && ticks.length) { const offset = (end - start) / Math.max(ticks.length - 1, 1) / 2; start -= offset; end += offset; } this._startValue = start; this._endValue = end; this._valueRange = end - start; } getLabelForValue(value) { return formatNumber(value, this.chart.options.locale, this.options.ticks.format); } } class LinearScale extends LinearScaleBase { determineDataLimits() { const {min, max} = this.getMinMax(true); this.min = isNumberFinite(min) ? min : 0; this.max = isNumberFinite(max) ? max : 1; this.handleTickRangeOptions(); } computeTickLimit() { const horizontal = this.isHorizontal(); const length = horizontal ? this.width : this.height; const minRotation = toRadians(this.options.ticks.minRotation); const ratio = (horizontal ? Math.sin(minRotation) : Math.cos(minRotation)) || 0.001; const tickFont = this._resolveTickFontOptions(0); return Math.ceil(length / Math.min(40, tickFont.lineHeight / ratio)); } getPixelForValue(value) { return value === null ? NaN : this.getPixelForDecimal((value - this._startValue) / this._valueRange); } getValueForPixel(pixel) { return this._startValue + this.getDecimalForPixel(pixel) * this._valueRange; } } LinearScale.id = 'linear'; LinearScale.defaults = { ticks: { callback: Ticks.formatters.numeric } }; function isMajor(tickVal) { const remain = tickVal / (Math.pow(10, Math.floor(log10(tickVal)))); return remain === 1; } function generateTicks(generationOptions, dataRange) { const endExp = Math.floor(log10(dataRange.max)); const endSignificand = Math.ceil(dataRange.max / Math.pow(10, endExp)); const ticks = []; let tickVal = finiteOrDefault(generationOptions.min, Math.pow(10, Math.floor(log10(dataRange.min)))); let exp = Math.floor(log10(tickVal)); let significand = Math.floor(tickVal / Math.pow(10, exp)); let precision = exp < 0 ? Math.pow(10, Math.abs(exp)) : 1; do { ticks.push({value: tickVal, major: isMajor(tickVal)}); ++significand; if (significand === 10) { significand = 1; ++exp; precision = exp >= 0 ? 1 : precision; } tickVal = Math.round(significand * Math.pow(10, exp) * precision) / precision; } while (exp < endExp || (exp === endExp && significand < endSignificand)); const lastTick = finiteOrDefault(generationOptions.max, tickVal); ticks.push({value: lastTick, major: isMajor(tickVal)}); return ticks; } class LogarithmicScale extends Scale { constructor(cfg) { super(cfg); this.start = undefined; this.end = undefined; this._startValue = undefined; this._valueRange = 0; } parse(raw, index) { const value = LinearScaleBase.prototype.parse.apply(this, [raw, index]); if (value === 0) { this._zero = true; return undefined; } return isNumberFinite(value) && value > 0 ? value : null; } determineDataLimits() { const {min, max} = this.getMinMax(true); this.min = isNumberFinite(min) ? Math.max(0, min) : null; this.max = isNumberFinite(max) ? Math.max(0, max) : null; if (this.options.beginAtZero) { this._zero = true; } this.handleTickRangeOptions(); } handleTickRangeOptions() { const {minDefined, maxDefined} = this.getUserBounds(); let min = this.min; let max = this.max; const setMin = v => (min = minDefined ? min : v); const setMax = v => (max = maxDefined ? max : v); const exp = (v, m) => Math.pow(10, Math.floor(log10(v)) + m); if (min === max) { if (min <= 0) { setMin(1); setMax(10); } else { setMin(exp(min, -1)); setMax(exp(max, +1)); } } if (min <= 0) { setMin(exp(max, -1)); } if (max <= 0) { setMax(exp(min, +1)); } if (this._zero && this.min !== this._suggestedMin && min === exp(this.min, 0)) { setMin(exp(min, -1)); } this.min = min; this.max = max; } buildTicks() { const opts = this.options; const generationOptions = { min: this._userMin, max: this._userMax }; const ticks = generateTicks(generationOptions, this); if (opts.bounds === 'ticks') { _setMinAndMaxByKey(ticks, this, 'value'); } if (opts.reverse) { ticks.reverse(); this.start = this.max; this.end = this.min; } else { this.start = this.min; this.end = this.max; } return ticks; } getLabelForValue(value) { return value === undefined ? '0' : formatNumber(value, this.chart.options.locale, this.options.ticks.format); } configure() { const start = this.min; super.configure(); this._startValue = log10(start); this._valueRange = log10(this.max) - log10(start); } getPixelForValue(value) { if (value === undefined || value === 0) { value = this.min; } if (value === null || isNaN(value)) { return NaN; } return this.getPixelForDecimal(value === this.min ? 0 : (log10(value) - this._startValue) / this._valueRange); } getValueForPixel(pixel) { const decimal = this.getDecimalForPixel(pixel); return Math.pow(10, this._startValue + decimal * this._valueRange); } } LogarithmicScale.id = 'logarithmic'; LogarithmicScale.defaults = { ticks: { callback: Ticks.formatters.logarithmic, major: { enabled: true } } }; function getTickBackdropHeight(opts) { const tickOpts = opts.ticks; if (tickOpts.display && opts.display) { const padding = toPadding(tickOpts.backdropPadding); return valueOrDefault(tickOpts.font && tickOpts.font.size, defaults.font.size) + padding.height; } return 0; } function measureLabelSize(ctx, font, label) { label = isArray(label) ? label : [label]; return { w: _longestText(ctx, font.string, label), h: label.length * font.lineHeight }; } function determineLimits(angle, pos, size, min, max) { if (angle === min || angle === max) { return { start: pos - (size / 2), end: pos + (size / 2) }; } else if (angle < min || angle > max) { return { start: pos - size, end: pos }; } return { start: pos, end: pos + size }; } function fitWithPointLabels(scale) { const orig = { l: scale.left + scale._padding.left, r: scale.right - scale._padding.right, t: scale.top + scale._padding.top, b: scale.bottom - scale._padding.bottom }; const limits = Object.assign({}, orig); const labelSizes = []; const padding = []; const valueCount = scale._pointLabels.length; const pointLabelOpts = scale.options.pointLabels; const additionalAngle = pointLabelOpts.centerPointLabels ? PI / valueCount : 0; for (let i = 0; i < valueCount; i++) { const opts = pointLabelOpts.setContext(scale.getPointLabelContext(i)); padding[i] = opts.padding; const pointPosition = scale.getPointPosition(i, scale.drawingArea + padding[i], additionalAngle); const plFont = toFont(opts.font); const textSize = measureLabelSize(scale.ctx, plFont, scale._pointLabels[i]); labelSizes[i] = textSize; const angleRadians = _normalizeAngle(scale.getIndexAngle(i) + additionalAngle); const angle = Math.round(toDegrees(angleRadians)); const hLimits = determineLimits(angle, pointPosition.x, textSize.w, 0, 180); const vLimits = determineLimits(angle, pointPosition.y, textSize.h, 90, 270); updateLimits(limits, orig, angleRadians, hLimits, vLimits); } scale.setCenterPoint( orig.l - limits.l, limits.r - orig.r, orig.t - limits.t, limits.b - orig.b ); scale._pointLabelItems = buildPointLabelItems(scale, labelSizes, padding); } function updateLimits(limits, orig, angle, hLimits, vLimits) { const sin = Math.abs(Math.sin(angle)); const cos = Math.abs(Math.cos(angle)); let x = 0; let y = 0; if (hLimits.start < orig.l) { x = (orig.l - hLimits.start) / sin; limits.l = Math.min(limits.l, orig.l - x); } else if (hLimits.end > orig.r) { x = (hLimits.end - orig.r) / sin; limits.r = Math.max(limits.r, orig.r + x); } if (vLimits.start < orig.t) { y = (orig.t - vLimits.start) / cos; limits.t = Math.min(limits.t, orig.t - y); } else if (vLimits.end > orig.b) { y = (vLimits.end - orig.b) / cos; limits.b = Math.max(limits.b, orig.b + y); } } function buildPointLabelItems(scale, labelSizes, padding) { const items = []; const valueCount = scale._pointLabels.length; const opts = scale.options; const extra = getTickBackdropHeight(opts) / 2; const outerDistance = scale.drawingArea; const additionalAngle = opts.pointLabels.centerPointLabels ? PI / valueCount : 0; for (let i = 0; i < valueCount; i++) { const pointLabelPosition = scale.getPointPosition(i, outerDistance + extra + padding[i], additionalAngle); const angle = Math.round(toDegrees(_normalizeAngle(pointLabelPosition.angle + HALF_PI))); const size = labelSizes[i]; const y = yForAngle(pointLabelPosition.y, size.h, angle); const textAlign = getTextAlignForAngle(angle); const left = leftForTextAlign(pointLabelPosition.x, size.w, textAlign); items.push({ x: pointLabelPosition.x, y, textAlign, left, top: y, right: left + size.w, bottom: y + size.h }); } return items; } function getTextAlignForAngle(angle) { if (angle === 0 || angle === 180) { return 'center'; } else if (angle < 180) { return 'left'; } return 'right'; } function leftForTextAlign(x, w, align) { if (align === 'right') { x -= w; } else if (align === 'center') { x -= (w / 2); } return x; } function yForAngle(y, h, angle) { if (angle === 90 || angle === 270) { y -= (h / 2); } else if (angle > 270 || angle < 90) { y -= h; } return y; } function drawPointLabels(scale, labelCount) { const {ctx, options: {pointLabels}} = scale; for (let i = labelCount - 1; i >= 0; i--) { const optsAtIndex = pointLabels.setContext(scale.getPointLabelContext(i)); const plFont = toFont(optsAtIndex.font); const {x, y, textAlign, left, top, right, bottom} = scale._pointLabelItems[i]; const {backdropColor} = optsAtIndex; if (!isNullOrUndef(backdropColor)) { const borderRadius = toTRBLCorners(optsAtIndex.borderRadius); const padding = toPadding(optsAtIndex.backdropPadding); ctx.fillStyle = backdropColor; const backdropLeft = left - padding.left; const backdropTop = top - padding.top; const backdropWidth = right - left + padding.width; const backdropHeight = bottom - top + padding.height; if (Object.values(borderRadius).some(v => v !== 0)) { ctx.beginPath(); addRoundedRectPath(ctx, { x: backdropLeft, y: backdropTop, w: backdropWidth, h: backdropHeight, radius: borderRadius, }); ctx.fill(); } else { ctx.fillRect(backdropLeft, backdropTop, backdropWidth, backdropHeight); } } renderText( ctx, scale._pointLabels[i], x, y + (plFont.lineHeight / 2), plFont, { color: optsAtIndex.color, textAlign: textAlign, textBaseline: 'middle' } ); } } function pathRadiusLine(scale, radius, circular, labelCount) { const {ctx} = scale; if (circular) { ctx.arc(scale.xCenter, scale.yCenter, radius, 0, TAU); } else { let pointPosition = scale.getPointPosition(0, radius); ctx.moveTo(pointPosition.x, pointPosition.y); for (let i = 1; i < labelCount; i++) { pointPosition = scale.getPointPosition(i, radius); ctx.lineTo(pointPosition.x, pointPosition.y); } } } function drawRadiusLine(scale, gridLineOpts, radius, labelCount) { const ctx = scale.ctx; const circular = gridLineOpts.circular; const {color, lineWidth} = gridLineOpts; if ((!circular && !labelCount) || !color || !lineWidth || radius < 0) { return; } ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.setLineDash(gridLineOpts.borderDash); ctx.lineDashOffset = gridLineOpts.borderDashOffset; ctx.beginPath(); pathRadiusLine(scale, radius, circular, labelCount); ctx.closePath(); ctx.stroke(); ctx.restore(); } function createPointLabelContext(parent, index, label) { return createContext(parent, { label, index, type: 'pointLabel' }); } class RadialLinearScale extends LinearScaleBase { constructor(cfg) { super(cfg); this.xCenter = undefined; this.yCenter = undefined; this.drawingArea = undefined; this._pointLabels = []; this._pointLabelItems = []; } setDimensions() { const padding = this._padding = toPadding(getTickBackdropHeight(this.options) / 2); const w = this.width = this.maxWidth - padding.width; const h = this.height = this.maxHeight - padding.height; this.xCenter = Math.floor(this.left + w / 2 + padding.left); this.yCenter = Math.floor(this.top + h / 2 + padding.top); this.drawingArea = Math.floor(Math.min(w, h) / 2); } determineDataLimits() { const {min, max} = this.getMinMax(false); this.min = isNumberFinite(min) && !isNaN(min) ? min : 0; this.max = isNumberFinite(max) && !isNaN(max) ? max : 0; this.handleTickRangeOptions(); } computeTickLimit() { return Math.ceil(this.drawingArea / getTickBackdropHeight(this.options)); } generateTickLabels(ticks) { LinearScaleBase.prototype.generateTickLabels.call(this, ticks); this._pointLabels = this.getLabels() .map((value, index) => { const label = callback(this.options.pointLabels.callback, [value, index], this); return label || label === 0 ? label : ''; }) .filter((v, i) => this.chart.getDataVisibility(i)); } fit() { const opts = this.options; if (opts.display && opts.pointLabels.display) { fitWithPointLabels(this); } else { this.setCenterPoint(0, 0, 0, 0); } } setCenterPoint(leftMovement, rightMovement, topMovement, bottomMovement) { this.xCenter += Math.floor((leftMovement - rightMovement) / 2); this.yCenter += Math.floor((topMovement - bottomMovement) / 2); this.drawingArea -= Math.min(this.drawingArea / 2, Math.max(leftMovement, rightMovement, topMovement, bottomMovement)); } getIndexAngle(index) { const angleMultiplier = TAU / (this._pointLabels.length || 1); const startAngle = this.options.startAngle || 0; return _normalizeAngle(index * angleMultiplier + toRadians(startAngle)); } getDistanceFromCenterForValue(value) { if (isNullOrUndef(value)) { return NaN; } const scalingFactor = this.drawingArea / (this.max - this.min); if (this.options.reverse) { return (this.max - value) * scalingFactor; } return (value - this.min) * scalingFactor; } getValueForDistanceFromCenter(distance) { if (isNullOrUndef(distance)) { return NaN; } const scaledDistance = distance / (this.drawingArea / (this.max - this.min)); return this.options.reverse ? this.max - scaledDistance : this.min + scaledDistance; } getPointLabelContext(index) { const pointLabels = this._pointLabels || []; if (index >= 0 && index < pointLabels.length) { const pointLabel = pointLabels[index]; return createPointLabelContext(this.getContext(), index, pointLabel); } } getPointPosition(index, distanceFromCenter, additionalAngle = 0) { const angle = this.getIndexAngle(index) - HALF_PI + additionalAngle; return { x: Math.cos(angle) * distanceFromCenter + this.xCenter, y: Math.sin(angle) * distanceFromCenter + this.yCenter, angle }; } getPointPositionForValue(index, value) { return this.getPointPosition(index, this.getDistanceFromCenterForValue(value)); } getBasePosition(index) { return this.getPointPositionForValue(index || 0, this.getBaseValue()); } getPointLabelPosition(index) { const {left, top, right, bottom} = this._pointLabelItems[index]; return { left, top, right, bottom, }; } drawBackground() { const {backgroundColor, grid: {circular}} = this.options; if (backgroundColor) { const ctx = this.ctx; ctx.save(); ctx.beginPath(); pathRadiusLine(this, this.getDistanceFromCenterForValue(this._endValue), circular, this._pointLabels.length); ctx.closePath(); ctx.fillStyle = backgroundColor; ctx.fill(); ctx.restore(); } } drawGrid() { const ctx = this.ctx; const opts = this.options; const {angleLines, grid} = opts; const labelCount = this._pointLabels.length; let i, offset, position; if (opts.pointLabels.display) { drawPointLabels(this, labelCount); } if (grid.display) { this.ticks.forEach((tick, index) => { if (index !== 0) { offset = this.getDistanceFromCenterForValue(tick.value); const optsAtIndex = grid.setContext(this.getContext(index - 1)); drawRadiusLine(this, optsAtIndex, offset, labelCount); } }); } if (angleLines.display) { ctx.save(); for (i = labelCount - 1; i >= 0; i--) { const optsAtIndex = angleLines.setContext(this.getPointLabelContext(i)); const {color, lineWidth} = optsAtIndex; if (!lineWidth || !color) { continue; } ctx.lineWidth = lineWidth; ctx.strokeStyle = color; ctx.setLineDash(optsAtIndex.borderDash); ctx.lineDashOffset = optsAtIndex.borderDashOffset; offset = this.getDistanceFromCenterForValue(opts.ticks.reverse ? this.min : this.max); position = this.getPointPosition(i, offset); ctx.beginPath(); ctx.moveTo(this.xCenter, this.yCenter); ctx.lineTo(position.x, position.y); ctx.stroke(); } ctx.restore(); } } drawBorder() {} drawLabels() { const ctx = this.ctx; const opts = this.options; const tickOpts = opts.ticks; if (!tickOpts.display) { return; } const startAngle = this.getIndexAngle(0); let offset, width; ctx.save(); ctx.translate(this.xCenter, this.yCenter); ctx.rotate(startAngle); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; this.ticks.forEach((tick, index) => { if (index === 0 && !opts.reverse) { return; } const optsAtIndex = tickOpts.setContext(this.getContext(index)); const tickFont = toFont(optsAtIndex.font); offset = this.getDistanceFromCenterForValue(this.ticks[index].value); if (optsAtIndex.showLabelBackdrop) { ctx.font = tickFont.string; width = ctx.measureText(tick.label).width; ctx.fillStyle = optsAtIndex.backdropColor; const padding = toPadding(optsAtIndex.backdropPadding); ctx.fillRect( -width / 2 - padding.left, -offset - tickFont.size / 2 - padding.top, width + padding.width, tickFont.size + padding.height ); } renderText(ctx, tick.label, 0, -offset, tickFont, { color: optsAtIndex.color, }); }); ctx.restore(); } drawTitle() {} } RadialLinearScale.id = 'radialLinear'; RadialLinearScale.defaults = { display: true, animate: true, position: 'chartArea', angleLines: { display: true, lineWidth: 1, borderDash: [], borderDashOffset: 0.0 }, grid: { circular: false }, startAngle: 0, ticks: { showLabelBackdrop: true, callback: Ticks.formatters.numeric }, pointLabels: { backdropColor: undefined, backdropPadding: 2, display: true, font: { size: 10 }, callback(label) { return label; }, padding: 5, centerPointLabels: false } }; RadialLinearScale.defaultRoutes = { 'angleLines.color': 'borderColor', 'pointLabels.color': 'color', 'ticks.color': 'color' }; RadialLinearScale.descriptors = { angleLines: { _fallback: 'grid' } }; const INTERVALS = { millisecond: {common: true, size: 1, steps: 1000}, second: {common: true, size: 1000, steps: 60}, minute: {common: true, size: 60000, steps: 60}, hour: {common: true, size: 3600000, steps: 24}, day: {common: true, size: 86400000, steps: 30}, week: {common: false, size: 604800000, steps: 4}, month: {common: true, size: 2.628e9, steps: 12}, quarter: {common: false, size: 7.884e9, steps: 4}, year: {common: true, size: 3.154e10} }; const UNITS = (Object.keys(INTERVALS)); function sorter(a, b) { return a - b; } function parse(scale, input) { if (isNullOrUndef(input)) { return null; } const adapter = scale._adapter; const {parser, round, isoWeekday} = scale._parseOpts; let value = input; if (typeof parser === 'function') { value = parser(value); } if (!isNumberFinite(value)) { value = typeof parser === 'string' ? adapter.parse(value, parser) : adapter.parse(value); } if (value === null) { return null; } if (round) { value = round === 'week' && (isNumber(isoWeekday) || isoWeekday === true) ? adapter.startOf(value, 'isoWeek', isoWeekday) : adapter.startOf(value, round); } return +value; } function determineUnitForAutoTicks(minUnit, min, max, capacity) { const ilen = UNITS.length; for (let i = UNITS.indexOf(minUnit); i < ilen - 1; ++i) { const interval = INTERVALS[UNITS[i]]; const factor = interval.steps ? interval.steps : Number.MAX_SAFE_INTEGER; if (interval.common && Math.ceil((max - min) / (factor * interval.size)) <= capacity) { return UNITS[i]; } } return UNITS[ilen - 1]; } function determineUnitForFormatting(scale, numTicks, minUnit, min, max) { for (let i = UNITS.length - 1; i >= UNITS.indexOf(minUnit); i--) { const unit = UNITS[i]; if (INTERVALS[unit].common && scale._adapter.diff(max, min, unit) >= numTicks - 1) { return unit; } } return UNITS[minUnit ? UNITS.indexOf(minUnit) : 0]; } function determineMajorUnit(unit) { for (let i = UNITS.indexOf(unit) + 1, ilen = UNITS.length; i < ilen; ++i) { if (INTERVALS[UNITS[i]].common) { return UNITS[i]; } } } function addTick(ticks, time, timestamps) { if (!timestamps) { ticks[time] = true; } else if (timestamps.length) { const {lo, hi} = _lookup(timestamps, time); const timestamp = timestamps[lo] >= time ? timestamps[lo] : timestamps[hi]; ticks[timestamp] = true; } } function setMajorTicks(scale, ticks, map, majorUnit) { const adapter = scale._adapter; const first = +adapter.startOf(ticks[0].value, majorUnit); const last = ticks[ticks.length - 1].value; let major, index; for (major = first; major <= last; major = +adapter.add(major, 1, majorUnit)) { index = map[major]; if (index >= 0) { ticks[index].major = true; } } return ticks; } function ticksFromTimestamps(scale, values, majorUnit) { const ticks = []; const map = {}; const ilen = values.length; let i, value; for (i = 0; i < ilen; ++i) { value = values[i]; map[value] = i; ticks.push({ value, major: false }); } return (ilen === 0 || !majorUnit) ? ticks : setMajorTicks(scale, ticks, map, majorUnit); } class TimeScale extends Scale { constructor(props) { super(props); this._cache = { data: [], labels: [], all: [] }; this._unit = 'day'; this._majorUnit = undefined; this._offsets = {}; this._normalized = false; this._parseOpts = undefined; } init(scaleOpts, opts) { const time = scaleOpts.time || (scaleOpts.time = {}); const adapter = this._adapter = new _adapters._date(scaleOpts.adapters.date); mergeIf(time.displayFormats, adapter.formats()); this._parseOpts = { parser: time.parser, round: time.round, isoWeekday: time.isoWeekday }; super.init(scaleOpts); this._normalized = opts.normalized; } parse(raw, index) { if (raw === undefined) { return null; } return parse(this, raw); } beforeLayout() { super.beforeLayout(); this._cache = { data: [], labels: [], all: [] }; } determineDataLimits() { const options = this.options; const adapter = this._adapter; const unit = options.time.unit || 'day'; let {min, max, minDefined, maxDefined} = this.getUserBounds(); function _applyBounds(bounds) { if (!minDefined && !isNaN(bounds.min)) { min = Math.min(min, bounds.min); } if (!maxDefined && !isNaN(bounds.max)) { max = Math.max(max, bounds.max); } } if (!minDefined || !maxDefined) { _applyBounds(this._getLabelBounds()); if (options.bounds !== 'ticks' || options.ticks.source !== 'labels') { _applyBounds(this.getMinMax(false)); } } min = isNumberFinite(min) && !isNaN(min) ? min : +adapter.startOf(Date.now(), unit); max = isNumberFinite(max) && !isNaN(max) ? max : +adapter.endOf(Date.now(), unit) + 1; this.min = Math.min(min, max - 1); this.max = Math.max(min + 1, max); } _getLabelBounds() { const arr = this.getLabelTimestamps(); let min = Number.POSITIVE_INFINITY; let max = Number.NEGATIVE_INFINITY; if (arr.length) { min = arr[0]; max = arr[arr.length - 1]; } return {min, max}; } buildTicks() { const options = this.options; const timeOpts = options.time; const tickOpts = options.ticks; const timestamps = tickOpts.source === 'labels' ? this.getLabelTimestamps() : this._generate(); if (options.bounds === 'ticks' && timestamps.length) { this.min = this._userMin || timestamps[0]; this.max = this._userMax || timestamps[timestamps.length - 1]; } const min = this.min; const max = this.max; const ticks = _filterBetween(timestamps, min, max); this._unit = timeOpts.unit || (tickOpts.autoSkip ? determineUnitForAutoTicks(timeOpts.minUnit, this.min, this.max, this._getLabelCapacity(min)) : determineUnitForFormatting(this, ticks.length, timeOpts.minUnit, this.min, this.max)); this._majorUnit = !tickOpts.major.enabled || this._unit === 'year' ? undefined : determineMajorUnit(this._unit); this.initOffsets(timestamps); if (options.reverse) { ticks.reverse(); } return ticksFromTimestamps(this, ticks, this._majorUnit); } afterAutoSkip() { if (this.options.offsetAfterAutoskip) { this.initOffsets(this.ticks.map(tick => +tick.value)); } } initOffsets(timestamps) { let start = 0; let end = 0; let first, last; if (this.options.offset && timestamps.length) { first = this.getDecimalForValue(timestamps[0]); if (timestamps.length === 1) { start = 1 - first; } else { start = (this.getDecimalForValue(timestamps[1]) - first) / 2; } last = this.getDecimalForValue(timestamps[timestamps.length - 1]); if (timestamps.length === 1) { end = last; } else { end = (last - this.getDecimalForValue(timestamps[timestamps.length - 2])) / 2; } } const limit = timestamps.length < 3 ? 0.5 : 0.25; start = _limitValue(start, 0, limit); end = _limitValue(end, 0, limit); this._offsets = {start, end, factor: 1 / (start + 1 + end)}; } _generate() { const adapter = this._adapter; const min = this.min; const max = this.max; const options = this.options; const timeOpts = options.time; const minor = timeOpts.unit || determineUnitForAutoTicks(timeOpts.minUnit, min, max, this._getLabelCapacity(min)); const stepSize = valueOrDefault(timeOpts.stepSize, 1); const weekday = minor === 'week' ? timeOpts.isoWeekday : false; const hasWeekday = isNumber(weekday) || weekday === true; const ticks = {}; let first = min; let time, count; if (hasWeekday) { first = +adapter.startOf(first, 'isoWeek', weekday); } first = +adapter.startOf(first, hasWeekday ? 'day' : minor); if (adapter.diff(max, min, minor) > 100000 * stepSize) { throw new Error(min + ' and ' + max + ' are too far apart with stepSize of ' + stepSize + ' ' + minor); } const timestamps = options.ticks.source === 'data' && this.getDataTimestamps(); for (time = first, count = 0; time < max; time = +adapter.add(time, stepSize, minor), count++) { addTick(ticks, time, timestamps); } if (time === max || options.bounds === 'ticks' || count === 1) { addTick(ticks, time, timestamps); } return Object.keys(ticks).sort((a, b) => a - b).map(x => +x); } getLabelForValue(value) { const adapter = this._adapter; const timeOpts = this.options.time; if (timeOpts.tooltipFormat) { return adapter.format(value, timeOpts.tooltipFormat); } return adapter.format(value, timeOpts.displayFormats.datetime); } _tickFormatFunction(time, index, ticks, format) { const options = this.options; const formats = options.time.displayFormats; const unit = this._unit; const majorUnit = this._majorUnit; const minorFormat = unit && formats[unit]; const majorFormat = majorUnit && formats[majorUnit]; const tick = ticks[index]; const major = majorUnit && majorFormat && tick && tick.major; const label = this._adapter.format(time, format || (major ? majorFormat : minorFormat)); const formatter = options.ticks.callback; return formatter ? callback(formatter, [label, index, ticks], this) : label; } generateTickLabels(ticks) { let i, ilen, tick; for (i = 0, ilen = ticks.length; i < ilen; ++i) { tick = ticks[i]; tick.label = this._tickFormatFunction(tick.value, i, ticks); } } getDecimalForValue(value) { return value === null ? NaN : (value - this.min) / (this.max - this.min); } getPixelForValue(value) { const offsets = this._offsets; const pos = this.getDecimalForValue(value); return this.getPixelForDecimal((offsets.start + pos) * offsets.factor); } getValueForPixel(pixel) { const offsets = this._offsets; const pos = this.getDecimalForPixel(pixel) / offsets.factor - offsets.end; return this.min + pos * (this.max - this.min); } _getLabelSize(label) { const ticksOpts = this.options.ticks; const tickLabelWidth = this.ctx.measureText(label).width; const angle = toRadians(this.isHorizontal() ? ticksOpts.maxRotation : ticksOpts.minRotation); const cosRotation = Math.cos(angle); const sinRotation = Math.sin(angle); const tickFontSize = this._resolveTickFontOptions(0).size; return { w: (tickLabelWidth * cosRotation) + (tickFontSize * sinRotation), h: (tickLabelWidth * sinRotation) + (tickFontSize * cosRotation) }; } _getLabelCapacity(exampleTime) { const timeOpts = this.options.time; const displayFormats = timeOpts.displayFormats; const format = displayFormats[timeOpts.unit] || displayFormats.millisecond; const exampleLabel = this._tickFormatFunction(exampleTime, 0, ticksFromTimestamps(this, [exampleTime], this._majorUnit), format); const size = this._getLabelSize(exampleLabel); const capacity = Math.floor(this.isHorizontal() ? this.width / size.w : this.height / size.h) - 1; return capacity > 0 ? capacity : 1; } getDataTimestamps() { let timestamps = this._cache.data || []; let i, ilen; if (timestamps.length) { return timestamps; } const metas = this.getMatchingVisibleMetas(); if (this._normalized && metas.length) { return (this._cache.data = metas[0].controller.getAllParsedValues(this)); } for (i = 0, ilen = metas.length; i < ilen; ++i) { timestamps = timestamps.concat(metas[i].controller.getAllParsedValues(this)); } return (this._cache.data = this.normalize(timestamps)); } getLabelTimestamps() { const timestamps = this._cache.labels || []; let i, ilen; if (timestamps.length) { return timestamps; } const labels = this.getLabels(); for (i = 0, ilen = labels.length; i < ilen; ++i) { timestamps.push(parse(this, labels[i])); } return (this._cache.labels = this._normalized ? timestamps : this.normalize(timestamps)); } normalize(values) { return _arrayUnique(values.sort(sorter)); } } TimeScale.id = 'time'; TimeScale.defaults = { bounds: 'data', adapters: {}, time: { parser: false, unit: false, round: false, isoWeekday: false, minUnit: 'millisecond', displayFormats: {} }, ticks: { source: 'auto', major: { enabled: false } } }; function interpolate(table, val, reverse) { let lo = 0; let hi = table.length - 1; let prevSource, nextSource, prevTarget, nextTarget; if (reverse) { if (val >= table[lo].pos && val <= table[hi].pos) { ({lo, hi} = _lookupByKey(table, 'pos', val)); } ({pos: prevSource, time: prevTarget} = table[lo]); ({pos: nextSource, time: nextTarget} = table[hi]); } else { if (val >= table[lo].time && val <= table[hi].time) { ({lo, hi} = _lookupByKey(table, 'time', val)); } ({time: prevSource, pos: prevTarget} = table[lo]); ({time: nextSource, pos: nextTarget} = table[hi]); } const span = nextSource - prevSource; return span ? prevTarget + (nextTarget - prevTarget) * (val - prevSource) / span : prevTarget; } class TimeSeriesScale extends TimeScale { constructor(props) { super(props); this._table = []; this._minPos = undefined; this._tableRange = undefined; } initOffsets() { const timestamps = this._getTimestampsForTable(); const table = this._table = this.buildLookupTable(timestamps); this._minPos = interpolate(table, this.min); this._tableRange = interpolate(table, this.max) - this._minPos; super.initOffsets(timestamps); } buildLookupTable(timestamps) { const {min, max} = this; const items = []; const table = []; let i, ilen, prev, curr, next; for (i = 0, ilen = timestamps.length; i < ilen; ++i) { curr = timestamps[i]; if (curr >= min && curr <= max) { items.push(curr); } } if (items.length < 2) { return [ {time: min, pos: 0}, {time: max, pos: 1} ]; } for (i = 0, ilen = items.length; i < ilen; ++i) { next = items[i + 1]; prev = items[i - 1]; curr = items[i]; if (Math.round((next + prev) / 2) !== curr) { table.push({time: curr, pos: i / (ilen - 1)}); } } return table; } _getTimestampsForTable() { let timestamps = this._cache.all || []; if (timestamps.length) { return timestamps; } const data = this.getDataTimestamps(); const label = this.getLabelTimestamps(); if (data.length && label.length) { timestamps = this.normalize(data.concat(label)); } else { timestamps = data.length ? data : label; } timestamps = this._cache.all = timestamps; return timestamps; } getDecimalForValue(value) { return (interpolate(this._table, value) - this._minPos) / this._tableRange; } getValueForPixel(pixel) { const offsets = this._offsets; const decimal = this.getDecimalForPixel(pixel) / offsets.factor - offsets.end; return interpolate(this._table, decimal * this._tableRange + this._minPos, true); } } TimeSeriesScale.id = 'timeseries'; TimeSeriesScale.defaults = TimeScale.defaults; var scales = /*#__PURE__*/Object.freeze({ __proto__: null, CategoryScale: CategoryScale, LinearScale: LinearScale, LogarithmicScale: LogarithmicScale, RadialLinearScale: RadialLinearScale, TimeScale: TimeScale, TimeSeriesScale: TimeSeriesScale }); Chart.register(controllers, scales, elements, plugins); Chart.helpers = {...helpers}; Chart._adapters = _adapters; Chart.Animation = Animation; Chart.Animations = Animations; Chart.animator = animator; Chart.controllers = registry.controllers.items; Chart.DatasetController = DatasetController; Chart.Element = Element; Chart.elements = elements; Chart.Interaction = Interaction; Chart.layouts = layouts; Chart.platforms = platforms; Chart.Scale = Scale; Chart.Ticks = Ticks; Object.assign(Chart, controllers, scales, elements, plugins, platforms); Chart.Chart = Chart; if (typeof window !== 'undefined') { window.Chart = Chart; } return Chart; })); chart_pie.js 0000644 00000006040 15152050146 0007037 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Chart pie. * * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @module core/chart_pie */ define(['core/chart_base'], function(Base) { /** * Pie chart. * * @class * @extends {module:core/chart_base} */ function Pie() { Base.prototype.constructor.apply(this, arguments); } Pie.prototype = Object.create(Base.prototype); /** @override */ Pie.prototype.TYPE = 'pie'; /** * Whether the chart should be displayed as doughnut or not. * * @type {Bool} * @protected */ Pie.prototype._doughnut = null; /** @override */ Pie.prototype.create = function(Klass, data) { var chart = Base.prototype.create.apply(this, arguments); chart.setDoughnut(data.doughnut); return chart; }; /** * Overridden to add appropriate colors to the series. * * @override */ Pie.prototype.addSeries = function(series) { if (series.getColor() === null) { var colors = []; var configColorSet = this.getConfigColorSet() || Base.prototype.COLORSET; for (var i = 0; i < series.getCount(); i++) { colors.push(configColorSet[i % configColorSet.length]); } series.setColors(colors); } return Base.prototype.addSeries.apply(this, arguments); }; /** * Get whether the chart should be displayed as doughnut or not. * * @method getDoughnut * @returns {Bool} */ Pie.prototype.getDoughnut = function() { return this._doughnut; }; /** * Set whether the chart should be displayed as doughnut or not. * * @method setDoughnut * @param {Bool} doughnut True for doughnut type, false for pie. */ Pie.prototype.setDoughnut = function(doughnut) { this._doughnut = Boolean(doughnut); }; /** * Validate a series. * * Overrides parent implementation to validate that there is only * one series per chart instance. * * @override */ Pie.prototype._validateSeries = function() { if (this._series.length >= 1) { throw new Error('Pie charts only support one serie.'); } return Base.prototype._validateSeries.apply(this, arguments); }; return Pie; }); ajax.js 0000644 00000027745 15152050146 0006043 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Standard Ajax wrapper for Moodle. It calls the central Ajax script, * which can call any existing webservice using the current session. * In addition, it can batch multiple requests and return multiple responses. * * @module core/ajax * @copyright 2015 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 2.9 */ define(['jquery', 'core/config', 'core/log', 'core/url'], function($, config, Log, URL) { /** * A request to be performed. * * @typedef {object} request * @property {string} methodname The remote method to be called * @property {object} args The arguments to pass when fetching the remote content */ // Keeps track of when the user leaves the page so we know not to show an error. var unloading = false; /** * Success handler. Called when the ajax call succeeds. Checks each response and * resolves or rejects the deferred from that request. * * @method requestSuccess * @private * @param {Object[]} responses Array of responses containing error, exception and data attributes. */ var requestSuccess = function(responses) { // Call each of the success handlers. var requests = this, exception = null, i = 0, request, response, nosessionupdate; if (responses.error) { // There was an error with the request as a whole. // We need to reject each promise. // Unfortunately this may lead to duplicate dialogues, but each Promise must be rejected. for (; i < requests.length; i++) { request = requests[i]; request.deferred.reject(responses); } return; } for (i = 0; i < requests.length; i++) { request = requests[i]; response = responses[i]; // We may not have responses for all the requests. if (typeof response !== "undefined") { if (response.error === false) { // Call the done handler if it was provided. request.deferred.resolve(response.data); } else { exception = response.exception; nosessionupdate = requests[i].nosessionupdate; break; } } else { // This is not an expected case. exception = new Error('missing response'); break; } } // Something failed, reject the remaining promises. if (exception !== null) { // Redirect to the login page. if (exception.errorcode === "servicerequireslogin" && !nosessionupdate) { window.location = URL.relativeUrl("/login/index.php"); } else { requests.forEach(function(request) { request.deferred.reject(exception); }); } } }; /** * Fail handler. Called when the ajax call fails. Rejects all deferreds. * * @method requestFail * @private * @param {jqXHR} jqXHR The ajax object. * @param {string} textStatus The status string. * @param {Error|Object} exception The error thrown. */ var requestFail = function(jqXHR, textStatus, exception) { // Reject all the promises. var requests = this; var i = 0; for (i = 0; i < requests.length; i++) { var request = requests[i]; if (unloading) { // No need to trigger an error because we are already navigating. Log.error("Page unloaded."); Log.error(exception); } else { request.deferred.reject(exception); } } }; return /** @alias module:core/ajax */ { // Public variables and functions. /** * Make a series of ajax requests and return all the responses. * * @method call * @param {request[]} requests Array of requests with each containing methodname and args properties. * done and fail callbacks can be set for each element in the array, or the * can be attached to the promises returned by this function. * @param {Boolean} [async=true] If false this function will not return until the promises are resolved. * @param {Boolean} [loginrequired=true] When false this function calls an endpoint which does not use the * session. * Note: This may only be used with external functions which have been marked as * `'loginrequired' => false` * @param {Boolean} [nosessionupdate=false] If true, the timemodified for the session will not be updated. * @param {Number} [timeout] number of milliseconds to wait for a response. Defaults to no limit. * @param {Number} [cachekey] A cache key used to improve browser-side caching. * Typically the same `cachekey` is used for all function calls. * When the key changes, this causes the URL used to perform the fetch to change, which * prevents the existing browser cache from being used. * Note: This option is only availbale when `loginrequired` is `false`. * See {@link https://tracker.moodle.org/browser/MDL-65794} for more information. * @return {Promise[]} The Promises for each of the supplied requests. * The order of the Promise matches the order of requests exactly. * * @example <caption>A simple example that you might find in a repository module</caption> * * import {call as fetchMany} from 'core/ajax'; * * export const fetchMessages = timeSince => fetchMany([{methodname: 'core_message_get_messages', args: {timeSince}}])[0]; * * export const fetchNotifications = timeSince => fetchMany([{ * methodname: 'core_message_get_notifications', * args: { * timeSince, * } * }])[0]; * * export const fetchSomethingElse = (some, params, here) => fetchMany([{ * methodname: 'core_get_something_else', * args: { * some, * params, * gohere: here, * }, * }])[0]; * * @example <caption>An example of fetching a string using the cachekey parameter</caption> * import {call as fetchMany} from 'core/ajax'; * import * as Notification from 'core/notification'; * * export const performAction = (some, args) => { * Promises.all(fetchMany([{methodname: 'core_get_string', args: { * stringid: 'do_not_copy', * component: 'core', * lang: 'en', * stringparams: [], * }}], true, false, false, undefined, M.cfg.langrev)) * .then(([doNotCopyString]) => { * window.console.log(doNotCopyString); * }) * .catch(Notification.exception); * }; * */ call: function(requests, async, loginrequired, nosessionupdate, timeout, cachekey) { $(window).bind('beforeunload', function() { unloading = true; }); var ajaxRequestData = [], i, promises = [], methodInfo = [], requestInfo = ''; var maxUrlLength = 2000; if (typeof loginrequired === "undefined") { loginrequired = true; } if (typeof async === "undefined") { async = true; } if (typeof timeout === 'undefined') { timeout = 0; } if (typeof cachekey === 'undefined') { cachekey = null; } else { cachekey = parseInt(cachekey); if (cachekey <= 0) { cachekey = null; } else if (!cachekey) { cachekey = null; } } if (typeof nosessionupdate === "undefined") { nosessionupdate = false; } for (i = 0; i < requests.length; i++) { var request = requests[i]; ajaxRequestData.push({ index: i, methodname: request.methodname, args: request.args }); request.nosessionupdate = nosessionupdate; request.deferred = $.Deferred(); promises.push(request.deferred.promise()); // Allow setting done and fail handlers as arguments. // This is just a shortcut for the calling code. if (typeof request.done !== "undefined") { request.deferred.done(request.done); } if (typeof request.fail !== "undefined") { request.deferred.fail(request.fail); } request.index = i; methodInfo.push(request.methodname); } if (methodInfo.length <= 5) { requestInfo = methodInfo.sort().join(); } else { requestInfo = methodInfo.length + '-method-calls'; } ajaxRequestData = JSON.stringify(ajaxRequestData); var settings = { type: 'POST', context: requests, dataType: 'json', processData: false, async: async, contentType: "application/json", timeout: timeout }; var script = 'service.php'; var url = config.wwwroot + '/lib/ajax/'; if (!loginrequired) { script = 'service-nologin.php'; url += script + '?info=' + requestInfo; if (cachekey) { url += '&cachekey=' + cachekey; settings.type = 'GET'; } } else { url += script + '?sesskey=' + config.sesskey + '&info=' + requestInfo; } if (nosessionupdate) { url += '&nosessionupdate=true'; } if (settings.type === 'POST') { settings.data = ajaxRequestData; } else { var urlUseGet = url + '&args=' + encodeURIComponent(ajaxRequestData); if (urlUseGet.length > maxUrlLength) { settings.type = 'POST'; settings.data = ajaxRequestData; } else { url = urlUseGet; } } // Jquery deprecated done and fail with async=false so we need to do this 2 ways. if (async) { $.ajax(url, settings) .done(requestSuccess) .fail(requestFail); } else { settings.success = requestSuccess; settings.error = requestFail; $.ajax(url, settings); } return promises; } }; }); menu_navigation.js 0000644 00000022314 15152050146 0010266 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Keyboard initialization for a given html node. * * @module core/menu_navigation * @copyright 2021 Moodle * @author Mathew May <mathew.solutions> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const SELECTORS = { 'menuitem': '[role="menuitem"]', 'tab': '[role="tab"]', 'dropdowntoggle': '[data-toggle="dropdown"]', }; let openDropdownNode = null; /** * Small helper function to check if a given node is null or not. * * @param {HTMLElement|null} item The node that we want to compare. * @param {HTMLElement} fallback Either the first node or final node that can be focused on. * @return {HTMLElement} */ const clickErrorHandler = (item, fallback) => { if (item !== null) { return item; } else { return fallback; } }; /** * Control classes etc of the selected dropdown item and its' parent <a> * * @param {HTMLElement} src The node within the dropdown the user selected. */ const menuItemHelper = src => { let parent; // Do not apply any actions if the selected dropdown item is explicitly instructing to not display an active state. if (src.dataset.disableactive) { return; } // Handling for dropdown escapes. // A bulk of the handling is already done by aria.js just add polish. if (src.classList.contains('dropdown-item')) { parent = src.closest('.dropdown-menu'); const dropDownToggle = document.getElementById(parent.getAttribute('aria-labelledby')); dropDownToggle.classList.add('active'); dropDownToggle.setAttribute('tabindex', 0); } else if (src.matches(`${SELECTORS.tab},${SELECTORS.menuitem}`) && !src.matches(SELECTORS.dropdowntoggle)) { parent = src.parentElement.parentElement.querySelector('.dropdown-menu'); } else { return; } // Remove active class from any other dropdown elements. Array.prototype.forEach.call(parent.children, node => { const menuItem = node.querySelector(SELECTORS.menuitem); if (menuItem !== null) { menuItem.classList.remove('active'); // Remove aria selection state. menuItem.removeAttribute('aria-current'); } }); // Set the applicable element's selection state. if (src.getAttribute('role') === 'menuitem') { src.setAttribute('aria-current', 'true'); } }; /** * Defined keyboard event handling so we can remove listeners on nodes on resize etc. * * @param {event} e The triggering element and key presses etc. */ const keyboardListenerEvents = e => { const src = e.srcElement; const firstNode = e.currentTarget.firstElementChild; const lastNode = findUsableLastNode(e.currentTarget); // Handling for dropdown escapes. // A bulk of the handling is already done by aria.js just add polish. if (src.classList.contains('dropdown-item')) { if (e.key == 'ArrowRight' || e.key == 'ArrowLeft') { e.preventDefault(); if (openDropdownNode !== null) { openDropdownNode.parentElement.click(); } } if (e.key == ' ' || e.key == 'Enter') { e.preventDefault(); menuItemHelper(src); if (!src.parentElement.classList.contains('dropdown')) { src.click(); } } } else { const rtl = window.right_to_left(); const arrowNext = rtl ? 'ArrowLeft' : 'ArrowRight'; const arrowPrevious = rtl ? 'ArrowRight' : 'ArrowLeft'; if (src.getAttribute('role') === 'menuitem') { // When not rendered within a dropdown menu, handle keyboard navigation if the element is rendered as a menu item. if (e.key == arrowNext) { e.preventDefault(); setFocusNext(src, firstNode); } if (e.key == arrowPrevious) { e.preventDefault(); setFocusPrev(src, lastNode); } // Let aria.js handle the dropdowns. if (e.key == 'ArrowUp' || e.key == 'ArrowDown') { openDropdownNode = src; e.preventDefault(); } if (e.key == 'Home') { e.preventDefault(); setFocusHomeEnd(firstNode); } if (e.key == 'End') { e.preventDefault(); setFocusHomeEnd(lastNode); } } if (e.key == ' ' || e.key == 'Enter') { e.preventDefault(); // Aria.js handles dropdowns etc. if (!src.parentElement.classList.contains('dropdown')) { src.click(); } } } }; /** * Defined click event handling so we can remove listeners on nodes on resize etc. * * @param {event} e The triggering element and key presses etc. */ const clickListenerEvents = e => { const src = e.srcElement; menuItemHelper(src); }; /** * The initial entry point that a given module can pass a HTMLElement. * * @param {HTMLElement} elementRoot The menu to add handlers upon. */ export default elementRoot => { // Remove any and all instances of old listeners on the passed element. elementRoot.removeEventListener('keydown', keyboardListenerEvents); elementRoot.removeEventListener('click', clickListenerEvents); // (Re)apply our event listeners to the passed element. elementRoot.addEventListener('keydown', keyboardListenerEvents); elementRoot.addEventListener('click', clickListenerEvents); }; /** * Handle the focusing to the next element in the dropdown. * * @param {HTMLElement|null} currentNode The node that we want to take action on. * @param {HTMLElement} firstNode The backup node to focus as a last resort. */ const setFocusNext = (currentNode, firstNode) => { const listElement = currentNode.parentElement; const nextListItem = ((el) => { do { el = el.nextElementSibling; } while (el && !el.offsetHeight); // We only work with the visible tabs. return el; })(listElement); const nodeToSelect = clickErrorHandler(nextListItem, firstNode); const parent = listElement.parentElement; const isTabList = parent.getAttribute('role') === 'tablist'; const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem; const menuItem = nodeToSelect.querySelector(itemSelector); menuItem.focus(); }; /** * Handle the focusing to the previous element in the dropdown. * * @param {HTMLElement|null} currentNode The node that we want to take action on. * @param {HTMLElement} lastNode The backup node to focus as a last resort. */ const setFocusPrev = (currentNode, lastNode) => { const listElement = currentNode.parentElement; const nextListItem = ((el) => { do { el = el.previousElementSibling; } while (el && !el.offsetHeight); // We only work with the visible tabs. return el; })(listElement); const nodeToSelect = clickErrorHandler(nextListItem, lastNode); const parent = listElement.parentElement; const isTabList = parent.getAttribute('role') === 'tablist'; const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem; const menuItem = nodeToSelect.querySelector(itemSelector); menuItem.focus(); }; /** * Focus on either the start or end of a nav list. * * @param {HTMLElement} node The element to focus on. */ const setFocusHomeEnd = node => { node.querySelector(SELECTORS.menuitem).focus(); }; /** * We need to look within the menu to find a last node we can add focus to. * * @param {HTMLElement} elementRoot Menu to find a final child node within. * @return {HTMLElement} */ const findUsableLastNode = elementRoot => { const lastNode = elementRoot.lastElementChild; // An example is the more menu existing but hidden on the page for the time being. if (!lastNode.classList.contains('d-none')) { return elementRoot.lastElementChild; } else { // Cast the HTMLCollection & reverse it. const extractedNodes = Array.prototype.map.call(elementRoot.children, node => { return node; }).reverse(); // Get rid of any nodes we can not set focus on. const nodesToUse = extractedNodes.filter((node => { if (!node.classList.contains('d-none')) { return node; } })); // If we find no elements we can set focus on, fall back to the absolute first element. if (nodesToUse.length !== 0) { return nodesToUse[0]; } else { return elementRoot.firstElementChild; } } }; reactive.js 0000644 00000002600 15152050146 0006701 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Generic reactive module used in the course editor. * * @module core/reactive * @copyright 2021 Ferran Recio <ferran@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import BaseComponent from 'core/local/reactive/basecomponent'; import Reactive from 'core/local/reactive/reactive'; import DragDrop from 'core/local/reactive/dragdrop'; import {initDebug} from 'core/local/reactive/debug'; // Register a debug module if we are in debug mode. let debug; if (M.cfg.developerdebug && M.reactive === undefined) { const debugOBject = initDebug(); M.reactive = debugOBject.debuggers; debug = debugOBject.debug; } export {Reactive, BaseComponent, DragDrop, debug};
| ver. 1.4 |
Github
|
.
| PHP 7.4.33 | ���֧ߧ֧�ѧ�ڧ� ����ѧߧڧ��: 2.91 |
proxy
|
phpinfo
|
���ѧ����ۧܧ�