├── .ddev ├── config.yaml └── providers │ ├── acquia.yaml │ ├── lagoon.yaml │ └── upsun.yaml ├── .github └── ISSUE_TEMPLATE │ ├── BUG-REPORT.yml │ ├── FEATURE-REQUEST.yml │ ├── QUESTION.yml │ └── config.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock ├── config └── tools.php ├── docs └── assets │ ├── author-instructions-example.png │ ├── width-field.png │ └── width-settings.png ├── ecs.php └── src ├── Tools.php ├── assetbundles └── tools │ ├── ToolsAsset.php │ └── dist │ ├── css │ ├── nouislider.css │ ├── nouislider.pips.css │ └── tools.css │ └── js │ ├── nouislider.js │ └── tools.js ├── controllers └── ToolsController.php ├── fields ├── Ancestors.php ├── AuthorInstructions.php ├── CategoriesMultipleGroups.php ├── CategoriesSearch.php ├── DisabledCategories.php ├── DisabledDropdown.php ├── DisabledEntries.php ├── DisabledLightswitch.php ├── DisabledNumber.php ├── DisabledPlainText.php ├── DisabledProducts.php ├── DisabledUsers.php ├── DisabledVariants.php ├── EntriesSearch.php ├── Grid.php ├── ProductsSearch.php ├── VariantsSearch.php ├── Width.php └── data │ ├── GridData.php │ └── WidthData.php ├── icon.svg ├── models └── Settings.php ├── templates ├── _components │ ├── fields │ │ ├── ancestors │ │ │ └── settings.twig │ │ ├── authorinstructions │ │ │ ├── input.twig │ │ │ └── settings.twig │ │ ├── categoriessearch │ │ │ └── input.twig │ │ ├── disabledplaintext │ │ │ └── settings.twig │ │ ├── entriessearch │ │ │ └── input.twig │ │ ├── grid │ │ │ ├── input.twig │ │ │ └── settings.twig │ │ ├── productssearch │ │ │ └── input.twig │ │ ├── variantssearch │ │ │ └── input.twig │ │ └── width │ │ │ └── input.twig │ └── widgets │ │ └── rollyourown │ │ └── settings.twig └── _includes │ └── elementssearch.twig └── widgets └── RollYourOwn.php /.ddev/config.yaml: -------------------------------------------------------------------------------- 1 | name: craft-odds-and-ends 2 | type: php 3 | docroot: "" 4 | php_version: "8.2" 5 | webserver_type: nginx-fpm 6 | xdebug_enabled: false 7 | additional_hostnames: [] 8 | additional_fqdns: [] 9 | database: 10 | type: mysql 11 | version: "8.0" 12 | use_dns_when_possible: true 13 | composer_version: "2" 14 | web_environment: [] 15 | nodejs_version: "18" 16 | 17 | # Key features of DDEV's config.yaml: 18 | 19 | # name: # Name of the project, automatically provides 20 | # http://projectname.ddev.site and https://projectname.ddev.site 21 | 22 | # type: # drupal6/7/8, backdrop, typo3, wordpress, php 23 | 24 | # docroot: # Relative path to the directory containing index.php. 25 | 26 | # php_version: "8.1" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3" 27 | 28 | # You can explicitly specify the webimage but this 29 | # is not recommended, as the images are often closely tied to DDEV's' behavior, 30 | # so this can break upgrades. 31 | 32 | # webimage: # nginx/php docker image. 33 | 34 | # database: 35 | # type: # mysql, mariadb, postgres 36 | # version: # database version, like "10.4" or "8.0" 37 | # MariaDB versions can be 5.5-10.8 and 10.11, MySQL versions can be 5.5-8.0 38 | # PostgreSQL versions can be 9-15. 39 | 40 | # router_http_port: # Port to be used for http (defaults to global configuration, usually 80) 41 | # router_https_port: # Port for https (defaults to global configuration, usually 443) 42 | 43 | # xdebug_enabled: false # Set to true to enable Xdebug and "ddev start" or "ddev restart" 44 | # Note that for most people the commands 45 | # "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better, 46 | # as leaving Xdebug enabled all the time is a big performance hit. 47 | 48 | # xhprof_enabled: false # Set to true to enable Xhprof and "ddev start" or "ddev restart" 49 | # Note that for most people the commands 50 | # "ddev xhprof" to enable Xhprof and "ddev xhprof off" to disable it work better, 51 | # as leaving Xhprof enabled all the time is a big performance hit. 52 | 53 | # webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn 54 | 55 | # timezone: Europe/Berlin 56 | # This is the timezone used in the containers and by PHP; 57 | # it can be set to any valid timezone, 58 | # see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones 59 | # For example Europe/Dublin or MST7MDT 60 | 61 | # composer_root: 62 | # Relative path to the Composer root directory from the project root. This is 63 | # the directory which contains the composer.json and where all Composer related 64 | # commands are executed. 65 | 66 | # composer_version: "2" 67 | # You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 68 | # to use the latest major version available at the time your container is built. 69 | # It is also possible to use each other Composer version channel. This includes: 70 | # - 2.2 (latest Composer LTS version) 71 | # - stable 72 | # - preview 73 | # - snapshot 74 | # Alternatively, an explicit Composer version may be specified, for example "2.2.18". 75 | # To reinstall Composer after the image was built, run "ddev debug refresh". 76 | 77 | # nodejs_version: "18" 78 | # change from the default system Node.js version to another supported version, like 14, 16, 18, 20. 79 | # Note that you can use 'ddev nvm' or nvm inside the web container to provide nearly any 80 | # Node.js version, including v6, etc. 81 | 82 | # additional_hostnames: 83 | # - somename 84 | # - someothername 85 | # would provide http and https URLs for "somename.ddev.site" 86 | # and "someothername.ddev.site". 87 | 88 | # additional_fqdns: 89 | # - example.com 90 | # - sub1.example.com 91 | # would provide http and https URLs for "example.com" and "sub1.example.com" 92 | # Please take care with this because it can cause great confusion. 93 | 94 | # upload_dirs: "custom/upload/dir" 95 | # 96 | # upload_dirs: 97 | # - custom/upload/dir 98 | # - ../private 99 | # 100 | # would set the destination paths for ddev import-files to /custom/upload/dir 101 | # When Mutagen is enabled this path is bind-mounted so that all the files 102 | # in the upload_dirs don't have to be synced into Mutagen. 103 | 104 | # disable_upload_dirs_warning: false 105 | # If true, turns off the normal warning that says 106 | # "You have Mutagen enabled and your 'php' project type doesn't have upload_dirs set" 107 | 108 | # working_dir: 109 | # web: /var/www/html 110 | # db: /home 111 | # would set the default working directory for the web and db services. 112 | # These values specify the destination directory for ddev ssh and the 113 | # directory in which commands passed into ddev exec are run. 114 | 115 | # omit_containers: [db, ddev-ssh-agent] 116 | # Currently only these containers are supported. Some containers can also be 117 | # omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit 118 | # the "db" container, several standard features of DDEV that access the 119 | # database container will be unusable. In the global configuration it is also 120 | # possible to omit ddev-router, but not here. 121 | 122 | # performance_mode: "global" 123 | # DDEV offers performance optimization strategies to improve the filesystem 124 | # performance depending on your host system. Should be configured globally. 125 | # 126 | # If set, will override the global config. Possible values are: 127 | # - "global": uses the value from the global config. 128 | # - "none": disables performance optimization for this project. 129 | # - "mutagen": enables Mutagen for this project. 130 | # - "nfs": enables NFS for this project. 131 | # 132 | # See https://ddev.readthedocs.io/en/latest/users/install/performance/#nfs 133 | # See https://ddev.readthedocs.io/en/latest/users/install/performance/#mutagen 134 | 135 | # fail_on_hook_fail: False 136 | # Decide whether 'ddev start' should be interrupted by a failing hook 137 | 138 | # host_https_port: "59002" 139 | # The host port binding for https can be explicitly specified. It is 140 | # dynamic unless otherwise specified. 141 | # This is not used by most people, most people use the *router* instead 142 | # of the localhost port. 143 | 144 | # host_webserver_port: "59001" 145 | # The host port binding for the ddev-webserver can be explicitly specified. It is 146 | # dynamic unless otherwise specified. 147 | # This is not used by most people, most people use the *router* instead 148 | # of the localhost port. 149 | 150 | # host_db_port: "59002" 151 | # The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic 152 | # unless explicitly specified. 153 | 154 | # mailpit_http_port: "8025" 155 | # mailpit_https_port: "8026" 156 | # The Mailpit ports can be changed from the default 8025 and 8026 157 | 158 | # host_mailpit_port: "8025" 159 | # The mailpit port is not normally bound on the host at all, instead being routed 160 | # through ddev-router, but it can be bound directly to localhost if specified here. 161 | 162 | # webimage_extra_packages: [php7.4-tidy, php-bcmath] 163 | # Extra Debian packages that are needed in the webimage can be added here 164 | 165 | # dbimage_extra_packages: [telnet,netcat] 166 | # Extra Debian packages that are needed in the dbimage can be added here 167 | 168 | # use_dns_when_possible: true 169 | # If the host has internet access and the domain configured can 170 | # successfully be looked up, DNS will be used for hostname resolution 171 | # instead of editing /etc/hosts 172 | # Defaults to true 173 | 174 | # project_tld: ddev.site 175 | # The top-level domain used for project URLs 176 | # The default "ddev.site" allows DNS lookup via a wildcard 177 | # If you prefer you can change this to "ddev.local" to preserve 178 | # pre-v1.9 behavior. 179 | 180 | # ngrok_args: --basic-auth username:pass1234 181 | # Provide extra flags to the "ngrok http" command, see 182 | # https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h" 183 | 184 | # disable_settings_management: false 185 | # If true, DDEV will not create CMS-specific settings files like 186 | # Drupal's settings.php/settings.ddev.php or TYPO3's AdditionalConfiguration.php 187 | # In this case the user must provide all such settings. 188 | 189 | # You can inject environment variables into the web container with: 190 | # web_environment: 191 | # - SOMEENV=somevalue 192 | # - SOMEOTHERENV=someothervalue 193 | 194 | # no_project_mount: false 195 | # (Experimental) If true, DDEV will not mount the project into the web container; 196 | # the user is responsible for mounting it manually or via a script. 197 | # This is to enable experimentation with alternate file mounting strategies. 198 | # For advanced users only! 199 | 200 | # bind_all_interfaces: false 201 | # If true, host ports will be bound on all network interfaces, 202 | # not the localhost interface only. This means that ports 203 | # will be available on the local network if the host firewall 204 | # allows it. 205 | 206 | # default_container_timeout: 120 207 | # The default time that DDEV waits for all containers to become ready can be increased from 208 | # the default 120. This helps in importing huge databases, for example. 209 | 210 | #web_extra_exposed_ports: 211 | #- name: nodejs 212 | # container_port: 3000 213 | # http_port: 2999 214 | # https_port: 3000 215 | #- name: something 216 | # container_port: 4000 217 | # https_port: 4000 218 | # http_port: 3999 219 | # Allows a set of extra ports to be exposed via ddev-router 220 | # Fill in all three fields even if you don’t intend to use the https_port! 221 | # If you don’t add https_port, then it defaults to 0 and ddev-router will fail to start. 222 | # 223 | # The port behavior on the ddev-webserver must be arranged separately, for example 224 | # using web_extra_daemons. 225 | # For example, with a web app on port 3000 inside the container, this config would 226 | # expose that web app on https://.ddev.site:9999 and http://.ddev.site:9998 227 | # web_extra_exposed_ports: 228 | # - name: myapp 229 | # container_port: 3000 230 | # http_port: 9998 231 | # https_port: 9999 232 | 233 | #web_extra_daemons: 234 | #- name: "http-1" 235 | # command: "/var/www/html/node_modules/.bin/http-server -p 3000" 236 | # directory: /var/www/html 237 | #- name: "http-2" 238 | # command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000" 239 | # directory: /var/www/html 240 | 241 | # override_config: false 242 | # By default, config.*.yaml files are *merged* into the configuration 243 | # But this means that some things can't be overridden 244 | # For example, if you have 'nfs_mount_enabled: true'' you can't override it with a merge 245 | # and you can't erase existing hooks or all environment variables. 246 | # However, with "override_config: true" in a particular config.*.yaml file, 247 | # 'nfs_mount_enabled: false' can override the existing values, and 248 | # hooks: 249 | # post-start: [] 250 | # or 251 | # web_environment: [] 252 | # or 253 | # additional_hostnames: [] 254 | # can have their intended affect. 'override_config' affects only behavior of the 255 | # config.*.yaml file it exists in. 256 | 257 | # Many DDEV commands can be extended to run tasks before or after the 258 | # DDEV command is executed, for example "post-start", "post-import-db", 259 | # "pre-composer", "post-composer" 260 | # See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more 261 | # information on the commands that can be extended and the tasks you can define 262 | # for them. Example: 263 | #hooks: 264 | -------------------------------------------------------------------------------- /.ddev/providers/acquia.yaml: -------------------------------------------------------------------------------- 1 | #ddev-generated 2 | # Acquia provider configuration. 3 | 4 | # To use this configuration, 5 | 6 | # 1. Get your Acquia API token from your Account Settings->API Tokens. 7 | # 2. Make sure your ssh key is authorized on your Acquia account at Account Settings->SSH Keys 8 | # 3. `ddev auth ssh` (this typically needs only be done once per ddev session, not every pull). 9 | # 4. Add / update the web_environment section in ~/.ddev/global_config.yaml 10 | # or your project config.yamlwith the API keys: 11 | # ```yaml 12 | # web_environment: 13 | # - ACQUIA_API_KEY=xxxxxxxx 14 | # - ACQUIA_API_SECRET=xxxxx 15 | # ``` 16 | # 5. Add the ACQUIA_ENVIRONMENT_ID environment variable to your project config.yaml, for example: 17 | # ```yaml 18 | # web_environment: 19 | # - ACQUIA_ENVIRONMENT_ID=project1.dev 20 | # - On the Acquia Cloud Platform you can find this out by navigating to the environments page, 21 | # clicking on the header and look for the "SSH URL" line. 22 | # Eg. `project1.dev@cool-projects.acquia-sites.com` would have a project ID of `project1.dev` 23 | # 6. `ddev restart` 24 | # 7. Use `ddev pull acquia` to pull the project database and files. 25 | # 8. Optionally use `ddev push acquia` to push local files and database to Acquia. Note that `ddev push` is a command that can potentially damage your production site, so this is not recommended. 26 | 27 | # Debugging: Use `ddev exec acli command` and `ddev exec acli auth:login` 28 | 29 | # Instead of setting the environment variables in configuration files, you can use 30 | # `ddev pull acquia --environment=ACQUIA_ENVIRONMENT_ID=yourproject.dev` for example 31 | 32 | auth_command: 33 | command: | 34 | set -eu -o pipefail 35 | if [ -z "${ACQUIA_API_KEY:-}" ] || [ -z "${ACQUIA_API_SECRET:-}" ]; then echo "Please make sure you have set ACQUIA_API_KEY and ACQUIA_API_SECRET in ~/.ddev/global_config.yaml" && exit 1; fi 36 | if [ -z "${ACQUIA_ENVIRONMENT_ID:-}" ] ; then echo "Please set ACQUIA_ENVIRONMENT_ID via config.yaml or with '--environment=ACQUIA_ENVIRONMENT_ID=xxx'" && exit 1; fi 37 | ssh-add -l >/dev/null || ( echo "Please 'ddev auth ssh' before running this command." && exit 1 ) 38 | acli -n auth:login -n --key="${ACQUIA_API_KEY}" --secret="${ACQUIA_API_SECRET}" 39 | 40 | db_pull_command: 41 | command: | 42 | set -eu -o pipefail 43 | # xargs here just trims whitespace 44 | # We could use an easier technique when https://github.com/acquia/cli/issues/1629 is resolved 45 | # just using `acli pull:db ${ACQUIA_ENVIRONMENT_ID}` 46 | echo "Using ACQUIA_ENVIRONMENT_ID=${ACQUIA_ENVIRONMENT_ID}" 47 | set -x # You can enable bash debugging output by uncommenting 48 | db_dump=$(acli pull:db ${ACQUIA_ENVIRONMENT_ID} --no-interaction --no-import | tail -2l | xargs | sed 's/^.* //') 49 | ls /var/www/html/.ddev >/dev/null # This just refreshes stale NFS if possible 50 | cp ${db_dump} /var/www/html/.ddev/.downloads/db.sql.gz 51 | 52 | files_import_command: 53 | command: | 54 | # set -x # You can enable bash debugging output by uncommenting 55 | set -eu -o pipefail 56 | acli -n pull:files ${ACQUIA_ENVIRONMENT_ID} 57 | 58 | # push is a dangerous command. If not absolutely needed it's better to delete these lines. 59 | db_push_command: 60 | command: | 61 | set -eu -o pipefail 62 | export ACLI_DB_HOST=db ACLI_DB_NAME=db ACLI_DB_USER=db ACLI_DB_PASSWORD=db 63 | set -x # You can enable bash debugging output by uncommenting 64 | acli push:db ${ACQUIA_ENVIRONMENT_ID} --no-interaction 65 | 66 | # push is a dangerous command. If not absolutely needed it's better to delete these lines. 67 | files_push_command: 68 | command: | 69 | # set -x # You can enable bash debugging output by uncommenting 70 | set -eu -o pipefail 71 | acli push:files ${ACQUIA_ENVIRONMENT_ID} --no-interaction 72 | -------------------------------------------------------------------------------- /.ddev/providers/lagoon.yaml: -------------------------------------------------------------------------------- 1 | #ddev-generated 2 | # Lagoon provider configuration. 3 | 4 | # To use this configuration, 5 | 6 | # 1. Check out the project and then configure it with 'ddev config'. You'll want to use 'ddev start' and make sure the basic functionality is working. Your project must have a .lagoon.yml properly configured. 7 | # 2. Configure an SSH key for your Lagoon user https://docs.lagoon.sh/using-lagoon-advanced/ssh/ 8 | # 3. `ddev auth ssh`. 9 | # 4. Add LAGOON_PROJECT and LAGOON_ENVIRONMENT variables to your project in 'web_environment' or a '.ddev/.env' 10 | # 5. `ddev restart` 11 | # 12 | # 'ddev pull lagoon' 13 | 14 | auth_command: 15 | command: | 16 | set -eu -o pipefail 17 | ssh-add -l >/dev/null || ( echo "Please 'ddev auth ssh' before running this command." && exit 1 ) 18 | if [ -z "${LAGOON_PROJECT:-}" ]; then echo "Please make sure you have set the LAGOON_PROJECT environment variable in your 'web_environment' or .ddev/.env." && exit 1; fi 19 | if [ -z "${LAGOON_ENVIRONMENT}" ]; then echo "Please make sure you have set the LAGOON_ENVIRONMENT environment variable in your 'web_environment' or .ddev/.env." && exit 1; fi 20 | 21 | 22 | db_import_command: 23 | command: | 24 | # set -x # You can enable bash debugging output by uncommenting 25 | set -eu -o pipefail 26 | export MARIADB_HOST=db MARIADB_USERNAME=db MARIADB_PASSWORD=db MARIADB_DATABASE=db 27 | lagoon-sync sync mariadb -p ${LAGOON_PROJECT} -e ${LAGOON_ENVIRONMENT} --no-interaction 28 | 29 | files_import_command: 30 | command: | 31 | #set -x # You can enable bash debugging output by uncommenting 32 | set -eu -o pipefail 33 | lagoon-sync sync files -p ${LAGOON_PROJECT} -e ${LAGOON_ENVIRONMENT} --no-interaction 34 | 35 | # push is a dangerous command. If not absolutely needed it's better to delete these lines. 36 | db_push_command: 37 | command: | 38 | set -eu -o pipefail 39 | #set -x # You can enable bash debugging output by uncommenting 40 | export MARIADB_HOST=db MARIADB_USERNAME=db MARIADB_PASSWORD=db MARIADB_DATABASE=db 41 | lagoon-sync sync mariadb -p ${LAGOON_PROJECT} -t ${LAGOON_ENVIRONMENT} -e local --no-interaction 42 | 43 | # push is a dangerous command. If not absolutely needed it's better to delete these lines. 44 | files_push_command: 45 | command: | 46 | set -eu -o pipefail 47 | #set -x # You can enable bash debugging output by uncommenting 48 | lagoon-sync sync files -p ${LAGOON_PROJECT} -e local -t ${LAGOON_ENVIRONMENT} --no-interaction 49 | 50 | -------------------------------------------------------------------------------- /.ddev/providers/upsun.yaml: -------------------------------------------------------------------------------- 1 | #ddev-generated 2 | # Upsun provider configuration. This works out of the box, but can be edited to add 3 | # your own preferences. If you edit it, remove the `ddev-generated` line from the top so 4 | # that it won't be overwritten. 5 | 6 | # To use this configuration, 7 | 8 | # 1. Check out the site from Upsun and then configure it with `ddev config`. You'll want to use `ddev start` and make sure the basic functionality is working. 9 | # 2. Obtain and configure an API token. 10 | # a. Login to the Upsun Dashboard and go to My Profile->API Tokens to create an API token for DDEV to use. 11 | # b. Add the API token to the `web_environment` section in your global ddev configuration at ~/.ddev/global_config.yaml: 12 | # ```yaml 13 | # web_environment: 14 | # - UPSUN_CLI_TOKEN=abcdeyourtoken 15 | # ``` 16 | # 3. Add UPSUN_PROJECT and UPSUN_ENVIRONMENT variables to your project `.ddev/config.yaml` or a `.ddev/config.upsun.yaml` 17 | # ```yaml 18 | # web_environment: 19 | # - UPSUN_PROJECT=nf4amudfn23biyourproject 20 | # - UPSUN_ENVIRONMENT=main 21 | # 4. `ddev restart` 22 | # 5. Run `ddev pull upsun`. After you agree to the prompt, the current upstream database and files will be downloaded. 23 | # 6. Optionally use `ddev push upsun` to push local files and database to Upsun. Note that `ddev push` is a command that can potentially damage your production site, so this is not recommended. 24 | 25 | # Debugging: Use `ddev exec upsun` to see what Upsun knows about 26 | # your configuration and whether it's working correctly. 27 | 28 | auth_command: 29 | command: | 30 | set -eu -o pipefail 31 | if [ -z "${UPSUN_CLI_TOKEN:-}" ]; then echo "Please make sure you have set UPSUN_CLI_TOKEN." && exit 1; fi 32 | if [ -z "${UPSUN_PROJECT:-}" ]; then echo "Please make sure you have set UPSUN_PROJECT." && exit 1; fi 33 | if [ -z "${UPSUN_ENVIRONMENT:-}" ]; then echo "Please make sure you have set UPSUN_ENVIRONMENT." && exit 1; fi 34 | 35 | db_pull_command: 36 | command: | 37 | # set -x # You can enable bash debugging output by uncommenting 38 | set -eu -o pipefail 39 | export UPSUN_CLI_NO_INTERACTION=1 40 | ls /var/www/html/.ddev >/dev/null # This just refreshes stale NFS if possible 41 | upsun db:dump --yes --gzip --file=/var/www/html/.ddev/.downloads/db.sql.gz --project="${UPSUN_PROJECT}" --environment="${UPSUN_ENVIRONMENT}" 42 | 43 | files_import_command: 44 | command: | 45 | # set -x # You can enable bash debugging output by uncommenting 46 | set -eu -o pipefail 47 | export UPSUN_CLI_NO_INTERACTION=1 48 | # Use $UPSUN_MOUNTS if it exists to get list of mounts to download, otherwise just web/sites/default/files (drupal) 49 | declare -a mounts=(${UPSUN_MOUNTS:-/web/sites/default/files}) 50 | upsun mount:download --all --yes --quiet --project="${UPSUN_PROJECT}" --environment="${UPSUN_ENVIRONMENT}" --target=/var/www/html 51 | 52 | 53 | # push is a dangerous command. If not absolutely needed it's better to delete these lines. 54 | db_push_command: 55 | command: | 56 | # set -x # You can enable bash debugging output by uncommenting 57 | set -eu -o pipefail 58 | export UPSUN_CLI_NO_INTERACTION=1 59 | ls /var/www/html/.ddev >/dev/null # This just refreshes stale NFS if possible 60 | pushd /var/www/html/.ddev/.downloads >/dev/null 61 | gzip -dc db.sql.gz | upsun db:sql --project="${UPSUN_PROJECT}" --environment="${UPSUN_ENVIRONMENT}" 62 | 63 | # push is a dangerous command. If not absolutely needed it's better to delete these lines. 64 | # TODO: This is a naive, Drupal-centric push, which needs adjustment for the mount to be pushed. 65 | files_push_command: 66 | command: | 67 | # set -x # You can enable bash debugging output by uncommenting 68 | set -eu -o pipefail 69 | export UPSUN_CLI_NO_INTERACTION=1 70 | ls "${DDEV_FILES_DIR}" >/dev/null # This just refreshes stale NFS if possible 71 | upsun mount:upload --yes --quiet --project="${UPSUN_PROJECT}" --environment="${UPSUN_ENVIRONMENT}" --source="${DDEV_FILES_DIR}" --mount=web/sites/default/files 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG-REPORT.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Let us know about a problem with Odds & Ends 3 | labels: 4 | - "bug report" 5 | - "bug report status: new" 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for taking the time to fill out this bug report! Please check to make sure your bug hasn't already been reported before proceeding. Please also make sure you correctly follow all instructions when filling out this form. Failure to do so may result in your bug report being closed as invalid. 11 | - type: textarea 12 | id: bug-description 13 | attributes: 14 | label: Bug Description 15 | description: Details of the bug you've encountered. 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: steps-to-reproduce 20 | attributes: 21 | label: Steps to reproduce 22 | description: Instructions that can be followed to reliably reproduce the bug. 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: expected-behaviour 27 | attributes: 28 | label: Expected behaviour 29 | description: What should be expected to happen instead of the buggy behaviour. 30 | - type: input 31 | id: plugin-version 32 | attributes: 33 | label: Odds & Ends version 34 | description: Enter the exact Odds & Ends version number you are using. 35 | validations: 36 | required: true 37 | - type: input 38 | id: craft-cms-version 39 | attributes: 40 | label: Craft CMS version 41 | description: Enter the exact Craft CMS version number you are using. 42 | validations: 43 | required: true 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for Odds & Ends 3 | labels: 4 | - enhancement 5 | body: 6 | - type: textarea 7 | id: feature-description 8 | attributes: 9 | label: What would you like to see added to or changed in Odds & Ends, and why? 10 | validations: 11 | required: true 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/QUESTION.yml: -------------------------------------------------------------------------------- 1 | name: Question 2 | description: Ask a question about Odds & Ends 3 | labels: 4 | - question 5 | body: 6 | - type: textarea 7 | id: question 8 | attributes: 9 | label: What question would you like to ask? 10 | validations: 11 | required: true 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # CRAFT ENVIRONMENT 2 | .env.php 3 | .env.sh 4 | .env 5 | 6 | # COMPOSER 7 | /vendor 8 | 9 | # BUILD FILES 10 | /bower_components/* 11 | /node_modules/* 12 | /build/* 13 | /yarn-error.log 14 | 15 | # MISC FILES 16 | .cache 17 | .DS_Store 18 | .idea 19 | .project 20 | .settings 21 | *.esproj 22 | *.sublime-workspace 23 | *.sublime-project 24 | *.tmproj 25 | *.tmproject 26 | .vscode/* 27 | !.vscode/settings.json 28 | !.vscode/tasks.json 29 | !.vscode/launch.json 30 | !.vscode/extensions.json 31 | config.codekit3 32 | prepros-6.config -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## 5.0.0 - 2024-05-01 8 | 9 | ### Added 10 | - Added Craft 5 compatibility 11 | 12 | ### Removed 13 | - Removed Craft 4 compatibility 14 | 15 | ## 4.3.1 - 2023-09-14 16 | 17 | ### Fixed 18 | - Fixed a bug where fields from version 2.x wouldn't be migrated on projects that aren't using the project config (thanks @jamie-s-white) 19 | 20 | ## 4.3.0 - 2023-07-02 21 | 22 | ### Added 23 | - Added `spicyweb\oddsandends\fields\DisabledUsers` - Users (Disabled) field type 24 | 25 | ## 4.2.0 - 2023-03-27 26 | 27 | ### Added 28 | - Added the ability to disable plugin components via the new `disableNormalFields`, `disableCommerceFields`, and `disableWidgets` plugin settings (thanks @kbergha) 29 | 30 | ### Changed 31 | - Odds & Ends now requires Craft 4.4.0 or later 32 | 33 | ### Fixed 34 | - Fixed an error that occurred on Craft 4.4.0 or later 35 | 36 | ## 4.1.2 - 2023-03-09 37 | 38 | ### Fixed 39 | - Fixed an error that occurred if the Roll Your Own widget was set to use a nonexistent template 40 | 41 | ## 4.1.1 - 2022-11-30 42 | 43 | ### Fixed 44 | - Fixed an error that occurred when executing a GraphQL query if a Width field existed in the Craft install 45 | 46 | ## 4.1.0 - 2022-10-20 47 | 48 | ### Added 49 | - Added `spicyweb\oddsandends\fields\DisabledProducts` - Commerce Products (Disabled) field type 50 | - Added `spicyweb\oddsandends\fields\DisabledVariants` - Commerce Variants (Disabled) field type 51 | - Added `spicyweb\oddsandends\fields\ProductsSearch` - Commerce Products (Search) field type 52 | - Added `spicyweb\oddsandends\fields\VariantsSearch` - Commerce Variants (Search) field type 53 | 54 | ### Fixed 55 | - Fixed some style issues with element search fields 56 | 57 | ## 4.0.0 - 2022-10-19 58 | 59 | ### Added 60 | - Added support for Craft 4 (requires Craft 4.2.1 or later) 61 | 62 | ### Removed 63 | - Removed support for Craft 3 64 | 65 | ## 3.0.3 - 2023-09-14 66 | 67 | ### Fixed 68 | - Fixed a bug where fields from version 2.x wouldn't be migrated on projects that aren't using the project config (thanks @jamie-s-white) 69 | 70 | ## 3.0.2 - 2022-10-20 71 | 72 | ### Fixed 73 | - Fixed an error that could occur when editing element search field contents 74 | - Fixed a bug where previously selected elements for element search fields couldn't be removed 75 | 76 | ## 3.0.1 - 2022-10-19 77 | 78 | ### Fixed 79 | - Fixed an error that occurred when attempting inline editing of entries selected for an Entries (Search) field, and categories selected for a Categories (Search) field 80 | 81 | ## 3.0.0 - 2022-10-18 82 | 83 | > {note} The plugin’s package name has changed to `spicyweb/craft-odds-and-ends`. If you’re updating with Composer, you will need to run `composer require spicyweb/craft-odds-and-ends` and then `composer remove supercool/tools`. 84 | 85 | ### Changed 86 | - Name changed from 'Tools' to 'Odds & Ends' 87 | - Now maintained by Spicy Web 88 | - Now requires Craft 3.7.55.3 or later Craft 3 releases 89 | 90 | ### Fixed 91 | - Fixed an error that occurred when creating a width field on Craft 3.7.46 or later Craft 3 releases 92 | - Fixed a bug where Roll Your Own widgets were always displaying 'Roll Your Own' as the title, instead of the user-specified title 93 | - Fixed a JavaScript error that could occur with Entries (Search) and Categories (Search) fields 94 | - Fixed a bug where Entries (Search) and Categories (Search) fields could fail to return results 95 | - Fixed an error that occurred with the Entries (Search) field if single sections were one of the sources set for the field 96 | - Fixed an error that occurred when loading Ancestors field settings 97 | - Fixed a bug where Ancestors field modals could fail to show elements 98 | 99 | ## 2.2.3 - 2022-05-24 100 | 101 | ### Fixed 102 | - Width field styling 103 | 104 | ## 2.2.2 - 2022-05-09 105 | 106 | ### Fixed 107 | - Width field data type for php 7.4 108 | 109 | ## 2.2.1.1 - 2021-01-20 110 | 111 | ### Fixed 112 | - Validation error when grid data was typecasted to int 113 | 114 | ## 2.2.1 - 2021-01-19 115 | 116 | ### Changed 117 | - Settings and UI for grid field 118 | 119 | ## 2.2.0 - 2020-09-24 120 | 121 | ### Added 122 | - Support for craft 3.5 123 | 124 | ## 2.1.10.1 - 2021-01-20 125 | 126 | ### Fixed 127 | - Small validation fix for grid field rework 128 | 129 | ## 2.1.10 - 2021-01-19 130 | 131 | ### Changed 132 | - Grid field settings and UI overhauled 133 | 134 | ## 2.1.9 - 2020-10-07 135 | 136 | ### Fixed 137 | - Fixed width field for Craft 3.3.5+ 138 | 139 | ## 2.1.8 - 2019-03-15 140 | 141 | ### Added 142 | - Added `supercool\tools\fields\data\GridData::leftRight()` field for use in templates 143 | 144 | ## 2.1.7 - 2019-03-04 145 | 146 | ### Fixed 147 | - Fixed a composer.json issue 148 | 149 | ## 2.1.6 - 2019-03-04 150 | 151 | ### Fixed 152 | - Fixed an error that could occur with width fields 153 | 154 | ## 2.1.5 - 2018-12-17 155 | 156 | ### Added 157 | - Added default value for width 158 | 159 | ## 2.1.4 - 2018-12-13 160 | 161 | ### Added 162 | - Added global settings for width 163 | 164 | ## 2.1.3 - 2018-12-11 165 | 166 | ### Fixed 167 | - Fixed multiple width fields 168 | 169 | ## 2.1.2 - 2018-12-06 170 | 171 | ### Added 172 | - Added toggle for show/hide width field 173 | 174 | ## 2.1.1 - 2018-11-13 175 | 176 | ### Fixed 177 | - Fixed a composer.json issue 178 | 179 | ## 2.1.0 - 2018-11-13 180 | 181 | ### Added 182 | - Added a grid field type which is going to be used as a width field 183 | 184 | ## 2.0.0 - 2018-08-02 185 | 186 | ### Added 187 | - Initial Craft CMS 3 release 188 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-2024 Spicy Web 4 | Copyright (c) 2018 Supercool Ltd 5 | 6 | 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: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Odds & Ends 4 | 5 | A collection of useful tools for Craft CMS websites. 6 | 7 | ## Field types 8 | 9 | ### Width 10 | This lets you define the width of a block as well as left and right padding. This field simply outputs three sets of classes which can be defined when setting the field up. 11 | 12 | ![Width Field Settings](docs/assets/width-settings.png) 13 | ![Width Field](docs/assets/width-field.png) 14 | 15 | ### Author Instructions 16 | This lets you output markdown instead of a field, which is useful when you have a Matrix block that doesn’t have any fields. 17 | 18 | ![Author Instructions Example](docs/assets/author-instructions-example.png) 19 | 20 | ### Categories (multiple groups) 21 | A Categories input that lets you select multiple Category groups. 22 | 23 | ### Ancestors 24 | An Entries input that only shows the ancestors of the current Entry. 25 | 26 | ### Search Fields 27 | Like the Tags input, but for entries and categories (without the auto-creation feature). If Craft Commerce is installed, search fields for products and variants are also available. 28 | 29 | ### Disabled Fields 30 | The same as regular fields, but disabled. Useful for situations where you want to integrate with a third party API and store that information in a field but don’t want the user to change it. 31 | 32 | The following field types are currently supported: 33 | 34 | - Entries 35 | - Categories 36 | - Lightswitch 37 | - Number 38 | - PlainText 39 | - Dropdown 40 | - Commerce Products 41 | - Commerce Variants 42 | 43 | ## Widgets 44 | 45 | ### Roll Your Own 46 | A simple widget that lets you assign a template to load from your site templates folder. Go nuts. 47 | 48 | ## Miscellaneous 49 | 50 | ### Download File 51 | 52 | A controller action that will download an asset file. 53 | 54 | The `id` parameter is required and must be a valid asset ID. 55 | 56 | Usage: 57 | ``` 58 | Download 59 | ``` 60 | 61 | 62 | ## Configuration 63 | 64 | By default, all normal field types and widgets are enabled. 65 | The commerce field types are only enabled if Craft Commerce is installed and enabled. 66 | You can disable each field type and widget by adding the following to your project's `config/tools.php` file: 67 | 68 | ```php 69 | use spicyweb\oddsandends\fields\AuthorInstructions; 70 | use spicyweb\oddsandends\fields\DisabledProducts; 71 | use spicyweb\oddsandends\widgets\RollYourOwn; 72 | 73 | return [ 74 | 'disableNormalFields' => [ 75 | AuthorInstructions::class, 76 | ], 77 | 'disableCommerceFields' => [ 78 | DisabledProducts::class, 79 | ], 80 | 'disableWidgets' => [ 81 | RollYourOwn::class, 82 | ], 83 | ]; 84 | ``` 85 | 86 | Multi environment config is supported. See [Craft's docs](https://craftcms.com/docs/4.x/config/#multi-environment-configs) for more info. 87 | See `config/tools.php` in this repo for an example on how to disable any field and widget. 88 | 89 | --- 90 | 91 | *Created by [Supercool](https://supercooldesign.co.uk)* 92 |
93 | *Maintained by [Spicy Web](https://spicyweb.com.au)* 94 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spicyweb/craft-odds-and-ends", 3 | "description": "A collection of useful tools for Craft CMS websites", 4 | "type": "craft-plugin", 5 | "version": "5.0.0", 6 | "keywords": [ 7 | "craft", 8 | "cms", 9 | "craftcms", 10 | "craft-plugin" 11 | ], 12 | "support": { 13 | "docs": "https://github.com/spicywebau/craft-odds-and-ends/blob/5.0.0/README.md", 14 | "issues": "https://github.com/spicywebau/craft-odds-and-ends/issues" 15 | }, 16 | "license": "MIT", 17 | "authors": [ 18 | { 19 | "name": "Spicy Web", 20 | "homepage": "https://spicyweb.com.au" 21 | } 22 | ], 23 | "require": { 24 | "craftcms/cms": "^5.0.0", 25 | "php": "^8.2" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "spicyweb\\oddsandends\\": "src/" 30 | } 31 | }, 32 | "extra": { 33 | "name": "Odds & Ends", 34 | "handle": "tools", 35 | "schemaVersion": "4.3.1", 36 | "hasCpSettings": false, 37 | "hasCpSection": false, 38 | "changelogUrl": "https://github.com/spicywebau/craft-odds-and-ends/blob/5.x/CHANGELOG.md", 39 | "class": "spicyweb\\oddsandends\\Tools" 40 | }, 41 | "minimum-stability": "dev", 42 | "prefer-stable": true, 43 | "require-dev": { 44 | "craftcms/ecs": "dev-main", 45 | "craftcms/rector": "dev-main" 46 | }, 47 | "config": { 48 | "allow-plugins": { 49 | "yiisoft/yii2-composer": true, 50 | "craftcms/plugin-installer": true 51 | } 52 | }, 53 | "scripts": { 54 | "check-cs": "ecs check --ansi", 55 | "fix-cs": "ecs check --ansi --fix" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /config/tools.php: -------------------------------------------------------------------------------- 1 | [ 34 | // Ancestors::class, 35 | // AuthorInstructions::class, 36 | // CategoriesMultipleGroups::class, 37 | // CategoriesSearch::class, 38 | // DisabledCategories::class, 39 | // DisabledDropdown::class, 40 | // DisabledEntries::class, 41 | // DisabledLightswitch::class, 42 | // DisabledNumber::class, 43 | // DisabledPlainText::class, 44 | // DisabledUsers::class, 45 | // EntriesSearch::class, 46 | // Grid::class, 47 | // Width::class, 48 | ], 49 | 'disableCommerceFields' => [ 50 | // DisabledProducts::class, 51 | // DisabledVariants::class, 52 | // ProductsSearch::class, 53 | // VariantsSearch::class, 54 | ], 55 | 'disableWidgets' => [ 56 | // RollYourOwn::class, 57 | ], 58 | ]; 59 | -------------------------------------------------------------------------------- /docs/assets/author-instructions-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-odds-and-ends/525607a50912cb9d73aa4f018ef7fa9423dc83aa/docs/assets/author-instructions-example.png -------------------------------------------------------------------------------- /docs/assets/width-field.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-odds-and-ends/525607a50912cb9d73aa4f018ef7fa9423dc83aa/docs/assets/width-field.png -------------------------------------------------------------------------------- /docs/assets/width-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-odds-and-ends/525607a50912cb9d73aa4f018ef7fa9423dc83aa/docs/assets/width-settings.png -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | parallel(); 10 | $ecsConfig->paths([ 11 | __DIR__ . '/src', 12 | __FILE__, 13 | ]); 14 | 15 | $ecsConfig->sets([SetList::CRAFT_CMS_3]); 16 | }; 17 | -------------------------------------------------------------------------------- /src/Tools.php: -------------------------------------------------------------------------------- 1 | 40 | * @author Supercool 41 | * @since 2.0.0 42 | */ 43 | class Tools extends Plugin 44 | { 45 | /** 46 | * @var Tools The plugin instance. 47 | */ 48 | public static ?Tools $plugin = null; 49 | 50 | /** 51 | * @inheritdoc 52 | */ 53 | public string $minVersionRequired = '4.3.1'; 54 | 55 | /** 56 | * @inheritdoc 57 | */ 58 | public string $schemaVersion = '4.3.1'; 59 | 60 | /** 61 | * @inheritdoc 62 | */ 63 | public function init(): void 64 | { 65 | parent::init(); 66 | self::$plugin = $this; 67 | 68 | // Register our fields 69 | Event::on( 70 | Fields::className(), 71 | Fields::EVENT_REGISTER_FIELD_TYPES, 72 | function(RegisterComponentTypesEvent $event) { 73 | $enableNormalFields = [ 74 | AuthorInstructionsField::class, 75 | DisabledLightswitchField::class, 76 | DisabledPlainTextField::class, 77 | DisabledNumberField::class, 78 | DisabledEntriesField::class, 79 | DisabledCategoriesField::class, 80 | DisabledDropdownField::class, 81 | DisabledUsersField::class, 82 | EntriesSearchField::class, 83 | CategoriesSearchField::class, 84 | CategoriesMultipleGroupsField::class, 85 | WidthField::class, 86 | AncestorsField::class, 87 | GridField::class, 88 | ]; 89 | 90 | $enableNormalFields = array_diff($enableNormalFields, $this->settings->disableNormalFields); 91 | Craft::debug($this->name.' enable normal fields: ' . implode(', ', $enableNormalFields), __METHOD__); 92 | 93 | foreach ($enableNormalFields as $field) { 94 | $event->types[] = $field; 95 | } 96 | 97 | $pluginsService = Craft::$app->getPlugins(); 98 | if ($pluginsService->isPluginInstalled('commerce') && $pluginsService->isPluginEnabled('commerce')) { 99 | $enableCommerceFields = [ 100 | DisabledProductsField::class, 101 | DisabledVariantsField::class, 102 | ProductsSearchField::class, 103 | VariantsSearchField::class, 104 | ]; 105 | 106 | $enableCommerceFields = array_diff($enableCommerceFields, $this->settings->disableCommerceFields); 107 | Craft::debug($this->name.' enable commerce fields: ' . implode(', ', $enableCommerceFields), __METHOD__); 108 | 109 | foreach ($enableCommerceFields as $field) { 110 | $event->types[] = $field; 111 | } 112 | } 113 | } 114 | ); 115 | 116 | // Register widgets 117 | Event::on( 118 | Dashboard::className(), 119 | Dashboard::EVENT_REGISTER_WIDGET_TYPES, 120 | function(RegisterComponentTypesEvent $event) { 121 | $enableWidgets = [ 122 | RollYourOwnWidget::class, 123 | ]; 124 | 125 | $enableWidgets = array_diff($enableWidgets, $this->settings->disableWidgets); 126 | Craft::debug($this->name.' enable widgets: ' . implode(', ', $enableWidgets), __METHOD__); 127 | 128 | foreach ($enableWidgets as $widget) { 129 | $event->types[] = $widget; 130 | } 131 | } 132 | ); 133 | 134 | Craft::info(Craft::t('tools', '{name} plugin loaded', ['name' => $this->name]), __METHOD__); 135 | } 136 | 137 | /** 138 | * @inheritdoc 139 | */ 140 | protected function createSettingsModel(): ?Model 141 | { 142 | return new Settings(); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/assetbundles/tools/ToolsAsset.php: -------------------------------------------------------------------------------- 1 | 13 | * @author Supercool 14 | * @since 2.0.0 15 | */ 16 | class ToolsAsset extends AssetBundle 17 | { 18 | /** 19 | * @inheritdoc 20 | */ 21 | public function init(): void 22 | { 23 | // define the path that your publishable resources live 24 | $this->sourcePath = "@spicyweb/oddsandends/assetbundles/tools/dist"; 25 | 26 | // define the dependencies 27 | $this->depends = [ 28 | CpAsset::class, 29 | ]; 30 | 31 | // define the relative path to CSS/JS files that should be registered with the page 32 | // when this asset bundle is registered 33 | $this->js = [ 34 | 'js/nouislider.js', 35 | 'js/tools.js', 36 | ]; 37 | 38 | $this->css = [ 39 | 'css/nouislider.css', 40 | 'css/nouislider.pips.css', 41 | 'css/tools.css', 42 | ]; 43 | 44 | parent::init(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/assetbundles/tools/dist/css/nouislider.css: -------------------------------------------------------------------------------- 1 | /* Functional styling; 2 | * These styles are required for noUiSlider to function. 3 | * You don't need to change these rules to apply your design. 4 | */ 5 | .noUi-target, 6 | .noUi-target * { 7 | -webkit-touch-callout: none; 8 | -webkit-user-select: none; 9 | -ms-touch-action: none; 10 | touch-action: none; 11 | -ms-user-select: none; 12 | -moz-user-select: none; 13 | user-select: none; 14 | -moz-box-sizing: border-box; 15 | box-sizing: border-box; 16 | } 17 | .noUi-target { 18 | position: relative; 19 | direction: ltr; 20 | } 21 | .noUi-base { 22 | width: 100%; 23 | height: 100%; 24 | position: relative; 25 | z-index: 1; /* Fix 401 */ 26 | } 27 | .noUi-connect { 28 | position: absolute; 29 | right: 0; 30 | top: 0; 31 | left: 0; 32 | bottom: 0; 33 | } 34 | .noUi-origin { 35 | position: absolute; 36 | height: 0; 37 | width: 0; 38 | } 39 | .noUi-handle { 40 | position: relative; 41 | z-index: 1; 42 | cursor: col-resize !important; 43 | } 44 | 45 | .noUi-state-tap .noUi-connect, 46 | .noUi-state-tap .noUi-origin { 47 | -webkit-transition: top 0.3s, right 0.3s, bottom 0.3s, left 0.3s; 48 | transition: top 0.3s, right 0.3s, bottom 0.3s, left 0.3s; 49 | } 50 | .noUi-state-drag * { 51 | cursor: grabbing; 52 | } 53 | 54 | /* Painting and performance; 55 | * Browsers can paint handles in their own layer. 56 | */ 57 | .noUi-base, 58 | .noUi-handle { 59 | -webkit-transform: translate3d(0,0,0); 60 | transform: translate3d(0,0,0); 61 | } 62 | 63 | /* Slider size and handle placement; 64 | */ 65 | .noUi-horizontal { 66 | height: 18px; 67 | } 68 | .noUi-horizontal .noUi-handle { 69 | width: 20px; 70 | height: 18px; 71 | left: 0; 72 | } 73 | 74 | .noUi-horizontal .noUi-handle:hover { 75 | animation: pulse 1s infinite; 76 | } 77 | 78 | @keyframes pulse { 79 | 0% { 80 | transform: scale(1) 81 | } 82 | 50% { 83 | transform: scale(1.2) 84 | } 85 | 100% { 86 | transform: scale(1) 87 | } 88 | } 89 | 90 | .noUi-horizontal .noUi-handle.grid__secondHandle { 91 | left: -20px; 92 | } 93 | 94 | /* Styling; 95 | */ 96 | .noUi-target { 97 | background: #FAFAFA; 98 | border-radius: 4px; 99 | border: 1px solid #D3D3D3; 100 | box-shadow: inset 0 1px 1px #F0F0F0, 0 3px 6px -5px #BBB; 101 | } 102 | 103 | /* Handles and cursors; 104 | */ 105 | .noUi-draggable { 106 | cursor: grab; 107 | display: flex; 108 | justify-content: center; 109 | align-items: center; 110 | font-weight: bold; 111 | font-size: 12px; 112 | color: #fafafa; 113 | background: #566576; 114 | border-radius: 3px; 115 | transition: background .3s; 116 | } 117 | 118 | .is-at-limit .noUi-draggable { 119 | background: #da5a47; 120 | } 121 | 122 | .noUi-state-drag .noUi-draggable { 123 | cursor: grabbing; 124 | } 125 | 126 | .noUi-vertical .noUi-draggable { 127 | cursor: n-resize; 128 | } 129 | .noUi-handle { 130 | border: 1px solid #D9D9D9; 131 | border-radius: 3px; 132 | background: #FFF; 133 | cursor: default; 134 | box-shadow: inset 0 0 1px #FFF, 135 | inset 0 1px 7px #EBEBEB, 136 | 0 3px 6px -3px #BBB; 137 | } 138 | .noUi-active { 139 | box-shadow: inset 0 0 1px #FFF, 140 | inset 0 1px 7px #DDD, 141 | 0 3px 6px -3px #BBB; 142 | } 143 | 144 | /* Handle stripes; 145 | */ 146 | .noUi-handle:before, 147 | .noUi-handle:after { 148 | content: ""; 149 | display: block; 150 | background: #E8E7E6; 151 | } 152 | .noUi-handle:after { 153 | left: 17px; 154 | } 155 | 156 | /* Disabled state; 157 | */ 158 | 159 | [disabled] .noUi-connect { 160 | background: #B8B8B8; 161 | } 162 | [disabled].noUi-target, 163 | [disabled].noUi-handle, 164 | [disabled] .noUi-handle { 165 | cursor: not-allowed; 166 | } 167 | 168 | /*---------------------------------------------------- 169 | Custom Style 170 | -----------------------------------------------------*/ 171 | 172 | .noUi-target { 173 | box-shadow: none; 174 | border: 1px solid rgba(0, 0, 20, 0.1); 175 | height: 20px; 176 | margin-top: 10px; 177 | margin-bottom: 0px; 178 | background: #ffffff; 179 | } 180 | 181 | .noUi-pips .noUi-value-sub, .noUi-pips .noUi-value-large, .noUi-pips .noUi-marker-large { 182 | display: none; 183 | } 184 | 185 | .noUi-pips .noUi-marker-sub, .noUi-pips .noUi-marker-large { 186 | top: -0; 187 | height: 14px; 188 | width: 4px; 189 | background: rgba(0, 0, 20, 0.1); 190 | } 191 | 192 | .noUi-handle { 193 | border: none; 194 | border-radius: 0px; 195 | box-shadow: none; 196 | background: none; 197 | display: flex; 198 | justify-content: center; 199 | align-items: center; 200 | } 201 | 202 | .noUi-handle:before { 203 | display: none; 204 | } 205 | 206 | .noUi-handle:after { 207 | content: "move"; 208 | background: none; 209 | font-family: 'Craft'; 210 | word-wrap: normal !important; 211 | color: rgba(0,0,0,0.2); 212 | font-size: 12px; 213 | } 214 | 215 | .noUi__output { 216 | background-color: #BDC0C4; 217 | display: inline-block; 218 | margin-top: 10px; 219 | padding: 3px; 220 | border-radius: 2px; 221 | font-size: 12px; 222 | color: #fff; 223 | } 224 | 225 | .noUi__total { 226 | background-color: #BDC0C4; 227 | display: inline-block; 228 | padding: 3px; 229 | border-radius: 2px; 230 | position: absolute; 231 | top: 0; 232 | right: 0; 233 | font-size: 10px; 234 | color: #fff; 235 | } 236 | 237 | .noUi__info-block { 238 | display: inline-block; 239 | width: 50%; 240 | color: #BDC0C4; 241 | font-size: 10px; 242 | } 243 | 244 | .noUi__info-block div { 245 | display: inline-block; 246 | margin-right: 5px; 247 | } 248 | 249 | .noUi__width-info span { 250 | display: inline-block; 251 | width: 8px; 252 | height: 8px; 253 | background-color: #DC5942; 254 | vertical-align: inherit; 255 | } 256 | 257 | .noUi__margin-info span { 258 | display: inline-block; 259 | width: 8px; 260 | height: 8px; 261 | background-color: #566576; 262 | vertical-align: inherit; 263 | } 264 | 265 | .noUi__grid-value { 266 | display: block; 267 | width: 75px; 268 | background-color: #BDC0C4; 269 | padding: 2px; 270 | border-radius: 2px; 271 | font-size: 12px; 272 | color: #fff; 273 | text-align: center; 274 | } 275 | 276 | /* hacky targeting fix for craft 2 width field */ 277 | [data-type*="Width"] .noUi-horizontal .noUi-handle { 278 | width: 15px; 279 | height: 15px; 280 | left: -16px; 281 | top: -6px; 282 | } 283 | 284 | [data-type*="Width"] .noUi-horizontal { 285 | height: 18px; 286 | } 287 | [data-type*="Width"] .noUi-horizontal .noUi-handle { 288 | width: 15px; 289 | height: 15px; 290 | left: -16px; 291 | top: -6px; 292 | } 293 | [data-type*="Width"] .noUi-vertical { 294 | width: 18px; 295 | } 296 | [data-type*="Width"] .noUi-vertical .noUi-handle { 297 | width: 28px; 298 | height: 34px; 299 | left: -6px; 300 | top: -17px; 301 | } 302 | 303 | [data-type*="Width"] .noUi-target { 304 | background: #FAFAFA; 305 | border-radius: 4px; 306 | border: 1px solid #D3D3D3; 307 | box-shadow: inset 0 1px 1px #F0F0F0, 0 3px 6px -5px #BBB; 308 | } 309 | [data-type*="Width"] .noUi-connect { 310 | background: #3FB8AF; 311 | box-shadow: inset 0 0 3px rgba(51,51,51,0.45); 312 | -webkit-transition: background 450ms; 313 | transition: background 450ms; 314 | } 315 | 316 | [data-type*="Width"] .noUi-draggable { 317 | cursor: w-resize; 318 | } 319 | [data-type*="Width"] .noUi-vertical .noUi-draggable { 320 | cursor: n-resize; 321 | } 322 | [data-type*="Width"] .noUi-handle { 323 | border: 1px solid #D9D9D9; 324 | border-radius: 3px; 325 | background: #FFF; 326 | cursor: default; 327 | box-shadow: inset 0 0 1px #FFF, 328 | inset 0 1px 7px #EBEBEB, 329 | 0 3px 6px -3px #BBB; 330 | } 331 | [data-type*="Width"] .noUi-active { 332 | box-shadow: inset 0 0 1px #FFF, 333 | inset 0 1px 7px #DDD, 334 | 0 3px 6px -3px #BBB; 335 | } 336 | 337 | [data-type*="Width"] .noUi-handle:before, 338 | [data-type*="Width"] .noUi-handle:after { 339 | content: ""; 340 | display: block; 341 | position: absolute; 342 | height: 14px; 343 | width: 1px; 344 | background: #E8E7E6; 345 | left: 14px; 346 | top: 6px; 347 | } 348 | [data-type*="Width"] .noUi-handle:after { 349 | left: 17px; 350 | } 351 | [data-type*="Width"] .noUi-vertical .noUi-handle:before, 352 | [data-type*="Width"] .noUi-vertical .noUi-handle:after { 353 | width: 14px; 354 | height: 1px; 355 | left: 6px; 356 | top: 14px; 357 | } 358 | [data-type*="Width"] .noUi-vertical .noUi-handle:after { 359 | top: 17px; 360 | } 361 | 362 | [data-type*="Width"] .noUi-target { 363 | border-radius: 0px; 364 | box-shadow: none; 365 | border: none; 366 | height: 5px; 367 | background: #BCBFC3; 368 | margin-top: 50px; 369 | margin-bottom: 50px; 370 | } 371 | 372 | [data-type*="Width"] .noUi-pips .noUi-value-sub, [data-type*="Width"] .noUi-pips .noUi-value-large { 373 | display: none; 374 | } 375 | 376 | [data-type*="Width"] .noUi-pips .noUi-marker-sub, [data-type*="Width"] .noUi-pips .noUi-marker-large { 377 | display: block; 378 | top: -5px; 379 | height: 14px; 380 | width: 2px; 381 | background: #BCBFC3; 382 | } 383 | 384 | [data-type*="Width"] .noUi-base .noUi-connect:nth-child(4) { 385 | background: #DC5942; 386 | } 387 | 388 | [data-type*="Width"] .noUi-base .noUi-connect:nth-child(6), [data-type*="Width"] .noUi-base .noUi-connect:nth-child(2) { 389 | background: #566576; 390 | } 391 | 392 | [data-type*="Width"] .firstHandle, .fourthHandle { 393 | top: 10px !important; 394 | } 395 | 396 | [data-type*="Width"] .secondHandle, .thirdHandle { 397 | top: -30px !important; 398 | } 399 | 400 | [data-type*="Width"] .noUi-handle { 401 | border: none; 402 | border-radius: 0px; 403 | box-shadow: none; 404 | background: none; 405 | } 406 | 407 | [data-type*="Width"] .noUi-handle:before { 408 | display: none; 409 | } 410 | 411 | [data-type*="Width"] .noUi-handle:after { 412 | content: "move"; 413 | position: absolute; 414 | top: 0; 415 | left: 11px; 416 | background: none; 417 | font-family: 'Craft'; 418 | word-wrap: normal !important; 419 | color: rgba(0,0,0,0.2); 420 | cursor: move; 421 | } 422 | 423 | [data-type*="Width"] .noUi-handle:hover:after { 424 | color: #0d78f2; 425 | } 426 | 427 | [data-type*="Width"] .noUi__output { 428 | background-color: #BDC0C4; 429 | display: inline-block; 430 | margin-top: 10px; 431 | padding: 3px; 432 | border-radius: 2px; 433 | font-size: 12px; 434 | color: #fff; 435 | } 436 | 437 | [data-type*="Width"] .noUi__total { 438 | background-color: #BDC0C4; 439 | display: inline-block; 440 | padding: 3px; 441 | border-radius: 2px; 442 | position: absolute; 443 | top: 32px; 444 | right: 0; 445 | font-size: 10px; 446 | color: #fff; 447 | } 448 | 449 | [data-type*="Width"] .noUi__info-block { 450 | position: absolute; 451 | top: 36px; 452 | left: 0; 453 | color: #BDC0C4; 454 | font-size: 10px; 455 | } 456 | 457 | [data-type*="Width"] .noUi__info-block div { 458 | display: inline-block; 459 | margin-right: 5px; 460 | } 461 | 462 | [data-type*="Width"] .noUi__width-info span { 463 | display: inline-block; 464 | width: 8px; 465 | height: 8px; 466 | background-color: #DC5942; 467 | vertical-align: inherit; 468 | } 469 | 470 | [data-type*="Width"] .noUi__margin-info span { 471 | display: inline-block; 472 | width: 8px; 473 | height: 8px; 474 | background-color: #566576; 475 | vertical-align: inherit; 476 | } -------------------------------------------------------------------------------- /src/assetbundles/tools/dist/css/nouislider.pips.css: -------------------------------------------------------------------------------- 1 | 2 | /* Base; 3 | * 4 | */ 5 | .noUi-pips, 6 | .noUi-pips * { 7 | -moz-box-sizing: border-box; 8 | box-sizing: border-box; 9 | } 10 | .noUi-pips { 11 | position: absolute; 12 | color: #999; 13 | z-index: 1; 14 | pointer-events: none; 15 | } 16 | 17 | /* Values; 18 | * 19 | */ 20 | .noUi-value { 21 | position: absolute; 22 | text-align: center; 23 | } 24 | .noUi-value-sub { 25 | color: #ccc; 26 | font-size: 10px; 27 | } 28 | 29 | /* Markings; 30 | * 31 | */ 32 | .noUi-marker { 33 | position: absolute; 34 | background: rgba(0, 0, 20, 0.1); 35 | } 36 | .noUi-marker-sub { 37 | background: rgba(0, 0, 20, 0.1); 38 | } 39 | .noUi-marker-large { 40 | background: rgba(0, 0, 20, 0.1); 41 | } 42 | 43 | /* Horizontal layout; 44 | * 45 | */ 46 | .noUi-pips-horizontal { 47 | padding: 0; 48 | height: 20px; 49 | top: 0%; 50 | left: 0; 51 | width: 100%; 52 | } 53 | .noUi-value-horizontal { 54 | -webkit-transform: translate3d(-50%,50%,0); 55 | transform: translate3d(-50%,50%,0); 56 | } 57 | 58 | .noUi-marker-horizontal.noUi-marker { 59 | width: 1px; 60 | height: 5px; 61 | } 62 | .noUi-marker-horizontal.noUi-marker-sub { 63 | height: 18px; 64 | } 65 | .noUi-marker-horizontal.noUi-marker-large { 66 | height: 15px; 67 | } 68 | 69 | /* Vertical layout; 70 | * 71 | */ 72 | .noUi-pips-vertical { 73 | padding: 0 10px; 74 | height: 100%; 75 | top: 0; 76 | left: 100%; 77 | } 78 | .noUi-value-vertical { 79 | -webkit-transform: translate3d(0,50%,0); 80 | transform: translate3d(0,50%,0); 81 | padding-left: 25px; 82 | } 83 | 84 | .noUi-marker-vertical.noUi-marker { 85 | width: 5px; 86 | height: 2px; 87 | margin-top: -1px; 88 | } 89 | .noUi-marker-vertical.noUi-marker-sub { 90 | width: 10px; 91 | } 92 | .noUi-marker-vertical.noUi-marker-large { 93 | width: 15px; 94 | } 95 | -------------------------------------------------------------------------------- /src/assetbundles/tools/dist/css/tools.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Entries fieldtype with inline search 3 | */ 4 | .oddsandends-elementsearch .add { position: relative; z-index: 1; display: inline-block; width: 12em; } 5 | 6 | body.ltr .oddsandends-elementsearch .add .text { padding-right: 30px; } 7 | body.rtl .oddsandends-elementsearch .add .text { padding-left: 30px; } 8 | 9 | .oddsandends-elementsearch .add .spinner { position: absolute; top: 0; } 10 | body.ltr .oddsandends-elementsearch .add .spinner { right: 5px; } 11 | body.rtl .oddsandends-elementsearch .add .spinner { left: 5px; } 12 | 13 | body.ltr .oddsandends-elementsearch__menu ul li a { padding-left: 32px; } 14 | body.rtl .oddsandends-elementsearch__menu ul li a { padding-right: 32px; } 15 | 16 | body.ltr .oddsandends-elementsearch__menu ul li a .status { float: left; } 17 | body.rtl .oddsandends-elementsearch__menu ul li a .status { float: right; } 18 | body.ltr .oddsandends-elementsearch__menu ul li a .status { margin: 3px 0 0 -18px ; } 19 | body.rtl .oddsandends-elementsearch__menu ul li a .status { margin: 3px -18px 0 0 ; } 20 | 21 | 22 | /** 23 | * Hide delete icons in disabled fields 24 | */ 25 | .oddsandends-disabledcategories .delete, 26 | .oddsandends-disabledelements .delete { 27 | display: none; 28 | } 29 | 30 | 31 | /** 32 | * Styling Author Instruction field 33 | */ 34 | .oddsandends__author-instructions { 35 | background-color: #f1f1f1; 36 | border-left: 4px solid #FF5500; 37 | padding: 15px; 38 | font-size: 15px; 39 | } 40 | -------------------------------------------------------------------------------- /src/assetbundles/tools/dist/js/nouislider.js: -------------------------------------------------------------------------------- 1 | /*! nouislider - 9.0.0 - 2016-09-29 21:44:02 */ 2 | 3 | (function (factory) { 4 | 5 | if ( typeof define === 'function' && define.amd ) { 6 | 7 | // AMD. Register as an anonymous module. 8 | define([], factory); 9 | 10 | } else if ( typeof exports === 'object' ) { 11 | 12 | // Node/CommonJS 13 | module.exports = factory(); 14 | 15 | } else { 16 | 17 | // Browser globals 18 | window.noUiSlider = factory(); 19 | } 20 | 21 | }(function( ){ 22 | 23 | 'use strict'; 24 | 25 | 26 | // Creates a node, adds it to target, returns the new node. 27 | function addNodeTo ( target, className ) { 28 | var div = document.createElement('div'); 29 | addClass(div, className); 30 | target.appendChild(div); 31 | return div; 32 | } 33 | 34 | // Removes duplicates from an array. 35 | function unique ( array ) { 36 | return array.filter(function(a){ 37 | return !this[a] ? this[a] = true : false; 38 | }, {}); 39 | } 40 | 41 | // Round a value to the closest 'to'. 42 | function closest ( value, to ) { 43 | return Math.round(value / to) * to; 44 | } 45 | 46 | // Current position of an element relative to the document. 47 | function offset ( elem, orientation ) { 48 | 49 | var rect = elem.getBoundingClientRect(), 50 | doc = elem.ownerDocument, 51 | docElem = doc.documentElement, 52 | pageOffset = getPageOffset(); 53 | 54 | // getBoundingClientRect contains left scroll in Chrome on Android. 55 | // I haven't found a feature detection that proves this. Worst case 56 | // scenario on mis-match: the 'tap' feature on horizontal sliders breaks. 57 | if ( /webkit.*Chrome.*Mobile/i.test(navigator.userAgent) ) { 58 | pageOffset.x = 0; 59 | } 60 | 61 | return orientation ? (rect.top + pageOffset.y - docElem.clientTop) : (rect.left + pageOffset.x - docElem.clientLeft); 62 | } 63 | 64 | // Checks whether a value is numerical. 65 | function isNumeric ( a ) { 66 | return typeof a === 'number' && !isNaN( a ) && isFinite( a ); 67 | } 68 | 69 | // Sets a class and removes it after [duration] ms. 70 | function addClassFor ( element, className, duration ) { 71 | if (duration > 0) { 72 | addClass(element, className); 73 | setTimeout(function(){ 74 | removeClass(element, className); 75 | }, duration); 76 | } 77 | } 78 | 79 | // Limits a value to 0 - 100 80 | function limit ( a ) { 81 | return Math.max(Math.min(a, 100), 0); 82 | } 83 | 84 | // Wraps a variable as an array, if it isn't one yet. 85 | // Note that an input array is returned by reference! 86 | function asArray ( a ) { 87 | return Array.isArray(a) ? a : [a]; 88 | } 89 | 90 | // Counts decimals 91 | function countDecimals ( numStr ) { 92 | numStr = String(numStr); 93 | var pieces = numStr.split("."); 94 | return pieces.length > 1 ? pieces[1].length : 0; 95 | } 96 | 97 | // http://youmightnotneedjquery.com/#add_class 98 | function addClass ( el, className ) { 99 | if ( el.classList ) { 100 | el.classList.add(className); 101 | } else { 102 | el.className += ' ' + className; 103 | } 104 | } 105 | 106 | // http://youmightnotneedjquery.com/#remove_class 107 | function removeClass ( el, className ) { 108 | if ( el.classList ) { 109 | el.classList.remove(className); 110 | } else { 111 | el.className = el.className.replace(new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'), ' '); 112 | } 113 | } 114 | 115 | // https://plainjs.com/javascript/attributes/adding-removing-and-testing-for-classes-9/ 116 | function hasClass ( el, className ) { 117 | return el.classList ? el.classList.contains(className) : new RegExp('\\b' + className + '\\b').test(el.className); 118 | } 119 | 120 | // https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollY#Notes 121 | function getPageOffset ( ) { 122 | 123 | var supportPageOffset = window.pageXOffset !== undefined, 124 | isCSS1Compat = ((document.compatMode || "") === "CSS1Compat"), 125 | x = supportPageOffset ? window.pageXOffset : isCSS1Compat ? document.documentElement.scrollLeft : document.body.scrollLeft, 126 | y = supportPageOffset ? window.pageYOffset : isCSS1Compat ? document.documentElement.scrollTop : document.body.scrollTop; 127 | 128 | return { 129 | x: x, 130 | y: y 131 | }; 132 | } 133 | 134 | // we provide a function to compute constants instead 135 | // of accessing window.* as soon as the module needs it 136 | // so that we do not compute anything if not needed 137 | function getActions ( ) { 138 | 139 | // Determine the events to bind. IE11 implements pointerEvents without 140 | // a prefix, which breaks compatibility with the IE10 implementation. 141 | return window.navigator.pointerEnabled ? { 142 | start: 'pointerdown', 143 | move: 'pointermove', 144 | end: 'pointerup' 145 | } : window.navigator.msPointerEnabled ? { 146 | start: 'MSPointerDown', 147 | move: 'MSPointerMove', 148 | end: 'MSPointerUp' 149 | } : { 150 | start: 'mousedown touchstart', 151 | move: 'mousemove touchmove', 152 | end: 'mouseup touchend' 153 | }; 154 | } 155 | 156 | 157 | // Value calculation 158 | 159 | // Determine the size of a sub-range in relation to a full range. 160 | function subRangeRatio ( pa, pb ) { 161 | return (100 / (pb - pa)); 162 | } 163 | 164 | // (percentage) How many percent is this value of this range? 165 | function fromPercentage ( range, value ) { 166 | return (value * 100) / ( range[1] - range[0] ); 167 | } 168 | 169 | // (percentage) Where is this value on this range? 170 | function toPercentage ( range, value ) { 171 | return fromPercentage( range, range[0] < 0 ? 172 | value + Math.abs(range[0]) : 173 | value - range[0] ); 174 | } 175 | 176 | // (value) How much is this percentage on this range? 177 | function isPercentage ( range, value ) { 178 | return ((value * ( range[1] - range[0] )) / 100) + range[0]; 179 | } 180 | 181 | 182 | // Range conversion 183 | 184 | function getJ ( value, arr ) { 185 | 186 | var j = 1; 187 | 188 | while ( value >= arr[j] ){ 189 | j += 1; 190 | } 191 | 192 | return j; 193 | } 194 | 195 | // (percentage) Input a value, find where, on a scale of 0-100, it applies. 196 | function toStepping ( xVal, xPct, value ) { 197 | 198 | if ( value >= xVal.slice(-1)[0] ){ 199 | return 100; 200 | } 201 | 202 | var j = getJ( value, xVal ), va, vb, pa, pb; 203 | 204 | va = xVal[j-1]; 205 | vb = xVal[j]; 206 | pa = xPct[j-1]; 207 | pb = xPct[j]; 208 | 209 | return pa + (toPercentage([va, vb], value) / subRangeRatio (pa, pb)); 210 | } 211 | 212 | // (value) Input a percentage, find where it is on the specified range. 213 | function fromStepping ( xVal, xPct, value ) { 214 | 215 | // There is no range group that fits 100 216 | if ( value >= 100 ){ 217 | return xVal.slice(-1)[0]; 218 | } 219 | 220 | var j = getJ( value, xPct ), va, vb, pa, pb; 221 | 222 | va = xVal[j-1]; 223 | vb = xVal[j]; 224 | pa = xPct[j-1]; 225 | pb = xPct[j]; 226 | 227 | return isPercentage([va, vb], (value - pa) * subRangeRatio (pa, pb)); 228 | } 229 | 230 | // (percentage) Get the step that applies at a certain value. 231 | function getStep ( xPct, xSteps, snap, value ) { 232 | 233 | if ( value === 100 ) { 234 | return value; 235 | } 236 | 237 | var j = getJ( value, xPct ), a, b; 238 | 239 | // If 'snap' is set, steps are used as fixed points on the slider. 240 | if ( snap ) { 241 | 242 | a = xPct[j-1]; 243 | b = xPct[j]; 244 | 245 | // Find the closest position, a or b. 246 | if ((value - a) > ((b-a)/2)){ 247 | return b; 248 | } 249 | 250 | return a; 251 | } 252 | 253 | if ( !xSteps[j-1] ){ 254 | return value; 255 | } 256 | 257 | return xPct[j-1] + closest( 258 | value - xPct[j-1], 259 | xSteps[j-1] 260 | ); 261 | } 262 | 263 | 264 | // Entry parsing 265 | 266 | function handleEntryPoint ( index, value, that ) { 267 | 268 | var percentage; 269 | 270 | // Wrap numerical input in an array. 271 | if ( typeof value === "number" ) { 272 | value = [value]; 273 | } 274 | 275 | // Reject any invalid input, by testing whether value is an array. 276 | if ( Object.prototype.toString.call( value ) !== '[object Array]' ){ 277 | throw new Error("noUiSlider: 'range' contains invalid value."); 278 | } 279 | 280 | // Covert min/max syntax to 0 and 100. 281 | if ( index === 'min' ) { 282 | percentage = 0; 283 | } else if ( index === 'max' ) { 284 | percentage = 100; 285 | } else { 286 | percentage = parseFloat( index ); 287 | } 288 | 289 | // Check for correct input. 290 | if ( !isNumeric( percentage ) || !isNumeric( value[0] ) ) { 291 | throw new Error("noUiSlider: 'range' value isn't numeric."); 292 | } 293 | 294 | // Store values. 295 | that.xPct.push( percentage ); 296 | that.xVal.push( value[0] ); 297 | 298 | // NaN will evaluate to false too, but to keep 299 | // logging clear, set step explicitly. Make sure 300 | // not to override the 'step' setting with false. 301 | if ( !percentage ) { 302 | if ( !isNaN( value[1] ) ) { 303 | that.xSteps[0] = value[1]; 304 | } 305 | } else { 306 | that.xSteps.push( isNaN(value[1]) ? false : value[1] ); 307 | } 308 | 309 | that.xHighestCompleteStep.push(0); 310 | } 311 | 312 | function handleStepPoint ( i, n, that ) { 313 | 314 | // Ignore 'false' stepping. 315 | if ( !n ) { 316 | return true; 317 | } 318 | 319 | // Factor to range ratio 320 | that.xSteps[i] = fromPercentage([ 321 | that.xVal[i] 322 | ,that.xVal[i+1] 323 | ], n) / subRangeRatio ( 324 | that.xPct[i], 325 | that.xPct[i+1] ); 326 | 327 | var totalSteps = (that.xVal[i+1] - that.xVal[i]) / that.xNumSteps[i]; 328 | var highestStep = Math.ceil(Number(totalSteps.toFixed(3)) - 1); 329 | var step = that.xVal[i] + (that.xNumSteps[i] * highestStep); 330 | 331 | that.xHighestCompleteStep[i] = step; 332 | } 333 | 334 | 335 | // Interface 336 | 337 | // The interface to Spectrum handles all direction-based 338 | // conversions, so the above values are unaware. 339 | 340 | function Spectrum ( entry, snap, direction, singleStep ) { 341 | 342 | this.xPct = []; 343 | this.xVal = []; 344 | this.xSteps = [ singleStep || false ]; 345 | this.xNumSteps = [ false ]; 346 | this.xHighestCompleteStep = []; 347 | 348 | this.snap = snap; 349 | this.direction = direction; 350 | 351 | var index, ordered = [ /* [0, 'min'], [1, '50%'], [2, 'max'] */ ]; 352 | 353 | // Map the object keys to an array. 354 | for ( index in entry ) { 355 | if ( entry.hasOwnProperty(index) ) { 356 | ordered.push([entry[index], index]); 357 | } 358 | } 359 | 360 | // Sort all entries by value (numeric sort). 361 | if ( ordered.length && typeof ordered[0][0] === "object" ) { 362 | ordered.sort(function(a, b) { return a[0][0] - b[0][0]; }); 363 | } else { 364 | ordered.sort(function(a, b) { return a[0] - b[0]; }); 365 | } 366 | 367 | 368 | // Convert all entries to subranges. 369 | for ( index = 0; index < ordered.length; index++ ) { 370 | handleEntryPoint(ordered[index][1], ordered[index][0], this); 371 | } 372 | 373 | // Store the actual step values. 374 | // xSteps is sorted in the same order as xPct and xVal. 375 | this.xNumSteps = this.xSteps.slice(0); 376 | 377 | // Convert all numeric steps to the percentage of the subrange they represent. 378 | for ( index = 0; index < this.xNumSteps.length; index++ ) { 379 | handleStepPoint(index, this.xNumSteps[index], this); 380 | } 381 | } 382 | 383 | Spectrum.prototype.getMargin = function ( value ) { 384 | 385 | var step = this.xNumSteps[0]; 386 | 387 | if ( step && (value % step) ) { 388 | throw new Error("noUiSlider: 'limit' and 'margin' must be divisible by step."); 389 | } 390 | 391 | return this.xPct.length === 2 ? fromPercentage(this.xVal, value) : false; 392 | }; 393 | 394 | Spectrum.prototype.toStepping = function ( value ) { 395 | 396 | value = toStepping( this.xVal, this.xPct, value ); 397 | 398 | return value; 399 | }; 400 | 401 | Spectrum.prototype.fromStepping = function ( value ) { 402 | 403 | return fromStepping( this.xVal, this.xPct, value ); 404 | }; 405 | 406 | Spectrum.prototype.getStep = function ( value ) { 407 | 408 | value = getStep(this.xPct, this.xSteps, this.snap, value ); 409 | 410 | return value; 411 | }; 412 | 413 | Spectrum.prototype.getNearbySteps = function ( value ) { 414 | 415 | var j = getJ(value, this.xPct); 416 | 417 | return { 418 | stepBefore: { startValue: this.xVal[j-2], step: this.xNumSteps[j-2], highestStep: this.xHighestCompleteStep[j-2] }, 419 | thisStep: { startValue: this.xVal[j-1], step: this.xNumSteps[j-1], highestStep: this.xHighestCompleteStep[j-1] }, 420 | stepAfter: { startValue: this.xVal[j-0], step: this.xNumSteps[j-0], highestStep: this.xHighestCompleteStep[j-0] } 421 | }; 422 | }; 423 | 424 | Spectrum.prototype.countStepDecimals = function () { 425 | var stepDecimals = this.xNumSteps.map(countDecimals); 426 | return Math.max.apply(null, stepDecimals); 427 | }; 428 | 429 | // Outside testing 430 | Spectrum.prototype.convert = function ( value ) { 431 | return this.getStep(this.toStepping(value)); 432 | }; 433 | 434 | /* Every input option is tested and parsed. This'll prevent 435 | endless validation in internal methods. These tests are 436 | structured with an item for every option available. An 437 | option can be marked as required by setting the 'r' flag. 438 | The testing function is provided with three arguments: 439 | - The provided value for the option; 440 | - A reference to the options object; 441 | - The name for the option; 442 | 443 | The testing function returns false when an error is detected, 444 | or true when everything is OK. It can also modify the option 445 | object, to make sure all values can be correctly looped elsewhere. */ 446 | 447 | var defaultFormatter = { 'to': function( value ){ 448 | return value !== undefined && value.toFixed(2); 449 | }, 'from': Number }; 450 | 451 | function testStep ( parsed, entry ) { 452 | 453 | if ( !isNumeric( entry ) ) { 454 | throw new Error("noUiSlider: 'step' is not numeric."); 455 | } 456 | 457 | // The step option can still be used to set stepping 458 | // for linear sliders. Overwritten if set in 'range'. 459 | parsed.singleStep = entry; 460 | } 461 | 462 | function testRange ( parsed, entry ) { 463 | 464 | // Filter incorrect input. 465 | if ( typeof entry !== 'object' || Array.isArray(entry) ) { 466 | throw new Error("noUiSlider: 'range' is not an object."); 467 | } 468 | 469 | // Catch missing start or end. 470 | if ( entry.min === undefined || entry.max === undefined ) { 471 | throw new Error("noUiSlider: Missing 'min' or 'max' in 'range'."); 472 | } 473 | 474 | // Catch equal start or end. 475 | if ( entry.min === entry.max ) { 476 | throw new Error("noUiSlider: 'range' 'min' and 'max' cannot be equal."); 477 | } 478 | 479 | parsed.spectrum = new Spectrum(entry, parsed.snap, parsed.dir, parsed.singleStep); 480 | } 481 | 482 | function testStart ( parsed, entry ) { 483 | 484 | entry = asArray(entry); 485 | 486 | // Validate input. Values aren't tested, as the public .val method 487 | // will always provide a valid location. 488 | if ( !Array.isArray( entry ) || !entry.length ) { 489 | throw new Error("noUiSlider: 'start' option is incorrect."); 490 | } 491 | 492 | // Store the number of handles. 493 | parsed.handles = entry.length; 494 | 495 | // When the slider is initialized, the .val method will 496 | // be called with the start options. 497 | parsed.start = entry; 498 | } 499 | 500 | function testSnap ( parsed, entry ) { 501 | 502 | // Enforce 100% stepping within subranges. 503 | parsed.snap = entry; 504 | 505 | if ( typeof entry !== 'boolean' ){ 506 | throw new Error("noUiSlider: 'snap' option must be a boolean."); 507 | } 508 | } 509 | 510 | function testAnimate ( parsed, entry ) { 511 | 512 | // Enforce 100% stepping within subranges. 513 | parsed.animate = entry; 514 | 515 | if ( typeof entry !== 'boolean' ){ 516 | throw new Error("noUiSlider: 'animate' option must be a boolean."); 517 | } 518 | } 519 | 520 | function testAnimationDuration ( parsed, entry ) { 521 | 522 | parsed.animationDuration = entry; 523 | 524 | if ( typeof entry !== 'number' ){ 525 | throw new Error("noUiSlider: 'animationDuration' option must be a number."); 526 | } 527 | } 528 | 529 | function testConnect ( parsed, entry ) { 530 | 531 | var connect = [false]; 532 | var i; 533 | 534 | if ( entry === true || entry === false ) { 535 | 536 | for ( i = 1; i < parsed.handles; i++ ) { 537 | connect.push(entry); 538 | } 539 | 540 | connect.push(false); 541 | } 542 | 543 | else if ( !Array.isArray( entry ) || !entry.length || entry.length !== parsed.handles + 1 ) { 544 | throw new Error("noUiSlider: 'connect' option doesn't match handle count."); 545 | } 546 | 547 | else { 548 | connect = entry; 549 | } 550 | 551 | parsed.connect = connect; 552 | } 553 | 554 | function testOrientation ( parsed, entry ) { 555 | 556 | // Set orientation to an a numerical value for easy 557 | // array selection. 558 | switch ( entry ){ 559 | case 'horizontal': 560 | parsed.ort = 0; 561 | break; 562 | case 'vertical': 563 | parsed.ort = 1; 564 | break; 565 | default: 566 | throw new Error("noUiSlider: 'orientation' option is invalid."); 567 | } 568 | } 569 | 570 | function testMargin ( parsed, entry ) { 571 | 572 | if ( !isNumeric(entry) ){ 573 | throw new Error("noUiSlider: 'margin' option must be numeric."); 574 | } 575 | 576 | // Issue #582 577 | if ( entry === 0 ) { 578 | return; 579 | } 580 | 581 | parsed.margin = parsed.spectrum.getMargin(entry); 582 | 583 | if ( !parsed.margin ) { 584 | throw new Error("noUiSlider: 'margin' option is only supported on linear sliders."); 585 | } 586 | } 587 | 588 | function testLimit ( parsed, entry ) { 589 | 590 | if ( !isNumeric(entry) ){ 591 | throw new Error("noUiSlider: 'limit' option must be numeric."); 592 | } 593 | 594 | parsed.limit = parsed.spectrum.getMargin(entry); 595 | 596 | if ( !parsed.limit || parsed.handles < 2 ) { 597 | throw new Error("noUiSlider: 'limit' option is only supported on linear sliders with 2 or more handles."); 598 | } 599 | } 600 | 601 | function testDirection ( parsed, entry ) { 602 | 603 | // Set direction as a numerical value for easy parsing. 604 | // Invert connection for RTL sliders, so that the proper 605 | // handles get the connect/background classes. 606 | switch ( entry ) { 607 | case 'ltr': 608 | parsed.dir = 0; 609 | break; 610 | case 'rtl': 611 | parsed.dir = 1; 612 | break; 613 | default: 614 | throw new Error("noUiSlider: 'direction' option was not recognized."); 615 | } 616 | } 617 | 618 | function testBehaviour ( parsed, entry ) { 619 | 620 | // Make sure the input is a string. 621 | if ( typeof entry !== 'string' ) { 622 | throw new Error("noUiSlider: 'behaviour' must be a string containing options."); 623 | } 624 | 625 | // Check if the string contains any keywords. 626 | // None are required. 627 | var tap = entry.indexOf('tap') >= 0; 628 | var drag = entry.indexOf('drag') >= 0; 629 | var fixed = entry.indexOf('fixed') >= 0; 630 | var snap = entry.indexOf('snap') >= 0; 631 | var hover = entry.indexOf('hover') >= 0; 632 | 633 | if ( fixed ) { 634 | 635 | if ( parsed.handles !== 2 ) { 636 | throw new Error("noUiSlider: 'fixed' behaviour must be used with 2 handles"); 637 | } 638 | 639 | // Use margin to enforce fixed state 640 | testMargin(parsed, parsed.start[1] - parsed.start[0]); 641 | } 642 | 643 | parsed.events = { 644 | tap: tap || snap, 645 | drag: drag, 646 | fixed: fixed, 647 | snap: snap, 648 | hover: hover 649 | }; 650 | } 651 | 652 | function testTooltips ( parsed, entry ) { 653 | 654 | if ( entry === false ) { 655 | return; 656 | } 657 | 658 | else if ( entry === true ) { 659 | 660 | parsed.tooltips = []; 661 | 662 | for ( var i = 0; i < parsed.handles; i++ ) { 663 | parsed.tooltips.push(true); 664 | } 665 | } 666 | 667 | else { 668 | 669 | parsed.tooltips = asArray(entry); 670 | 671 | if ( parsed.tooltips.length !== parsed.handles ) { 672 | throw new Error("noUiSlider: must pass a formatter for all handles."); 673 | } 674 | 675 | parsed.tooltips.forEach(function(formatter){ 676 | if ( typeof formatter !== 'boolean' && (typeof formatter !== 'object' || typeof formatter.to !== 'function') ) { 677 | throw new Error("noUiSlider: 'tooltips' must be passed a formatter or 'false'."); 678 | } 679 | }); 680 | } 681 | } 682 | 683 | function testFormat ( parsed, entry ) { 684 | 685 | parsed.format = entry; 686 | 687 | // Any object with a to and from method is supported. 688 | if ( typeof entry.to === 'function' && typeof entry.from === 'function' ) { 689 | return true; 690 | } 691 | 692 | throw new Error("noUiSlider: 'format' requires 'to' and 'from' methods."); 693 | } 694 | 695 | function testCssPrefix ( parsed, entry ) { 696 | 697 | if ( entry !== undefined && typeof entry !== 'string' && entry !== false ) { 698 | throw new Error("noUiSlider: 'cssPrefix' must be a string or `false`."); 699 | } 700 | 701 | parsed.cssPrefix = entry; 702 | } 703 | 704 | function testCssClasses ( parsed, entry ) { 705 | 706 | if ( entry !== undefined && typeof entry !== 'object' ) { 707 | throw new Error("noUiSlider: 'cssClasses' must be an object."); 708 | } 709 | 710 | if ( typeof parsed.cssPrefix === 'string' ) { 711 | parsed.cssClasses = {}; 712 | 713 | for ( var key in entry ) { 714 | if ( !entry.hasOwnProperty(key) ) { continue; } 715 | 716 | parsed.cssClasses[key] = parsed.cssPrefix + entry[key]; 717 | } 718 | } else { 719 | parsed.cssClasses = entry; 720 | } 721 | } 722 | 723 | function testUseRaf ( parsed, entry ) { 724 | if ( entry === true || entry === false ) { 725 | parsed.useRequestAnimationFrame = entry; 726 | } else { 727 | throw new Error("noUiSlider: 'useRequestAnimationFrame' option should be true (default) or false."); 728 | } 729 | } 730 | 731 | // Test all developer settings and parse to assumption-safe values. 732 | function testOptions ( options ) { 733 | 734 | // To prove a fix for #537, freeze options here. 735 | // If the object is modified, an error will be thrown. 736 | // Object.freeze(options); 737 | 738 | var parsed = { 739 | margin: 0, 740 | limit: 0, 741 | animate: true, 742 | animationDuration: 300, 743 | format: defaultFormatter 744 | }, tests; 745 | 746 | // Tests are executed in the order they are presented here. 747 | tests = { 748 | 'step': { r: false, t: testStep }, 749 | 'start': { r: true, t: testStart }, 750 | 'connect': { r: true, t: testConnect }, 751 | 'direction': { r: true, t: testDirection }, 752 | 'snap': { r: false, t: testSnap }, 753 | 'animate': { r: false, t: testAnimate }, 754 | 'animationDuration': { r: false, t: testAnimationDuration }, 755 | 'range': { r: true, t: testRange }, 756 | 'orientation': { r: false, t: testOrientation }, 757 | 'margin': { r: false, t: testMargin }, 758 | 'limit': { r: false, t: testLimit }, 759 | 'behaviour': { r: true, t: testBehaviour }, 760 | 'format': { r: false, t: testFormat }, 761 | 'tooltips': { r: false, t: testTooltips }, 762 | 'cssPrefix': { r: false, t: testCssPrefix }, 763 | 'cssClasses': { r: false, t: testCssClasses }, 764 | 'useRequestAnimationFrame': { r: false, t: testUseRaf } 765 | }; 766 | 767 | var defaults = { 768 | 'connect': false, 769 | 'direction': 'ltr', 770 | 'behaviour': 'tap', 771 | 'orientation': 'horizontal', 772 | 'cssPrefix' : 'noUi-', 773 | 'cssClasses': { 774 | target: 'target', 775 | base: 'base', 776 | origin: 'origin', 777 | handle: 'handle', 778 | horizontal: 'horizontal', 779 | vertical: 'vertical', 780 | background: 'background', 781 | connect: 'connect', 782 | ltr: 'ltr', 783 | rtl: 'rtl', 784 | draggable: 'draggable', 785 | drag: 'state-drag', 786 | tap: 'state-tap', 787 | active: 'active', 788 | tooltip: 'tooltip', 789 | pips: 'pips', 790 | pipsHorizontal: 'pips-horizontal', 791 | pipsVertical: 'pips-vertical', 792 | marker: 'marker', 793 | markerHorizontal: 'marker-horizontal', 794 | markerVertical: 'marker-vertical', 795 | markerNormal: 'marker-normal', 796 | markerLarge: 'marker-large', 797 | markerSub: 'marker-sub', 798 | value: 'value', 799 | valueHorizontal: 'value-horizontal', 800 | valueVertical: 'value-vertical', 801 | valueNormal: 'value-normal', 802 | valueLarge: 'value-large', 803 | valueSub: 'value-sub' 804 | }, 805 | 'useRequestAnimationFrame': true 806 | }; 807 | 808 | // Run all options through a testing mechanism to ensure correct 809 | // input. It should be noted that options might get modified to 810 | // be handled properly. E.g. wrapping integers in arrays. 811 | Object.keys(tests).forEach(function( name ){ 812 | 813 | // If the option isn't set, but it is required, throw an error. 814 | if ( options[name] === undefined && defaults[name] === undefined ) { 815 | 816 | if ( tests[name].r ) { 817 | throw new Error("noUiSlider: '" + name + "' is required."); 818 | } 819 | 820 | return true; 821 | } 822 | 823 | tests[name].t( parsed, options[name] === undefined ? defaults[name] : options[name] ); 824 | }); 825 | 826 | // Forward pips options 827 | parsed.pips = options.pips; 828 | 829 | var styles = [['left', 'top'], ['right', 'bottom']]; 830 | 831 | // Pre-define the styles. 832 | parsed.style = styles[parsed.dir][parsed.ort]; 833 | parsed.styleOposite = styles[parsed.dir?0:1][parsed.ort]; 834 | 835 | return parsed; 836 | } 837 | 838 | 839 | function closure ( target, options, originalOptions ){ 840 | 841 | var actions = getActions( ); 842 | 843 | // All variables local to 'closure' are prefixed with 'scope_' 844 | var scope_Target = target; 845 | var scope_Locations = []; 846 | var scope_Base; 847 | var scope_Handles; 848 | var scope_HandleNumbers = []; 849 | var scope_Connects; 850 | var scope_Spectrum = options.spectrum; 851 | var scope_Values = []; 852 | var scope_Events = {}; 853 | var scope_Self; 854 | 855 | 856 | // Append a origin to the base 857 | function addOrigin ( base, handleNumber ) { 858 | var origin = addNodeTo(base, options.cssClasses.origin); 859 | var handle = addNodeTo(origin, options.cssClasses.handle); 860 | handle.setAttribute('data-handle', handleNumber); 861 | return origin; 862 | } 863 | 864 | // Insert nodes for connect elements 865 | function addConnect ( base, add ) { 866 | 867 | if ( !add ) { 868 | return false; 869 | } 870 | 871 | return addNodeTo(base, options.cssClasses.connect); 872 | } 873 | 874 | // Add handles to the slider base. 875 | function addElements ( connectOptions, base ) { 876 | 877 | scope_Handles = []; 878 | scope_Connects = []; 879 | 880 | scope_Connects.push(addConnect(base, connectOptions[0])); 881 | 882 | // [::::O====O====O====] 883 | // connectOptions = [0, 1, 1, 1] 884 | 885 | for ( var i = 0; i < options.handles; i++ ) { 886 | // Keep a list of all added handles. 887 | scope_Handles.push(addOrigin(base, i)); 888 | scope_HandleNumbers[i] = i; 889 | scope_Connects.push(addConnect(base, connectOptions[i + 1])); 890 | } 891 | } 892 | 893 | // Initialize a single slider. 894 | function addSlider ( target ) { 895 | 896 | // Apply classes and data to the target. 897 | addClass(target, options.cssClasses.target); 898 | 899 | if ( options.dir === 0 ) { 900 | addClass(target, options.cssClasses.ltr); 901 | } else { 902 | addClass(target, options.cssClasses.rtl); 903 | } 904 | 905 | if ( options.ort === 0 ) { 906 | addClass(target, options.cssClasses.horizontal); 907 | } else { 908 | addClass(target, options.cssClasses.vertical); 909 | } 910 | 911 | scope_Base = addNodeTo(target, options.cssClasses.base); 912 | } 913 | 914 | 915 | function addTooltip ( handle, handleNumber ) { 916 | 917 | if ( !options.tooltips[handleNumber] ) { 918 | return false; 919 | } 920 | 921 | return addNodeTo(handle.firstChild, options.cssClasses.tooltip); 922 | } 923 | 924 | // The tooltips option is a shorthand for using the 'update' event. 925 | function tooltips ( ) { 926 | 927 | // Tooltips are added with options.tooltips in original order. 928 | var tips = scope_Handles.map(addTooltip); 929 | 930 | bindEvent('update', function(values, handleNumber, unencoded) { 931 | 932 | if ( !tips[handleNumber] ) { 933 | return; 934 | } 935 | 936 | var formattedValue = values[handleNumber]; 937 | 938 | if ( options.tooltips[handleNumber] !== true ) { 939 | formattedValue = options.tooltips[handleNumber].to(unencoded[handleNumber]); 940 | } 941 | 942 | tips[handleNumber].innerHTML = formattedValue; 943 | }); 944 | } 945 | 946 | 947 | function getGroup ( mode, values, stepped ) { 948 | 949 | // Use the range. 950 | if ( mode === 'range' || mode === 'steps' ) { 951 | return scope_Spectrum.xVal; 952 | } 953 | 954 | if ( mode === 'count' ) { 955 | 956 | // Divide 0 - 100 in 'count' parts. 957 | var spread = ( 100 / (values-1) ), v, i = 0; 958 | values = []; 959 | 960 | // List these parts and have them handled as 'positions'. 961 | while ((v=i++*spread) <= 100 ) { 962 | values.push(v); 963 | } 964 | 965 | mode = 'positions'; 966 | } 967 | 968 | if ( mode === 'positions' ) { 969 | 970 | // Map all percentages to on-range values. 971 | return values.map(function( value ){ 972 | return scope_Spectrum.fromStepping( stepped ? scope_Spectrum.getStep( value ) : value ); 973 | }); 974 | } 975 | 976 | if ( mode === 'values' ) { 977 | 978 | // If the value must be stepped, it needs to be converted to a percentage first. 979 | if ( stepped ) { 980 | 981 | return values.map(function( value ){ 982 | 983 | // Convert to percentage, apply step, return to value. 984 | return scope_Spectrum.fromStepping( scope_Spectrum.getStep( scope_Spectrum.toStepping( value ) ) ); 985 | }); 986 | 987 | } 988 | 989 | // Otherwise, we can simply use the values. 990 | return values; 991 | } 992 | } 993 | 994 | function generateSpread ( density, mode, group ) { 995 | 996 | function safeIncrement(value, increment) { 997 | // Avoid floating point variance by dropping the smallest decimal places. 998 | return (value + increment).toFixed(7) / 1; 999 | } 1000 | 1001 | var indexes = {}, 1002 | firstInRange = scope_Spectrum.xVal[0], 1003 | lastInRange = scope_Spectrum.xVal[scope_Spectrum.xVal.length-1], 1004 | ignoreFirst = false, 1005 | ignoreLast = false, 1006 | prevPct = 0; 1007 | 1008 | // Create a copy of the group, sort it and filter away all duplicates. 1009 | group = unique(group.slice().sort(function(a, b){ return a - b; })); 1010 | 1011 | // Make sure the range starts with the first element. 1012 | if ( group[0] !== firstInRange ) { 1013 | group.unshift(firstInRange); 1014 | ignoreFirst = true; 1015 | } 1016 | 1017 | // Likewise for the last one. 1018 | if ( group[group.length - 1] !== lastInRange ) { 1019 | group.push(lastInRange); 1020 | ignoreLast = true; 1021 | } 1022 | 1023 | group.forEach(function ( current, index ) { 1024 | 1025 | // Get the current step and the lower + upper positions. 1026 | var step, i, q, 1027 | low = current, 1028 | high = group[index+1], 1029 | newPct, pctDifference, pctPos, type, 1030 | steps, realSteps, stepsize; 1031 | 1032 | // When using 'steps' mode, use the provided steps. 1033 | // Otherwise, we'll step on to the next subrange. 1034 | if ( mode === 'steps' ) { 1035 | step = scope_Spectrum.xNumSteps[ index ]; 1036 | } 1037 | 1038 | // Default to a 'full' step. 1039 | if ( !step ) { 1040 | step = high-low; 1041 | } 1042 | 1043 | // Low can be 0, so test for false. If high is undefined, 1044 | // we are at the last subrange. Index 0 is already handled. 1045 | if ( low === false || high === undefined ) { 1046 | return; 1047 | } 1048 | 1049 | // Make sure step isn't 0, which would cause an infinite loop (#654) 1050 | step = Math.max(step, 0.0000001); 1051 | 1052 | // Find all steps in the subrange. 1053 | for ( i = low; i <= high; i = safeIncrement(i, step) ) { 1054 | 1055 | // Get the percentage value for the current step, 1056 | // calculate the size for the subrange. 1057 | newPct = scope_Spectrum.toStepping( i ); 1058 | pctDifference = newPct - prevPct; 1059 | 1060 | steps = pctDifference / density; 1061 | realSteps = Math.round(steps); 1062 | 1063 | // This ratio represents the ammount of percentage-space a point indicates. 1064 | // For a density 1 the points/percentage = 1. For density 2, that percentage needs to be re-devided. 1065 | // Round the percentage offset to an even number, then divide by two 1066 | // to spread the offset on both sides of the range. 1067 | stepsize = pctDifference/realSteps; 1068 | 1069 | // Divide all points evenly, adding the correct number to this subrange. 1070 | // Run up to <= so that 100% gets a point, event if ignoreLast is set. 1071 | for ( q = 1; q <= realSteps; q += 1 ) { 1072 | 1073 | // The ratio between the rounded value and the actual size might be ~1% off. 1074 | // Correct the percentage offset by the number of points 1075 | // per subrange. density = 1 will result in 100 points on the 1076 | // full range, 2 for 50, 4 for 25, etc. 1077 | pctPos = prevPct + ( q * stepsize ); 1078 | indexes[pctPos.toFixed(5)] = ['x', 0]; 1079 | } 1080 | 1081 | // Determine the point type. 1082 | type = (group.indexOf(i) > -1) ? 1 : ( mode === 'steps' ? 2 : 0 ); 1083 | 1084 | // Enforce the 'ignoreFirst' option by overwriting the type for 0. 1085 | if ( !index && ignoreFirst ) { 1086 | type = 0; 1087 | } 1088 | 1089 | if ( !(i === high && ignoreLast)) { 1090 | // Mark the 'type' of this point. 0 = plain, 1 = real value, 2 = step value. 1091 | indexes[newPct.toFixed(5)] = [i, type]; 1092 | } 1093 | 1094 | // Update the percentage count. 1095 | prevPct = newPct; 1096 | } 1097 | }); 1098 | 1099 | return indexes; 1100 | } 1101 | 1102 | function addMarking ( spread, filterFunc, formatter ) { 1103 | 1104 | var element = document.createElement('div'), 1105 | out = '', 1106 | valueSizeClasses = [ 1107 | options.cssClasses.valueNormal, 1108 | options.cssClasses.valueLarge, 1109 | options.cssClasses.valueSub 1110 | ], 1111 | markerSizeClasses = [ 1112 | options.cssClasses.markerNormal, 1113 | options.cssClasses.markerLarge, 1114 | options.cssClasses.markerSub 1115 | ], 1116 | valueOrientationClasses = [ 1117 | options.cssClasses.valueHorizontal, 1118 | options.cssClasses.valueVertical 1119 | ], 1120 | markerOrientationClasses = [ 1121 | options.cssClasses.markerHorizontal, 1122 | options.cssClasses.markerVertical 1123 | ]; 1124 | 1125 | addClass(element, options.cssClasses.pips); 1126 | addClass(element, options.ort === 0 ? options.cssClasses.pipsHorizontal : options.cssClasses.pipsVertical); 1127 | 1128 | function getClasses( type, source ){ 1129 | var a = source === options.cssClasses.value, 1130 | orientationClasses = a ? valueOrientationClasses : markerOrientationClasses, 1131 | sizeClasses = a ? valueSizeClasses : markerSizeClasses; 1132 | 1133 | return source + ' ' + orientationClasses[options.ort] + ' ' + sizeClasses[type]; 1134 | } 1135 | 1136 | function getTags( offset, source, values ) { 1137 | return 'class="' + getClasses(values[1], source) + '" style="' + options.style + ': ' + offset + '%"'; 1138 | } 1139 | 1140 | function addSpread ( offset, values ){ 1141 | 1142 | // Apply the filter function, if it is set. 1143 | values[1] = (values[1] && filterFunc) ? filterFunc(values[0], values[1]) : values[1]; 1144 | 1145 | // Add a marker for every point 1146 | out += '
'; 1147 | 1148 | // Values are only appended for points marked '1' or '2'. 1149 | if ( values[1] ) { 1150 | out += '
' + formatter.to(values[0]) + '
'; 1151 | } 1152 | } 1153 | 1154 | // Append all points. 1155 | Object.keys(spread).forEach(function(a){ 1156 | addSpread(a, spread[a]); 1157 | }); 1158 | 1159 | element.innerHTML = out; 1160 | 1161 | return element; 1162 | } 1163 | 1164 | function pips ( grid ) { 1165 | 1166 | var mode = grid.mode, 1167 | density = grid.density || 1, 1168 | filter = grid.filter || false, 1169 | values = grid.values || false, 1170 | stepped = grid.stepped || false, 1171 | group = getGroup( mode, values, stepped ), 1172 | spread = generateSpread( density, mode, group ), 1173 | format = grid.format || { 1174 | to: Math.round 1175 | }; 1176 | 1177 | return scope_Target.appendChild(addMarking( 1178 | spread, 1179 | filter, 1180 | format 1181 | )); 1182 | } 1183 | 1184 | 1185 | // Shorthand for base dimensions. 1186 | function baseSize ( ) { 1187 | var rect = scope_Base.getBoundingClientRect(), alt = 'offset' + ['Width', 'Height'][options.ort]; 1188 | return options.ort === 0 ? (rect.width||scope_Base[alt]) : (rect.height||scope_Base[alt]); 1189 | } 1190 | 1191 | // Handler for attaching events trough a proxy. 1192 | function attachEvent ( events, element, callback, data ) { 1193 | 1194 | // This function can be used to 'filter' events to the slider. 1195 | // element is a node, not a nodeList 1196 | 1197 | var method = function ( e ){ 1198 | 1199 | if ( scope_Target.hasAttribute('disabled') ) { 1200 | return false; 1201 | } 1202 | 1203 | // Stop if an active 'tap' transition is taking place. 1204 | if ( hasClass(scope_Target, options.cssClasses.tap) ) { 1205 | return false; 1206 | } 1207 | 1208 | e = fixEvent(e, data.pageOffset); 1209 | 1210 | // Ignore right or middle clicks on start #454 1211 | if ( events === actions.start && e.buttons !== undefined && e.buttons > 1 ) { 1212 | return false; 1213 | } 1214 | 1215 | // Ignore right or middle clicks on start #454 1216 | if ( data.hover && e.buttons ) { 1217 | return false; 1218 | } 1219 | 1220 | e.calcPoint = e.points[ options.ort ]; 1221 | 1222 | // Call the event handler with the event [ and additional data ]. 1223 | callback ( e, data ); 1224 | }; 1225 | 1226 | var methods = []; 1227 | 1228 | // Bind a closure on the target for every event type. 1229 | events.split(' ').forEach(function( eventName ){ 1230 | element.addEventListener(eventName, method, false); 1231 | methods.push([eventName, method]); 1232 | }); 1233 | 1234 | return methods; 1235 | } 1236 | 1237 | // Provide a clean event with standardized offset values. 1238 | function fixEvent ( e, pageOffset ) { 1239 | 1240 | // Prevent scrolling and panning on touch events, while 1241 | // attempting to slide. The tap event also depends on this. 1242 | e.preventDefault(); 1243 | 1244 | // Filter the event to register the type, which can be 1245 | // touch, mouse or pointer. Offset changes need to be 1246 | // made on an event specific basis. 1247 | var touch = e.type.indexOf('touch') === 0, 1248 | mouse = e.type.indexOf('mouse') === 0, 1249 | pointer = e.type.indexOf('pointer') === 0, 1250 | x,y, event = e; 1251 | 1252 | // IE10 implemented pointer events with a prefix; 1253 | if ( e.type.indexOf('MSPointer') === 0 ) { 1254 | pointer = true; 1255 | } 1256 | 1257 | if ( touch ) { 1258 | 1259 | // Fix bug when user touches with two or more fingers on mobile devices. 1260 | // It's useful when you have two or more sliders on one page, 1261 | // that can be touched simultaneously. 1262 | // #649, #663, #668 1263 | if ( event.touches.length > 1 ) { 1264 | return false; 1265 | } 1266 | 1267 | // noUiSlider supports one movement at a time, 1268 | // so we can select the first 'changedTouch'. 1269 | x = e.changedTouches[0].pageX; 1270 | y = e.changedTouches[0].pageY; 1271 | } 1272 | 1273 | pageOffset = pageOffset || getPageOffset(); 1274 | 1275 | if ( mouse || pointer ) { 1276 | x = e.clientX + pageOffset.x; 1277 | y = e.clientY + pageOffset.y; 1278 | } 1279 | 1280 | event.pageOffset = pageOffset; 1281 | event.points = [x, y]; 1282 | event.cursor = mouse || pointer; // Fix #435 1283 | 1284 | return event; 1285 | } 1286 | 1287 | function calcPointToPercentage ( calcPoint ) { 1288 | var location = calcPoint - offset(scope_Base, options.ort); 1289 | var proposal = ( location * 100 ) / baseSize(); 1290 | return options.dir ? 100 - proposal : proposal; 1291 | } 1292 | 1293 | function getClosestHandle ( proposal ) { 1294 | 1295 | var closest = 100; 1296 | var handleNumber = false; 1297 | 1298 | scope_Handles.forEach(function(handle, index){ 1299 | 1300 | // Disabled handles are ignored 1301 | if ( handle.hasAttribute('disabled') ) { 1302 | return; 1303 | } 1304 | 1305 | var pos = Math.abs(scope_Locations[index] - proposal); 1306 | 1307 | if ( pos < closest ) { 1308 | handleNumber = index; 1309 | closest = pos; 1310 | } 1311 | }); 1312 | 1313 | return handleNumber; 1314 | } 1315 | 1316 | // Moves handle(s) by a percentage 1317 | // (bool, % to move, [% where handle started, ...], [index in scope_Handles, ...]) 1318 | function moveHandles ( upward, proposal, locations, handleNumbers ) { 1319 | 1320 | var proposals = locations.slice(); 1321 | 1322 | var b = [!upward, upward]; 1323 | var f = [upward, !upward]; 1324 | 1325 | // Copy handleNumbers so we don't change the dataset 1326 | handleNumbers = handleNumbers.slice(); 1327 | 1328 | // Check to see which handle is 'leading'. 1329 | // If that one can't move the second can't either. 1330 | if ( upward ) { 1331 | handleNumbers.reverse(); 1332 | } 1333 | 1334 | // Step 1: get the maximum percentage that any of the handles can move 1335 | if ( handleNumbers.length > 1 ) { 1336 | 1337 | handleNumbers.forEach(function(handleNumber, o) { 1338 | 1339 | var to = checkHandlePosition(proposals, handleNumber, proposals[handleNumber] + proposal, b[o], f[o]); 1340 | 1341 | // Stop if one of the handles can't move. 1342 | if ( to === false ) { 1343 | proposal = 0; 1344 | } else { 1345 | proposal = to - proposals[handleNumber]; 1346 | proposals[handleNumber] = to; 1347 | } 1348 | }); 1349 | } 1350 | 1351 | // If using one handle, check backward AND forward 1352 | else { 1353 | b = f = [true]; 1354 | } 1355 | 1356 | var state = false; 1357 | 1358 | // Step 2: Try to set the handles with the found percentage 1359 | handleNumbers.forEach(function(handleNumber, o) { 1360 | state = setHandle(handleNumber, locations[handleNumber] + proposal, b[o], f[o]) || state; 1361 | }); 1362 | 1363 | // Step 3: If a handle moved, fire events 1364 | if ( state ) { 1365 | handleNumbers.forEach(function(handleNumber){ 1366 | fireEvent('update', handleNumber); 1367 | fireEvent('slide', handleNumber); 1368 | }); 1369 | } 1370 | } 1371 | 1372 | // External event handling 1373 | function fireEvent ( eventName, handleNumber, tap ) { 1374 | 1375 | Object.keys(scope_Events).forEach(function( targetEvent ) { 1376 | 1377 | var eventType = targetEvent.split('.')[0]; 1378 | 1379 | if ( eventName === eventType ) { 1380 | scope_Events[targetEvent].forEach(function( callback ) { 1381 | 1382 | callback.call( 1383 | // Use the slider public API as the scope ('this') 1384 | scope_Self, 1385 | // Return values as array, so arg_1[arg_2] is always valid. 1386 | scope_Values.map(options.format.to), 1387 | // Handle index, 0 or 1 1388 | handleNumber, 1389 | // Unformatted slider values 1390 | scope_Values.slice(), 1391 | // Event is fired by tap, true or false 1392 | tap || false, 1393 | // Left offset of the handle, in relation to the slider 1394 | scope_Locations.slice() 1395 | ); 1396 | }); 1397 | } 1398 | }); 1399 | } 1400 | 1401 | 1402 | // Fire 'end' when a mouse or pen leaves the document. 1403 | function documentLeave ( event, data ) { 1404 | if ( event.type === "mouseout" && event.target.nodeName === "HTML" && event.relatedTarget === null ){ 1405 | eventEnd (event, data); 1406 | } 1407 | } 1408 | 1409 | // Handle movement on document for handle and range drag. 1410 | function eventMove ( event, data ) { 1411 | 1412 | // Fix #498 1413 | // Check value of .buttons in 'start' to work around a bug in IE10 mobile (data.buttonsProperty). 1414 | // https://connect.microsoft.com/IE/feedback/details/927005/mobile-ie10-windows-phone-buttons-property-of-pointermove-event-always-zero 1415 | // IE9 has .buttons and .which zero on mousemove. 1416 | // Firefox breaks the spec MDN defines. 1417 | if ( navigator.appVersion.indexOf("MSIE 9") === -1 && event.buttons === 0 && data.buttonsProperty !== 0 ) { 1418 | return eventEnd(event, data); 1419 | } 1420 | 1421 | // Check if we are moving up or down 1422 | var movement = (options.dir ? -1 : 1) * (event.calcPoint - data.startCalcPoint); 1423 | 1424 | // Convert the movement into a percentage of the slider width/height 1425 | var proposal = (movement * 100) / data.baseSize; 1426 | 1427 | moveHandles(movement > 0, proposal, data.locations, data.handleNumbers); 1428 | } 1429 | 1430 | // Unbind move events on document, call callbacks. 1431 | function eventEnd ( event, data ) { 1432 | 1433 | // The handle is no longer active, so remove the class. 1434 | var active = scope_Base.querySelector( '.' + options.cssClasses.active ); 1435 | 1436 | if ( active !== null ) { 1437 | removeClass(active, options.cssClasses.active); 1438 | } 1439 | 1440 | // Remove cursor styles and text-selection events bound to the body. 1441 | if ( event.cursor ) { 1442 | document.body.style.cursor = ''; 1443 | document.body.removeEventListener('selectstart', document.body.noUiListener); 1444 | } 1445 | 1446 | // Unbind the move and end events, which are added on 'start'. 1447 | document.documentElement.noUiListeners.forEach(function( c ) { 1448 | document.documentElement.removeEventListener(c[0], c[1]); 1449 | }); 1450 | 1451 | // Remove dragging class. 1452 | removeClass(scope_Target, options.cssClasses.drag); 1453 | 1454 | setZindex(); 1455 | 1456 | data.handleNumbers.forEach(function(handleNumber){ 1457 | fireEvent('set', handleNumber); 1458 | fireEvent('change', handleNumber); 1459 | fireEvent('end', handleNumber); 1460 | }); 1461 | } 1462 | 1463 | // Bind move events on document. 1464 | function eventStart ( event, data ) { 1465 | 1466 | // Mark the handle as 'active' so it can be styled. 1467 | if ( data.handleNumbers.length === 1 ) { 1468 | 1469 | var handle = scope_Handles[data.handleNumbers[0]]; 1470 | 1471 | // Ignore 'disabled' handles 1472 | if ( handle.hasAttribute('disabled') ) { 1473 | return false; 1474 | } 1475 | 1476 | addClass(handle.children[0], options.cssClasses.active); 1477 | } 1478 | 1479 | // Fix #551, where a handle gets selected instead of dragged. 1480 | event.preventDefault(); 1481 | 1482 | // A drag should never propagate up to the 'tap' event. 1483 | event.stopPropagation(); 1484 | 1485 | // Attach the move and end events. 1486 | var moveEvent = attachEvent(actions.move, document.documentElement, eventMove, { 1487 | startCalcPoint: event.calcPoint, 1488 | baseSize: baseSize(), 1489 | pageOffset: event.pageOffset, 1490 | handleNumbers: data.handleNumbers, 1491 | buttonsProperty: event.buttons, 1492 | locations: scope_Locations.slice() 1493 | }); 1494 | 1495 | var endEvent = attachEvent(actions.end, document.documentElement, eventEnd, { 1496 | handleNumbers: data.handleNumbers 1497 | }); 1498 | 1499 | var outEvent = attachEvent("mouseout", document.documentElement, documentLeave, { 1500 | handleNumbers: data.handleNumbers 1501 | }); 1502 | 1503 | document.documentElement.noUiListeners = moveEvent.concat(endEvent, outEvent); 1504 | 1505 | // Text selection isn't an issue on touch devices, 1506 | // so adding cursor styles can be skipped. 1507 | if ( event.cursor ) { 1508 | 1509 | // Prevent the 'I' cursor and extend the range-drag cursor. 1510 | document.body.style.cursor = getComputedStyle(event.target).cursor; 1511 | 1512 | // Mark the target with a dragging state. 1513 | if ( scope_Handles.length > 1 ) { 1514 | addClass(scope_Target, options.cssClasses.drag); 1515 | } 1516 | 1517 | var f = function(){ 1518 | return false; 1519 | }; 1520 | 1521 | document.body.noUiListener = f; 1522 | 1523 | // Prevent text selection when dragging the handles. 1524 | document.body.addEventListener('selectstart', f, false); 1525 | } 1526 | 1527 | data.handleNumbers.forEach(function(handleNumber){ 1528 | fireEvent('start', handleNumber); 1529 | }); 1530 | } 1531 | 1532 | // Move closest handle to tapped location. 1533 | function eventTap ( event ) { 1534 | 1535 | // The tap event shouldn't propagate up 1536 | event.stopPropagation(); 1537 | 1538 | var proposal = calcPointToPercentage(event.calcPoint); 1539 | var handleNumber = getClosestHandle(proposal); 1540 | 1541 | // Tackle the case that all handles are 'disabled'. 1542 | if ( handleNumber === false ) { 1543 | return false; 1544 | } 1545 | 1546 | // Flag the slider as it is now in a transitional state. 1547 | // Transition takes a configurable amount of ms (default 300). Re-enable the slider after that. 1548 | if ( !options.events.snap ) { 1549 | addClassFor(scope_Target, options.cssClasses.tap, options.animationDuration); 1550 | } 1551 | 1552 | setHandle(handleNumber, proposal, true, true); 1553 | 1554 | setZindex(); 1555 | 1556 | fireEvent('slide', handleNumber, true); 1557 | fireEvent('set', handleNumber, true); 1558 | fireEvent('change', handleNumber, true); 1559 | fireEvent('update', handleNumber, true); 1560 | 1561 | if ( options.events.snap ) { 1562 | eventStart(event, { handleNumbers: [handleNumber] }); 1563 | } 1564 | } 1565 | 1566 | // Fires a 'hover' event for a hovered mouse/pen position. 1567 | function eventHover ( event ) { 1568 | 1569 | var proposal = calcPointToPercentage(event.calcPoint); 1570 | 1571 | var to = scope_Spectrum.getStep(proposal); 1572 | var value = scope_Spectrum.fromStepping(to); 1573 | 1574 | Object.keys(scope_Events).forEach(function( targetEvent ) { 1575 | if ( 'hover' === targetEvent.split('.')[0] ) { 1576 | scope_Events[targetEvent].forEach(function( callback ) { 1577 | callback.call( scope_Self, value ); 1578 | }); 1579 | } 1580 | }); 1581 | } 1582 | 1583 | // Attach events to several slider parts. 1584 | function bindSliderEvents ( behaviour ) { 1585 | 1586 | // Attach the standard drag event to the handles. 1587 | if ( !behaviour.fixed ) { 1588 | 1589 | scope_Handles.forEach(function( handle, index ){ 1590 | 1591 | // These events are only bound to the visual handle 1592 | // element, not the 'real' origin element. 1593 | attachEvent ( actions.start, handle.children[0], eventStart, { 1594 | handleNumbers: [index] 1595 | }); 1596 | }); 1597 | } 1598 | 1599 | // Attach the tap event to the slider base. 1600 | if ( behaviour.tap ) { 1601 | attachEvent (actions.start, scope_Base, eventTap, {}); 1602 | } 1603 | 1604 | // Fire hover events 1605 | if ( behaviour.hover ) { 1606 | attachEvent (actions.move, scope_Base, eventHover, { hover: true }); 1607 | } 1608 | 1609 | // Make the range draggable. 1610 | if ( behaviour.drag ){ 1611 | 1612 | scope_Connects.forEach(function( connect, index ){ 1613 | 1614 | if ( connect === false || index === 0 || index === scope_Connects.length - 1 ) { 1615 | return; 1616 | } 1617 | 1618 | var handleBefore = scope_Handles[index - 1]; 1619 | var handleAfter = scope_Handles[index]; 1620 | var eventHolders = [connect]; 1621 | 1622 | addClass(connect, options.cssClasses.draggable); 1623 | 1624 | // When the range is fixed, the entire range can 1625 | // be dragged by the handles. The handle in the first 1626 | // origin will propagate the start event upward, 1627 | // but it needs to be bound manually on the other. 1628 | if ( behaviour.fixed ) { 1629 | eventHolders.push(handleBefore.children[0]); 1630 | eventHolders.push(handleAfter.children[0]); 1631 | } 1632 | 1633 | eventHolders.forEach(function( eventHolder ) { 1634 | attachEvent ( actions.start, eventHolder, eventStart, { 1635 | handles: [handleBefore, handleAfter], 1636 | handleNumbers: [index - 1, index] 1637 | }); 1638 | }); 1639 | }); 1640 | } 1641 | } 1642 | 1643 | 1644 | // Split out the handle positioning logic so the Move event can use it, too 1645 | function checkHandlePosition ( reference, handleNumber, to, lookBackward, lookForward ) { 1646 | 1647 | // For sliders with multiple handles, limit movement to the other handle. 1648 | // Apply the margin option by adding it to the handle positions. 1649 | if ( scope_Handles.length > 1 ) { 1650 | 1651 | if ( lookBackward && handleNumber > 0 ) { 1652 | to = Math.max(to, reference[handleNumber - 1] + options.margin); 1653 | } 1654 | 1655 | if ( lookForward && handleNumber < scope_Handles.length - 1 ) { 1656 | to = Math.min(to, reference[handleNumber + 1] - options.margin); 1657 | } 1658 | } 1659 | 1660 | // The limit option has the opposite effect, limiting handles to a 1661 | // maximum distance from another. Limit must be > 0, as otherwise 1662 | // handles would be unmoveable. 1663 | if ( scope_Handles.length > 1 && options.limit ) { 1664 | 1665 | if ( lookBackward && handleNumber > 0 ) { 1666 | to = Math.min(to, reference[handleNumber - 1] + options.limit); 1667 | } 1668 | 1669 | if ( lookForward && handleNumber < scope_Handles.length - 1 ) { 1670 | to = Math.max(to, reference[handleNumber + 1] - options.limit); 1671 | } 1672 | } 1673 | 1674 | to = scope_Spectrum.getStep(to); 1675 | 1676 | // Limit percentage to the 0 - 100 range 1677 | to = limit(to); 1678 | 1679 | // Return false if handle can't move 1680 | if ( to === reference[handleNumber] ) { 1681 | return false; 1682 | } 1683 | 1684 | return to; 1685 | } 1686 | 1687 | function toPct ( pct ) { 1688 | return pct + '%'; 1689 | } 1690 | 1691 | // Updates scope_Locations and scope_Values, updates visual state 1692 | function updateHandlePosition ( handleNumber, to ) { 1693 | 1694 | // Update locations. 1695 | scope_Locations[handleNumber] = to; 1696 | 1697 | // Convert the value to the slider stepping/range. 1698 | scope_Values[handleNumber] = scope_Spectrum.fromStepping(to); 1699 | 1700 | // Called synchronously or on the next animationFrame 1701 | var stateUpdate = function() { 1702 | scope_Handles[handleNumber].style[options.style] = toPct(to); 1703 | updateConnect(handleNumber); 1704 | updateConnect(handleNumber + 1); 1705 | }; 1706 | 1707 | // Set the handle to the new position. 1708 | // Use requestAnimationFrame for efficient painting. 1709 | // No significant effect in Chrome, Edge sees dramatic performace improvements. 1710 | // Option to disable is useful for unit tests, and single-step debugging. 1711 | if ( window.requestAnimationFrame && options.useRequestAnimationFrame ) { 1712 | window.requestAnimationFrame(stateUpdate); 1713 | } else { 1714 | stateUpdate(); 1715 | } 1716 | } 1717 | 1718 | function setZindex ( ) { 1719 | 1720 | scope_HandleNumbers.forEach(function(handleNumber){ 1721 | // Handles before the slider middle are stacked later = higher, 1722 | // Handles after the middle later is lower 1723 | // [[7] [8] .......... | .......... [5] [4] 1724 | var dir = (scope_Locations[handleNumber] > 50 ? -1 : 1); 1725 | var zIndex = 3 + (scope_Handles.length + (dir * handleNumber)); 1726 | scope_Handles[handleNumber].childNodes[0].style.zIndex = zIndex; 1727 | }); 1728 | } 1729 | 1730 | // Test suggested values and apply margin, step. 1731 | function setHandle ( handleNumber, to, lookBackward, lookForward ) { 1732 | 1733 | to = checkHandlePosition(scope_Locations, handleNumber, to, lookBackward, lookForward); 1734 | 1735 | if ( to === false ) { 1736 | return false; 1737 | } 1738 | 1739 | updateHandlePosition(handleNumber, to); 1740 | 1741 | return true; 1742 | } 1743 | 1744 | // Updates style attribute for connect nodes 1745 | function updateConnect ( index ) { 1746 | 1747 | // Skip connects set to false 1748 | if ( !scope_Connects[index] ) { 1749 | return; 1750 | } 1751 | 1752 | var l = 0; 1753 | var h = 100; 1754 | 1755 | if ( index !== 0 ) { 1756 | l = scope_Locations[index - 1]; 1757 | } 1758 | 1759 | if ( index !== scope_Connects.length - 1 ) { 1760 | h = scope_Locations[index]; 1761 | } 1762 | 1763 | scope_Connects[index].style[options.style] = toPct(l); 1764 | scope_Connects[index].style[options.styleOposite] = toPct(100 - h); 1765 | } 1766 | 1767 | // ... 1768 | function setValue ( to, handleNumber ) { 1769 | 1770 | // Setting with null indicates an 'ignore'. 1771 | // Inputting 'false' is invalid. 1772 | if ( to === null || to === false ) { 1773 | return; 1774 | } 1775 | 1776 | // If a formatted number was passed, attemt to decode it. 1777 | if ( typeof to === 'number' ) { 1778 | to = String(to); 1779 | } 1780 | 1781 | to = options.format.from(to); 1782 | 1783 | // Request an update for all links if the value was invalid. 1784 | // Do so too if setting the handle fails. 1785 | if ( to !== false && !isNaN(to) ) { 1786 | setHandle(handleNumber, scope_Spectrum.toStepping(to), false, false); 1787 | } 1788 | } 1789 | 1790 | // Set the slider value. 1791 | function valueSet ( input, fireSetEvent ) { 1792 | 1793 | var values = asArray(input); 1794 | var isInit = scope_Locations[0] === undefined; 1795 | 1796 | // Event fires by default 1797 | fireSetEvent = (fireSetEvent === undefined ? true : !!fireSetEvent); 1798 | 1799 | values.forEach(setValue); 1800 | 1801 | // Animation is optional. 1802 | // Make sure the initial values were set before using animated placement. 1803 | if ( options.animate && !isInit ) { 1804 | addClassFor(scope_Target, options.cssClasses.tap, options.animationDuration); 1805 | } 1806 | 1807 | // Now that all base values are set, apply constraints 1808 | scope_HandleNumbers.forEach(function(handleNumber){ 1809 | setHandle(handleNumber, scope_Locations[handleNumber], true, false); 1810 | }); 1811 | 1812 | setZindex(); 1813 | 1814 | scope_HandleNumbers.forEach(function(handleNumber){ 1815 | 1816 | fireEvent('update', handleNumber); 1817 | 1818 | // Fire the event only for handles that received a new value, as per #579 1819 | if ( values[handleNumber] !== null && fireSetEvent ) { 1820 | fireEvent('set', handleNumber); 1821 | } 1822 | }); 1823 | } 1824 | 1825 | function valueReset ( fireSetEvent ) { 1826 | valueSet(options.start, fireSetEvent); 1827 | } 1828 | 1829 | // Get the slider value. 1830 | function valueGet ( ) { 1831 | 1832 | var values = scope_Values.map(options.format.to); 1833 | 1834 | // If only one handle is used, return a single value. 1835 | if ( values.length === 1 ){ 1836 | return values[0]; 1837 | } 1838 | 1839 | return values; 1840 | } 1841 | 1842 | // Removes classes from the root and empties it. 1843 | function destroy ( ) { 1844 | 1845 | for ( var key in options.cssClasses ) { 1846 | if ( !options.cssClasses.hasOwnProperty(key) ) { continue; } 1847 | removeClass(scope_Target, options.cssClasses[key]); 1848 | } 1849 | 1850 | while (scope_Target.firstChild) { 1851 | scope_Target.removeChild(scope_Target.firstChild); 1852 | } 1853 | 1854 | delete scope_Target.noUiSlider; 1855 | } 1856 | 1857 | // Get the current step size for the slider. 1858 | function getCurrentStep ( ) { 1859 | 1860 | // Check all locations, map them to their stepping point. 1861 | // Get the step point, then find it in the input list. 1862 | return scope_Locations.map(function( location, index ){ 1863 | 1864 | var nearbySteps = scope_Spectrum.getNearbySteps( location ); 1865 | var value = scope_Values[index]; 1866 | var increment = nearbySteps.thisStep.step; 1867 | var decrement = null; 1868 | 1869 | // If the next value in this step moves into the next step, 1870 | // the increment is the start of the next step - the current value 1871 | if ( increment !== false ) { 1872 | if ( value + increment > nearbySteps.stepAfter.startValue ) { 1873 | increment = nearbySteps.stepAfter.startValue - value; 1874 | } 1875 | } 1876 | 1877 | // If the value is beyond the starting point 1878 | if ( value > nearbySteps.thisStep.startValue ) { 1879 | decrement = nearbySteps.thisStep.step; 1880 | } 1881 | 1882 | else if ( nearbySteps.stepBefore.step === false ) { 1883 | decrement = false; 1884 | } 1885 | 1886 | // If a handle is at the start of a step, it always steps back into the previous step first 1887 | else { 1888 | decrement = value - nearbySteps.stepBefore.highestStep; 1889 | } 1890 | 1891 | // Now, if at the slider edges, there is not in/decrement 1892 | if ( location === 100 ) { 1893 | increment = null; 1894 | } 1895 | 1896 | else if ( location === 0 ) { 1897 | decrement = null; 1898 | } 1899 | 1900 | // As per #391, the comparison for the decrement step can have some rounding issues. 1901 | var stepDecimals = scope_Spectrum.countStepDecimals(); 1902 | 1903 | // Round per #391 1904 | if ( increment !== null && increment !== false ) { 1905 | increment = Number(increment.toFixed(stepDecimals)); 1906 | } 1907 | 1908 | if ( decrement !== null && decrement !== false ) { 1909 | decrement = Number(decrement.toFixed(stepDecimals)); 1910 | } 1911 | 1912 | return [decrement, increment]; 1913 | }); 1914 | } 1915 | 1916 | // Attach an event to this slider, possibly including a namespace 1917 | function bindEvent ( namespacedEvent, callback ) { 1918 | scope_Events[namespacedEvent] = scope_Events[namespacedEvent] || []; 1919 | scope_Events[namespacedEvent].push(callback); 1920 | 1921 | // If the event bound is 'update,' fire it immediately for all handles. 1922 | if ( namespacedEvent.split('.')[0] === 'update' ) { 1923 | scope_Handles.forEach(function(a, index){ 1924 | fireEvent('update', index); 1925 | }); 1926 | } 1927 | } 1928 | 1929 | // Undo attachment of event 1930 | function removeEvent ( namespacedEvent ) { 1931 | 1932 | var event = namespacedEvent && namespacedEvent.split('.')[0], 1933 | namespace = event && namespacedEvent.substring(event.length); 1934 | 1935 | Object.keys(scope_Events).forEach(function( bind ){ 1936 | 1937 | var tEvent = bind.split('.')[0], 1938 | tNamespace = bind.substring(tEvent.length); 1939 | 1940 | if ( (!event || event === tEvent) && (!namespace || namespace === tNamespace) ) { 1941 | delete scope_Events[bind]; 1942 | } 1943 | }); 1944 | } 1945 | 1946 | // Updateable: margin, limit, step, range, animate, snap 1947 | function updateOptions ( optionsToUpdate, fireSetEvent ) { 1948 | 1949 | // Spectrum is created using the range, snap, direction and step options. 1950 | // 'snap' and 'step' can be updated, 'direction' cannot, due to event binding. 1951 | // If 'snap' and 'step' are not passed, they should remain unchanged. 1952 | var v = valueGet(); 1953 | 1954 | var updateAble = ['margin', 'limit', 'range', 'animate', 'snap', 'step', 'format']; 1955 | 1956 | // Only change options that we're actually passed to update. 1957 | updateAble.forEach(function(name){ 1958 | if ( optionsToUpdate[name] !== undefined ) { 1959 | originalOptions[name] = optionsToUpdate[name]; 1960 | } 1961 | }); 1962 | 1963 | var newOptions = testOptions(originalOptions); 1964 | 1965 | // Load new options into the slider state 1966 | updateAble.forEach(function(name){ 1967 | if ( optionsToUpdate[name] !== undefined ) { 1968 | options[name] = newOptions[name]; 1969 | } 1970 | }); 1971 | 1972 | // Save current spectrum direction as testOptions in testRange call 1973 | // doesn't rely on current direction 1974 | newOptions.spectrum.direction = scope_Spectrum.direction; 1975 | scope_Spectrum = newOptions.spectrum; 1976 | 1977 | // Limit and margin depend on the spectrum but are stored outside of it. (#677) 1978 | options.margin = newOptions.margin; 1979 | options.limit = newOptions.limit; 1980 | 1981 | // Invalidate the current positioning so valueSet forces an update. 1982 | scope_Locations = []; 1983 | valueSet(optionsToUpdate.start || v, fireSetEvent); 1984 | } 1985 | 1986 | // Throw an error if the slider was already initialized. 1987 | if ( scope_Target.noUiSlider ) { 1988 | throw new Error('Slider was already initialized.'); 1989 | } 1990 | 1991 | // Create the base element, initialise HTML and set classes. 1992 | // Add handles and connect elements. 1993 | addSlider(scope_Target); 1994 | addElements(options.connect, scope_Base); 1995 | 1996 | scope_Self = { 1997 | destroy: destroy, 1998 | steps: getCurrentStep, 1999 | on: bindEvent, 2000 | off: removeEvent, 2001 | get: valueGet, 2002 | set: valueSet, 2003 | reset: valueReset, 2004 | // Exposed for unit testing, don't use this in your application. 2005 | __moveHandles: function(a, b, c) { moveHandles(a, b, scope_Locations, c); }, 2006 | options: originalOptions, // Issue #600, #678 2007 | updateOptions: updateOptions, 2008 | target: scope_Target, // Issue #597 2009 | pips: pips // Issue #594 2010 | }; 2011 | 2012 | // Attach user events. 2013 | bindSliderEvents(options.events); 2014 | 2015 | // Use the public value method to set the start values. 2016 | valueSet(options.start); 2017 | 2018 | if ( options.pips ) { 2019 | pips(options.pips); 2020 | } 2021 | 2022 | if ( options.tooltips ) { 2023 | tooltips(); 2024 | } 2025 | 2026 | return scope_Self; 2027 | 2028 | } 2029 | 2030 | 2031 | // Run the standard initializer 2032 | function initialize ( target, originalOptions ) { 2033 | 2034 | if ( !target.nodeName ) { 2035 | throw new Error('noUiSlider.create requires a single element.'); 2036 | } 2037 | 2038 | // Test the options and create the slider environment; 2039 | var options = testOptions( originalOptions, target ); 2040 | var api = closure( target, options, originalOptions ); 2041 | 2042 | target.noUiSlider = api; 2043 | 2044 | return api; 2045 | } 2046 | 2047 | // Use an object instead of a function for future expansibility; 2048 | return { 2049 | create: initialize 2050 | }; 2051 | 2052 | })); -------------------------------------------------------------------------------- /src/assetbundles/tools/dist/js/tools.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Spicy Web 3 | * @author Josh Angell 4 | * @copyright Copyright (c) 2022, Spicy Web 5 | * @copyright Copyright (c) 2016, Supercool Ltd 6 | */ 7 | 8 | (function($){ 9 | 10 | 11 | if (typeof OddsAndEnds == 'undefined') 12 | { 13 | OddsAndEnds = {} 14 | } 15 | 16 | 17 | /** 18 | * Make all links in instructions open in a new window - useful for fields 19 | * where only markdown is supported 20 | */ 21 | OddsAndEnds.TargetBlankInstructionLinks = Garnish.Base.extend( 22 | { 23 | init: function() 24 | { 25 | this.addListener(Garnish.$win, 'load resize', 'apply'); 26 | }, 27 | 28 | apply: function() 29 | { 30 | Garnish.$bod.find('.instructions a').each(function() 31 | { 32 | $(this).attr('target', '_blank'); 33 | }); 34 | } 35 | }); 36 | 37 | 38 | /** 39 | * Search elements like Tags - forked from `Craft.TagSelectInput` 40 | */ 41 | OddsAndEnds.ElementSearchInput = Craft.BaseElementSelectInput.extend( 42 | { 43 | searchTimeout: null, 44 | searchMenu: null, 45 | limit: null, 46 | 47 | $container: null, 48 | $elementsContainer: null, 49 | $elements: null, 50 | $addElementContainer: null, 51 | $addElementInput: null, 52 | $spinner: null, 53 | 54 | _ignoreBlur: false, 55 | _initialized: false, 56 | 57 | init: function(settings) 58 | { 59 | this.base($.extend({}, settings)); 60 | 61 | this.$addElementContainer = this.$container.children('.add'); 62 | this.$addElementInput = this.$addElementContainer.children('.text'); 63 | this.$spinner = this.$addElementInput.next(); 64 | 65 | // No reason for this to be sortable if we're only allowing 1 selection 66 | if (this.settings.limit == 1) 67 | { 68 | this.settings.sortable = false; 69 | } 70 | 71 | // Ping this so the input hides on load if it needs to 72 | this.updateAddElementsBtn(); 73 | 74 | // Bind listeners 75 | this.addListener(this.$addElementInput, 'textchange', $.proxy(function() 76 | { 77 | if (this.searchTimeout) 78 | { 79 | clearTimeout(this.searchTimeout); 80 | } 81 | 82 | this.searchTimeout = setTimeout($.proxy(this, 'searchForElements'), 500); 83 | }, this)); 84 | 85 | this.addListener(this.$addElementInput, 'keypress', function(ev) 86 | { 87 | if (ev.keyCode == Garnish.RETURN_KEY) 88 | { 89 | ev.preventDefault(); 90 | 91 | if (this.searchMenu) 92 | { 93 | this.selectElement(this.searchMenu.$options[0]); 94 | } 95 | } 96 | }); 97 | 98 | this.addListener(this.$addElementInput, 'focus', function() 99 | { 100 | if (this.searchMenu) 101 | { 102 | this.searchMenu.show(); 103 | } 104 | }); 105 | 106 | this.addListener(this.$addElementInput, 'blur', function() 107 | { 108 | if (this._ignoreBlur) 109 | { 110 | this._ignoreBlur = false; 111 | return; 112 | } 113 | 114 | setTimeout($.proxy(function() 115 | { 116 | if (this.searchMenu) 117 | { 118 | this.searchMenu.hide(); 119 | } 120 | }, this), 1); 121 | }); 122 | 123 | this._initialized = true; 124 | }, 125 | 126 | getElements: function() 127 | { 128 | return this.$elementsContainer.find('.element'); 129 | }, 130 | 131 | // I’m hi-jacking these as they get fired when the limit is breached 132 | disableAddElementsBtn: function() 133 | { 134 | if (this.$addElementContainer && !this.$addElementContainer.hasClass('disabled')) 135 | { 136 | this.$addElementInput.prop('disabled', true); 137 | this.$addElementContainer 138 | .velocity('fadeOut', { duration: Craft.BaseElementSelectInput.ADD_FX_DURATION }) 139 | .addClass('disabled'); 140 | } 141 | }, 142 | enableAddElementsBtn: function() 143 | { 144 | if (this.$addElementContainer && this.$addElementContainer.hasClass('disabled')) 145 | { 146 | this.$addElementInput.prop('disabled', false); 147 | this.$addElementContainer 148 | .velocity('fadeIn', { display: 'inline-block', duration: Craft.BaseElementSelectInput.REMOVE_FX_DURATION }) 149 | .removeClass('disabled'); 150 | this.$addElementInput.trigger('focus'); 151 | } 152 | }, 153 | 154 | searchForElements: function() 155 | { 156 | if (this.searchMenu) 157 | { 158 | this.killSearchMenu(); 159 | } 160 | 161 | var val = this.$addElementInput.val(); 162 | 163 | if (val) 164 | { 165 | this.$spinner.removeClass('hidden'); 166 | 167 | var excludeIds = []; 168 | this.$elements.each(function() 169 | { 170 | var id = $(this).data('id'); 171 | if (id) 172 | { 173 | excludeIds.push(id); 174 | } 175 | }); 176 | 177 | if (this.settings.sourceElementId) 178 | { 179 | excludeIds.push(this.settings.sourceElementId); 180 | } 181 | 182 | var data = { 183 | search: this.$addElementInput.val(), 184 | sources: this.settings.sources, 185 | elementType: this.settings.elementType, 186 | excludeIds: excludeIds 187 | }; 188 | 189 | Craft.postActionRequest('tools/tools/search-for-elements', data, $.proxy(function(response, textStatus) 190 | { 191 | this.$spinner.addClass('hidden'); 192 | 193 | if (textStatus == 'success') 194 | { 195 | var $menu = $('