diff --git a/.bazelrc b/.bazelrc index 80d0a762af..6b9b4aaca3 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,5 +1,15 @@ -# Compile/run with Java 21 -build --java_language_version=21 +# Enable Java 21 build --java_runtime_version=21 +build --java_language_version=21 build --tool_java_language_version=21 build --tool_java_runtime_version=21 + +# Don't leak PATH and LD_LIBRARY_PATH into the build. +build --incompatible_strict_action_env + +# To facilitate testing in bazelci incompatible flags +# @see https://github.com/bazelbuild/bazel/pull/26906#issue-3386957462 +build --incompatible_autoload_externally= + +# For bazel 8 BCR CI +build --incompatible_disable_native_repo_rules diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 0000000000..6da4de57dc --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +8.4.1 diff --git a/.bcr/config.yml b/.bcr/config.yml deleted file mode 100644 index 994a1e5e8b..0000000000 --- a/.bcr/config.yml +++ /dev/null @@ -1,3 +0,0 @@ -fixedReleaser: - login: mollyibot - email: mollyibot@google.com diff --git a/.bcr/metadata.template.json b/.bcr/metadata.template.json index 7565a4213b..2d531842a7 100644 --- a/.bcr/metadata.template.json +++ b/.bcr/metadata.template.json @@ -1,25 +1,15 @@ { - "homepage": "https://github.com/bazelbuild/rules_closure", + "homepage": "https://github.com/stackb/rules_closure", "maintainers": [ { - "name": "Goktug Gokdogan", - "email": "goktug@google.com", - "github": "gkdn" - }, - { - "name": "Julien Dramaix", - "email": "dramaix@google.com", - "github": "jDramaix" - }, - { - "name": "Yuan Tian", - "email": "mollyibot@google.com", - "github": "mollyibot" + "name": "Paul Cody", + "email": "pcj@stack.build", + "github": "pcj" } ], "repository": [ - "github:bazelbuild/rules_closure" + "github:stackb/rules_closure" ], "versions": [], "yanked_versions": {} -} +} \ No newline at end of file diff --git a/.bcr/presubmit.yml b/.bcr/presubmit.yml index 8cfb8c8c97..b19745d3a0 100644 --- a/.bcr/presubmit.yml +++ b/.bcr/presubmit.yml @@ -1,15 +1,12 @@ -matrix: - platform: - - macos - - ubuntu2004 - bazel: - - "7.x" - - "8.x" -tasks: - run_tests: - bazel: ${{ bazel }} - platform: ${{ platform }} - build_targets: - - "..." - test_targets: - - "..." +bcr_test_module: + module_path: "." + matrix: + platform: ["debian11", "ubuntu2404", "macos"] + bazel: [7.x, 8.x] + tasks: + run_tests: + name: "Build module" + platform: ${{ platform }} + bazel: ${{ bazel }} + test_targets: + - "//..." diff --git a/.bcr/source.template.json b/.bcr/source.template.json index 9efefe985e..f8192ee372 100644 --- a/.bcr/source.template.json +++ b/.bcr/source.template.json @@ -1,5 +1,5 @@ { "integrity": "", - "strip_prefix": "{REPO}-{VERSION}", + "strip_prefix": "{REPO}-{TAG}", "url": "https://github.com/{OWNER}/{REPO}/releases/download/{TAG}/{REPO}-{TAG}.tar.gz" } \ No newline at end of file diff --git a/.github/workflows/ci.bazelrc b/.github/workflows/ci.bazelrc new file mode 100644 index 0000000000..5f63ad54c3 --- /dev/null +++ b/.github/workflows/ci.bazelrc @@ -0,0 +1,13 @@ +# Debug where options came from +build --announce_rc + +# Don't rely on test logs being easily accessible from the test runner, +# though it makes the log noisier. +test --test_output=errors + +# This directory is configured in GitHub actions to be persisted between runs. +build --disk_cache=$HOME/.cache/bazel +build --repository_cache=$HOME/.cache/bazel-repo + +# Allows tests to run bazelisk-in-bazel, since this is the cache folder used +test --test_env=XDG_CACHE_HOME diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000000..6fbee1f082 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,34 @@ +name: CI + +# Controls when the action will run. +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + # Cancel previous actions from the same PR: https://stackoverflow.com/a/72408109 + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - uses: bazel-contrib/setup-bazel@0.15.0 + with: + # Avoid downloading Bazel every time. + bazelisk-cache: true + # Store build cache per workflow. + disk-cache: true + # Share repository cache between workflows. + repository-cache: true + - name: bazel test + run: >- + bazelisk + --bazelrc=.github/workflows/ci.bazelrc + --bazelrc=.bazelrc + test + //... diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000000..aa1cb7ccda --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,36 @@ +# Publish new releases to Bazel Central Registry. +name: Publish to BCR +on: + # Run the publish workflow after a successful release + # Will be triggered from the release.yaml workflow + workflow_call: + inputs: + tag_name: + required: true + type: string + secrets: + publish_token: + required: true + # In case of problems, let release engineers retry by manually dispatching + # the workflow from the GitHub UI + workflow_dispatch: + inputs: + tag_name: + description: git tag being released + required: true + type: string +jobs: + publish: + uses: bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml@v0.2.3 + with: + draft: false + tag_name: ${{ inputs.tag_name }} + # GitHub repository which is a fork of the upstream where the Pull Request will be opened. + registry_fork: stackb/bazel-central-registry + permissions: + attestations: write + contents: write + id-token: write + secrets: + # Necessary to push to the BCR fork, and to open a pull request against a registry + publish_token: ${{ secrets.publish_token || secrets.BCR_PUBLISH_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000000..4274bfd73f --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,36 @@ +# Cut a release whenever a new tag is pushed to the repo. +name: Release +on: + # Can be triggered from the tag.yaml workflow + workflow_call: + inputs: + tag_name: + required: true + type: string + secrets: + publish_token: + required: true + # Or, developers can manually push a tag from their clone + push: + tags: + - "v*.*.*" +permissions: + id-token: write + attestations: write + contents: write +jobs: + release: + uses: bazel-contrib/.github/.github/workflows/release_ruleset.yaml@v7.2.3 + # uses: ./.github/workflows/release_ruleset.yaml # copied-from: bazel-contrib/.github/.github/workflows/release_ruleset.yaml@v7.2.3 + with: + prerelease: false + release_files: rules_closure-*.tar.gz + tag_name: ${{ inputs.tag_name || github.ref_name }} + secrets: inherit + publish: + needs: release + uses: ./.github/workflows/publish.yaml + with: + tag_name: ${{ inputs.tag_name || github.ref_name }} + secrets: + publish_token: ${{ secrets.publish_token || secrets.BCR_PUBLISH_TOKEN }} diff --git a/.github/workflows/release_prep.sh b/.github/workflows/release_prep.sh new file mode 100755 index 0000000000..80856c2a3e --- /dev/null +++ b/.github/workflows/release_prep.sh @@ -0,0 +1,28 @@ + +#!/usr/bin/env bash + +set -o errexit -o nounset -o pipefail + +# Set by GH actions, see +# https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables +readonly TAG=$1 +# The prefix is chosen to match what GitHub generates for source archives. +# This guarantees that users can easily switch from a released artifact to a source archive +# with minimal differences in their code (e.g. strip_prefix remains the same) +readonly PREFIX="rules_closure-${TAG}" +readonly ARCHIVE="${PREFIX}.tar.gz" + +# NB: configuration for 'git archive' is in /.gitattributes +git archive --format=tar --prefix=${PREFIX}/ ${TAG} | gzip > $ARCHIVE +SHA=$(shasum -a 256 $ARCHIVE | awk '{print $1}') + +# The stdout of this program will be used as the top of the release notes for this release. +cat << EOF +## Using bzlmod with Bazel 6 or later: + +Add to your \`MODULE.bazel\` file: + +\`\`\`starlark +bazel_dep(name = "rules_closure", version = "${TAG}") +\`\`\` +EOF diff --git a/.github/workflows/release_ruleset.yaml b/.github/workflows/release_ruleset.yaml new file mode 100644 index 0000000000..b790a305b0 --- /dev/null +++ b/.github/workflows/release_ruleset.yaml @@ -0,0 +1,208 @@ +# Reusable workflow that can be referenced by repositories in their `.github/workflows/release.yaml`. +# See example usage in https://github.com/bazel-contrib/rules-template/blob/main/.github/workflows/release.yaml +# +# This workflow calls `.github/workflows/release_prep.sh` as the command to prepare the release. +# Release notes are expected to be outputted to stdout from the release prep command. +# +# This workflow uses https://github.com/bazel-contrib/setup-bazel to prepare the cache folders. +# Caching may be disabled by setting `mount_bazel_caches` to false. +# +# The workflow requires the following permissions to be set on the invoking job: +# +# permissions: +# id-token: write # Needed to attest provenance +# attestations: write # Needed to attest provenance +# contents: write # Needed to upload release files + +permissions: {} + +on: + # Make this workflow reusable, see + # https://github.blog/2022-02-10-using-reusable-workflows-github-actions + workflow_call: + inputs: + release_files: + required: true + description: | + Newline-delimited globs of paths to assets to upload for release. + relative to the module repository. The paths should include any files + such as a release archive created by the release_prep script`. + + See https://github.com/softprops/action-gh-release#inputs. + type: string + # TODO: there's a security design problem here: + # Users of a workflow_dispatch trigger could fill in something via the GH Web UI + # that would cause the release to use an arbitrary script. + # That change wouldn't be reflected in the sources in the repo, and therefore + # would not be verifiable by the attestation. + # For now, we force this path to be hard-coded. + # + # release_prep_command: + # default: .github/workflows/release_prep.sh + # description: | + # Command to run to prepare the release and generate release notes. + # Release notes are expected to be outputted to stdout. + # type: string + bazel_test_command: + default: "bazel test //..." + description: | + Bazel test command that may be overridden to set custom flags and targets. + The --disk_cache=~/.cache/bazel-disk-cache --repository_cache=~/.cache/bazel-repository-cache flags are + automatically appended to the command. + type: string + mount_bazel_caches: + default: true + description: | + Whether to enable caching in the bazel-contrib/setup-bazel action. + type: boolean + prerelease: + default: true + description: Indicator of whether or not this is a prerelease. + type: boolean + draft: + default: false + description: | + Whether the release should be created as a draft or published immediately. + type: boolean + tag_name: + description: | + The tag which is being released. + By default, https://github.com/softprops/action-gh-release will use `github.ref_name`. + type: string + +jobs: + build: + outputs: + release-files-artifact-id: ${{ steps.upload-release-files.outputs.artifact-id }} + release-notes-artifact-id: ${{ steps.upload-release-notes.outputs.artifact-id }} + runs-on: self-hosted + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag_name }} + + - uses: bazel-contrib/setup-bazel@0.14.0 + with: + disk-cache: ${{ inputs.mount_bazel_caches }} + repository-cache: ${{ inputs.mount_bazel_caches }} + + - name: Test + run: ${{ inputs.bazel_test_command }} --disk_cache=~/.cache/bazel-disk-cache --repository_cache=~/.cache/bazel-repository-cache + + # Fetch built artifacts (if any) from earlier jobs, which the release script may want to read. + # Extract into ${GITHUB_WORKSPACE}/artifacts/* + - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 + + - name: Build release artifacts and prepare release notes + run: | + if [ ! -f ".github/workflows/release_prep.sh" ]; then + echo "ERROR: create a .github/workflows/release_prep.sh script" + exit 1 + fi + .github/workflows/release_prep.sh ${{ inputs.tag_name || github.ref_name }} > release_notes.txt + + - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 #v4.6.0 + id: upload-release-files + with: + name: release_files + path: ${{ inputs.release_files }} + + - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 #v4.6.0 + id: upload-release-notes + with: + name: release_notes + path: release_notes.txt + + attest: + needs: build + outputs: + attestations-artifact-id: ${{ steps.upload-attestations.outputs.artifact-id }} + permissions: + id-token: write + attestations: write + runs-on: ubuntu-latest + steps: + # actions/download-artifact@v4 does not yet support downloading via the immutable artifact-id, + # but the Javascript library does. See: https://github.com/actions/download-artifact/issues/349 + - run: npm install @actions/artifact@2.1.9 + - name: download-release-files + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + ARTIFACT_ID: ${{ needs.build.outputs.release-files-artifact-id }} + with: + script: | + const {default: artifactClient} = require('@actions/artifact') + const { ARTIFACT_ID } = process.env + await artifactClient.downloadArtifact(ARTIFACT_ID, { path: 'release_files/'}) + + # https://github.com/actions/attest-build-provenance + - name: Attest release files + id: attest_release + uses: actions/attest-build-provenance@v2 + with: + subject-path: release_files/**/* + + # The Bazel Central Registry requires an attestation per release archive, but the + # actions/attest-build-provenance action only produces a single attestation for a + # list of subjects. Copy the combined attestations into individually named + # .intoto.jsonl files. + - name: Write release archive attestations into intoto.jsonl + id: write_release_archive_attestation + run: | + # https://bazel.build/rules/lib/repo/http#http_archive + RELEASE_ARCHIVE_REGEX="(\.zip|\.jar|\.war|\.aar|\.tar|\.tar\.gz|\.tgz|\.tar\.xz|\.txz|\.tar\.xzt|\.tzst|\.tar\.bz2|\.ar|\.deb)$" + + ATTESTATIONS_DIR=$(mktemp --directory) + for filename in $(find release_files/ -type f -printf "%f\n"); do + if [[ "${filename}" =~ $RELEASE_ARCHIVE_REGEX ]]; then + ATTESTATION_FILE="$(basename "${filename}").intoto.jsonl" + echo "Writing attestation to ${ATTESTATION_FILE}" + cat ${{ steps.attest_release.outputs.bundle-path }} | jq --compact-output > "${ATTESTATIONS_DIR}/${ATTESTATION_FILE}" + fi + done + echo "release_archive_attestations_dir=${ATTESTATIONS_DIR}" >> $GITHUB_OUTPUT + - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 #v4.6.0 + id: upload-attestations + with: + name: attestations + path: ${{ steps.write_release_archive_attestation.outputs.release_archive_attestations_dir }}/* + + release: + needs: [build, attest] + permissions: + contents: write + runs-on: ubuntu-latest + steps: + # actions/download-artifact@v4 does not yet support downloading via the immutable artifact-id, + # but the Javascript library does. See: https://github.com/actions/download-artifact/issues/349 + - run: npm install @actions/artifact@2.1.9 + - name: download-artifacts + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + RELEASE_FILES_ARTIFACT_ID: ${{ needs.build.outputs.release-files-artifact-id }} + RELEASE_NOTES_ARTIFACT_ID: ${{ needs.build.outputs.release-notes-artifact-id }} + ATTESTATIONS_ARTIFACT_ID: ${{ needs.attest.outputs.attestations-artifact-id }} + with: + script: | + const {default: artifactClient} = require('@actions/artifact') + const { RELEASE_FILES_ARTIFACT_ID, RELEASE_NOTES_ARTIFACT_ID, ATTESTATIONS_ARTIFACT_ID } = process.env + await Promise.all([ + artifactClient.downloadArtifact(RELEASE_FILES_ARTIFACT_ID, { path: 'release_files/'}), + artifactClient.downloadArtifact(RELEASE_NOTES_ARTIFACT_ID, { path: 'release_notes/'}), + artifactClient.downloadArtifact(ATTESTATIONS_ARTIFACT_ID, { path: 'attestations/'}) + ]) + + - name: Release + uses: softprops/action-gh-release@v2 + with: + prerelease: ${{ inputs.prerelease }} + draft: ${{ inputs.draft }} + # Use GH feature to populate the changelog automatically + generate_release_notes: true + body_path: release_notes/release_notes.txt + fail_on_unmatched_files: true + tag_name: ${{ inputs.tag_name }} + files: | + release_files/**/* + attestations/* diff --git a/MODULE.bazel b/MODULE.bazel index a795b4e314..25881e5c97 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,94 +1,77 @@ module( - name = "rules_closure", - version = "0.15.0", -) - -bazel_dep( - name = "bazel_skylib", - version = "1.7.1", -) - -bazel_dep( - name = "platforms", - version = "0.0.5", -) - -# TODO(mollyibot): Remove this in the future. -bazel_dep( - name = "rules_proto", - version = "7.0.2", -) - -bazel_dep( - name = "protobuf", - version = "31.0", - repo_name = "com_google_protobuf", -) - -bazel_dep( - name = "google_bazel_common", - version = "0.0.1", -) - -bazel_dep( - name = "rules_webtesting", - version = "0.4.1", -) - -bazel_dep( - name = "rules_web_testing_java", - version = "0.4.1", -) - -##### Java dependencies ##### -bazel_dep( - name = "rules_java", - version = "8.6.1", -) - -bazel_dep( - name = "rules_jvm_external", - version = "6.6", -) - -maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") - -maven.install( - artifacts = [ - "args4j:args4j:2.33", - "com.google.closure-stylesheets:closure-stylesheets:1.5.0", - "com.google.dagger:dagger-producers:2.43.2", - "com.google.jimfs:jimfs:1.1", - "net.java.dev.javacc:javacc:7.0.13", - "org.jsoup:jsoup:1.16.1", - "org.seleniumhq.selenium:selenium-remote-driver:4.27.0", - "org.seleniumhq.selenium:selenium-api:4.27.0", - "org.seleniumhq.selenium:selenium-support:4.27.0", - ], -) - -# Using maven.artifact here because the version number vxxxxx cannot parsed correctly. -maven.artifact( - artifact = "closure-compiler", - group = "com.google.javascript", - version = "v20250402", -) - -# javacc:javacc was not updated since 2008 and relocated to net.java.dev.javacc -maven.override( - coordinates = "javacc:javacc", - target = "@maven//:net_java_dev_javacc_javacc", -) - -use_repo(maven, "maven") + name = "stackb_rules_closure", + version = "0.0.0", + compatibility_level = 1, +) + +# ------------------------------------------------------------------- +# Direct Dependencies +# ------------------------------------------------------------------- + +bazel_dep(name = "bazel_skylib", version = "1.8.2") +bazel_dep(name = "build_stack_rules_proto", version = "4.1.1") +bazel_dep(name = "closure-templates", version = "1.0.1") +bazel_dep(name = "google_bazel_common", version = "0.0.1") +bazel_dep(name = "platforms", version = "1.0.0") +bazel_dep(name = "protobuf_javascript", version = "0.0.0") +bazel_dep(name = "protobuf", version = "32.1", repo_name = "com_google_protobuf") +bazel_dep(name = "rules_java", version = "8.16.1") +bazel_dep(name = "rules_shell", version = "0.6.1") +bazel_dep(name = "rules_jvm_external", version = "6.8") +bazel_dep(name = "rules_proto", version = "7.1.0") +bazel_dep(name = "rules_python", version = "1.5.3") +bazel_dep(name = "rules_tsickle", version = "1.1.0") +bazel_dep(name = "rules_web_testing_java", version = "0.4.1") +bazel_dep(name = "rules_webtesting", version = "0.4.1") + +# google_bazel_common override is needed for @closure-compiler +git_override( + module_name = "google_bazel_common", + # Pin to newer version to fix b/408030907 + commit = "2cab52929507935aa43d460a3976d3bedc814d3a", + remote = "https://github.com/google/bazel-common", +) + +# ------------------------------------------------------------------- +# Overrides +# ------------------------------------------------------------------- + +# NOTE: cannot upgrade past protobuf editions... +# +# Commit: 263ee701cba6b75e1f8eddad5adcdf74718318b1 +# Date: 2025-10-08 21:32:22 +0000 UTC +# URL: https://github.com/protocolbuffers/protobuf-javascript/commit/263ee701cba6b75e1f8eddad5adcdf74718318b1 +# +# remove writeZigzagVarint64BigInt +# Size: 401599 (402 kB) +archive_override( + module_name = "protobuf_javascript", + sha256 = "8a50071fbca5e4a26361e6c9d81dd842207f0005f7cc1720226f20c25a231805", + strip_prefix = "protobuf-javascript-263ee701cba6b75e1f8eddad5adcdf74718318b1", + urls = ["https://github.com/protocolbuffers/protobuf-javascript/archive/263ee701cba6b75e1f8eddad5adcdf74718318b1.tar.gz"], + patches = ["closure/protobuf/protobuf_javascript.patch"], + patch_strip = 1, +) + +# Commit: 04fc63fb40bf30d2e5e0b3786028eb41218de979 +# Date: 2025-10-09 04:43:15 +0000 UTC +# URL: https://github.com/stackb/rules_proto/commit/04fc63fb40bf30d2e5e0b3786028eb41218de979 +# +# Remove lock file for @maven +# +# It is shared by multiple workspaces and seems to cause problems when locked +# Size: 4088796 (4.1 MB) +archive_override( + module_name = "build_stack_rules_proto", + sha256 = "aff21579deef91316c726582bec71c621dde2ae2ec74099a55b637bda8997333", + strip_prefix = "rules_proto-04fc63fb40bf30d2e5e0b3786028eb41218de979", + urls = ["https://github.com/stackb/rules_proto/archive/04fc63fb40bf30d2e5e0b3786028eb41218de979.tar.gz"], +) + +# ------------------------------------------------------------------- +# additional http dependencies +# ------------------------------------------------------------------- -##### Python dependencies ##### -bazel_dep( - name = "rules_python", - version = "1.0.0", -) - -##### Other dependencies ##### http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file") http_file( @@ -160,3 +143,60 @@ platform_http_file( "https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-macosx.zip", ], ) + +# ------------------------------------------------------------------- +# java configuration +# ------------------------------------------------------------------- + +# Compatibility layer +compat = use_extension("@rules_java//java:rules_java_deps.bzl", "compatibility_proxy") +use_repo(compat, "compatibility_proxy") + +# ------------------------------------------------------------------- +# maven configuration +# ------------------------------------------------------------------- + +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") + +# NOTE: even though we isolate OUR maven deps into a separate namespace, we +# still need the ones from other contributing modules. That is why this +# maven.install() declaration exists, and I am surprised bzlmod / +# rules_jvm_external works this way. +maven.install( + name = "maven", + known_contributing_modules = [ + "build_stack_rules_proto", + "grpc-java", + "stackb_rules_closure", + "protobuf", + "rules_web_testing_java", + ], +) +maven.install( + name = "maven_rules_closure", + artifacts = [ + "args4j:args4j:2.33", + "com.google.closure-stylesheets:closure-stylesheets:1.5.0", + "com.google.dagger:dagger-producers:2.43.2", + "com.google.jimfs:jimfs:1.1", + "net.java.dev.javacc:javacc:7.0.13", + "org.jsoup:jsoup:1.16.1", + "org.seleniumhq.selenium:selenium-remote-driver:4.27.0", + "org.seleniumhq.selenium:selenium-api:4.27.0", + "org.seleniumhq.selenium:selenium-support:4.27.0", + ], + lock_file = "//:maven_rules_closure_install.json", +) +maven.artifact( + name = "maven_rules_closure", + artifact = "closure-compiler", + group = "com.google.javascript", + # version = "v20250402", # Using maven.artifact here because the version number vxxxxx cannot parsed correctly. + version = "v20250820", # Using maven.artifact here because the version number vxxxxx cannot parsed correctly. +) +maven.override( + name = "maven_rules_closure", + coordinates = "javacc:javacc", + target = "@maven_rules_closure//:net_java_dev_javacc_javacc", # javacc:javacc was not updated since 2008 and relocated to net.java.dev.javacc +) +use_repo(maven, "maven_rules_closure") diff --git a/README.md b/README.md index b047348fac..5dd82195b7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,20 @@ +# Notice + +This is fork of [bazelbuild/rules_closure](https://github.com/bazelbuild/rules_closure) with the following main differences: + +- repo name is `@stackb_rules_closure` instead of `@rules_closure`. +- the `closure/library` has been restored. +- missing shims and third-party closure js code has been vendored in `google3/`. +- support for protobuf-javascript has been restored. +- support for closure-templates has been restored. +- integrated example app (`//closure/compiler/test/app:web`). +- bzlmod deps updated +- stricter .bazelrc flags + # Closure Rules for Bazel (αlpha) [![Bazel CI build status](https://badge.buildkite.com/7569410e2a2661076591897283051b6d137f35102167253fed.svg)](https://buildkite.com/bazel/closure-compiler-rules-closure-postsubmit) -JavaScript | Stylesheets | Miscellaneous ---- | --- | --- +JavaScript | Stylesheets | Miscellaneous +---------------------|-----------------------|----------------- [closure_js_library] | [closure_css_library] | [phantomjs_test] [closure_js_binary] | [closure_css_binary] | | [closure_js_test] | | @@ -64,7 +77,7 @@ First you must [install Bazel]. Then you add the following to your MODULE.bazel file: ```bzl -bazel_dep(name = "rules_closure", version = "0.15.0") +bazel_dep(name = "stackb_rules_closure", version = "0.15.0") ``` The root module has to declare the same override for rules_webtesting, rules_scala, and google_bazel_common temporarily until they are registered @@ -77,9 +90,9 @@ for that matter; they will be fetched automatically by Bazel. Please see the test directories within this project for concrete examples of usage: -- [//closure/testing/test](https://github.com/bazelbuild/rules_closure/tree/master/closure/testing/test) -- [//closure/compiler/test](https://github.com/bazelbuild/rules_closure/tree/master/closure/compiler/test) -- [//closure/stylesheets/test](https://github.com/bazelbuild/rules_closure/tree/master/closure/stylesheets/test) +- [//closure/testing/test](https://github.com/stackb/stackb_rules_closure/tree/master/closure/testing/test) +- [//closure/compiler/test](https://github.com/stackb/stackb_rules_closure/tree/master/closure/compiler/test) +- [//closure/stylesheets/test](https://github.com/stackb/stackb_rules_closure/tree/master/closure/stylesheets/test) # Reference @@ -88,7 +101,7 @@ Please see the test directories within this project for concrete examples of usa ## closure\_js\_library ```starlark -load("@rules_closure//closure:defs.bzl", "closure_js_library") +load("//closure:defs.bzl", "closure_js_library") closure_js_library(name, srcs, data, deps, exports, suppress, convention, no_closure_library) ``` @@ -188,7 +201,7 @@ This rule can be referenced as though it were the following: ## closure\_js\_binary ```starlark -load("@rules_closure//closure:defs.bzl", "closure_js_binary") +load("//closure:defs.bzl", "closure_js_binary") closure_js_binary(name, deps, css, debug, language, entry_points, dependency_mode, compilation_level, formatting, output_wrapper, property_renaming_report, defs) @@ -301,7 +314,7 @@ This rule can be referenced as though it were the following: - **defs:** (List of strings; optional) Specifies additional flags to be passed to the Closure Compiler, e.g. `"--hide_warnings_for=some/path/"`. To see what flags are available, run: - `bazel run @rules_closure//third_party/java/jscomp:main -- --help` + `bazel run @stackb_rules_closure//third_party/java/jscomp:main -- --help` ### Support for AngularJS @@ -322,7 +335,7 @@ closure_js_binary( ## closure\_js\_test ```starlark -load("@rules_closure//closure:defs.bzl", "closure_js_test") +load("//closure:defs.bzl", "closure_js_test") closure_js_test(name, srcs, data, deps, css, html, language, suppress, compilation_level, entry_points, defs) ``` @@ -394,7 +407,7 @@ This rule can be referenced as though it were the following: ## phantomjs\_test ```starlark -load("@rules_closure//closure:defs.bzl", "phantomjs_test") +load("//closure:defs.bzl", "phantomjs_test") phantomjs_test(name, data, deps, html, harness, runner) ``` @@ -423,17 +436,17 @@ This rule can be referenced as though it were the following: ` + + + + + + + \ No newline at end of file diff --git a/closure/compiler/test/app/main.js b/closure/compiler/test/app/main.js new file mode 100644 index 0000000000..fd9d08cda5 --- /dev/null +++ b/closure/compiler/test/app/main.js @@ -0,0 +1,12 @@ +goog.module("example.main"); + +const ExampleApp = goog.require("example.App"); + +/** + * Main entry point for the browser application. + */ +goog.exportSymbol('example.main', function () { + const app = new ExampleApp(); + app.render(document.body); +}); + diff --git a/closure/compiler/test/goog_es6_interop/BUILD b/closure/compiler/test/goog_es6_interop/BUILD index ea4782eeed..65f97a0dfd 100644 --- a/closure/compiler/test/goog_es6_interop/BUILD +++ b/closure/compiler/test/goog_es6_interop/BUILD @@ -39,7 +39,7 @@ closure_js_library( "person_factory.js", ], # TODO(yannic): Remove this suppression when - # `bazelbuild/rules_closure#436` is fixed. + # `stackb/stackb_rules_closure#436` is fixed. suppress = ["moduleLoad"], deps = [ ":person", diff --git a/closure/defs.bzl b/closure/defs.bzl index dbed2e3aee..a37b738af7 100644 --- a/closure/defs.bzl +++ b/closure/defs.bzl @@ -22,6 +22,8 @@ load("//closure/private:defs.bzl", _CLOSURE_JS_TOOLCHAIN_ATTRS = "CLOSURE_JS_TOO load("//closure/private:files_equal_test.bzl", _files_equal_test = "files_equal_test") load("//closure/stylesheets:closure_css_binary.bzl", _closure_css_binary = "closure_css_binary") load("//closure/stylesheets:closure_css_library.bzl", _closure_css_library = "closure_css_library") +load("//closure/templates:closure_js_template_library.bzl", _closure_js_template_library = "closure_js_template_library") +load("//closure/templates:closure_templates_plugin.bzl", _closure_templates_plugin = "closure_templates_plugin") load("//closure/testing:closure_js_test.bzl", _closure_js_test = "closure_js_test") load("//closure/testing:phantomjs_test.bzl", _phantomjs_test = "phantomjs_test") @@ -34,6 +36,8 @@ CLOSURE_JS_TOOLCHAIN_ATTRS = _CLOSURE_JS_TOOLCHAIN_ATTRS files_equal_test = _files_equal_test closure_css_binary = _closure_css_binary closure_css_library = _closure_css_library +closure_js_template_library = _closure_js_template_library +closure_templates_plugin = _closure_templates_plugin closure_js_test = _closure_js_test phantomjs_test = _phantomjs_test filegroup_external = _filegroup_external diff --git a/closure/goog/BUILD.bazel b/closure/goog/BUILD.bazel new file mode 100644 index 0000000000..1936b28b0a --- /dev/null +++ b/closure/goog/BUILD.bazel @@ -0,0 +1,13 @@ +# This file is a open-sourced version of internal BUILD file for closure base. +# They are too different to attempt a copybara transform between them. +# This file is used in the closure-library repo by external Bazel users (via stackb_rules_closure) + +load("//closure/compiler:closure_base_js_library.bzl", "closure_base_js_library") + +package(default_visibility = ["//visibility:public"]) + +closure_base_js_library( + name = "base", + srcs = ["base.js"], + visibility = ["//visibility:public"], +) diff --git a/closure/goog/a11y/aria/BUILD b/closure/goog/a11y/aria/BUILD new file mode 100644 index 0000000000..38d58a3d0b --- /dev/null +++ b/closure/goog/a11y/aria/BUILD @@ -0,0 +1,59 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "announcer", + srcs = ["announcer.js"], + lenient = True, + deps = [ + ":aria", + ":attributes", + "//closure/goog/disposable", + "//closure/goog/dom", + "//closure/goog/dom:tagname", + "//closure/goog/object", + "//closure/goog/string", + ], +) + +closure_js_library( + name = "aria", + srcs = ["aria.js"], + lenient = True, + deps = [ + ":attributes", + ":datatables", + ":roles", + "//closure/goog/array", + "//closure/goog/asserts", + "//closure/goog/dom", + "//closure/goog/dom:tagname", + "//closure/goog/object", + "//closure/goog/string", + ], +) + +closure_js_library( + name = "attributes", + srcs = ["attributes.js"], + lenient = True, +) + +closure_js_library( + name = "datatables", + srcs = ["datatables.js"], + lenient = True, + deps = [ + ":attributes", + "//closure/goog/object", + ], +) + +closure_js_library( + name = "roles", + srcs = ["roles.js"], + lenient = True, +) diff --git a/closure/goog/a11y/aria/announcer.js b/closure/goog/a11y/aria/announcer.js new file mode 100644 index 0000000000..eca8f63192 --- /dev/null +++ b/closure/goog/a11y/aria/announcer.js @@ -0,0 +1,137 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + + +/** + * @fileoverview Announcer that allows messages to be spoken by assistive + * technologies. + */ + +goog.provide('goog.a11y.aria.Announcer'); +goog.require('goog.Disposable'); +goog.require('goog.a11y.aria'); +goog.require('goog.a11y.aria.LivePriority'); +goog.require('goog.a11y.aria.State'); +goog.require('goog.dom'); +goog.require('goog.dom.TagName'); +goog.require('goog.object'); +goog.require('goog.string'); + + + +/** + * Class that allows messages to be spoken by assistive technologies that the + * user may have active. + * + * @param {goog.dom.DomHelper=} opt_domHelper DOM helper. + * @constructor + * @extends {goog.Disposable} + * @final + */ +goog.a11y.aria.Announcer = function(opt_domHelper) { + 'use strict'; + goog.a11y.aria.Announcer.base(this, 'constructor'); + + /** + * @type {goog.dom.DomHelper} + * @private + */ + this.domHelper_ = opt_domHelper || goog.dom.getDomHelper(); + + /** + * Map of priority to live region elements to use for communicating updates. + * Elements are created on demand. + * @type {Object} + * @private + */ + this.liveRegions_ = {}; + + /** + * Map of live region to the last message inserted in that region. + * @type {?Object} + * @private + */ + this.lastMessageAnnouncedPerPriority_ = {}; +}; +goog.inherits(goog.a11y.aria.Announcer, goog.Disposable); + + +/** @override */ +goog.a11y.aria.Announcer.prototype.disposeInternal = function() { + 'use strict'; + goog.object.forEach( + this.liveRegions_, this.domHelper_.removeNode, this.domHelper_); + this.liveRegions_ = null; + this.domHelper_ = null; + this.lastMessageAnnouncedPerPriority_ = null; + goog.a11y.aria.Announcer.base(this, 'disposeInternal'); +}; + + +/** + * Announce a message to be read by any assistive technologies the user may + * have active. + * @param {string} message The message to announce to screen readers. + * @param {goog.a11y.aria.LivePriority=} opt_priority The priority of the + * message. Defaults to POLITE. + */ +goog.a11y.aria.Announcer.prototype.say = function(message, opt_priority) { + 'use strict'; + const priority = opt_priority || goog.a11y.aria.LivePriority.POLITE; + const liveRegion = this.getLiveRegion_(priority); + // TODO(user): Remove the code once Chrome fix the bug on their + // end. Add nonbreaking space such that there's a change to aria live region + // to verbalize repeated character or text. + const lastMessageAnnounced = this.lastMessageAnnouncedPerPriority_[priority]; + const announceMessage = + lastMessageAnnounced && lastMessageAnnounced === message ? + message + goog.string.Unicode.NBSP : + message; + if (message) { + this.lastMessageAnnouncedPerPriority_[priority] = announceMessage; + } + goog.dom.setTextContent(liveRegion, announceMessage); +}; + +/** + * Returns the id value for an aria-live region for a given priority. + * @param {!goog.a11y.aria.LivePriority} priority The required priority. + * @return {string} The generated id on the liveRegion. + */ +goog.a11y.aria.Announcer.prototype.getLiveRegionId = function(priority) { + return this.getLiveRegion_(priority).getAttribute('id'); +}; + +/** + * Returns an aria-live region that can be used to communicate announcements. + * @param {!goog.a11y.aria.LivePriority} priority The required priority. + * @return {!Element} A live region of the requested priority. + * @private + */ +goog.a11y.aria.Announcer.prototype.getLiveRegion_ = function(priority) { + 'use strict'; + var liveRegion = this.liveRegions_[priority]; + if (liveRegion) { + // Make sure the live region is not aria-hidden. + goog.a11y.aria.removeState(liveRegion, goog.a11y.aria.State.HIDDEN); + return liveRegion; + } + + liveRegion = this.domHelper_.createElement(goog.dom.TagName.DIV); + // Generate a unique id for the live region. + liveRegion.id = `goog-lr-${goog.getUid(liveRegion)}`; + // Note that IE has a habit of declaring things that aren't display:none as + // invisible to third-party tools like JAWs, so we can't just use height:0. + liveRegion.style.position = 'absolute'; + liveRegion.style.top = '-1000px'; + liveRegion.style.height = '1px'; + liveRegion.style.overflow = 'hidden'; + goog.a11y.aria.setState(liveRegion, goog.a11y.aria.State.LIVE, priority); + goog.a11y.aria.setState(liveRegion, goog.a11y.aria.State.ATOMIC, 'true'); + this.domHelper_.getDocument().body.appendChild(liveRegion); + this.liveRegions_[priority] = liveRegion; + return liveRegion; +}; diff --git a/closure/goog/a11y/aria/announcer_test.js b/closure/goog/a11y/aria/announcer_test.js new file mode 100644 index 0000000000..e57465a35e --- /dev/null +++ b/closure/goog/a11y/aria/announcer_test.js @@ -0,0 +1,176 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.a11y.aria.AnnouncerTest'); +goog.setTestOnly(); + +const Announcer = goog.require('goog.a11y.aria.Announcer'); +const LivePriority = goog.require('goog.a11y.aria.LivePriority'); +const MockClock = goog.require('goog.testing.MockClock'); +const State = goog.require('goog.a11y.aria.State'); +const TagName = goog.require('goog.dom.TagName'); +const aria = goog.require('goog.a11y.aria'); +const asserts = goog.require('goog.asserts'); +const googArray = goog.require('goog.array'); +const googDispose = goog.require('goog.dispose'); +const googDom = goog.require('goog.dom'); +const googString = goog.require('goog.string'); +const iframe = goog.require('goog.dom.iframe'); +const testSuite = goog.require('goog.testing.testSuite'); + +let sandbox; +let someDiv; +let someSpan; +let mockClock; + +function getLiveRegion(priority, domHelper = undefined) { + const dom = domHelper || googDom.getDomHelper(); + const divs = dom.getElementsByTagNameAndClass(TagName.DIV, null); + const liveRegions = []; + googArray.forEach(divs, (div) => { + if (aria.getState(div, 'live') == priority) { + liveRegions.push(div); + } + }); + assertEquals(1, liveRegions.length); + return liveRegions[0]; +} + +function checkLiveRegionContains(text, priority, domHelper = undefined) { + const liveRegion = getLiveRegion(priority, domHelper); + mockClock.tick(1); + assertEquals(text, googDom.getTextContent(liveRegion)); +} +testSuite({ + setUp() { + sandbox = asserts.assert(googDom.getElement('sandbox')); + someDiv = googDom.createDom(TagName.DIV, {id: 'someDiv'}, 'DIV'); + someSpan = googDom.createDom(TagName.SPAN, {id: 'someSpan'}, 'SPAN'); + sandbox.appendChild(someDiv); + someDiv.appendChild(someSpan); + + mockClock = new MockClock(true); + }, + + tearDown() { + googDom.removeChildren(sandbox); + someDiv = null; + someSpan = null; + + googDispose(mockClock); + }, + + testAnnouncerAndDispose() { + const text = 'test content'; + const announcer = new Announcer(googDom.getDomHelper()); + announcer.say(text); + checkLiveRegionContains(text, 'polite'); + googDispose(announcer); + }, + + testAnnouncerTwice() { + const text = 'test content1'; + const text2 = 'test content2'; + const announcer = new Announcer(googDom.getDomHelper()); + announcer.say(text); + announcer.say(text2); + checkLiveRegionContains(text2, 'polite'); + googDispose(announcer); + }, + + testAnnouncerTwiceSameMessagePolite() { + const text = 'test content'; + const repeatedText = text + googString.Unicode.NBSP; + const announcer = new Announcer(googDom.getDomHelper()); + announcer.say(text); + const firstLiveRegion = getLiveRegion('polite'); + announcer.say(text, undefined); + const secondLiveRegion = getLiveRegion('polite'); + assertEquals(firstLiveRegion, secondLiveRegion); + checkLiveRegionContains(repeatedText, 'polite'); + googDispose(announcer); + }, + + testAnnouncerAssertive() { + const text = 'test content'; + const announcer = new Announcer(googDom.getDomHelper()); + announcer.say(text, LivePriority.ASSERTIVE); + checkLiveRegionContains(text, 'assertive'); + googDispose(announcer); + }, + + testAnnouncerTwiceSameMessageAssertive() { + const text = 'test content'; + const repeatedText = text + googString.Unicode.NBSP; + const announcer = new Announcer(googDom.getDomHelper()); + announcer.say(text, LivePriority.ASSERTIVE); + const firstLiveRegion = getLiveRegion('assertive'); + announcer.say(text, LivePriority.ASSERTIVE); + const secondLiveRegion = getLiveRegion('assertive'); + assertEquals(firstLiveRegion, secondLiveRegion); + checkLiveRegionContains(repeatedText, 'assertive'); + googDispose(announcer); + }, + + testAnnouncerMultipleMessagesDifferentPriorities() { + const text = 'test content'; + const repeatedText = text + googString.Unicode.NBSP; + const announcer = new Announcer(googDom.getDomHelper()); + announcer.say(text, LivePriority.POLITE); + announcer.say(text, LivePriority.ASSERTIVE); + // We should not have added an extra space to the message since they are + // for different priorities. + checkLiveRegionContains(text, 'assertive'); + checkLiveRegionContains(text, 'polite'); + // If we repeat the same message again for either POLITE or ASSERTIVE + // priority, we should see the extra space appended to the message. + announcer.say(text, LivePriority.POLITE); + announcer.say(text, LivePriority.ASSERTIVE); + checkLiveRegionContains(repeatedText, 'assertive'); + checkLiveRegionContains(repeatedText, 'polite'); + googDispose(announcer); + }, + + testAnnouncerInIframe() { + const text = 'test content'; + const frame = iframe.createWithContent(sandbox); + const helper = + googDom.getDomHelper(googDom.getFrameContentDocument(frame).body); + const announcer = new Announcer(helper); + announcer.say(text, /** @type {?} */ ('polite')); + checkLiveRegionContains(text, 'polite', helper); + googDispose(announcer); + }, + + testAnnouncerWithAriaHidden() { + const text = 'test content1'; + const text2 = 'test content2'; + const announcer = new Announcer(googDom.getDomHelper()); + announcer.say(text); + // Set aria-hidden attribute on the live region (simulates a modal dialog + // being opened). + const liveRegion = getLiveRegion('polite'); + aria.setState(liveRegion, State.HIDDEN, true); + + // Announce a new message and make sure that the aria-hidden was removed. + announcer.say(text2); + checkLiveRegionContains(text2, 'polite'); + assertEquals('', aria.getState(liveRegion, State.HIDDEN)); + googDispose(announcer); + }, + + testAnnouncerSetsAndReturnsId() { + const announcer = new Announcer(googDom.getDomHelper()); + announcer.say('test'); + + // Read the dom to find the id + const domLiveRegionId = getLiveRegion('polite').getAttribute('id'); + + assertEquals( + announcer.getLiveRegionId(LivePriority.POLITE), domLiveRegionId); + googDispose(announcer); + }, +}); diff --git a/closure/goog/a11y/aria/announcer_test_dom.html b/closure/goog/a11y/aria/announcer_test_dom.html new file mode 100644 index 0000000000..30271f456f --- /dev/null +++ b/closure/goog/a11y/aria/announcer_test_dom.html @@ -0,0 +1,8 @@ + +
+
\ No newline at end of file diff --git a/closure/goog/a11y/aria/aria.js b/closure/goog/a11y/aria/aria.js new file mode 100644 index 0000000000..249f484441 --- /dev/null +++ b/closure/goog/a11y/aria/aria.js @@ -0,0 +1,433 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + + +/** + * @fileoverview Utilities for adding, removing and setting ARIA roles and + * states as defined by W3C ARIA standard: http://www.w3.org/TR/wai-aria/ + * All modern browsers have some form of ARIA support, so no browser checks are + * performed when adding ARIA to components. + */ + +goog.provide('goog.a11y.aria'); + +goog.require('goog.a11y.aria.Role'); +goog.require('goog.a11y.aria.State'); +goog.require('goog.a11y.aria.datatables'); +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.dom'); +goog.require('goog.dom.TagName'); +goog.require('goog.object'); +goog.require('goog.string'); + + +/** + * ARIA states/properties prefix. + * @private + */ +goog.a11y.aria.ARIA_PREFIX_ = 'aria-'; + + +/** + * ARIA role attribute. + * @private + */ +goog.a11y.aria.ROLE_ATTRIBUTE_ = 'role'; + + +/** + * A list of tag names for which we don't need to set ARIA role and states + * because they have well supported semantics for screen readers or because + * they don't contain content to be made accessible. + * @private + */ +goog.a11y.aria.TAGS_WITH_ASSUMED_ROLES_ = goog.object.createSet([ + goog.dom.TagName.A, goog.dom.TagName.AREA, goog.dom.TagName.BUTTON, + goog.dom.TagName.HEAD, goog.dom.TagName.INPUT, goog.dom.TagName.LINK, + goog.dom.TagName.MENU, goog.dom.TagName.META, goog.dom.TagName.OPTGROUP, + goog.dom.TagName.OPTION, goog.dom.TagName.PROGRESS, goog.dom.TagName.STYLE, + goog.dom.TagName.SELECT, goog.dom.TagName.SOURCE, goog.dom.TagName.TEXTAREA, + goog.dom.TagName.TITLE, goog.dom.TagName.TRACK +]); + + +/** + * A list of roles which are considered container roles. + * Container roles are ARIA roles which use the aria-activedescendant property + * to manage their active descendants or children. See + * {@link http://www.w3.org/TR/wai-aria/states_and_properties + * #aria-activedescendant} for more information. + * @private @const {!Array} + */ +goog.a11y.aria.CONTAINER_ROLES_ = [ + goog.a11y.aria.Role.COMBOBOX, goog.a11y.aria.Role.GRID, + goog.a11y.aria.Role.GROUP, goog.a11y.aria.Role.LISTBOX, + goog.a11y.aria.Role.MENU, goog.a11y.aria.Role.MENUBAR, + goog.a11y.aria.Role.RADIOGROUP, goog.a11y.aria.Role.ROW, + goog.a11y.aria.Role.ROWGROUP, goog.a11y.aria.Role.TAB_LIST, + goog.a11y.aria.Role.TEXTBOX, goog.a11y.aria.Role.TOOLBAR, + goog.a11y.aria.Role.TREE, goog.a11y.aria.Role.TREEGRID +]; + + +/** + * Sets the role of an element. If the roleName is + * empty string or null, the role for the element is removed. + * We encourage clients to call the goog.a11y.aria.removeRole + * method instead of setting null and empty string values. + * Special handling for this case is added to ensure + * backword compatibility with existing code. + * + * @param {!Element} element DOM node to set role of. + * @param {!goog.a11y.aria.Role|string} roleName role name(s). + */ +goog.a11y.aria.setRole = function(element, roleName) { + 'use strict'; + if (!roleName) { + // Setting the ARIA role to empty string is not allowed + // by the ARIA standard. + goog.a11y.aria.removeRole(element); + } else { + if (goog.asserts.ENABLE_ASSERTS) { + goog.asserts.assert( + goog.object.containsValue(goog.a11y.aria.Role, roleName), + 'No such ARIA role ' + roleName); + } + element.setAttribute(goog.a11y.aria.ROLE_ATTRIBUTE_, roleName); + } +}; + + +/** + * Gets role of an element. + * @param {!Element} element DOM element to get role of. + * @return {?goog.a11y.aria.Role} ARIA Role name. + */ +goog.a11y.aria.getRole = function(element) { + 'use strict'; + var role = element.getAttribute(goog.a11y.aria.ROLE_ATTRIBUTE_); + return /** @type {goog.a11y.aria.Role} */ (role) || null; +}; + + +/** + * Removes role of an element. + * @param {!Element} element DOM element to remove the role from. + */ +goog.a11y.aria.removeRole = function(element) { + 'use strict'; + element.removeAttribute(goog.a11y.aria.ROLE_ATTRIBUTE_); +}; + + +/** + * Sets the state or property of an element. + * @param {!Element} element DOM node where we set state. + * @param {!(goog.a11y.aria.State|string)} stateName State attribute being set. + * Automatically adds prefix 'aria-' to the state name if the attribute is + * not an extra attribute. + * @param {string|boolean|number|!Array} value Value + * for the state attribute. + */ +goog.a11y.aria.setState = function(element, stateName, value) { + 'use strict'; + if (Array.isArray(value)) { + value = value.join(' '); + } + var attrStateName = goog.a11y.aria.getAriaAttributeName_(stateName); + if (value === '' || value == undefined) { + var defaultValueMap = goog.a11y.aria.datatables.getDefaultValuesMap(); + // Work around for browsers that don't properly support ARIA. + // According to the ARIA W3C standard, user agents should allow + // setting empty value which results in setting the default value + // for the ARIA state if such exists. The exact text from the ARIA W3C + // standard (http://www.w3.org/TR/wai-aria/states_and_properties): + // "When a value is indicated as the default, the user agent + // MUST follow the behavior prescribed by this value when the state or + // property is empty or undefined." + // The defaultValueMap contains the default values for the ARIA states + // and has as a key the goog.a11y.aria.State constant for the state. + if (stateName in defaultValueMap) { + element.setAttribute(attrStateName, defaultValueMap[stateName]); + } else { + element.removeAttribute(attrStateName); + } + } else { + element.setAttribute(attrStateName, value); + } +}; + + +/** + * Toggles the ARIA attribute of an element. + * Meant for attributes with a true/false value, but works with any attribute. + * If the attribute does not have a true/false value, the following rules apply: + * A not empty attribute will be removed. + * An empty attribute will be set to true. + * @param {!Element} el DOM node for which to set attribute. + * @param {!(goog.a11y.aria.State|string)} attr ARIA attribute being set. + * Automatically adds prefix 'aria-' to the attribute name if the attribute + * is not an extra attribute. + */ +goog.a11y.aria.toggleState = function(el, attr) { + 'use strict'; + var val = goog.a11y.aria.getState(el, attr); + if (!goog.string.isEmptyOrWhitespace(goog.string.makeSafe(val)) && + !(val == 'true' || val == 'false')) { + goog.a11y.aria.removeState(el, /** @type {!goog.a11y.aria.State} */ (attr)); + return; + } + goog.a11y.aria.setState(el, attr, val == 'true' ? 'false' : 'true'); +}; + + +/** + * Remove the state or property for the element. + * @param {!Element} element DOM node where we set state. + * @param {!goog.a11y.aria.State} stateName State name. + */ +goog.a11y.aria.removeState = function(element, stateName) { + 'use strict'; + element.removeAttribute(goog.a11y.aria.getAriaAttributeName_(stateName)); +}; + + +/** + * Gets value of specified state or property. + * @param {!Element} element DOM node to get state from. + * @param {!goog.a11y.aria.State|string} stateName State name. + * @return {string} Value of the state attribute. + */ +goog.a11y.aria.getState = function(element, stateName) { + 'use strict'; + // TODO(user): return properly typed value result -- + // boolean, number, string, null. We should be able to chain + // getState(...) and setState(...) methods. + + var attr = + /** @type {string|number|boolean} */ ( + element.getAttribute( + goog.a11y.aria.getAriaAttributeName_(stateName))); + var isNullOrUndefined = attr == null || attr == undefined; + return isNullOrUndefined ? '' : String(attr); +}; + + +/** + * Returns the activedescendant element for the input element by + * using the activedescendant ARIA property of the given element. + * @param {!Element} element DOM node to get activedescendant + * element for. + * @return {?Element} DOM node of the activedescendant, if found. + */ +goog.a11y.aria.getActiveDescendant = function(element) { + 'use strict'; + var id = + goog.a11y.aria.getState(element, goog.a11y.aria.State.ACTIVEDESCENDANT); + return goog.dom.getOwnerDocument(element).getElementById(id); +}; + + +/** + * Sets the activedescendant ARIA property value for an element. + * If the activeElement is not null, it should have an id set. + * @param {!Element} element DOM node to set activedescendant ARIA property to. + * @param {?Element} activeElement DOM node being set as activedescendant. + */ +goog.a11y.aria.setActiveDescendant = function(element, activeElement) { + 'use strict'; + var id = ''; + if (activeElement) { + id = activeElement.id; + goog.asserts.assert(id, 'The active element should have an id.'); + } + + goog.a11y.aria.setState(element, goog.a11y.aria.State.ACTIVEDESCENDANT, id); +}; + + +/** + * Gets the label of the given element. + * @param {!Element} element DOM node to get label from. + * @return {string} label The label. + */ +goog.a11y.aria.getLabel = function(element) { + 'use strict'; + return goog.a11y.aria.getState(element, goog.a11y.aria.State.LABEL); +}; + + +/** + * Sets the label of the given element. + * @param {!Element} element DOM node to set label to. + * @param {string} label The label to set. + */ +goog.a11y.aria.setLabel = function(element, label) { + 'use strict'; + goog.a11y.aria.setState(element, goog.a11y.aria.State.LABEL, label); +}; + + +/** + * Asserts that the element has a role set if it's not an HTML element whose + * semantics is well supported by most screen readers. + * Only to be used internally by the ARIA library in goog.a11y.aria.*. + * @param {!Element} element The element to assert an ARIA role set. + * @param {!IArrayLike} allowedRoles The child roles of + * the roles. + */ +goog.a11y.aria.assertRoleIsSetInternalUtil = function(element, allowedRoles) { + 'use strict'; + if (goog.a11y.aria.TAGS_WITH_ASSUMED_ROLES_[element.tagName]) { + return; + } + var elementRole = /** @type {string}*/ (goog.a11y.aria.getRole(element)); + goog.asserts.assert( + elementRole != null, 'The element ARIA role cannot be null.'); + + goog.asserts.assert( + goog.array.contains(allowedRoles, elementRole), + 'Non existing or incorrect role set for element.' + + 'The role set is "' + elementRole + '". The role should be any of "' + + allowedRoles + '". Check the ARIA specification for more details ' + + 'http://www.w3.org/TR/wai-aria/roles.'); +}; + + +/** + * Gets the boolean value of an ARIA state/property. + * @param {!Element} element The element to get the ARIA state for. + * @param {!goog.a11y.aria.State|string} stateName the ARIA state name. + * @return {?boolean} Boolean value for the ARIA state value or null if + * the state value is not 'true', not 'false', or not set. + */ +goog.a11y.aria.getStateBoolean = function(element, stateName) { + 'use strict'; + var attr = + /** @type {string|boolean|null} */ (element.getAttribute( + goog.a11y.aria.getAriaAttributeName_(stateName))); + goog.asserts.assert( + typeof attr === 'boolean' || attr == null || attr == 'true' || + attr == 'false'); + if (attr == null) { + return attr; + } + return typeof attr === 'boolean' ? attr : attr == 'true'; +}; + + +/** + * Gets the number value of an ARIA state/property. + * @param {!Element} element The element to get the ARIA state for. + * @param {!goog.a11y.aria.State|string} stateName the ARIA state name. + * @return {?number} Number value for the ARIA state value or null if + * the state value is not a number or not set. + */ +goog.a11y.aria.getStateNumber = function(element, stateName) { + 'use strict'; + var attr = + /** @type {string|number} */ (element.getAttribute( + goog.a11y.aria.getAriaAttributeName_(stateName))); + goog.asserts.assert( + (attr == null || !isNaN(Number(attr))) && typeof attr !== 'boolean'); + return attr == null ? null : Number(attr); +}; + + +/** + * Gets the string value of an ARIA state/property. + * @param {!Element} element The element to get the ARIA state for. + * @param {!goog.a11y.aria.State|string} stateName the ARIA state name. + * @return {?string} String value for the ARIA state value or null if + * the state value is empty string or not set. + */ +goog.a11y.aria.getStateString = function(element, stateName) { + 'use strict'; + var attr = + element.getAttribute(goog.a11y.aria.getAriaAttributeName_(stateName)); + goog.asserts.assert( + (attr == null || typeof attr === 'string') && + (attr == '' || isNaN(Number(attr))) && attr != 'true' && attr != 'false'); + return (attr == null || attr == '') ? null : attr; +}; + + +/** + * Gets array of strings value of the specified state or + * property for the element. + * Only to be used internally by the ARIA library in goog.a11y.aria.*. + * @param {!Element} element DOM node to get state from. + * @param {!goog.a11y.aria.State} stateName State name. + * @return {!IArrayLike} string Array + * value of the state attribute. + */ +goog.a11y.aria.getStringArrayStateInternalUtil = function(element, stateName) { + 'use strict'; + var attrValue = + element.getAttribute(goog.a11y.aria.getAriaAttributeName_(stateName)); + return goog.a11y.aria.splitStringOnWhitespace_(attrValue); +}; + + +/** + * Returns true if element has an ARIA state/property, false otherwise. + * @param {!Element} element The element to get the ARIA state for. + * @param {!goog.a11y.aria.State|string} stateName the ARIA state name. + * @return {boolean} + */ +goog.a11y.aria.hasState = function(element, stateName) { + 'use strict'; + return element.hasAttribute(goog.a11y.aria.getAriaAttributeName_(stateName)); +}; + + +/** + * Returns whether the element has a container ARIA role. + * Container roles are ARIA roles that use the aria-activedescendant property + * to manage their active descendants or children. See + * {@link http://www.w3.org/TR/wai-aria/states_and_properties + * #aria-activedescendant} for more information. + * @param {!Element} element + * @return {boolean} + */ +goog.a11y.aria.isContainerRole = function(element) { + 'use strict'; + var role = goog.a11y.aria.getRole(element); + return goog.array.contains(goog.a11y.aria.CONTAINER_ROLES_, role); +}; + + +/** + * Splits the input stringValue on whitespace. + * @param {string} stringValue The value of the string to split. + * @return {!IArrayLike} string Array + * value as result of the split. + * @private + */ +goog.a11y.aria.splitStringOnWhitespace_ = function(stringValue) { + 'use strict'; + return stringValue ? stringValue.split(/\s+/) : []; +}; + + +/** + * Adds the 'aria-' prefix to ariaName. + * @param {string} ariaName ARIA state/property name. + * @private + * @return {string} The ARIA attribute name with added 'aria-' prefix. + * @throws {Error} If no such attribute exists. + */ +goog.a11y.aria.getAriaAttributeName_ = function(ariaName) { + 'use strict'; + if (goog.asserts.ENABLE_ASSERTS) { + goog.asserts.assert(ariaName, 'ARIA attribute cannot be empty.'); + goog.asserts.assert( + goog.object.containsValue(goog.a11y.aria.State, ariaName), + 'No such ARIA attribute ' + ariaName); + } + return goog.a11y.aria.ARIA_PREFIX_ + ariaName; +}; diff --git a/closure/goog/a11y/aria/aria_test.js b/closure/goog/a11y/aria/aria_test.js new file mode 100644 index 0000000000..f875c72220 --- /dev/null +++ b/closure/goog/a11y/aria/aria_test.js @@ -0,0 +1,306 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.a11y.ariaTest'); +goog.setTestOnly(); + +const Role = goog.require('goog.a11y.aria.Role'); +const State = goog.require('goog.a11y.aria.State'); +const TagName = goog.require('goog.dom.TagName'); +const aria = goog.require('goog.a11y.aria'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); + +let sandbox; +let someDiv; +let someSpan; +let htmlButton; + +testSuite({ + setUp() { + sandbox = dom.getElement('sandbox'); + someDiv = dom.createDom(TagName.DIV, {id: 'someDiv'}, 'DIV'); + someSpan = dom.createDom(TagName.SPAN, {id: 'someSpan'}, 'SPAN'); + htmlButton = dom.createDom(TagName.BUTTON, {id: 'someButton'}, 'BUTTON'); + dom.appendChild(sandbox, someDiv); + dom.appendChild(someDiv, someSpan); + }, + + tearDown() { + dom.removeChildren(sandbox); + someDiv = null; + someSpan = null; + htmlButton = null; + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testGetSetRole() { + assertNull('someDiv\'s role should be null', aria.getRole(someDiv)); + assertNull('someSpan\'s role should be null', aria.getRole(someSpan)); + + aria.setRole(someDiv, Role.MENU); + aria.setRole(someSpan, Role.MENU_ITEM); + + assertEquals( + 'someDiv\'s role should be MENU', Role.MENU, aria.getRole(someDiv)); + assertEquals( + 'someSpan\'s role should be MENU_ITEM', Role.MENU_ITEM, + aria.getRole(someSpan)); + + const div = dom.createElement(TagName.DIV); + dom.appendChild(sandbox, div); + dom.appendChild( + div, + dom.createDom(TagName.SPAN, {id: 'anotherSpan', role: Role.CHECKBOX})); + assertEquals( + 'anotherSpan\'s role should be CHECKBOX', Role.CHECKBOX, + aria.getRole(dom.getElement('anotherSpan'))); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testGetSetToggleState() { + assertThrows( + 'Should throw because no state is specified.', + /** + * @suppress {checkTypes} suppression added to enable type checking + */ + () => { + aria.getState(someDiv); + }); + assertThrows( + 'Should throw because no state is specified.', + /** + * @suppress {checkTypes} suppression added to enable type checking + */ + () => { + aria.getState(someDiv); + }); + aria.setState(someDiv, State.LABELLEDBY, 'someSpan'); + + assertEquals( + 'someDiv\'s labelledby state should be "someSpan"', 'someSpan', + aria.getState(someDiv, State.LABELLEDBY)); + + // Test setting for aria-activedescendant with empty value. + assertFalse( + someDiv.hasAttribute ? someDiv.hasAttribute('aria-activedescendant') : + !!someDiv.getAttribute('aria-activedescendant')); + aria.setState(someDiv, State.ACTIVEDESCENDANT, 'someSpan'); + assertEquals('someSpan', aria.getState(someDiv, State.ACTIVEDESCENDANT)); + aria.setState(someDiv, State.ACTIVEDESCENDANT, ''); + assertFalse( + someDiv.hasAttribute ? someDiv.hasAttribute('aria-activedescendant') : + !!someDiv.getAttribute('aria-activedescendant')); + + // Test setting state that has a default value to empty value. + assertFalse( + someDiv.hasAttribute ? someDiv.hasAttribute('aria-relevant') : + !!someDiv.getAttribute('aria-relevant')); + aria.setState(someDiv, State.RELEVANT, aria.RelevantValues.TEXT); + assertEquals( + aria.RelevantValues.TEXT, aria.getState(someDiv, State.RELEVANT)); + aria.setState(someDiv, State.RELEVANT, ''); + assertEquals( + aria.RelevantValues.ADDITIONS + ' ' + aria.RelevantValues.TEXT, + aria.getState(someDiv, State.RELEVANT)); + + // Test toggling an attribute that has a true/false value. + aria.setState(someDiv, State.EXPANDED, false); + assertEquals('false', aria.getState(someDiv, State.EXPANDED)); + aria.toggleState(someDiv, State.EXPANDED); + assertEquals('true', aria.getState(someDiv, State.EXPANDED)); + aria.setState(someDiv, State.EXPANDED, true); + assertEquals('true', aria.getState(someDiv, State.EXPANDED)); + aria.toggleState(someDiv, State.EXPANDED); + assertEquals('false', aria.getState(someDiv, State.EXPANDED)); + + // Test toggling an attribute that does not have a true/false value. + aria.setState(someDiv, State.RELEVANT, aria.RelevantValues.TEXT); + assertEquals( + aria.RelevantValues.TEXT, aria.getState(someDiv, State.RELEVANT)); + aria.toggleState(someDiv, State.RELEVANT); + assertEquals('', aria.getState(someDiv, State.RELEVANT)); + aria.removeState(someDiv, State.RELEVANT); + assertEquals('', aria.getState(someDiv, State.RELEVANT)); + // This is not a valid value, but this is what happens if toggle is misused. + aria.toggleState(someDiv, State.RELEVANT); + assertEquals('true', aria.getState(someDiv, State.RELEVANT)); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testGetStateString() { + aria.setState(someDiv, State.LABEL, 'test_label'); + aria.setState( + someSpan, State.LABEL, aria.getStateString(someDiv, State.LABEL)); + assertEquals( + aria.getState(someDiv, State.LABEL), + aria.getState(someSpan, State.LABEL)); + assertEquals( + 'The someDiv\'s enum value should be "test_label".', 'test_label', + aria.getState(someDiv, State.LABEL)); + assertEquals( + 'The someSpan\'s enum value should be "copy move".', 'test_label', + aria.getStateString(someSpan, State.LABEL)); + someDiv.setAttribute('aria-label', ''); + assertEquals(null, aria.getStateString(someDiv, State.LABEL)); + aria.setState(someDiv, State.MULTILINE, true); + let thrown = false; + try { + aria.getStateString(someDiv, State.MULTILINE); + } catch (e) { + thrown = true; + } + assertTrue('invalid use of getStateString on boolean.', thrown); + aria.setState(someDiv, State.LIVE, aria.LivePriority.ASSERTIVE); + thrown = false; + aria.setState(someDiv, State.LEVEL, 1); + try { + aria.getStateString(someDiv, State.LEVEL); + } catch (e) { + thrown = true; + } + assertTrue('invalid use of getStateString on numbers.', thrown); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testGetStateStringArray() { + aria.setState(someDiv, State.LABELLEDBY, ['1', '2']); + aria.setState( + someSpan, State.LABELLEDBY, + aria.getStringArrayStateInternalUtil(someDiv, State.LABELLEDBY)); + assertEquals( + aria.getState(someDiv, State.LABELLEDBY), + aria.getState(someSpan, State.LABELLEDBY)); + + assertEquals( + 'The someDiv\'s enum value should be "1 2".', '1 2', + aria.getState(someDiv, State.LABELLEDBY)); + assertEquals( + 'The someSpan\'s enum value should be "1 2".', '1 2', + aria.getState(someSpan, State.LABELLEDBY)); + + assertSameElements( + 'The someDiv\'s enum value should be "1 2".', ['1', '2'], + aria.getStringArrayStateInternalUtil(someDiv, State.LABELLEDBY)); + assertSameElements( + 'The someSpan\'s enum value should be "1 2".', ['1', '2'], + aria.getStringArrayStateInternalUtil(someSpan, State.LABELLEDBY)); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testGetStateNumber() { + aria.setState(someDiv, State.LEVEL, 1); + aria.setState( + someSpan, State.LEVEL, aria.getStateNumber(someDiv, State.LEVEL)); + assertEquals( + aria.getState(someDiv, State.LEVEL), + aria.getState(someSpan, State.LEVEL)); + assertEquals( + 'The someDiv\'s enum value should be "1".', '1', + aria.getState(someDiv, State.LEVEL)); + assertEquals( + 'The someSpan\'s enum value should be "1".', '1', + aria.getState(someSpan, State.LEVEL)); + assertEquals( + 'The someDiv\'s enum value should be "1".', 1, + aria.getStateNumber(someDiv, State.LEVEL)); + assertEquals( + 'The someSpan\'s enum value should be "1".', 1, + aria.getStateNumber(someSpan, State.LEVEL)); + aria.setState(someDiv, State.MULTILINE, true); + let thrown = false; + try { + aria.getStateNumber(someDiv, State.MULTILINE); + } catch (e) { + thrown = true; + } + assertTrue('invalid use of getStateNumber on boolean.', thrown); + aria.setState(someDiv, State.LIVE, aria.LivePriority.ASSERTIVE); + thrown = false; + try { + aria.getStateBoolean(someDiv, State.LIVE); + } catch (e) { + thrown = true; + } + assertTrue('invalid use of getStateNumber on strings.', thrown); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testGetStateBoolean() { + assertNull(aria.getStateBoolean(someDiv, State.MULTILINE)); + + aria.setState(someDiv, State.MULTILINE, false); + assertFalse(aria.getStateBoolean(someDiv, State.MULTILINE)); + + aria.setState(someDiv, State.MULTILINE, true); + aria.setState( + someSpan, State.MULTILINE, + aria.getStateBoolean(someDiv, State.MULTILINE)); + assertEquals( + aria.getState(someDiv, State.MULTILINE), + aria.getState(someSpan, State.MULTILINE)); + assertEquals( + 'The someDiv\'s enum value should be "true".', 'true', + aria.getState(someDiv, State.MULTILINE)); + assertEquals( + 'The someSpan\'s enum value should be "true".', 'true', + aria.getState(someSpan, State.MULTILINE)); + assertEquals( + 'The someDiv\'s enum value should be "true".', true, + aria.getStateBoolean(someDiv, State.MULTILINE)); + assertEquals( + 'The someSpan\'s enum value should be "true".', true, + aria.getStateBoolean(someSpan, State.MULTILINE)); + aria.setState(someDiv, State.LEVEL, 1); + let thrown = false; + try { + aria.getStateBoolean(someDiv, State.LEVEL); + } catch (e) { + thrown = true; + } + assertTrue('invalid use of getStateBoolean on numbers.', thrown); + aria.setState(someDiv, State.LIVE, aria.LivePriority.ASSERTIVE); + thrown = false; + try { + aria.getStateBoolean(someDiv, State.LIVE); + } catch (e) { + thrown = true; + } + assertTrue('invalid use of getStateBoolean on strings.', thrown); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testGetSetActiveDescendant() { + aria.setActiveDescendant(someDiv, null); + assertNull( + 'someDiv\'s activedescendant should be null', + aria.getActiveDescendant(someDiv)); + + aria.setActiveDescendant(someDiv, someSpan); + + assertEquals( + 'someDiv\'s active descendant should be "someSpan"', someSpan, + aria.getActiveDescendant(someDiv)); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testGetSetLabel() { + assertEquals('someDiv\'s label should be ""', '', aria.getLabel(someDiv)); + + aria.setLabel(someDiv, 'somelabel'); + assertEquals( + 'someDiv\'s label should be "somelabel"', 'somelabel', + aria.getLabel(someDiv)); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testHasState() { + aria.setState(someDiv, State.EXPANDED, false); + assertTrue(aria.hasState(someDiv, State.EXPANDED)); + aria.removeState(someDiv, State.EXPANDED); + assertFalse(aria.hasState(someDiv, State.EXPANDED)); + }, +}); diff --git a/closure/goog/a11y/aria/aria_test_dom.html b/closure/goog/a11y/aria/aria_test_dom.html new file mode 100644 index 0000000000..30271f456f --- /dev/null +++ b/closure/goog/a11y/aria/aria_test_dom.html @@ -0,0 +1,8 @@ + +
+
\ No newline at end of file diff --git a/closure/goog/a11y/aria/attributes.js b/closure/goog/a11y/aria/attributes.js new file mode 100644 index 0000000000..fad00f7c1f --- /dev/null +++ b/closure/goog/a11y/aria/attributes.js @@ -0,0 +1,396 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + + +/** + * @fileoverview The file contains generated enumerations for ARIA states + * and properties as defined by W3C ARIA standard: + * http://www.w3.org/TR/wai-aria/. + * + * This is auto-generated code. Do not manually edit! For more details + * about how to edit it via the generator check go/closure-ariagen. + */ + +goog.provide('goog.a11y.aria.AutoCompleteValues'); +goog.provide('goog.a11y.aria.CheckedValues'); +goog.provide('goog.a11y.aria.DropEffectValues'); +goog.provide('goog.a11y.aria.ExpandedValues'); +goog.provide('goog.a11y.aria.GrabbedValues'); +goog.provide('goog.a11y.aria.InvalidValues'); +goog.provide('goog.a11y.aria.LivePriority'); +goog.provide('goog.a11y.aria.OrientationValues'); +goog.provide('goog.a11y.aria.PressedValues'); +goog.provide('goog.a11y.aria.RelevantValues'); +goog.provide('goog.a11y.aria.SelectedValues'); +goog.provide('goog.a11y.aria.SortValues'); +goog.provide('goog.a11y.aria.State'); + + +/** + * ARIA states and properties. + * @enum {string} + */ +goog.a11y.aria.State = { + // ARIA property for setting the currently active descendant of an element, + // for example the selected item in a list box. Value: ID of an element. + ACTIVEDESCENDANT: 'activedescendant', + + // ARIA property that, if true, indicates that all of a changed region should + // be presented, instead of only parts. Value: one of {true, false}. + ATOMIC: 'atomic', + + // ARIA property to specify that input completion is provided. Value: + // one of {'inline', 'list', 'both', 'none'}. + AUTOCOMPLETE: 'autocomplete', + + // ARIA state to indicate that an element and its subtree are being updated. + // Value: one of {true, false}. + BUSY: 'busy', + + // ARIA state for a checked item. Value: one of {'true', 'false', 'mixed', + // undefined}. + CHECKED: 'checked', + + // ARIA state that defines an element's column index or position with respect + // to the total number of columns within a table, grid, or treegrid. + // Value: number. + COLINDEX: 'colindex', + + // ARIA property that identifies the element or elements whose contents or + // presence are controlled by this element. + // Value: space-separated IDs of other elements. + CONTROLS: 'controls', + + // ARIA property that identifies the element that represents the current + // item within a container or set of related elements. + // Value: one of {'page', 'step', 'location', 'date', 'time', true, false}. + CURRENT: 'current', + + // ARIA property that identifies the element or elements that describe + // this element. Value: space-separated IDs of other elements. + DESCRIBEDBY: 'describedby', + + // ARIA state for a disabled item. Value: one of {true, false}. + DISABLED: 'disabled', + + // ARIA property that indicates what functions can be performed when a + // dragged object is released on the drop target. Value: one of + // {'copy', 'move', 'link', 'execute', 'popup', 'none'}. + DROPEFFECT: 'dropeffect', + + // ARIA state for setting whether the element like a tree node is expanded. + // Value: one of {true, false, undefined}. + EXPANDED: 'expanded', + + // ARIA property that identifies the next element (or elements) in the + // recommended reading order of content. Value: space-separated ids of + // elements to flow to. + FLOWTO: 'flowto', + + // ARIA state that indicates an element's "grabbed" state in drag-and-drop. + // Value: one of {true, false, undefined}. + GRABBED: 'grabbed', + + // ARIA property indicating whether the element has a popup. + // Value: one of {true, false}. + HASPOPUP: 'haspopup', + + // ARIA state indicating that the element is not visible or perceivable + // to any user. Value: one of {true, false}. + HIDDEN: 'hidden', + + // ARIA state indicating that the entered value does not conform. Value: + // one of {false, true, 'grammar', 'spelling'} + INVALID: 'invalid', + + // ARIA property that provides a label to override any other text, value, or + // contents used to describe this element. Value: string. + LABEL: 'label', + + // ARIA property for setting the element which labels another element. + // Value: space-separated IDs of elements. + LABELLEDBY: 'labelledby', + + // ARIA property for setting the level of an element in the hierarchy. + // Value: integer. + LEVEL: 'level', + + // ARIA property indicating that an element will be updated, and + // describes the types of updates the user agents, assistive technologies, + // and user can expect from the live region. Value: one of {'off', 'polite', + // 'assertive'}. + LIVE: 'live', + + // ARIA property indicating whether a text box can accept multiline input. + // Value: one of {true, false}. + MULTILINE: 'multiline', + + // ARIA property indicating if the user may select more than one item. + // Value: one of {true, false}. + MULTISELECTABLE: 'multiselectable', + + // ARIA property indicating if the element is horizontal or vertical. + // Value: one of {'vertical', 'horizontal'}. + ORIENTATION: 'orientation', + + // ARIA property creating a visual, functional, or contextual parent/child + // relationship when the DOM hierarchy can't be used to represent it. + // Value: Space-separated IDs of elements. + OWNS: 'owns', + + // ARIA property that defines an element's number of position in a list. + // Value: integer. + POSINSET: 'posinset', + + // ARIA state for a pressed item. + // Value: one of {true, false, undefined, 'mixed'}. + PRESSED: 'pressed', + + // ARIA property indicating that an element is not editable. + // Value: one of {true, false}. + READONLY: 'readonly', + + // ARIA property indicating that change notifications within this subtree + // of a live region should be announced. Value: one of {'additions', + // 'removals', 'text', 'all', 'additions text'}. + RELEVANT: 'relevant', + + // ARIA property indicating that user input is required on this element + // before a form may be submitted. Value: one of {true, false}. + REQUIRED: 'required', + + // ARIA state that defines an element's row index or position with respect + // to the total number of rows within a table, grid, or treegrid. + // Value: number. + ROWINDEX: 'rowindex', + + // ARIA state for setting the currently selected item in the list. + // Value: one of {true, false, undefined}. + SELECTED: 'selected', + + // ARIA property defining the number of items in a list. Value: integer. + SETSIZE: 'setsize', + + // ARIA property indicating if items are sorted. Value: one of {'ascending', + // 'descending', 'none', 'other'}. + SORT: 'sort', + + // ARIA property for slider maximum value. Value: number. + VALUEMAX: 'valuemax', + + // ARIA property for slider minimum value. Value: number. + VALUEMIN: 'valuemin', + + // ARIA property for slider active value. Value: number. + VALUENOW: 'valuenow', + + // ARIA property for slider active value represented as text. + // Value: string. + VALUETEXT: 'valuetext' +}; + + +/** + * ARIA state values for AutoCompleteValues. + * @enum {string} + */ +goog.a11y.aria.AutoCompleteValues = { + // The system provides text after the caret as a suggestion + // for how to complete the field. + INLINE: 'inline', + // A list of choices appears from which the user can choose, + // but the edit box retains focus. + LIST: 'list', + // A list of choices appears and the currently selected suggestion + // also appears inline. + BOTH: 'both', + // No input completion suggestions are provided. + NONE: 'none' +}; + + +/** + * ARIA state values for DropEffectValues. + * @enum {string} + */ +goog.a11y.aria.DropEffectValues = { + // A duplicate of the source object will be dropped into the target. + COPY: 'copy', + // The source object will be removed from its current location + // and dropped into the target. + MOVE: 'move', + // A reference or shortcut to the dragged object + // will be created in the target object. + LINK: 'link', + // A function supported by the drop target is + // executed, using the drag source as an input. + EXECUTE: 'execute', + // There is a popup menu or dialog that allows the user to choose + // one of the drag operations (copy, move, link, execute) and any other + // drag functionality, such as cancel. + POPUP: 'popup', + // No operation can be performed; effectively + // cancels the drag operation if an attempt is made to drop on this object. + NONE: 'none' +}; + + +/** + * ARIA state values for LivePriority. + * @enum {string} + */ +goog.a11y.aria.LivePriority = { + // Updates to the region will not be presented to the user + // unless the assitive technology is currently focused on that region. + OFF: 'off', + // (Background change) Assistive technologies SHOULD announce + // updates at the next graceful opportunity, such as at the end of + // speaking the current sentence or when the user pauses typing. + POLITE: 'polite', + // This information has the highest priority and assistive + // technologies SHOULD notify the user immediately. + // Because an interruption may disorient users or cause them to not complete + // their current task, authors SHOULD NOT use the assertive value unless the + // interruption is imperative. + ASSERTIVE: 'assertive' +}; + + +/** + * ARIA state values for OrientationValues. + * @enum {string} + */ +goog.a11y.aria.OrientationValues = { + // The element is oriented vertically. + VERTICAL: 'vertical', + // The element is oriented horizontally. + HORIZONTAL: 'horizontal' +}; + + +/** + * ARIA state values for RelevantValues. + * @enum {string} + */ +goog.a11y.aria.RelevantValues = { + // Element nodes are added to the DOM within the live region. + ADDITIONS: 'additions', + // Text or element nodes within the live region are removed from the DOM. + REMOVALS: 'removals', + // Text is added to any DOM descendant nodes of the live region. + TEXT: 'text', + // Equivalent to the combination of all values, "additions removals text". + ALL: 'all' +}; + + +/** + * ARIA state values for SortValues. + * @enum {string} + */ +goog.a11y.aria.SortValues = { + // Items are sorted in ascending order by this column. + ASCENDING: 'ascending', + // Items are sorted in descending order by this column. + DESCENDING: 'descending', + // There is no defined sort applied to the column. + NONE: 'none', + // A sort algorithm other than ascending or descending has been applied. + OTHER: 'other' +}; + + +/** + * ARIA state values for CheckedValues. + * @enum {string} + */ +goog.a11y.aria.CheckedValues = { + // The selectable element is checked. + TRUE: 'true', + // The selectable element is not checked. + FALSE: 'false', + // Indicates a mixed mode value for a tri-state + // checkbox or menuitemcheckbox. + MIXED: 'mixed', + // The element does not support being checked. + UNDEFINED: 'undefined' +}; + + +/** + * ARIA state values for ExpandedValues. + * @enum {string} + */ +goog.a11y.aria.ExpandedValues = { + // The element, or another grouping element it controls, is expanded. + TRUE: 'true', + // The element, or another grouping element it controls, is collapsed. + FALSE: 'false', + // The element, or another grouping element + // it controls, is neither expandable nor collapsible; all its + // child elements are shown or there are no child elements. + UNDEFINED: 'undefined' +}; + + +/** + * ARIA state values for GrabbedValues. + * @enum {string} + */ +goog.a11y.aria.GrabbedValues = { + // Indicates that the element has been "grabbed" for dragging. + TRUE: 'true', + // Indicates that the element supports being dragged. + FALSE: 'false', + // Indicates that the element does not support being dragged. + UNDEFINED: 'undefined' +}; + + +/** + * ARIA state values for InvalidValues. + * @enum {string} + */ +goog.a11y.aria.InvalidValues = { + // There are no detected errors in the value. + FALSE: 'false', + // The value entered by the user has failed validation. + TRUE: 'true', + // A grammatical error was detected. + GRAMMAR: 'grammar', + // A spelling error was detected. + SPELLING: 'spelling' +}; + + +/** + * ARIA state values for PressedValues. + * @enum {string} + */ +goog.a11y.aria.PressedValues = { + // The element is pressed. + TRUE: 'true', + // The element supports being pressed but is not currently pressed. + FALSE: 'false', + // Indicates a mixed mode value for a tri-state toggle button. + MIXED: 'mixed', + // The element does not support being pressed. + UNDEFINED: 'undefined' +}; + + +/** + * ARIA state values for SelectedValues. + * @enum {string} + */ +goog.a11y.aria.SelectedValues = { + // The selectable element is selected. + TRUE: 'true', + // The selectable element is not selected. + FALSE: 'false', + // The element is not selectable. + UNDEFINED: 'undefined' +}; diff --git a/closure/goog/a11y/aria/datatables.js b/closure/goog/a11y/aria/datatables.js new file mode 100644 index 0000000000..350ee50344 --- /dev/null +++ b/closure/goog/a11y/aria/datatables.js @@ -0,0 +1,61 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + + + +/** + * @fileoverview The file contains data tables generated from the ARIA + * standard schema http://www.w3.org/TR/wai-aria/. + * + * This is auto-generated code. Do not manually edit! + */ + +goog.module('goog.a11y.aria.datatables'); +goog.module.declareLegacyNamespace(); + +const State = goog.require('goog.a11y.aria.State'); + + +/** + * A map that contains mapping between an ARIA state and the default value + * for it. Note that not all ARIA states have default values. + * + * @type {!Object|undefined} + */ +let defaultStateValueMap; + + +/** + * A method that creates a map that contains mapping between an ARIA state and + * the default value for it. Note that not all ARIA states have default values. + * + * @return {!Object} + * The names for each of the notification methods. + */ +exports.getDefaultValuesMap = function() { + if (!defaultStateValueMap) { + defaultStateValueMap = { + [State.ATOMIC]: false, + [State.AUTOCOMPLETE]: 'none', + [State.DROPEFFECT]: 'none', + [State.HASPOPUP]: false, + [State.LIVE]: 'off', + [State.MULTILINE]: false, + [State.MULTISELECTABLE]: false, + [State.ORIENTATION]: 'vertical', + [State.READONLY]: false, + [State.RELEVANT]: 'additions text', + [State.REQUIRED]: false, + [State.SORT]: 'none', + [State.BUSY]: false, + [State.DISABLED]: false, + [State.HIDDEN]: false, + [State.INVALID]: 'false', + }; + } + + return defaultStateValueMap; +}; diff --git a/closure/goog/a11y/aria/roles.js b/closure/goog/a11y/aria/roles.js new file mode 100644 index 0000000000..45e1c3819d --- /dev/null +++ b/closure/goog/a11y/aria/roles.js @@ -0,0 +1,224 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + + +/** + * @fileoverview The file contains generated enumerations for ARIA roles + * as defined by W3C ARIA standard: http://www.w3.org/TR/wai-aria/. + */ + +goog.provide('goog.a11y.aria.Role'); + + +/** + * ARIA role values. + * @enum {string} + * @suppress {lintChecks} TODO b/304590658 - re-enable this during the cleanup + */ +goog.a11y.aria.Role = { + // ARIA role for an alert element that doesn't need to be explicitly closed. + ALERT: 'alert', + + // ARIA role for an alert dialog element that takes focus and must be closed. + ALERTDIALOG: 'alertdialog', + + // ARIA role for an application that implements its own keyboard navigation. + APPLICATION: 'application', + + // ARIA role for an article. + ARTICLE: 'article', + + // ARIA role for a banner containing mostly site content, not page content. + BANNER: 'banner', + + // ARIA role for a button element. + BUTTON: 'button', + + // ARIA role for a checkbox button element; use with the CHECKED state. + CHECKBOX: 'checkbox', + + // ARIA role for a column header of a table or grid. + COLUMNHEADER: 'columnheader', + + // ARIA role for a combo box element. + COMBOBOX: 'combobox', + + // ARIA role for a supporting section of the document. + COMPLEMENTARY: 'complementary', + + // ARIA role for a large perceivable region that contains information + // about the parent document. + CONTENTINFO: 'contentinfo', + + // ARIA role for a definition of a term or concept. + DEFINITION: 'definition', + + // ARIA role for a dialog, some descendant must take initial focus. + DIALOG: 'dialog', + + // ARIA role for a directory, like a table of contents. + DIRECTORY: 'directory', + + // ARIA role for a part of a page that's a document, not a web application. + DOCUMENT: 'document', + + // ARIA role for a landmark region logically considered one form. + FORM: 'form', + + // ARIA role for an interactive control of tabular data. + GRID: 'grid', + + // ARIA role for a cell in a grid. + GRIDCELL: 'gridcell', + + // ARIA role for a group of related elements like tree item siblings. + GROUP: 'group', + + // ARIA role for a heading element. + HEADING: 'heading', + + // ARIA role for a container of elements that together comprise one image. + IMG: 'img', + + // ARIA role for a link. + LINK: 'link', + + // ARIA role for a list of non-interactive list items. + LIST: 'list', + + // ARIA role for a listbox. + LISTBOX: 'listbox', + + // ARIA role for a list item. + LISTITEM: 'listitem', + + // ARIA role for a live region where new information is added. + LOG: 'log', + + // ARIA landmark role for the main content in a document. Use only once. + MAIN: 'main', + + // ARIA role for a live region of non-essential information that changes. + MARQUEE: 'marquee', + + // ARIA role for a mathematical expression. + MATH: 'math', + + // ARIA role for a popup menu. + MENU: 'menu', + + // ARIA role for a menubar element containing menu elements. + MENUBAR: 'menubar', + + // ARIA role for menu item elements. + MENU_ITEM: 'menuitem', + + // ARIA role for menu item elements. + MENUITEM: 'menuitem', + + // ARIA role for a checkbox box element inside a menu. + MENU_ITEM_CHECKBOX: 'menuitemcheckbox', + + // ARIA role for a checkbox box element inside a menu. + MENUITEMCHECKBOX: 'menuitemcheckbox', + + // ARIA role for a radio button element inside a menu. + MENU_ITEM_RADIO: 'menuitemradio', + + // ARIA role for a radio button element inside a menu. + MENUITEMRADIO: 'menuitemradio', + + // ARIA landmark role for a collection of navigation links. + NAVIGATION: 'navigation', + + // ARIA role for a section ancillary to the main content. + NOTE: 'note', + + // ARIA role for option items that are children of combobox, listbox, menu, + // radiogroup, or tree elements. + OPTION: 'option', + + // ARIA role for ignorable cosmetic elements with no semantic significance. + PRESENTATION: 'presentation', + + // ARIA role for a progress bar element. + PROGRESSBAR: 'progressbar', + + // ARIA role for a radio button element. + RADIO: 'radio', + + // ARIA role for a group of connected radio button elements. + RADIOGROUP: 'radiogroup', + + // ARIA role for an important region of the page. + REGION: 'region', + + // ARIA role for a row of cells in a grid. + ROW: 'row', + + // ARIA role for a group of one or more rows in a grid. + ROWGROUP: 'rowgroup', + + // ARIA role for a row header of a table or grid. + ROWHEADER: 'rowheader', + + // ARIA role for a scrollbar element. + SCROLLBAR: 'scrollbar', + + // ARIA landmark role for a part of the page providing search functionality. + SEARCH: 'search', + + // ARIA role for a menu separator. + SEPARATOR: 'separator', + + // ARIA role for a slider. + SLIDER: 'slider', + + // ARIA role for a spin button. + SPINBUTTON: 'spinbutton', + + // ARIA role for a live region with advisory info less severe than an alert. + STATUS: 'status', + + // ARIA role for a tab button. + TAB: 'tab', + + // ARIA role for a tab bar (i.e. a list of tab buttons). + TAB_LIST: 'tablist', + + // ARIA role for a tab bar (i.e. a list of tab buttons). + TABLIST: 'tablist', + + // ARIA role for a tab page (i.e. the element holding tab contents). + TAB_PANEL: 'tabpanel', + + // ARIA role for a tab page (i.e. the element holding tab contents). + TABPANEL: 'tabpanel', + + // ARIA role for a textbox element. + TEXTBOX: 'textbox', + + // ARIA role for a textinfo element. + TEXTINFO: 'textinfo', + + // ARIA role for an element displaying elapsed time or time remaining. + TIMER: 'timer', + + // ARIA role for a toolbar element. + TOOLBAR: 'toolbar', + + // ARIA role for a tooltip element. + TOOLTIP: 'tooltip', + + // ARIA role for a tree. + TREE: 'tree', + + // ARIA role for a grid whose rows can be expanded and collapsed like a tree. + TREEGRID: 'treegrid', + + // ARIA role for a tree item that sometimes may be expanded or collapsed. + TREEITEM: 'treeitem' +}; diff --git a/closure/goog/array/BUILD b/closure/goog/array/BUILD new file mode 100644 index 0000000000..9bfe34b4b4 --- /dev/null +++ b/closure/goog/array/BUILD @@ -0,0 +1,12 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "array", + srcs = ["array.js"], + lenient = True, + deps = ["//closure/goog/asserts"], +) diff --git a/closure/goog/array/array.js b/closure/goog/array/array.js new file mode 100644 index 0000000000..93a547bdfa --- /dev/null +++ b/closure/goog/array/array.js @@ -0,0 +1,1782 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for manipulating arrays. + */ + + +goog.module('goog.array'); +goog.module.declareLegacyNamespace(); + +const asserts = goog.require('goog.asserts'); + + +/** + * @define {boolean} NATIVE_ARRAY_PROTOTYPES indicates whether the code should + * rely on Array.prototype functions, if available. + * + * The Array.prototype functions can be defined by external libraries like + * Prototype and setting this flag to false forces closure to use its own + * goog.array implementation. + * + * If your javascript can be loaded by a third party site and you are wary about + * relying on the prototype functions, specify + * "--define goog.NATIVE_ARRAY_PROTOTYPES=false" to the JSCompiler. + * + * Setting goog.TRUSTED_SITE to false will automatically set + * NATIVE_ARRAY_PROTOTYPES to false. + */ +goog.NATIVE_ARRAY_PROTOTYPES = + goog.define('goog.NATIVE_ARRAY_PROTOTYPES', goog.TRUSTED_SITE); + + +/** + * @define {boolean} If true, JSCompiler will use the native implementation of + * array functions where appropriate (e.g., `Array#filter`) and remove the + * unused pure JS implementation. + */ +const ASSUME_NATIVE_FUNCTIONS = goog.define( + 'goog.array.ASSUME_NATIVE_FUNCTIONS', goog.FEATURESET_YEAR > 2012); +exports.ASSUME_NATIVE_FUNCTIONS = ASSUME_NATIVE_FUNCTIONS; + + +/** + * Returns the last element in an array without removing it. + * Same as {@link goog.array.last}. + * @param {IArrayLike|string} array The array. + * @return {T} Last item in array. + * @template T + */ +function peek(array) { + return array[array.length - 1]; +} +exports.peek = peek; + + +/** + * Returns the last element in an array without removing it. + * Same as {@link goog.array.peek}. + * @param {IArrayLike|string} array The array. + * @return {T} Last item in array. + * @template T + */ +exports.last = peek; + +// NOTE(arv): Since most of the array functions are generic it allows you to +// pass an array-like object. Strings have a length and are considered array- +// like. However, the 'in' operator does not work on strings so we cannot just +// use the array path even if the browser supports indexing into strings. We +// therefore end up splitting the string. + + +/** + * Returns the index of the first element of an array with a specified value, or + * -1 if the element is not present in the array. + * + * See {@link http://tinyurl.com/developer-mozilla-org-array-indexof} + * + * @param {IArrayLike|string} arr The array to be searched. + * @param {T} obj The object for which we are searching. + * @param {number=} opt_fromIndex The index at which to start the search. If + * omitted the search starts at index 0. + * @return {number} The index of the first matching array element. + * @template T + */ +const indexOf = goog.NATIVE_ARRAY_PROTOTYPES && + (ASSUME_NATIVE_FUNCTIONS || Array.prototype.indexOf) ? + function(arr, obj, opt_fromIndex) { + asserts.assert(arr.length != null); + + return Array.prototype.indexOf.call(arr, obj, opt_fromIndex); + } : + function(arr, obj, opt_fromIndex) { + const fromIndex = opt_fromIndex == null ? + 0 : + (opt_fromIndex < 0 ? Math.max(0, arr.length + opt_fromIndex) : + opt_fromIndex); + + if (typeof arr === 'string') { + // Array.prototype.indexOf uses === so only strings should be found. + if (typeof obj !== 'string' || obj.length != 1) { + return -1; + } + return arr.indexOf(obj, fromIndex); + } + + for (let i = fromIndex; i < arr.length; i++) { + if (i in arr && arr[i] === obj) return i; + } + return -1; + }; +exports.indexOf = indexOf; + + +/** + * Returns the index of the last element of an array with a specified value, or + * -1 if the element is not present in the array. + * + * See {@link http://tinyurl.com/developer-mozilla-org-array-lastindexof} + * + * @param {!IArrayLike|string} arr The array to be searched. + * @param {T} obj The object for which we are searching. + * @param {?number=} opt_fromIndex The index at which to start the search. If + * omitted the search starts at the end of the array. + * @return {number} The index of the last matching array element. + * @template T + */ +const lastIndexOf = goog.NATIVE_ARRAY_PROTOTYPES && + (ASSUME_NATIVE_FUNCTIONS || Array.prototype.lastIndexOf) ? + function(arr, obj, opt_fromIndex) { + asserts.assert(arr.length != null); + + // Firefox treats undefined and null as 0 in the fromIndex argument which + // leads it to always return -1 + const fromIndex = opt_fromIndex == null ? arr.length - 1 : opt_fromIndex; + return Array.prototype.lastIndexOf.call(arr, obj, fromIndex); + } : + function(arr, obj, opt_fromIndex) { + let fromIndex = opt_fromIndex == null ? arr.length - 1 : opt_fromIndex; + + if (fromIndex < 0) { + fromIndex = Math.max(0, arr.length + fromIndex); + } + + if (typeof arr === 'string') { + // Array.prototype.lastIndexOf uses === so only strings should be found. + if (typeof obj !== 'string' || obj.length != 1) { + return -1; + } + return arr.lastIndexOf(obj, fromIndex); + } + + for (let i = fromIndex; i >= 0; i--) { + if (i in arr && arr[i] === obj) return i; + } + return -1; + }; +exports.lastIndexOf = lastIndexOf; + + +/** + * Calls a function for each element in an array. Skips holes in the array. + * See {@link http://tinyurl.com/developer-mozilla-org-array-foreach} + * + * @param {IArrayLike|string} arr Array or array like object over + * which to iterate. + * @param {?function(this: S, T, number, ?): ?} f The function to call for every + * element. This function takes 3 arguments (the element, the index and the + * array). The return value is ignored. + * @param {S=} opt_obj The object to be used as the value of 'this' within f. + * @template T,S + */ +const forEach = goog.NATIVE_ARRAY_PROTOTYPES && + (ASSUME_NATIVE_FUNCTIONS || Array.prototype.forEach) ? + function(arr, f, opt_obj) { + asserts.assert(arr.length != null); + + Array.prototype.forEach.call(arr, f, opt_obj); + } : + function(arr, f, opt_obj) { + const l = arr.length; // must be fixed during loop... see docs + const arr2 = (typeof arr === 'string') ? arr.split('') : arr; + for (let i = 0; i < l; i++) { + if (i in arr2) { + f.call(/** @type {?} */ (opt_obj), arr2[i], i, arr); + } + } + }; +exports.forEach = forEach; + + +/** + * Calls a function for each element in an array, starting from the last + * element rather than the first. + * + * @param {IArrayLike|string} arr Array or array + * like object over which to iterate. + * @param {?function(this: S, T, number, ?): ?} f The function to call for every + * element. This function + * takes 3 arguments (the element, the index and the array). The return + * value is ignored. + * @param {S=} opt_obj The object to be used as the value of 'this' + * within f. + * @template T,S + */ +function forEachRight(arr, f, opt_obj) { + const l = arr.length; // must be fixed during loop... see docs + const arr2 = (typeof arr === 'string') ? arr.split('') : arr; + for (let i = l - 1; i >= 0; --i) { + if (i in arr2) { + f.call(/** @type {?} */ (opt_obj), arr2[i], i, arr); + } + } +} +exports.forEachRight = forEachRight; + + +/** + * Calls a function for each element in an array, and if the function returns + * true adds the element to a new array. + * + * See {@link http://tinyurl.com/developer-mozilla-org-array-filter} + * + * @param {IArrayLike|string} arr Array or array + * like object over which to iterate. + * @param {?function(this:S, T, number, ?):boolean} f The function to call for + * every element. This function + * takes 3 arguments (the element, the index and the array) and must + * return a Boolean. If the return value is true the element is added to the + * result array. If it is false the element is not included. + * @param {S=} opt_obj The object to be used as the value of 'this' + * within f. + * @return {!Array} a new array in which only elements that passed the test + * are present. + * @template T,S + */ +const filter = goog.NATIVE_ARRAY_PROTOTYPES && + (ASSUME_NATIVE_FUNCTIONS || Array.prototype.filter) ? + function(arr, f, opt_obj) { + asserts.assert(arr.length != null); + + return Array.prototype.filter.call(arr, f, opt_obj); + } : + function(arr, f, opt_obj) { + const l = arr.length; // must be fixed during loop... see docs + const res = []; + let resLength = 0; + const arr2 = (typeof arr === 'string') ? arr.split('') : arr; + for (let i = 0; i < l; i++) { + if (i in arr2) { + const val = arr2[i]; // in case f mutates arr2 + if (f.call(/** @type {?} */ (opt_obj), val, i, arr)) { + res[resLength++] = val; + } + } + } + return res; + }; +exports.filter = filter; + + +/** + * Calls a function for each element in an array and inserts the result into a + * new array. + * + * See {@link http://tinyurl.com/developer-mozilla-org-array-map} + * + * @param {IArrayLike|string} arr Array or array like object + * over which to iterate. + * @param {function(this:THIS, VALUE, number, ?): RESULT} f The function to call + * for every element. This function takes 3 arguments (the element, + * the index and the array) and should return something. The result will be + * inserted into a new array. + * @param {THIS=} opt_obj The object to be used as the value of 'this' within f. + * @return {!Array} a new array with the results from f. + * @template THIS, VALUE, RESULT + */ +const map = goog.NATIVE_ARRAY_PROTOTYPES && + (ASSUME_NATIVE_FUNCTIONS || Array.prototype.map) ? + function(arr, f, opt_obj) { + asserts.assert(arr.length != null); + + return Array.prototype.map.call(arr, f, opt_obj); + } : + function(arr, f, opt_obj) { + const l = arr.length; // must be fixed during loop... see docs + const res = new Array(l); + const arr2 = (typeof arr === 'string') ? arr.split('') : arr; + for (let i = 0; i < l; i++) { + if (i in arr2) { + res[i] = f.call(/** @type {?} */ (opt_obj), arr2[i], i, arr); + } + } + return res; + }; +exports.map = map; + + +/** + * Passes every element of an array into a function and accumulates the result. + * + * See {@link http://tinyurl.com/developer-mozilla-org-array-reduce} + * Note that this implementation differs from the native Array.prototype.reduce + * in that the initial value is assumed to be defined (the MDN docs linked above + * recommend not omitting this parameter, although it is technically optional). + * + * For example: + * var a = [1, 2, 3, 4]; + * reduce(a, function(r, v, i, arr) {return r + v;}, 0); + * returns 10 + * + * @param {IArrayLike|string} arr Array or array + * like object over which to iterate. + * @param {function(this:S, R, T, number, ?) : R} f The function to call for + * every element. This function + * takes 4 arguments (the function's previous result or the initial value, + * the value of the current array element, the current array index, and the + * array itself) + * function(previousValue, currentValue, index, array). + * @param {?} val The initial value to pass into the function on the first call. + * @param {S=} opt_obj The object to be used as the value of 'this' + * within f. + * @return {R} Result of evaluating f repeatedly across the values of the array. + * @template T,S,R + */ +const reduce = goog.NATIVE_ARRAY_PROTOTYPES && + (ASSUME_NATIVE_FUNCTIONS || Array.prototype.reduce) ? + function(arr, f, val, opt_obj) { + asserts.assert(arr.length != null); + if (opt_obj) { + f = goog.bind(f, opt_obj); + } + return Array.prototype.reduce.call(arr, f, val); + } : + function(arr, f, val, opt_obj) { + let rval = val; + forEach(arr, function(val, index) { + rval = f.call(/** @type {?} */ (opt_obj), rval, val, index, arr); + }); + return rval; + }; +exports.reduce = reduce; + + +/** + * Passes every element of an array into a function and accumulates the result, + * starting from the last element and working towards the first. + * + * See {@link http://tinyurl.com/developer-mozilla-org-array-reduceright} + * + * For example: + * var a = ['a', 'b', 'c']; + * reduceRight(a, function(r, v, i, arr) {return r + v;}, ''); + * returns 'cba' + * + * @param {IArrayLike|string} arr Array or array + * like object over which to iterate. + * @param {?function(this:S, R, T, number, ?) : R} f The function to call for + * every element. This function + * takes 4 arguments (the function's previous result or the initial value, + * the value of the current array element, the current array index, and the + * array itself) + * function(previousValue, currentValue, index, array). + * @param {?} val The initial value to pass into the function on the first call. + * @param {S=} opt_obj The object to be used as the value of 'this' + * within f. + * @return {R} Object returned as a result of evaluating f repeatedly across the + * values of the array. + * @template T,S,R + */ +const reduceRight = goog.NATIVE_ARRAY_PROTOTYPES && + (ASSUME_NATIVE_FUNCTIONS || Array.prototype.reduceRight) ? + function(arr, f, val, opt_obj) { + asserts.assert(arr.length != null); + asserts.assert(f != null); + if (opt_obj) { + f = goog.bind(f, opt_obj); + } + return Array.prototype.reduceRight.call(arr, f, val); + } : + function(arr, f, val, opt_obj) { + let rval = val; + forEachRight(arr, function(val, index) { + rval = f.call(/** @type {?} */ (opt_obj), rval, val, index, arr); + }); + return rval; + }; +exports.reduceRight = reduceRight; + + +/** + * Calls f for each element of an array. If any call returns true, some() + * returns true (without checking the remaining elements). If all calls + * return false, some() returns false. + * + * See {@link http://tinyurl.com/developer-mozilla-org-array-some} + * + * @param {IArrayLike|string} arr Array or array + * like object over which to iterate. + * @param {?function(this:S, T, number, ?) : boolean} f The function to call for + * for every element. This function takes 3 arguments (the element, the + * index and the array) and should return a boolean. + * @param {S=} opt_obj The object to be used as the value of 'this' + * within f. + * @return {boolean} true if any element passes the test. + * @template T,S + */ +const some = goog.NATIVE_ARRAY_PROTOTYPES && + (ASSUME_NATIVE_FUNCTIONS || Array.prototype.some) ? + function(arr, f, opt_obj) { + asserts.assert(arr.length != null); + + return Array.prototype.some.call(arr, f, opt_obj); + } : + function(arr, f, opt_obj) { + const l = arr.length; // must be fixed during loop... see docs + const arr2 = (typeof arr === 'string') ? arr.split('') : arr; + for (let i = 0; i < l; i++) { + if (i in arr2 && f.call(/** @type {?} */ (opt_obj), arr2[i], i, arr)) { + return true; + } + } + return false; + }; +exports.some = some; + + +/** + * Call f for each element of an array. If all calls return true, every() + * returns true. If any call returns false, every() returns false and + * does not continue to check the remaining elements. + * + * See {@link http://tinyurl.com/developer-mozilla-org-array-every} + * + * @param {IArrayLike|string} arr Array or array + * like object over which to iterate. + * @param {?function(this:S, T, number, ?) : boolean} f The function to call for + * for every element. This function takes 3 arguments (the element, the + * index and the array) and should return a boolean. + * @param {S=} opt_obj The object to be used as the value of 'this' + * within f. + * @return {boolean} false if any element fails the test. + * @template T,S + */ +const every = goog.NATIVE_ARRAY_PROTOTYPES && + (ASSUME_NATIVE_FUNCTIONS || Array.prototype.every) ? + function(arr, f, opt_obj) { + asserts.assert(arr.length != null); + + return Array.prototype.every.call(arr, f, opt_obj); + } : + function(arr, f, opt_obj) { + const l = arr.length; // must be fixed during loop... see docs + const arr2 = (typeof arr === 'string') ? arr.split('') : arr; + for (let i = 0; i < l; i++) { + if (i in arr2 && !f.call(/** @type {?} */ (opt_obj), arr2[i], i, arr)) { + return false; + } + } + return true; + }; +exports.every = every; + + +/** + * Counts the array elements that fulfill the predicate, i.e. for which the + * callback function returns true. Skips holes in the array. + * + * @param {!IArrayLike|string} arr Array or array like object + * over which to iterate. + * @param {function(this: S, T, number, ?): boolean} f The function to call for + * every element. Takes 3 arguments (the element, the index and the array). + * @param {S=} opt_obj The object to be used as the value of 'this' within f. + * @return {number} The number of the matching elements. + * @template T,S + */ +function count(arr, f, opt_obj) { + let count = 0; + forEach(arr, function(element, index, arr) { + if (f.call(/** @type {?} */ (opt_obj), element, index, arr)) { + ++count; + } + }, opt_obj); + return count; +} +exports.count = count; + + +/** + * Search an array for the first element that satisfies a given condition and + * return that element. + * @param {IArrayLike|string} arr Array or array + * like object over which to iterate. + * @param {?function(this:S, T, number, ?) : boolean} f The function to call + * for every element. This function takes 3 arguments (the element, the + * index and the array) and should return a boolean. + * @param {S=} opt_obj An optional "this" context for the function. + * @return {T|null} The first array element that passes the test, or null if no + * element is found. + * @template T,S + */ +function find(arr, f, opt_obj) { + const i = findIndex(arr, f, opt_obj); + return i < 0 ? null : typeof arr === 'string' ? arr.charAt(i) : arr[i]; +} +exports.find = find; + + +/** + * Search an array for the first element that satisfies a given condition and + * return its index. + * @param {IArrayLike|string} arr Array or array + * like object over which to iterate. + * @param {?function(this:S, T, number, ?) : boolean} f The function to call for + * every element. This function + * takes 3 arguments (the element, the index and the array) and should + * return a boolean. + * @param {S=} opt_obj An optional "this" context for the function. + * @return {number} The index of the first array element that passes the test, + * or -1 if no element is found. + * @template T,S + */ +function findIndex(arr, f, opt_obj) { + const l = arr.length; // must be fixed during loop... see docs + const arr2 = (typeof arr === 'string') ? arr.split('') : arr; + for (let i = 0; i < l; i++) { + if (i in arr2 && f.call(/** @type {?} */ (opt_obj), arr2[i], i, arr)) { + return i; + } + } + return -1; +} +exports.findIndex = findIndex; + + +/** + * Search an array (in reverse order) for the last element that satisfies a + * given condition and return that element. + * @param {IArrayLike|string} arr Array or array + * like object over which to iterate. + * @param {?function(this:S, T, number, ?) : boolean} f The function to call + * for every element. This function + * takes 3 arguments (the element, the index and the array) and should + * return a boolean. + * @param {S=} opt_obj An optional "this" context for the function. + * @return {T|null} The last array element that passes the test, or null if no + * element is found. + * @template T,S + */ +function findRight(arr, f, opt_obj) { + const i = findIndexRight(arr, f, opt_obj); + return i < 0 ? null : typeof arr === 'string' ? arr.charAt(i) : arr[i]; +} +exports.findRight = findRight; + + +/** + * Search an array (in reverse order) for the last element that satisfies a + * given condition and return its index. + * @param {IArrayLike|string} arr Array or array + * like object over which to iterate. + * @param {?function(this:S, T, number, ?) : boolean} f The function to call + * for every element. This function + * takes 3 arguments (the element, the index and the array) and should + * return a boolean. + * @param {S=} opt_obj An optional "this" context for the function. + * @return {number} The index of the last array element that passes the test, + * or -1 if no element is found. + * @template T,S + */ +function findIndexRight(arr, f, opt_obj) { + const l = arr.length; // must be fixed during loop... see docs + const arr2 = (typeof arr === 'string') ? arr.split('') : arr; + for (let i = l - 1; i >= 0; i--) { + if (i in arr2 && f.call(/** @type {?} */ (opt_obj), arr2[i], i, arr)) { + return i; + } + } + return -1; +} +exports.findIndexRight = findIndexRight; + + +/** + * Whether the array contains the given object. + * @param {IArrayLike|string} arr The array to test for the presence of the + * element. + * @param {*} obj The object for which to test. + * @return {boolean} true if obj is present. + */ +function contains(arr, obj) { + return indexOf(arr, obj) >= 0; +} +exports.contains = contains; + + +/** + * Whether the array is empty. + * @param {IArrayLike|string} arr The array to test. + * @return {boolean} true if empty. + */ +function isEmpty(arr) { + return arr.length == 0; +} +exports.isEmpty = isEmpty; + + +/** + * Clears the array. + * @param {IArrayLike} arr Array or array like object to clear. + */ +function clear(arr) { + // For non real arrays we don't have the magic length so we delete the + // indices. + if (!Array.isArray(arr)) { + for (let i = arr.length - 1; i >= 0; i--) { + delete arr[i]; + } + } + arr.length = 0; +} +exports.clear = clear; + + +/** + * Pushes an item into an array, if it's not already in the array. + * @param {Array} arr Array into which to insert the item. + * @param {T} obj Value to add. + * @template T + */ +function insert(arr, obj) { + if (!contains(arr, obj)) { + arr.push(obj); + } +} +exports.insert = insert; + + +/** + * Inserts an object at the given index of the array. + * @param {IArrayLike} arr The array to modify. + * @param {*} obj The object to insert. + * @param {number=} opt_i The index at which to insert the object. If omitted, + * treated as 0. A negative index is counted from the end of the array. + */ +function insertAt(arr, obj, opt_i) { + splice(arr, opt_i, 0, obj); +} +exports.insertAt = insertAt; + + +/** + * Inserts at the given index of the array, all elements of another array. + * @param {IArrayLike} arr The array to modify. + * @param {IArrayLike} elementsToAdd The array of elements to add. + * @param {number=} opt_i The index at which to insert the object. If omitted, + * treated as 0. A negative index is counted from the end of the array. + */ +function insertArrayAt(arr, elementsToAdd, opt_i) { + goog.partial(splice, arr, opt_i, 0).apply(null, elementsToAdd); +} +exports.insertArrayAt = insertArrayAt; + + +/** + * Inserts an object into an array before a specified object. + * @param {Array} arr The array to modify. + * @param {T} obj The object to insert. + * @param {T=} opt_obj2 The object before which obj should be inserted. If obj2 + * is omitted or not found, obj is inserted at the end of the array. + * @template T + */ +function insertBefore(arr, obj, opt_obj2) { + let i; + if (arguments.length == 2 || (i = indexOf(arr, opt_obj2)) < 0) { + arr.push(obj); + } else { + insertAt(arr, obj, i); + } +} +exports.insertBefore = insertBefore; + + +/** + * Removes the first occurrence of a particular value from an array. + * @param {IArrayLike} arr Array from which to remove + * value. + * @param {T} obj Object to remove. + * @return {boolean} True if an element was removed. + * @template T + */ +function remove(arr, obj) { + const i = indexOf(arr, obj); + let rv; + if ((rv = i >= 0)) { + removeAt(arr, i); + } + return rv; +} +exports.remove = remove; + + +/** + * Removes the last occurrence of a particular value from an array. + * @param {!IArrayLike} arr Array from which to remove value. + * @param {T} obj Object to remove. + * @return {boolean} True if an element was removed. + * @template T + */ +function removeLast(arr, obj) { + const i = lastIndexOf(arr, obj); + if (i >= 0) { + removeAt(arr, i); + return true; + } + return false; +} +exports.removeLast = removeLast; + + +/** + * Removes from an array the element at index i + * @param {IArrayLike} arr Array or array like object from which to + * remove value. + * @param {number} i The index to remove. + * @return {boolean} True if an element was removed. + */ +function removeAt(arr, i) { + asserts.assert(arr.length != null); + + // use generic form of splice + // splice returns the removed items and if successful the length of that + // will be 1 + return Array.prototype.splice.call(arr, i, 1).length == 1; +} +exports.removeAt = removeAt; + + +/** + * Removes the first value that satisfies the given condition. + * @param {IArrayLike} arr Array or array + * like object over which to iterate. + * @param {?function(this:S, T, number, ?) : boolean} f The function to call + * for every element. This function + * takes 3 arguments (the element, the index and the array) and should + * return a boolean. + * @param {S=} opt_obj An optional "this" context for the function. + * @return {boolean} True if an element was removed. + * @template T,S + */ +function removeIf(arr, f, opt_obj) { + const i = findIndex(arr, f, opt_obj); + if (i >= 0) { + removeAt(arr, i); + return true; + } + return false; +} +exports.removeIf = removeIf; + + +/** + * Removes all values that satisfy the given condition. + * @param {IArrayLike} arr Array or array + * like object over which to iterate. + * @param {?function(this:S, T, number, ?) : boolean} f The function to call + * for every element. This function + * takes 3 arguments (the element, the index and the array) and should + * return a boolean. + * @param {S=} opt_obj An optional "this" context for the function. + * @return {number} The number of items removed + * @template T,S + */ +function removeAllIf(arr, f, opt_obj) { + let removedCount = 0; + forEachRight(arr, function(val, index) { + if (f.call(/** @type {?} */ (opt_obj), val, index, arr)) { + if (removeAt(arr, index)) { + removedCount++; + } + } + }); + return removedCount; +} +exports.removeAllIf = removeAllIf; + + +/** + * Returns a new array that is the result of joining the arguments. If arrays + * are passed then their items are added, however, if non-arrays are passed they + * will be added to the return array as is. + * + * Note that ArrayLike objects will be added as is, rather than having their + * items added. + * + * concat([1, 2], [3, 4]) -> [1, 2, 3, 4] + * concat(0, [1, 2]) -> [0, 1, 2] + * concat([1, 2], null) -> [1, 2, null] + * + * @param {...*} var_args Items to concatenate. Arrays will have each item + * added, while primitives and objects will be added as is. + * @return {!Array} The new resultant array. + */ +function concat(var_args) { + return Array.prototype.concat.apply([], arguments); +} +exports.concat = concat; + + +/** + * Returns a new array that contains the contents of all the arrays passed. + * @param {...!Array} var_args + * @return {!Array} + * @template T + */ +function join(var_args) { + return Array.prototype.concat.apply([], arguments); +} +exports.join = join; + + +/** + * Converts an object to an array. + * @param {IArrayLike|string} object The object to convert to an + * array. + * @return {!Array} The object converted into an array. If object has a + * length property, every property indexed with a non-negative number + * less than length will be included in the result. If object does not + * have a length property, an empty array will be returned. + * @template T + */ +function toArray(object) { + const length = object.length; + + // If length is not a number the following is false. This case is kept for + // backwards compatibility since there are callers that pass objects that are + // not array like. + if (length > 0) { + const rv = new Array(length); + for (let i = 0; i < length; i++) { + rv[i] = object[i]; + } + return rv; + } + return []; +} +exports.toArray = toArray; + + +/** + * Does a shallow copy of an array. + * @param {IArrayLike|string} arr Array or array-like object to + * clone. + * @return {!Array} Clone of the input array. + * @template T + */ +const clone = toArray; +exports.clone = clone; + + +/** + * Extends an array with another array, element, or "array like" object. + * This function operates 'in-place', it does not create a new Array. + * + * Example: + * var a = []; + * extend(a, [0, 1]); + * a; // [0, 1] + * extend(a, 2); + * a; // [0, 1, 2] + * + * @param {Array} arr1 The array to modify. + * @param {...(IArrayLike|VALUE)} var_args The elements or arrays of + * elements to add to arr1. + * @template VALUE + */ +function extend(arr1, var_args) { + for (let i = 1; i < arguments.length; i++) { + const arr2 = arguments[i]; + if (goog.isArrayLike(arr2)) { + const len1 = arr1.length || 0; + const len2 = arr2.length || 0; + arr1.length = len1 + len2; + for (let j = 0; j < len2; j++) { + arr1[len1 + j] = arr2[j]; + } + } else { + arr1.push(arr2); + } + } +} +exports.extend = extend; + + +/** + * Adds or removes elements from an array. This is a generic version of Array + * splice. This means that it might work on other objects similar to arrays, + * such as the arguments object. + * + * @param {IArrayLike} arr The array to modify. + * @param {number|undefined} index The index at which to start changing the + * array. If not defined, treated as 0. + * @param {number} howMany How many elements to remove (0 means no removal. A + * value below 0 is treated as zero and so is any other non number. Numbers + * are floored). + * @param {...T} var_args Optional, additional elements to insert into the + * array. + * @return {!Array} the removed elements. + * @template T + */ +function splice(arr, index, howMany, var_args) { + asserts.assert(arr.length != null); + + return Array.prototype.splice.apply(arr, slice(arguments, 1)); +} +exports.splice = splice; + + +/** + * Returns a new array from a segment of an array. This is a generic version of + * Array slice. This means that it might work on other objects similar to + * arrays, such as the arguments object. + * + * @param {IArrayLike|string} arr The array from + * which to copy a segment. + * @param {number} start The index of the first element to copy. + * @param {number=} opt_end The index after the last element to copy. + * @return {!Array} A new array containing the specified segment of the + * original array. + * @template T + */ +function slice(arr, start, opt_end) { + asserts.assert(arr.length != null); + + // passing 1 arg to slice is not the same as passing 2 where the second is + // null or undefined (in that case the second argument is treated as 0). + // we could use slice on the arguments object and then use apply instead of + // testing the length + if (arguments.length <= 2) { + return Array.prototype.slice.call(arr, start); + } else { + return Array.prototype.slice.call(arr, start, opt_end); + } +} +exports.slice = slice; + + +/** + * Removes all duplicates from an array (retaining only the first + * occurrence of each array element). This function modifies the + * array in place and doesn't change the order of the non-duplicate items. + * + * For objects, duplicates are identified as having the same unique ID as + * defined by {@link goog.getUid}. + * + * Alternatively you can specify a custom hash function that returns a unique + * value for each item in the array it should consider unique. + * + * Runtime: N, + * Worstcase space: 2N (no dupes) + * + * @param {IArrayLike} arr The array from which to remove + * duplicates. + * @param {Array=} opt_rv An optional array in which to return the results, + * instead of performing the removal inplace. If specified, the original + * array will remain unchanged. + * @param {function(T):string=} opt_hashFn An optional function to use to + * apply to every item in the array. This function should return a unique + * value for each item in the array it should consider unique. + * @template T + */ +function removeDuplicates(arr, opt_rv, opt_hashFn) { + const returnArray = opt_rv || arr; + const defaultHashFn = function(item) { + // Prefix each type with a single character representing the type to + // prevent conflicting keys (e.g. true and 'true'). + return goog.isObject(item) ? 'o' + goog.getUid(item) : + (typeof item).charAt(0) + item; + }; + const hashFn = opt_hashFn || defaultHashFn; + + let cursorInsert = 0; + let cursorRead = 0; + const seen = {}; + + while (cursorRead < arr.length) { + const current = arr[cursorRead++]; + const key = hashFn(current); + if (!Object.prototype.hasOwnProperty.call(seen, key)) { + seen[key] = true; + returnArray[cursorInsert++] = current; + } + } + returnArray.length = cursorInsert; +} +exports.removeDuplicates = removeDuplicates; + + +/** + * Searches the specified array for the specified target using the binary + * search algorithm. If no opt_compareFn is specified, elements are compared + * using defaultCompare, which compares the elements + * using the built in < and > operators. This will produce the expected + * behavior for homogeneous arrays of String(s) and Number(s). The array + * specified must be sorted in ascending order (as defined by the + * comparison function). If the array is not sorted, results are undefined. + * If the array contains multiple instances of the specified target value, the + * left-most instance will be found. + * + * Runtime: O(log n) + * + * @param {IArrayLike} arr The array to be searched. + * @param {TARGET} target The sought value. + * @param {function(TARGET, VALUE): number=} opt_compareFn Optional comparison + * function by which the array is ordered. Should take 2 arguments to + * compare, the target value and an element from your array, and return a + * negative number, zero, or a positive number depending on whether the + * first argument is less than, equal to, or greater than the second. + * @return {number} Lowest index of the target value if found, otherwise + * (-(insertion point) - 1). The insertion point is where the value should + * be inserted into arr to preserve the sorted property. Return value >= 0 + * iff target is found. + * @template TARGET, VALUE + */ +function binarySearch(arr, target, opt_compareFn) { + return binarySearch_( + arr, opt_compareFn || defaultCompare, false /* isEvaluator */, target); +} +exports.binarySearch = binarySearch; + + +/** + * Selects an index in the specified array using the binary search algorithm. + * The evaluator receives an element and determines whether the desired index + * is before, at, or after it. The evaluator must be consistent (formally, + * map(map(arr, evaluator, opt_obj), goog.math.sign) + * must be monotonically non-increasing). + * + * Runtime: O(log n) + * + * @param {IArrayLike} arr The array to be searched. + * @param {function(this:THIS, VALUE, number, ?): number} evaluator + * Evaluator function that receives 3 arguments (the element, the index and + * the array). Should return a negative number, zero, or a positive number + * depending on whether the desired index is before, at, or after the + * element passed to it. + * @param {THIS=} opt_obj The object to be used as the value of 'this' + * within evaluator. + * @return {number} Index of the leftmost element matched by the evaluator, if + * such exists; otherwise (-(insertion point) - 1). The insertion point is + * the index of the first element for which the evaluator returns negative, + * or arr.length if no such element exists. The return value is non-negative + * iff a match is found. + * @template THIS, VALUE + */ +function binarySelect(arr, evaluator, opt_obj) { + return binarySearch_( + arr, evaluator, true /* isEvaluator */, undefined /* opt_target */, + opt_obj); +} +exports.binarySelect = binarySelect; + + +/** + * Implementation of a binary search algorithm which knows how to use both + * comparison functions and evaluators. If an evaluator is provided, will call + * the evaluator with the given optional data object, conforming to the + * interface defined in binarySelect. Otherwise, if a comparison function is + * provided, will call the comparison function against the given data object. + * + * This implementation purposefully does not use goog.bind or goog.partial for + * performance reasons. + * + * Runtime: O(log n) + * + * @param {IArrayLike} arr The array to be searched. + * @param {function(?, ?, ?): number | function(?, ?): number} compareFn + * Either an evaluator or a comparison function, as defined by binarySearch + * and binarySelect above. + * @param {boolean} isEvaluator Whether the function is an evaluator or a + * comparison function. + * @param {?=} opt_target If the function is a comparison function, then + * this is the target to binary search for. + * @param {Object=} opt_selfObj If the function is an evaluator, this is an + * optional this object for the evaluator. + * @return {number} Lowest index of the target value if found, otherwise + * (-(insertion point) - 1). The insertion point is where the value should + * be inserted into arr to preserve the sorted property. Return value >= 0 + * iff target is found. + * @private + */ +function binarySearch_(arr, compareFn, isEvaluator, opt_target, opt_selfObj) { + let left = 0; // inclusive + let right = arr.length; // exclusive + let found; + while (left < right) { + const middle = left + ((right - left) >>> 1); + let compareResult; + if (isEvaluator) { + compareResult = compareFn.call(opt_selfObj, arr[middle], middle, arr); + } else { + // NOTE(dimvar): To avoid this cast, we'd have to use function overloading + // for the type of binarySearch_, which the type system can't express yet. + compareResult = /** @type {function(?, ?): number} */ (compareFn)( + opt_target, arr[middle]); + } + if (compareResult > 0) { + left = middle + 1; + } else { + right = middle; + // We are looking for the lowest index so we can't return immediately. + found = !compareResult; + } + } + // left is the index if found, or the insertion point otherwise. + // Avoiding bitwise not operator, as that causes a loss in precision for array + // indexes outside the bounds of a 32-bit signed integer. Array indexes have + // a maximum value of 2^32-2 https://tc39.es/ecma262/#array-index + return found ? left : -left - 1; +} + + +/** + * Sorts the specified array into ascending order. If no opt_compareFn is + * specified, elements are compared using + * defaultCompare, which compares the elements using + * the built in < and > operators. This will produce the expected behavior + * for homogeneous arrays of String(s) and Number(s), unlike the native sort, + * but will give unpredictable results for heterogeneous lists of strings and + * numbers with different numbers of digits. + * + * This sort is not guaranteed to be stable. + * + * Runtime: Same as `Array.prototype.sort` + * + * @param {Array} arr The array to be sorted. + * @param {?function(T,T):number=} opt_compareFn Optional comparison + * function by which the + * array is to be ordered. Should take 2 arguments to compare, and return a + * negative number, zero, or a positive number depending on whether the + * first argument is less than, equal to, or greater than the second. + * @template T + */ +function sort(arr, opt_compareFn) { + // TODO(arv): Update type annotation since null is not accepted. + arr.sort(opt_compareFn || defaultCompare); +} +exports.sort = sort; + + +/** + * Sorts the specified array into ascending order in a stable way. If no + * opt_compareFn is specified, elements are compared using + * defaultCompare, which compares the elements using + * the built in < and > operators. This will produce the expected behavior + * for homogeneous arrays of String(s) and Number(s). + * + * Runtime: Same as `Array.prototype.sort`, plus an additional + * O(n) overhead of copying the array twice. + * + * @param {Array} arr The array to be sorted. + * @param {?function(T, T): number=} opt_compareFn Optional comparison function + * by which the array is to be ordered. Should take 2 arguments to compare, + * and return a negative number, zero, or a positive number depending on + * whether the first argument is less than, equal to, or greater than the + * second. + * @template T + */ +function stableSort(arr, opt_compareFn) { + const compArr = new Array(arr.length); + for (let i = 0; i < arr.length; i++) { + compArr[i] = {index: i, value: arr[i]}; + } + const valueCompareFn = opt_compareFn || defaultCompare; + function stableCompareFn(obj1, obj2) { + return valueCompareFn(obj1.value, obj2.value) || obj1.index - obj2.index; + } + sort(compArr, stableCompareFn); + for (let i = 0; i < arr.length; i++) { + arr[i] = compArr[i].value; + } +} +exports.stableSort = stableSort; + + +/** + * Sort the specified array into ascending order based on item keys + * returned by the specified key function. + * If no opt_compareFn is specified, the keys are compared in ascending order + * using defaultCompare. + * + * Runtime: O(S(f(n)), where S is runtime of sort + * and f(n) is runtime of the key function. + * + * @param {Array} arr The array to be sorted. + * @param {function(T): K} keyFn Function taking array element and returning + * a key used for sorting this element. + * @param {?function(K, K): number=} opt_compareFn Optional comparison function + * by which the keys are to be ordered. Should take 2 arguments to compare, + * and return a negative number, zero, or a positive number depending on + * whether the first argument is less than, equal to, or greater than the + * second. + * @template T,K + */ +function sortByKey(arr, keyFn, opt_compareFn) { + const keyCompareFn = opt_compareFn || defaultCompare; + sort(arr, function(a, b) { + return keyCompareFn(keyFn(a), keyFn(b)); + }); +} +exports.sortByKey = sortByKey; + + +/** + * Sorts an array of objects by the specified object key and compare + * function. If no compare function is provided, the key values are + * compared in ascending order using defaultCompare. + * This won't work for keys that get renamed by the compiler. So use + * {'foo': 1, 'bar': 2} rather than {foo: 1, bar: 2}. + * @param {Array} arr An array of objects to sort. + * @param {string} key The object key to sort by. + * @param {Function=} opt_compareFn The function to use to compare key + * values. + */ +function sortObjectsByKey(arr, key, opt_compareFn) { + sortByKey(arr, function(obj) { + return obj[key]; + }, opt_compareFn); +} +exports.sortObjectsByKey = sortObjectsByKey; + + +/** + * Tells if the array is sorted. + * @param {!IArrayLike} arr The array. + * @param {?function(T,T):number=} opt_compareFn Function to compare the + * array elements. + * Should take 2 arguments to compare, and return a negative number, zero, + * or a positive number depending on whether the first argument is less + * than, equal to, or greater than the second. + * @param {boolean=} opt_strict If true no equal elements are allowed. + * @return {boolean} Whether the array is sorted. + * @template T + */ +function isSorted(arr, opt_compareFn, opt_strict) { + const compare = opt_compareFn || defaultCompare; + for (let i = 1; i < arr.length; i++) { + const compareResult = compare(arr[i - 1], arr[i]); + if (compareResult > 0 || compareResult == 0 && opt_strict) { + return false; + } + } + return true; +} +exports.isSorted = isSorted; + + +/** + * Compares two arrays for equality. Two arrays are considered equal if they + * have the same length and their corresponding elements are equal according to + * the comparison function. + * + * @param {IArrayLike} arr1 The first array to compare. + * @param {IArrayLike} arr2 The second array to compare. + * @param {?function(A,B):boolean=} opt_equalsFn Optional comparison function. + * Should take 2 arguments to compare, and return true if the arguments + * are equal. Defaults to {@link goog.array.defaultCompareEquality} which + * compares the elements using the built-in '===' operator. + * @return {boolean} Whether the two arrays are equal. + * @template A + * @template B + */ +function equals(arr1, arr2, opt_equalsFn) { + if (!goog.isArrayLike(arr1) || !goog.isArrayLike(arr2) || + arr1.length != arr2.length) { + return false; + } + const l = arr1.length; + const equalsFn = opt_equalsFn || defaultCompareEquality; + for (let i = 0; i < l; i++) { + if (!equalsFn(arr1[i], arr2[i])) { + return false; + } + } + return true; +} +exports.equals = equals; + + +/** + * 3-way array compare function. + * @param {!IArrayLike} arr1 The first array to + * compare. + * @param {!IArrayLike} arr2 The second array to + * compare. + * @param {function(VALUE, VALUE): number=} opt_compareFn Optional comparison + * function by which the array is to be ordered. Should take 2 arguments to + * compare, and return a negative number, zero, or a positive number + * depending on whether the first argument is less than, equal to, or + * greater than the second. + * @return {number} Negative number, zero, or a positive number depending on + * whether the first argument is less than, equal to, or greater than the + * second. + * @template VALUE + */ +function compare3(arr1, arr2, opt_compareFn) { + const compare = opt_compareFn || defaultCompare; + const l = Math.min(arr1.length, arr2.length); + for (let i = 0; i < l; i++) { + const result = compare(arr1[i], arr2[i]); + if (result != 0) { + return result; + } + } + return defaultCompare(arr1.length, arr2.length); +} +exports.compare3 = compare3; + + +/** + * Compares its two arguments for order, using the built in < and > + * operators. + * @param {VALUE} a The first object to be compared. + * @param {VALUE} b The second object to be compared. + * @return {number} A negative number, zero, or a positive number as the first + * argument is less than, equal to, or greater than the second, + * respectively. + * @template VALUE + */ +function defaultCompare(a, b) { + return a > b ? 1 : a < b ? -1 : 0; +} +exports.defaultCompare = defaultCompare; + + +/** + * Compares its two arguments for inverse order, using the built in < and > + * operators. + * @param {VALUE} a The first object to be compared. + * @param {VALUE} b The second object to be compared. + * @return {number} A negative number, zero, or a positive number as the first + * argument is greater than, equal to, or less than the second, + * respectively. + * @template VALUE + */ +function inverseDefaultCompare(a, b) { + return -defaultCompare(a, b); +} +exports.inverseDefaultCompare = inverseDefaultCompare; + + +/** + * Compares its two arguments for equality, using the built in === operator. + * @param {*} a The first object to compare. + * @param {*} b The second object to compare. + * @return {boolean} True if the two arguments are equal, false otherwise. + */ +function defaultCompareEquality(a, b) { + return a === b; +} +exports.defaultCompareEquality = defaultCompareEquality; + + +/** + * Inserts a value into a sorted array. The array is not modified if the + * value is already present. + * @param {IArrayLike} array The array to modify. + * @param {VALUE} value The object to insert. + * @param {function(VALUE, VALUE): number=} opt_compareFn Optional comparison + * function by which the array is ordered. Should take 2 arguments to + * compare, and return a negative number, zero, or a positive number + * depending on whether the first argument is less than, equal to, or + * greater than the second. + * @return {boolean} True if an element was inserted. + * @template VALUE + */ +function binaryInsert(array, value, opt_compareFn) { + const index = binarySearch(array, value, opt_compareFn); + if (index < 0) { + insertAt(array, value, -(index + 1)); + return true; + } + return false; +} +exports.binaryInsert = binaryInsert; + + +/** + * Removes a value from a sorted array. + * @param {!IArrayLike} array The array to modify. + * @param {VALUE} value The object to remove. + * @param {function(VALUE, VALUE): number=} opt_compareFn Optional comparison + * function by which the array is ordered. Should take 2 arguments to + * compare, and return a negative number, zero, or a positive number + * depending on whether the first argument is less than, equal to, or + * greater than the second. + * @return {boolean} True if an element was removed. + * @template VALUE + */ +function binaryRemove(array, value, opt_compareFn) { + const index = binarySearch(array, value, opt_compareFn); + return (index >= 0) ? removeAt(array, index) : false; +} +exports.binaryRemove = binaryRemove; + + +/** + * Splits an array into disjoint buckets according to a splitting function. + * @param {IArrayLike} array The array. + * @param {function(this:S, T, number, !IArrayLike):?} sorter Function to + * call for every element. This takes 3 arguments (the element, the index + * and the array) and must return a valid object key (a string, number, + * etc), or undefined, if that object should not be placed in a bucket. + * @param {S=} opt_obj The object to be used as the value of 'this' within + * sorter. + * @return {!Object>} An object, with keys being all of the unique + * return values of sorter, and values being arrays containing the items for + * which the splitter returned that key. + * @template T,S + */ +function bucket(array, sorter, opt_obj) { + const buckets = {}; + + for (let i = 0; i < array.length; i++) { + const value = array[i]; + const key = sorter.call(/** @type {?} */ (opt_obj), value, i, array); + if (key !== undefined) { + // Push the value to the right bucket, creating it if necessary. + const bucket = buckets[key] || (buckets[key] = []); + bucket.push(value); + } + } + + return buckets; +} +exports.bucket = bucket; + + +/** + * Splits an array into disjoint buckets according to a splitting function. + * @param {!IArrayLike} array The array. + * @param {function(V, number, !IArrayLike):(K|undefined)} sorter Function to + * call for every element. This takes 3 arguments (the element, the index, + * and the array) and must return a value to use as a key, or undefined, if + * that object should not be placed in a bucket. + * @return {!Map>} A map, with keys being all of the unique + * return values of sorter, and values being arrays containing the items for + * which the splitter returned that key. + * @template K,V + */ +function bucketToMap(array, sorter) { + const /** !Map> */ buckets = new Map(); + + for (let i = 0; i < array.length; i++) { + const value = array[i]; + const key = sorter(value, i, array); + if (key !== undefined) { + // Push the value to the right bucket, creating it if necessary. + let bucket = buckets.get(key); + if (!bucket) { + bucket = []; + buckets.set(key, bucket); + } + bucket.push(value); + } + } + + return buckets; +} +exports.bucketToMap = bucketToMap; + + +/** + * Creates a new object built from the provided array and the key-generation + * function. + * @param {IArrayLike} arr Array or array like object over + * which to iterate whose elements will be the values in the new object. + * @param {?function(this:S, T, number, ?) : string} keyFunc The function to + * call for every element. This function takes 3 arguments (the element, the + * index and the array) and should return a string that will be used as the + * key for the element in the new object. If the function returns the same + * key for more than one element, the value for that key is + * implementation-defined. + * @param {S=} opt_obj The object to be used as the value of 'this' + * within keyFunc. + * @return {!Object} The new object. + * @template T,S + */ +function toObject(arr, keyFunc, opt_obj) { + const ret = {}; + forEach(arr, function(element, index) { + ret[keyFunc.call(/** @type {?} */ (opt_obj), element, index, arr)] = + element; + }); + return ret; +} +exports.toObject = toObject; + + +/** + * Creates a new ES6 Map built from the provided array and the key-generation + * function. + * @param {!IArrayLike} arr Array or array like object over which to iterate + * whose elements will be the values in the new object. + * @param {?function(V, number, ?) : K} keyFunc The function to call for every + * element. This function takes 3 arguments (the element, the index, and the + * array) and should return a value that will be used as the key for the + * element in the new object. If the function returns the same key for more + * than one element, the value for that key is implementation-defined. + * @return {!Map} The new map. + * @template K,V + */ +function toMap(arr, keyFunc) { + const /** !Map */ map = new Map(); + + for (let i = 0; i < arr.length; i++) { + const element = arr[i]; + map.set(keyFunc(element, i, arr), element); + } + + return map; +} +exports.toMap = toMap; + + +/** + * Creates a range of numbers in an arithmetic progression. + * + * Range takes 1, 2, or 3 arguments: + *
+ * range(5) is the same as range(0, 5, 1) and produces [0, 1, 2, 3, 4]
+ * range(2, 5) is the same as range(2, 5, 1) and produces [2, 3, 4]
+ * range(-2, -5, -1) produces [-2, -3, -4]
+ * range(-2, -5, 1) produces [], since stepping by 1 wouldn't ever reach -5.
+ * 
+ * + * @param {number} startOrEnd The starting value of the range if an end argument + * is provided. Otherwise, the start value is 0, and this is the end value. + * @param {number=} opt_end The optional end value of the range. + * @param {number=} opt_step The step size between range values. Defaults to 1 + * if opt_step is undefined or 0. + * @return {!Array} An array of numbers for the requested range. May be + * an empty array if adding the step would not converge toward the end + * value. + */ +function range(startOrEnd, opt_end, opt_step) { + const array = []; + let start = 0; + let end = startOrEnd; + const step = opt_step || 1; + if (opt_end !== undefined) { + start = startOrEnd; + end = opt_end; + } + + if (step * (end - start) < 0) { + // Sign mismatch: start + step will never reach the end value. + return []; + } + + if (step > 0) { + for (let i = start; i < end; i += step) { + array.push(i); + } + } else { + for (let i = start; i > end; i += step) { + array.push(i); + } + } + return array; +} +exports.range = range; + + +/** + * Returns an array consisting of the given value repeated N times. + * + * @param {VALUE} value The value to repeat. + * @param {number} n The repeat count. + * @return {!Array} An array with the repeated value. + * @template VALUE + */ +function repeat(value, n) { + const array = []; + for (let i = 0; i < n; i++) { + array[i] = value; + } + return array; +} +exports.repeat = repeat; + + +/** + * Returns an array consisting of every argument with all arrays + * expanded in-place recursively. + * + * @param {...*} var_args The values to flatten. + * @return {!Array} An array containing the flattened values. + */ +function flatten(var_args) { + const CHUNK_SIZE = 8192; + + const result = []; + for (let i = 0; i < arguments.length; i++) { + const element = arguments[i]; + if (Array.isArray(element)) { + for (let c = 0; c < element.length; c += CHUNK_SIZE) { + const chunk = slice(element, c, c + CHUNK_SIZE); + const recurseResult = flatten.apply(null, chunk); + for (let r = 0; r < recurseResult.length; r++) { + result.push(recurseResult[r]); + } + } + } else { + result.push(element); + } + } + return result; +} +exports.flatten = flatten; + + +/** + * Rotates an array in-place. After calling this method, the element at + * index i will be the element previously at index (i - n) % + * array.length, for all values of i between 0 and array.length - 1, + * inclusive. + * + * For example, suppose list comprises [t, a, n, k, s]. After invoking + * rotate(array, 1) (or rotate(array, -4)), array will comprise [s, t, a, n, k]. + * + * @param {!Array} array The array to rotate. + * @param {number} n The amount to rotate. + * @return {!Array} The array. + * @template T + */ +function rotate(array, n) { + asserts.assert(array.length != null); + + if (array.length) { + n %= array.length; + if (n > 0) { + Array.prototype.unshift.apply(array, array.splice(-n, n)); + } else if (n < 0) { + Array.prototype.push.apply(array, array.splice(0, -n)); + } + } + return array; +} +exports.rotate = rotate; + + +/** + * Moves one item of an array to a new position keeping the order of the rest + * of the items. Example use case: keeping a list of JavaScript objects + * synchronized with the corresponding list of DOM elements after one of the + * elements has been dragged to a new position. + * @param {!IArrayLike} arr The array to modify. + * @param {number} fromIndex Index of the item to move between 0 and + * `arr.length - 1`. + * @param {number} toIndex Target index between 0 and `arr.length - 1`. + */ +function moveItem(arr, fromIndex, toIndex) { + asserts.assert(fromIndex >= 0 && fromIndex < arr.length); + asserts.assert(toIndex >= 0 && toIndex < arr.length); + // Remove 1 item at fromIndex. + const removedItems = Array.prototype.splice.call(arr, fromIndex, 1); + // Insert the removed item at toIndex. + Array.prototype.splice.call(arr, toIndex, 0, removedItems[0]); + // We don't use goog.array.insertAt and goog.array.removeAt, because they're + // significantly slower than splice. +} +exports.moveItem = moveItem; + + +/** + * Creates a new array for which the element at position i is an array of the + * ith element of the provided arrays. The returned array will only be as long + * as the shortest array provided; additional values are ignored. For example, + * the result of zipping [1, 2] and [3, 4, 5] is [[1,3], [2, 4]]. + * + * This is similar to the zip() function in Python. See {@link + * http://docs.python.org/library/functions.html#zip} + * + * @param {...!IArrayLike} var_args Arrays to be combined. + * @return {!Array>} A new array of arrays created from + * provided arrays. + */ +function zip(var_args) { + if (!arguments.length) { + return []; + } + const result = []; + let minLen = arguments[0].length; + for (let i = 1; i < arguments.length; i++) { + if (arguments[i].length < minLen) { + minLen = arguments[i].length; + } + } + for (let i = 0; i < minLen; i++) { + const value = []; + for (let j = 0; j < arguments.length; j++) { + value.push(arguments[j][i]); + } + result.push(value); + } + return result; +} +exports.zip = zip; + + +/** + * Shuffles the values in the specified array using the Fisher-Yates in-place + * shuffle (also known as the Knuth Shuffle). By default, calls Math.random() + * and so resets the state of that random number generator. Similarly, may reset + * the state of any other specified random number generator. + * + * Runtime: O(n) + * + * @param {!Array} arr The array to be shuffled. + * @param {function():number=} opt_randFn Optional random function to use for + * shuffling. + * Takes no arguments, and returns a random number on the interval [0, 1). + * Defaults to Math.random() using JavaScript's built-in Math library. + */ +function shuffle(arr, opt_randFn) { + const randFn = opt_randFn || Math.random; + + for (let i = arr.length - 1; i > 0; i--) { + // Choose a random array index in [0, i] (inclusive with i). + const j = Math.floor(randFn() * (i + 1)); + + const tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; + } +} +exports.shuffle = shuffle; + + +/** + * Returns a new array of elements from arr, based on the indexes of elements + * provided by index_arr. For example, the result of index copying + * ['a', 'b', 'c'] with index_arr [1,0,0,2] is ['b', 'a', 'a', 'c']. + * + * @param {!IArrayLike} arr The array to get a indexed copy from. + * @param {!IArrayLike} index_arr An array of indexes to get from arr. + * @return {!Array} A new array of elements from arr in index_arr order. + * @template T + */ +function copyByIndex(arr, index_arr) { + const result = []; + forEach(index_arr, function(index) { + result.push(arr[index]); + }); + return result; +} +exports.copyByIndex = copyByIndex; + + +/** + * Maps each element of the input array into zero or more elements of the output + * array. + * + * @param {!IArrayLike|string} arr Array or array like object + * over which to iterate. + * @param {function(this:THIS, VALUE, number, ?): !Array} f The function + * to call for every element. This function takes 3 arguments (the element, + * the index and the array) and should return an array. The result will be + * used to extend a new array. + * @param {THIS=} opt_obj The object to be used as the value of 'this' within f. + * @return {!Array} a new array with the concatenation of all arrays + * returned from f. + * @template THIS, VALUE, RESULT + */ +function concatMap(arr, f, opt_obj) { + return concat.apply([], map(arr, f, opt_obj)); +} +exports.concatMap = concatMap; diff --git a/closure/goog/array/array_test.js b/closure/goog/array/array_test.js new file mode 100644 index 0000000000..09987a1d89 --- /dev/null +++ b/closure/goog/array/array_test.js @@ -0,0 +1,2095 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.arrayTest'); +goog.setTestOnly(); + +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const TagName = goog.require('goog.dom.TagName'); +const dom = goog.require('goog.dom'); +const googArray = goog.require('goog.array'); +const recordFunction = goog.require('goog.testing.recordFunction'); +const testSuite = goog.require('goog.testing.testSuite'); + +/** + * @param {!IArrayLike} expected + * @param {!IArrayLike} original + */ +function assertRemovedDuplicates(expected, original) { + const tempArr = googArray.clone(original); + googArray.removeDuplicates(tempArr); + assertArrayEquals(expected, tempArr); +} + +/** + * @param {number} size + * @return {!IArrayLike} + */ +function buildSortedObjectArray(size) { + const objectArray = []; + for (let i = 0; i < size; i++) { + objectArray.push({'name': `name_${i}`, 'id': 'id_' + (size - i)}); + } + + return objectArray; +} + +/** + * @param {!Array} expect + * @param {!Array} array + * @param {number} rotate + */ +function assertRotated(expect, array, rotate) { + assertArrayEquals(expect, googArray.rotate(array, rotate)); +} + +testSuite({ + testArrayLast() { + assertEquals(googArray.last([1, 2, 3]), 3); + assertEquals(googArray.last([1]), 1); + assertUndefined(googArray.last([])); + }, + + testArrayLastWhenDeleted() { + const a = [1, 2, 3]; + delete a[2]; + assertUndefined(googArray.last(a)); + }, + + testArrayIndexOf() { + assertEquals(googArray.indexOf([0, 1, 2, 3], 1), 1); + assertEquals(googArray.indexOf([0, 1, 1, 1], 1), 1); + assertEquals(googArray.indexOf([0, 1, 2, 3], 4), -1); + assertEquals(googArray.indexOf([0, 1, 2, 3], 1, 1), 1); + assertEquals(googArray.indexOf([0, 1, 2, 3], 1, 2), -1); + assertEquals(googArray.indexOf([0, 1, 2, 3], 1, -3), 1); + assertEquals(googArray.indexOf([0, 1, 2, 3], 1, -2), -1); + }, + + testArrayIndexOfOmitsDeleted() { + const a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + assertEquals(googArray.indexOf(a, undefined), -1); + }, + + testArrayIndexOfString() { + assertEquals(googArray.indexOf('abcd', 'd'), 3); + assertEquals(googArray.indexOf('abbb', 'b', 2), 2); + assertEquals(googArray.indexOf('abcd', 'e'), -1); + assertEquals(googArray.indexOf('abcd', 'cd'), -1); + assertEquals(googArray.indexOf('0123', 1), -1); + }, + + testArrayLastIndexOf() { + assertEquals(googArray.lastIndexOf([0, 1, 2, 3], 1), 1); + assertEquals(googArray.lastIndexOf([0, 1, 1, 1], 1), 3); + assertEquals(googArray.lastIndexOf([0, 1, 1, 1], 1, 2), 2); + }, + + testArrayLastIndexOfOmitsDeleted() { + const a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + assertEquals(googArray.lastIndexOf(a, undefined), -1); + }, + + testArrayLastIndexOfString() { + assertEquals(googArray.lastIndexOf('abcd', 'b'), 1); + assertEquals(googArray.lastIndexOf('abbb', 'b'), 3); + assertEquals(googArray.lastIndexOf('abbb', 'b', 2), 2); + assertEquals(googArray.lastIndexOf('abcd', 'cd'), -1); + assertEquals(googArray.lastIndexOf('0123', 1), -1); + }, + + testArrayForEachBasic() { + let s = ''; + const a = ['a', 'b', 'c', 'd']; + googArray.forEach(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('Index is not a number', 'number', typeof index); + s += val + index; + }); + assertEquals('a0b1c2d3', s); + }, + + testArrayForEachWithEmptyArray() { + const a = new Array(100); + googArray.forEach(a, (val, index, a2) => { + fail('The function should not be called since no values were assigned.'); + }); + }, + + testArrayForEachWithOnlySomeValuesAsigned() { + let count = 0; + const a = new Array(1000); + a[100] = undefined; + googArray.forEach(a, (val, index, a2) => { + assertEquals(100, index); + count++; + }); + assertEquals( + 'Should only call function when a value of array was assigned.', 1, + count); + }, + + testArrayForEachWithArrayLikeObject() { + const counter = recordFunction(); + const a = {'length': 1, '0': 0, '100': 100, '101': 102}; + googArray.forEach(a, counter); + assertEquals( + 'Number of calls should not exceed the value of its length', 1, + counter.getCallCount()); + }, + + testArrayForEachOmitsDeleted() { + let s = ''; + const a = ['a', 'b', 'c', 'd']; + delete a[1]; + delete a[3]; + googArray.forEach(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('number', typeof index); + s += val + index; + }); + assertEquals('a0c2', s); + }, + + testArrayForEachScope() { + const scope = {}; + const a = ['a', 'b', 'c', 'd']; + googArray.forEach(a, function(val, index, a2) { + assertEquals(a, a2); + assertEquals('number', typeof index); + assertEquals(this, scope); + }, scope); + }, + + testArrayForEachRight() { + let s = ''; + const a = ['a', 'b', 'c', 'd']; + googArray.forEachRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('number', typeof index); + s += val + String(index); + }); + assertEquals('d3c2b1a0', s); + }, + + testArrayForEachRightOmitsDeleted() { + let s = ''; + const a = ['a', 'b', 'c', 'd']; + delete a[1]; + delete a[3]; + googArray.forEachRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('number', typeof index); + assertEquals('string', typeof val); + s += val + String(index); + }); + assertEquals('c2a0', s); + }, + + testArrayFilter() { + let a = [0, 1, 2, 3]; + a = googArray.filter(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 1; + }); + assertArrayEquals([2, 3], a); + }, + + testArrayFilterOmitsDeleted() { + let a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + a = googArray.filter(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('number', typeof val); + assertEquals('index is not a number', 'number', typeof index); + return val > 1; + }); + assertArrayEquals([2], a); + }, + + testArrayFilterPreservesValues() { + let a = [0, 1, 2, 3]; + a = googArray.filter(a, (val, index, a2) => { + assertEquals(a, a2); + // sometimes functions might be evil and do something like this, but we + // should still use the original values when returning the filtered array + a2[index] = a2[index] - 1; + return a2[index] >= 1; + }); + assertArrayEquals([2, 3], a); + }, + + testArrayMap() { + const a = [0, 1, 2, 3]; + const result = googArray.map(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val * val; + }); + assertArrayEquals([0, 1, 4, 9], result); + }, + + testArrayMapOmitsDeleted() { + const a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + const result = googArray.map(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('number', typeof val); + assertEquals('index is not a number', 'number', typeof index); + return val * val; + }); + const expected = [0, 1, 4, 9]; + delete expected[1]; + delete expected[3]; + + assertArrayEquals(expected, result); + assertFalse('1' in result); + assertFalse('3' in result); + }, + + testArrayReduce() { + const a = [0, 1, 2, 3]; + assertEquals(6, googArray.reduce(a, (rval, val, i, arr) => { + assertEquals('number', typeof i); + assertEquals(a, arr); + return rval + val; + }, 0)); + + /** @const */ + const scope = { + last: 0, + /** + * @param {number} r + * @param {number} v + * @param {number} i + * @param {!IArrayLike} arr + * @return {number} + * @this {?} + */ + testFn: function(r, v, i, arr) { + assertEquals('number', typeof i); + assertEquals(a, arr); + const l = this.last; + this.last = r + v; + return this.last + l; + }, + }; + + assertEquals(10, googArray.reduce(a, scope.testFn, 0, scope)); + }, + + testArrayReduceOmitDeleted() { + const a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + assertEquals(2, googArray.reduce(a, (rval, val, i, arr) => { + assertEquals('number', typeof i); + assertEquals(a, arr); + return rval + val; + }, 0)); + + /** @const */ + const scope = { + last: 0, + /** + * @param {number} r + * @param {number} v + * @param {number} i + * @param {!IArrayLike} arr + * @return {number} + * @this {?} + */ + testFn: function(r, v, i, arr) { + assertEquals('number', typeof i); + assertEquals(a, arr); + const l = this.last; + this.last = r + v; + return this.last + l; + }, + }; + + assertEquals(2, googArray.reduce(a, scope.testFn, 0, scope)); + }, + + testArrayReduceRight() { + let a = [0, 1, 2, 3, 4]; + assertEquals('43210', googArray.reduceRight(a, (rval, val, i, arr) => { + assertEquals('number', typeof i); + assertEquals(a, arr); + return rval + val; + }, '')); + + /** @const */ + const scope = { + last: '', + /** + * @param {string} r + * @param {string} v + * @param {number} i + * @param {!IArrayLike} arr + * @return {string} + * @this {?} + */ + testFn: function(r, v, i, arr) { + assertEquals('number', typeof i); + assertEquals(a, arr); + const l = this.last; + this.last = v; + return r + v + l; + }, + }; + + a = ['a', 'b', 'c']; + assertEquals('_cbcab', googArray.reduceRight(a, scope.testFn, '_', scope)); + }, + + testArrayReduceRightOmitsDeleted() { + let a = [0, 1, 2, 3, 4]; + delete a[1]; + delete a[4]; + assertEquals('320', googArray.reduceRight(a, (rval, val, i, arr) => { + assertEquals('number', typeof i); + assertEquals(a, arr); + return rval + val; + }, '')); + + /** @const */ + const scope = { + last: '', + /** + * @param {string} r + * @param {string} v + * @param {number} i + * @param {!IArrayLike} arr + * @return {string} + * @this {?} + */ + testFn: function(r, v, i, arr) { + assertEquals('number', typeof i); + assertEquals(a, arr); + const l = this.last; + this.last = v; + return r + v + l; + }, + }; + + a = ['a', 'b', 'c', 'd']; + delete a[1]; + delete a[3]; + assertEquals('_cac', googArray.reduceRight(a, scope.testFn, '_', scope)); + }, + + testArrayFind() { + let a = [0, 1, 2, 3]; + let b = googArray.find(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 1; + }); + assertEquals(2, b); + + b = googArray.find(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 100; + }); + assertNull(b); + + a = 'abCD'; + b = googArray.find(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val >= 'A' && val <= 'Z'; + }); + assertEquals('C', b); + + a = 'abcd'; + b = googArray.find(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val >= 'A' && val <= 'Z'; + }); + assertNull(b); + }, + + testArrayFindOmitsDeleted() { + const a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + let b = googArray.find(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 1; + }); + + assertEquals(2, b); + b = googArray.find(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 100; + }); + assertNull(b); + }, + + testArrayFindIndex() { + let a = [0, 1, 2, 3]; + let b = googArray.findIndex(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 1; + }); + assertEquals(2, b); + + b = googArray.findIndex(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 100; + }); + assertEquals(-1, b); + + a = 'abCD'; + b = googArray.findIndex(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val >= 'A' && val <= 'Z'; + }); + assertEquals(2, b); + + a = 'abcd'; + b = googArray.findIndex(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val >= 'A' && val <= 'Z'; + }); + assertEquals(-1, b); + }, + + testArrayFindIndexOmitsDeleted() { + const a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + let b = googArray.findIndex(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 1; + }); + assertEquals(2, b); + + b = googArray.findIndex(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 100; + }); + assertEquals(-1, b); + }, + + testArrayFindRight() { + const a = [0, 1, 2, 3]; + let b = googArray.findRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val < 3; + }); + assertEquals(2, b); + b = googArray.findRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 100; + }); + assertNull(b); + }, + + testArrayFindRightOmitsDeleted() { + const a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + let b = googArray.findRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val < 3; + }); + assertEquals(2, b); + b = googArray.findRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 100; + }); + assertNull(b); + }, + + testArrayFindIndexRight() { + let a = [0, 1, 2, 3]; + let b = googArray.findIndexRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val < 3; + }); + assertEquals(2, b); + + b = googArray.findIndexRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 100; + }); + assertEquals(-1, b); + + a = 'abCD'; + b = googArray.findIndexRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val >= 'a' && val <= 'z'; + }); + assertEquals(1, b); + + a = 'abcd'; + b = googArray.findIndexRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val >= 'A' && val <= 'Z'; + }); + assertEquals(-1, b); + }, + + testArrayFindIndexRightOmitsDeleted() { + const a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + let b = googArray.findIndexRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val < 3; + }); + assertEquals(2, b); + b = googArray.findIndexRight(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 100; + }); + assertEquals(-1, b); + }, + + testArraySome() { + const a = [0, 1, 2, 3]; + let b = googArray.some(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 1; + }); + assertTrue(b); + b = googArray.some(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 100; + }); + assertFalse(b); + }, + + testArraySomeOmitsDeleted() { + const a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + let b = googArray.some(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('number', typeof val); + assertEquals('index is not a number', 'number', typeof index); + return val > 1; + }); + assertTrue(b); + b = googArray.some(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('number', typeof val); + assertEquals('index is not a number', 'number', typeof index); + return val > 100; + }); + assertFalse(b); + }, + + testArrayEvery() { + const a = [0, 1, 2, 3]; + let b = googArray.every(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val >= 0; + }); + assertTrue(b); + b = googArray.every(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 1; + }); + assertFalse(b); + }, + + testArrayEveryOmitsDeleted() { + const a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + let b = googArray.every(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('number', typeof val); + assertEquals('index is not a number', 'number', typeof index); + return val >= 0; + }); + assertTrue(b); + b = googArray.every(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('number', typeof val); + assertEquals('index is not a number', 'number', typeof index); + return val > 1; + }); + assertFalse(b); + }, + + testArrayCount() { + const a = [0, 1, 2, 3, 4]; + const context = {}; + assertEquals(3, googArray.count(a, function(element, index, array) { + assertTrue(typeof (index) === 'number'); + assertEquals(a, array); + assertEquals(context, this); + return element % 2 == 0; + }, context)); + + delete a[2]; + assertEquals( + 'deleted element is ignored', 4, googArray.count(a, () => true)); + }, + + testArrayContains() { + const a = [0, 1, 2, 3]; + assertTrue('contain, Should contain 3', googArray.contains(a, 3)); + assertFalse('contain, Should not contain 4', googArray.contains(a, 4)); + + const s = 'abcd'; + assertTrue('contain, Should contain d', googArray.contains(s, 'd')); + assertFalse('contain, Should not contain e', googArray.contains(s, 'e')); + }, + + testArrayContainsOmitsDeleted() { + const a = [0, 1, 2, 3]; + delete a[1]; + delete a[3]; + assertFalse( + 'should not contain undefined', googArray.contains(a, undefined)); + }, + + testArrayInsert() { + const a = [0, 1, 2, 3]; + + googArray.insert(a, 4); + assertEquals('insert, Should append 4', a[4], 4); + googArray.insert(a, 3); + assertEquals('insert, Should not append 3', a.length, 5); + assertNotEquals('insert, Should not append 3', a[a.length - 1], 3); + }, + + testArrayInsertAt() { + const a = [0, 1, 2, 3]; + + googArray.insertAt(a, 4, 2); + assertArrayEquals('insertAt, insert in middle', [0, 1, 4, 2, 3], a); + googArray.insertAt(a, 5, 10); + assertArrayEquals( + 'insertAt, too large value should append', [0, 1, 4, 2, 3, 5], a); + googArray.insertAt(a, 6); + assertArrayEquals( + 'insertAt, null/undefined value should insert at 0', + [6, 0, 1, 4, 2, 3, 5], a); + googArray.insertAt(a, 7, -2); + assertArrayEquals( + 'insertAt, negative values start from end', [6, 0, 1, 4, 2, 7, 3, 5], + a); + }, + + testArrayInsertArrayAt() { + const a = [2, 5]; + googArray.insertArrayAt(a, [3, 4], 1); + assertArrayEquals('insertArrayAt, insert in middle', [2, 3, 4, 5], a); + googArray.insertArrayAt(a, [0, 1], 0); + assertArrayEquals( + 'insertArrayAt, insert at beginning', [0, 1, 2, 3, 4, 5], a); + googArray.insertArrayAt(a, [6, 7], 6); + assertArrayEquals( + 'insertArrayAt, insert at end', [0, 1, 2, 3, 4, 5, 6, 7], a); + googArray.insertArrayAt(a, ['x'], 4); + assertArrayEquals( + 'insertArrayAt, insert one element', [0, 1, 2, 3, 'x', 4, 5, 6, 7], a); + googArray.insertArrayAt(a, [], 4); + assertArrayEquals( + 'insertArrayAt, insert 0 elements', [0, 1, 2, 3, 'x', 4, 5, 6, 7], a); + googArray.insertArrayAt(a, ['y', 'z']); + assertArrayEquals( + 'insertArrayAt, undefined value should insert as 0', + ['y', 'z', 0, 1, 2, 3, 'x', 4, 5, 6, 7], a); + googArray.insertArrayAt(a, ['a'], /** @type {?} */ (null)); + assertArrayEquals( + 'insertArrayAt, null value should insert as 0', + ['a', 'y', 'z', 0, 1, 2, 3, 'x', 4, 5, 6, 7], a); + googArray.insertArrayAt(a, ['b'], 100); + assertArrayEquals( + 'insertArrayAt, too large value should append', + ['a', 'y', 'z', 0, 1, 2, 3, 'x', 4, 5, 6, 7, 'b'], a); + googArray.insertArrayAt(a, ['c', 'd'], -2); + assertArrayEquals( + 'insertArrayAt, negative values start from end', + ['a', 'y', 'z', 0, 1, 2, 3, 'x', 4, 5, 6, 'c', 'd', 7, 'b'], a); + }, + + testArrayInsertBefore() { + const a = ['a', 'b', 'c', 'd']; + googArray.insertBefore(a, 'e', 'b'); + assertArrayEquals( + 'insertBefore, with existing element', ['a', 'e', 'b', 'c', 'd'], a); + googArray.insertBefore(a, 'f', 'x'); + assertArrayEquals( + 'insertBefore, with non existing element', + ['a', 'e', 'b', 'c', 'd', 'f'], a); + }, + + testArrayRemove() { + const a = ['a', 'b', 'c', 'd']; + googArray.remove(a, 'c'); + assertArrayEquals('remove, remove existing element', ['a', 'b', 'd'], a); + googArray.remove(a, 'x'); + assertArrayEquals( + 'remove, remove non existing element', ['a', 'b', 'd'], a); + }, + + testArrayRemoveLast() { + const a = ['c', 'a', 'b', 'c', 'd', 'a']; + googArray.removeLast(a, 'c'); + let temp = ['c', 'a', 'b', 'd', 'a']; + assertArrayEquals('remove, remove existing element', temp, a); + googArray.removeLast(a, 'a'); + temp = ['c', 'a', 'b', 'd']; + assertArrayEquals('remove, remove existing element', temp, a); + googArray.removeLast(a, 'y'); + temp = ['c', 'a', 'b', 'd']; + assertArrayEquals('remove, remove non existing element', temp, a); + }, + + testArrayRemoveAt() { + let a = [0, 1, 2, 3]; + googArray.removeAt(a, 2); + assertArrayEquals('removeAt, remove existing index', [0, 1, 3], a); + a = [0, 1, 2, 3]; + googArray.removeAt(a, 10); + assertArrayEquals('removeAt, remove non existing index', [0, 1, 2, 3], a); + a = [0, 1, 2, 3]; + googArray.removeAt(a, -2); + assertArrayEquals('removeAt, remove with negative index', [0, 1, 3], a); + }, + + testArrayRemoveIf() { + let a = [0, 1, 2, 3]; + googArray.removeIf(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 1; + }); + assertArrayEquals('removeIf, remove existing element', [0, 1, 3], a); + + a = [0, 1, 2, 3]; + googArray.removeIf(a, (val, index, a2) => { + assertEquals(a, a2); + assertEquals('index is not a number', 'number', typeof index); + return val > 100; + }); + assertArrayEquals('removeIf, remove non-existing element', [0, 1, 2, 3], a); + }, + + testArrayClone() { + const a = [0, 1, 2, 3]; + const a2 = googArray.clone(a); + assertArrayEquals('clone, should be equal', a, a2); + + const b = {0: 0, 1: 1, 2: 2, 3: 3, length: 4}; + const b2 = googArray.clone(b); + for (let i = 0; i < b.length; i++) { + assertEquals('clone, should be equal', b[i], b2[i]); + } + }, + + testToArray() { + const a = [0, 1, 2, 3]; + const a2 = googArray.toArray(a); + assertArrayEquals('toArray, should be equal', a, a2); + + const b = {0: 0, 1: 1, 2: 2, 3: 3, length: 4}; + const b2 = googArray.toArray(b); + for (let i = 0; i < b.length; i++) { + assertEquals('toArray, should be equal', b[i], b2[i]); + } + }, + + testToArrayOnNonArrayLike() { + const nonArrayLike = {}; + assertArrayEquals( + 'toArray on non ArrayLike should return an empty array', [], + googArray.toArray(/** @type {?} */ (nonArrayLike))); + + const nonArrayLike2 = {length: 'hello world'}; + assertArrayEquals( + 'toArray on non ArrayLike should return an empty array', [], + googArray.toArray(/** @type {?} */ (nonArrayLike2))); + }, + + testExtend() { + let a = [0, 1]; + googArray.extend(a, [2, 3]); + let a2 = [0, 1, 2, 3]; + assertArrayEquals('extend, should be equal', a, a2); + + let b = [0, 1]; + googArray.extend(b, 2); + let b2 = [0, 1, 2]; + assertArrayEquals('extend, should be equal', b, b2); + + a = [0, 1]; + googArray.extend(a, [2, 3], [4, 5]); + a2 = [0, 1, 2, 3, 4, 5]; + assertArrayEquals('extend, should be equal', a, a2); + + b = [0, 1]; + googArray.extend(b, 2, 3); + b2 = [0, 1, 2, 3]; + assertArrayEquals('extend, should be equal', b, b2); + + const c = [0, 1]; + googArray.extend(c, 2, [3, 4], 5, [6]); + const c2 = [0, 1, 2, 3, 4, 5, 6]; + assertArrayEquals('extend, should be equal', c, c2); + + const d = [0, 1]; + const arrayLikeObject = {0: 2, 1: 3, length: 2}; + googArray.extend(d, arrayLikeObject); + const d2 = [0, 1, 2, 3]; + assertArrayEquals('extend, should be equal', d, d2); + + const e = [0, 1]; + const emptyArrayLikeObject = {length: 0}; + googArray.extend(e, emptyArrayLikeObject); + assertArrayEquals('extend, should be equal', e, e); + + const f = [0, 1]; + const length3ArrayLikeObject = {0: 2, 1: 4, 2: 8, length: 3}; + googArray.extend(f, length3ArrayLikeObject, length3ArrayLikeObject); + const f2 = [0, 1, 2, 4, 8, 2, 4, 8]; + assertArrayEquals('extend, should be equal', f2, f); + + const result = []; + // Remeber to check for flakey timeouts if increased. Particularly on IE. + let i = 100000; + const bigArray = Array(i); + while (i--) { + bigArray[i] = i; + } + googArray.extend(result, bigArray); + assertArrayEquals(bigArray, result); + }, + + testExtendWithArguments() { + function f(var_args) { + return arguments; + } + const a = [0]; + const a2 = [0, 1, 2, 3, 4, 5]; + googArray.extend(a, f(1, 2, 3), f(4, 5)); + assertArrayEquals('extend, should be equal', a, a2); + }, + + testExtendWithQuerySelector() { + const a = [0]; + const d = dom.getElementsByTagNameAndClass(TagName.DIV, 'foo'); + googArray.extend(a, d); + assertEquals(2, a.length); + }, + + testArraySplice() { + const a = [0, 1, 2, 3]; + googArray.splice(a, 1, 0, 4); + assertArrayEquals([0, 4, 1, 2, 3], a); + googArray.splice(a, 1, 1, 5); + assertArrayEquals([0, 5, 1, 2, 3], a); + googArray.splice(a, 1, 1); + assertArrayEquals([0, 1, 2, 3], a); + // var args + googArray.splice(a, 1, 1, 4, 5, 6); + assertArrayEquals([0, 4, 5, 6, 2, 3], a); + }, + + testArraySlice() { + let a = [0, 1, 2, 3]; + a = googArray.slice(a, 1, 3); + assertArrayEquals([1, 2], a); + a = [0, 1, 2, 3]; + a = googArray.slice(a, 1, 6); + assertArrayEquals('slice, with too large end', [1, 2, 3], a); + a = [0, 1, 2, 3]; + a = googArray.slice(a, 1, -1); + assertArrayEquals('slice, with negative end', [1, 2], a); + a = [0, 1, 2, 3]; + a = googArray.slice(a, -2, 3); + assertArrayEquals('slice, with negative start', [2], a); + }, + + testRemoveDuplicates() { + assertRemovedDuplicates([1, 2, 3, 4, 5, 6], [1, 2, 3, 4, 5, 6]); + assertRemovedDuplicates( + [9, 4, 2, 1, 3, 6, 0, -9], [9, 4, 2, 4, 4, 2, 9, 1, 3, 6, 0, -9]); + assertRemovedDuplicates( + ['four', 'one', 'two', 'three', 'THREE'], + ['four', 'one', 'two', 'one', 'three', 'THREE', 'four', 'two']); + assertRemovedDuplicates([], []); + assertRemovedDuplicates( + ['abc', 'hasOwnProperty', 'toString'], + ['abc', 'hasOwnProperty', 'toString', 'abc']); + + const o1 = {}; + const o2 = {}; + const o3 = {}; + const o4 = {}; + assertRemovedDuplicates([o1, o2, o3, o4], [o1, o1, o2, o3, o2, o4]); + + // Mixed object types. + assertRemovedDuplicates([1, '1', 2, '2'], [1, '1', 2, '2']); + assertRemovedDuplicates( + [true, 'true', false, 'false'], [true, 'true', false, 'false']); + assertRemovedDuplicates(['foo'], [String('foo'), 'foo']); + assertRemovedDuplicates([12], [Number(12), 12]); + + const obj = {}; + const uid = goog.getUid(obj); + assertRemovedDuplicates([obj, uid], [obj, uid]); + }, + + testRemoveDuplicates_customHashFn() { + const object1 = {key: 'foo'}; + const object2 = {key: 'bar'}; + const dupeObject = {key: 'foo'}; + const array = [object1, object2, dupeObject, 'bar']; + const hashFn = (object) => + goog.isObject(object) ? object.key : (typeof object).charAt(0) + object; + googArray.removeDuplicates(array, /* opt_rv */ undefined, hashFn); + assertArrayEquals([object1, object2, 'bar'], array); + }, + + testBinaryInsertRemove() { + const makeChecker = (array, fn, opt_compareFn) => + (value, expectResult, expectArray) => { + const result = fn(array, value, opt_compareFn); + assertEquals(expectResult, result); + assertArrayEquals(expectArray, array); + }; + + const a = []; + let check = makeChecker(a, googArray.binaryInsert); + check(3, true, [3]); + check(3, false, [3]); + check(1, true, [1, 3]); + check(5, true, [1, 3, 5]); + check(2, true, [1, 2, 3, 5]); + check(2, false, [1, 2, 3, 5]); + + check = makeChecker(a, googArray.binaryRemove); + check(0, false, [1, 2, 3, 5]); + check(3, true, [1, 2, 5]); + check(1, true, [2, 5]); + check(5, true, [2]); + check(2, true, []); + check(2, false, []); + + // test with custom comparison function, which reverse orders numbers + const revNumCompare = (a, b) => b - a; + + check = makeChecker(a, googArray.binaryInsert, revNumCompare); + check(3, true, [3]); + check(3, false, [3]); + check(1, true, [3, 1]); + check(5, true, [5, 3, 1]); + check(2, true, [5, 3, 2, 1]); + check(2, false, [5, 3, 2, 1]); + + check = makeChecker(a, googArray.binaryRemove, revNumCompare); + check(0, false, [5, 3, 2, 1]); + check(3, true, [5, 2, 1]); + check(1, true, [5, 2]); + check(5, true, [2]); + check(2, true, []); + check(2, false, []); + }, + + testBinarySearch() { + const insertionPoint = (position) => -(position + 1); + let pos; + + // test default comparison on array of String(s) + const a = [ + '1000', '9', 'AB', 'ABC', 'ABCABC', 'ABD', 'ABDA', 'B', + 'B', 'B', 'C', 'CA', 'CC', 'ZZZ', 'ab', 'abc', + 'abcabc', 'abd', 'abda', 'b', 'c', 'ca', 'cc', 'zzz', + ]; + + assertEquals( + '\'1000\' should be found at index 0', 0, + googArray.binarySearch(a, '1000')); + assertEquals( + '\'zzz\' should be found at index ' + (a.length - 1), a.length - 1, + googArray.binarySearch(a, 'zzz')); + assertEquals( + '\'C\' should be found at index 10', 10, + googArray.binarySearch(a, 'C')); + assertEquals( + '\'B\' should be found at index 7', 7, googArray.binarySearch(a, 'B')); + pos = googArray.binarySearch(a, '100'); + assertTrue('\'100\' should not be found', pos < 0); + assertEquals( + '\'100\' should have an insertion point of 0', 0, insertionPoint(pos)); + pos = googArray.binarySearch(a, 'zzz0'); + assertTrue('\'zzz0\' should not be found', pos < 0); + assertEquals( + '\'zzz0\' should have an insertion point of ' + (a.length), a.length, + insertionPoint(pos)); + pos = googArray.binarySearch(a, 'BA'); + assertTrue('\'BA\' should not be found', pos < 0); + assertEquals( + '\'BA\' should have an insertion point of 10', 10, insertionPoint(pos)); + + // test 0 length array with default comparison + const b = []; + + pos = googArray.binarySearch(b, 'a'); + assertTrue('\'a\' should not be found', pos < 0); + assertEquals( + '\'a\' should have an insertion point of 0', 0, insertionPoint(pos)); + + // test single element array with default lexiographical comparison + const c = ['only item']; + + assertEquals( + '\'only item\' should be found at index 0', 0, + googArray.binarySearch(c, 'only item')); + pos = googArray.binarySearch(c, 'a'); + assertTrue('\'a\' should not be found', pos < 0); + assertEquals( + '\'a\' should have an insertion point of 0', 0, insertionPoint(pos)); + pos = googArray.binarySearch(c, 'z'); + assertTrue('\'z\' should not be found', pos < 0); + assertEquals( + '\'z\' should have an insertion point of 1', 1, insertionPoint(pos)); + + // test default comparison on array of Number(s) + const d = [ + -897123.9, + -321434.58758, + -1321.3124, + -324, + -9, + -3, + 0, + 0, + 0, + 0.31255, + 5, + 142.88888708, + 334, + 342, + 453, + 54254, + ]; + + assertEquals( + '-897123.9 should be found at index 0', 0, + googArray.binarySearch(d, -897123.9)); + assertEquals( + '54254 should be found at index ' + (a.length - 1), d.length - 1, + googArray.binarySearch(d, 54254)); + assertEquals( + '-3 should be found at index 5', 5, googArray.binarySearch(d, -3)); + assertEquals( + '0 should be found at index 6', 6, googArray.binarySearch(d, 0)); + pos = googArray.binarySearch(d, -900000); + assertTrue('-900000 should not be found', pos < 0); + assertEquals( + '-900000 should have an insertion point of 0', 0, insertionPoint(pos)); + pos = googArray.binarySearch(d, 54255); + assertTrue('54255 should not be found', pos < 0); + assertEquals( + '54255 should have an insertion point of ' + (d.length), d.length, + insertionPoint(pos)); + pos = googArray.binarySearch(d, 1.1); + assertTrue('1.1 should not be found', pos < 0); + assertEquals( + '1.1 should have an insertion point of 10', 10, insertionPoint(pos)); + + // test with custom comparison function, which reverse orders numbers + const revNumCompare = (a, b) => b - a; + + const e = [ + 54254, + 453, + 342, + 334, + 142.88888708, + 5, + 0.31255, + 0, + 0, + 0, + -3, + -9, + -324, + -1321.3124, + -321434.58758, + -897123.9, + ]; + + assertEquals( + '54254 should be found at index 0', 0, + googArray.binarySearch(e, 54254, revNumCompare)); + assertEquals( + '-897123.9 should be found at index ' + (e.length - 1), e.length - 1, + googArray.binarySearch(e, -897123.9, revNumCompare)); + assertEquals( + '-3 should be found at index 10', 10, + googArray.binarySearch(e, -3, revNumCompare)); + assertEquals( + '0 should be found at index 7', 7, + googArray.binarySearch(e, 0, revNumCompare)); + pos = googArray.binarySearch(e, 54254.1, revNumCompare); + assertTrue('54254.1 should not be found', pos < 0); + assertEquals( + '54254.1 should have an insertion point of 0', 0, insertionPoint(pos)); + pos = googArray.binarySearch(e, -897124, revNumCompare); + assertTrue('-897124 should not be found', pos < 0); + assertEquals( + '-897124 should have an insertion point of ' + (e.length), e.length, + insertionPoint(pos)); + pos = googArray.binarySearch(e, 1.1, revNumCompare); + assertTrue('1.1 should not be found', pos < 0); + assertEquals( + '1.1 should have an insertion point of 6', 6, insertionPoint(pos)); + + // test 0 length array with custom comparison function + const f = []; + + pos = googArray.binarySearch(f, 0, revNumCompare); + assertTrue('0 should not be found', pos < 0); + assertEquals( + '0 should have an insertion point of 0', 0, insertionPoint(pos)); + + // test single element array with custom comparison function + const g = [1]; + + assertEquals( + '1 should be found at index 0', 0, + googArray.binarySearch(g, 1, revNumCompare)); + pos = googArray.binarySearch(g, 2, revNumCompare); + assertTrue('2 should not be found', pos < 0); + assertEquals( + '2 should have an insertion point of 0', 0, insertionPoint(pos)); + pos = googArray.binarySearch(g, 0, revNumCompare); + assertTrue('0 should not be found', pos < 0); + assertEquals( + '0 should have an insertion point of 1', 1, insertionPoint(pos)); + + // test left-most duplicated element is found + assertEquals( + 'binarySearch should find the index of the first 0', 0, + googArray.binarySearch([0, 0, 1], 0)); + assertEquals( + 'binarySearch should find the index of the first 1', 1, + googArray.binarySearch([0, 1, 1], 1)); + }, + + testBinarySearchMaximumSizeArray() { + const maxLength = 2 ** 32 - 1; + // [1, empty × 4294967293, 2] + const /** !IArrayLike */ giantSparseArray = { + length: maxLength, + 0: 1, + [maxLength - 1]: 2, + }; + const undefCmp = (a, b) => (a || 1.5) - (b || 1.5); + + // test with array-like object of maximum array length (2^32-1). + assertEquals( + '1 should be found at the start.', 0, + googArray.binarySearch(giantSparseArray, 1, undefCmp)); + assertEquals( + '0.5 should require insertion at the start.', -1, + googArray.binarySearch(giantSparseArray, 0.5, undefCmp)); + assertEquals( + '2 should be found at the end.', maxLength - 1, + googArray.binarySearch(giantSparseArray, 2, undefCmp)); + assertEquals( + '2.5 should require insertion at the end.', -maxLength - 1, + googArray.binarySearch(giantSparseArray, 2.5, undefCmp)); + }, + + testBinarySearchPerformance() { + // Ensure that Array#slice, Function#apply and Function#call are not called + // from within binarySearch, since they have performance implications in IE. + + const propertyReplacer = new PropertyReplacer(); + propertyReplacer.replace(Array.prototype, 'slice', () => { + fail('Should not call Array#slice from binary search.'); + }); + propertyReplacer.replace(Function.prototype, 'apply', () => { + fail('Should not call Function#apply from binary search.'); + }); + propertyReplacer.replace(Function.prototype, 'call', () => { + fail('Should not call Function#call from binary search.'); + }); + + try { + const array = + [1, 5, 7, 11, 13, 16, 19, 24, 28, 31, 33, 36, 40, 50, 52, 55]; + // Test with the default comparison function. + googArray.binarySearch(array, 48); + // Test with a custom comparison function. + googArray.binarySearch(array, 13, (a, b) => a > b ? 1 : a < b ? -1 : 0); + } finally { + // The test runner uses Function.prototype.apply to call tearDown in the + // global context so it has to be reset here. + propertyReplacer.reset(); + } + }, + + testBinarySelect() { + const insertionPoint = (position) => -(position + 1); + const numbers = [ + -897123.9, + -321434.58758, + -1321.3124, + -324, + -9, + -3, + 0, + 0, + 0, + 0.31255, + 5, + 142.88888708, + 334, + 342, + 453, + 54254, + ]; + const objects = googArray.map(numbers, (n) => ({n: n})); + function makeEvaluator(target) { + return (obj, i, arr) => { + assertEquals(objects, arr); + assertEquals(obj, arr[i]); + return target - obj.n; + }; + } + assertEquals( + '{n:-897123.9} should be found at index 0', 0, + googArray.binarySelect(objects, makeEvaluator(-897123.9))); + assertEquals( + '{n:54254} should be found at index ' + (objects.length - 1), + objects.length - 1, + googArray.binarySelect(objects, makeEvaluator(54254))); + assertEquals( + '{n:-3} should be found at index 5', 5, + googArray.binarySelect(objects, makeEvaluator(-3))); + assertEquals( + '{n:0} should be found at index 6', 6, + googArray.binarySelect(objects, makeEvaluator(0))); + let pos = googArray.binarySelect(objects, makeEvaluator(-900000)); + assertTrue('{n:-900000} should not be found', pos < 0); + assertEquals( + '{n:-900000} should have an insertion point of 0', 0, + insertionPoint(pos)); + pos = googArray.binarySelect(objects, makeEvaluator('54255')); + assertTrue('{n:54255} should not be found', pos < 0); + assertEquals( + '{n:54255} should have an insertion point of ' + (objects.length), + objects.length, insertionPoint(pos)); + pos = googArray.binarySelect(objects, makeEvaluator(1.1)); + assertTrue('{n:1.1} should not be found', pos < 0); + assertEquals( + '{n:1.1} should have an insertion point of 10', 10, + insertionPoint(pos)); + }, + + testArrayEquals() { + // Test argument types. + assertFalse('array == not array', googArray.equals([], null)); + assertFalse('not array == array', googArray.equals(null, [])); + assertFalse('not array == not array', googArray.equals(null, null)); + + // Test with default comparison function. + assertTrue('[] == []', googArray.equals([], [])); + assertTrue('[1] == [1]', googArray.equals([1], [1])); + assertTrue('["1"] == ["1"]', googArray.equals(['1'], ['1'])); + assertFalse('[1] == ["1"]', googArray.equals([1], ['1'])); + assertTrue('[null] == [null]', googArray.equals([null], [null])); + assertFalse('[null] == [undefined]', googArray.equals([null], [undefined])); + assertTrue('[1, 2] == [1, 2]', googArray.equals([1, 2], [1, 2])); + assertFalse('[1, 2] == [2, 1]', googArray.equals([1, 2], [2, 1])); + assertFalse('[1, 2] == [1]', googArray.equals([1, 2], [1])); + assertFalse('[1] == [1, 2]', googArray.equals([1], [1, 2])); + assertFalse('[{}] == [{}]', googArray.equals([{}], [{}])); + + // Test with custom comparison function. + const cmp = (a, b) => typeof a == typeof b; + assertTrue('[] cmp []', googArray.equals([], [], cmp)); + assertTrue('[1] cmp [1]', googArray.equals([1], [1], cmp)); + assertTrue('[1] cmp [2]', googArray.equals([1], [2], cmp)); + assertTrue('["1"] cmp ["1"]', googArray.equals(['1'], ['1'], cmp)); + assertTrue('["1"] cmp ["2"]', googArray.equals(['1'], ['2'], cmp)); + assertFalse('[1] cmp ["1"]', googArray.equals([1], ['1'], cmp)); + assertTrue('[1, 2] cmp [3, 4]', googArray.equals([1, 2], [3, 4], cmp)); + assertFalse('[1] cmp [2, 3]', googArray.equals([1], [2, 3], cmp)); + assertTrue('[{}] cmp [{}]', googArray.equals([{}], [{}], cmp)); + assertTrue('[{}] cmp [{a: 1}]', googArray.equals([{}], [{a: 1}], cmp)); + + // Test with array-like objects. + assertTrue('[5] == obj [5]', googArray.equals([5], {0: 5, length: 1})); + assertTrue('obj [5] == [5]', googArray.equals({0: 5, length: 1}, [5])); + assertTrue( + '["x"] == obj ["x"]', googArray.equals(['x'], {0: 'x', length: 1})); + assertTrue( + 'obj ["x"] == ["x"]', googArray.equals({0: 'x', length: 1}, ['x'])); + assertTrue( + '[5] == {0: 5, 1: 6, length: 1}', + googArray.equals([5], {0: 5, 1: 6, length: 1})); + assertTrue( + '{0: 5, 1: 6, length: 1} == [5]', + googArray.equals({0: 5, 1: 6, length: 1}, [5])); + assertFalse( + '[5, 6] == {0: 5, 1: 6, length: 1}', + googArray.equals([5, 6], {0: 5, 1: 6, length: 1})); + assertFalse( + '{0: 5, 1: 6, length: 1}, [5, 6]', + googArray.equals({0: 5, 1: 6, length: 1}, [5, 6])); + assertTrue( + '[5, 6] == obj [5, 6]', + googArray.equals([5, 6], {0: 5, 1: 6, length: 2})); + assertTrue( + 'obj [5, 6] == [5, 6]', + googArray.equals({0: 5, 1: 6, length: 2}, [5, 6])); + assertFalse( + '{0: 5, 1: 6} == [5, 6]', + googArray.equals(/** @type {?} */ ({0: 5, 1: 6}), [5, 6])); + }, + + testArrayCompare3Basic() { + assertEquals(0, googArray.compare3([], [])); + assertEquals(0, googArray.compare3(['111', '222'], ['111', '222'])); + assertEquals(-1, googArray.compare3(['111', '222'], ['1111', ''])); + assertEquals(1, googArray.compare3(['111', '222'], ['111'])); + assertEquals(1, googArray.compare3(['11', '222', '333'], [])); + assertEquals(-1, googArray.compare3([], ['11', '222', '333'])); + }, + + testArrayCompare3ComparatorFn() { + function cmp(a, b) { + return a - b; + } + assertEquals(0, googArray.compare3([], [], cmp)); + assertEquals(0, googArray.compare3([8, 4], [8, 4], cmp)); + assertEquals(-1, googArray.compare3([4, 3], [5, 0])); + assertEquals(1, googArray.compare3([6, 2], [6])); + assertEquals(1, googArray.compare3([1, 2, 3], [])); + assertEquals(-1, googArray.compare3([], [1, 2, 3])); + }, + + testSort() { + // Test sorting empty array + const a = []; + googArray.sort(a); + assertEquals( + 'Sorted empty array is still an empty array (length 0)', 0, a.length); + + // Test sorting homogenous array of String(s) of length > 1 + const b = [ + 'JUST', + '1', + 'test', + 'Array', + 'to', + 'test', + 'array', + 'Sort', + 'about', + 'NOW', + '!!', + ]; + const bSorted = [ + '!!', + '1', + 'Array', + 'JUST', + 'NOW', + 'Sort', + 'about', + 'array', + 'test', + 'test', + 'to', + ]; + googArray.sort(b); + assertArrayEquals(bSorted, b); + + // Test sorting already sorted array of String(s) of length > 1 + googArray.sort(b); + assertArrayEquals(bSorted, b); + + // Test sorting homogenous array of integer Number(s) of length > 1 + const c = [ + 100, + 1, + 2000, + -1, + 0, + 1000023, + 12312512, + -12331, + 123, + 54325, + -38104783, + 93708, + 908, + -213, + -4, + 5423, + 0, + ]; + const cSorted = [ + -38104783, + -12331, + -213, + -4, + -1, + 0, + 0, + 1, + 100, + 123, + 908, + 2000, + 5423, + 54325, + 93708, + 1000023, + 12312512, + ]; + googArray.sort(c); + assertArrayEquals(cSorted, c); + + // Test sorting already sorted array of integer Number(s) of length > 1 + googArray.sort(c); + assertArrayEquals(cSorted, c); + + // Test sorting homogenous array of Number(s) of length > 1 + const e = [ + -1321.3124, + 0.31255, + 54254, + 0, + 142.88888708, + -321434.58758, + -324, + 453, + 334, + -3, + 5, + -9, + 342, + -897123.9, + ]; + const eSorted = [ + -897123.9, + -321434.58758, + -1321.3124, + -324, + -9, + -3, + 0, + 0.31255, + 5, + 142.88888708, + 334, + 342, + 453, + 54254, + ]; + googArray.sort(e); + assertArrayEquals(eSorted, e); + + // Test sorting already sorted array of Number(s) of length > 1 + googArray.sort(e); + assertArrayEquals(eSorted, e); + + // Test sorting array of Number(s) of length > 1, + // using custom comparison function which does reverse ordering + const f = [ + -1321.3124, + 0.31255, + 54254, + 0, + 142.88888708, + -321434.58758, + -324, + 453, + 334, + -3, + 5, + -9, + 342, + -897123.9, + ]; + const fSorted = [ + 54254, + 453, + 342, + 334, + 142.88888708, + 5, + 0.31255, + 0, + -3, + -9, + -324, + -1321.3124, + -321434.58758, + -897123.9, + ]; + googArray.sort(f, (a, b) => b - a); + assertArrayEquals(fSorted, f); + + // Test sorting already sorted array of Number(s) of length > 1 + // using custom comparison function which does reverse ordering + googArray.sort(f, (a, b) => b - a); + assertArrayEquals(fSorted, f); + + // Test sorting array of custom Object(s) of length > 1 that have + // an overridden toString + /** @constructor @struct */ + function ComparedObject(value) { + this.value = value; + } + + ComparedObject.prototype.toString = function() { + return this.value; + }; + + const co1 = new ComparedObject('a'); + const co2 = new ComparedObject('b'); + const co3 = new ComparedObject('c'); + const co4 = new ComparedObject('d'); + + const g = [co3, co4, co2, co1]; + const gSorted = [co1, co2, co3, co4]; + googArray.sort(g); + assertArrayEquals(gSorted, g); + + // Test sorting already sorted array of custom Object(s) of length > 1 + // that have an overridden toString + googArray.sort(g); + assertArrayEquals(gSorted, g); + + // Test sorting an array of custom Object(s) of length > 1 using + // a custom comparison function + const h = [co4, co2, co1, co3]; + const hSorted = [co1, co2, co3, co4]; + googArray.sort( + h, (a, b) => a.value > b.value ? 1 : a.value < b.value ? -1 : 0); + assertArrayEquals(hSorted, h); + + // Test sorting already sorted array of custom Object(s) of length > 1 + // using a custom comparison function + googArray.sort(h); + assertArrayEquals(hSorted, h); + + // Test sorting arrays of length 1 + const i = ['one']; + const iSorted = ['one']; + googArray.sort(i); + assertArrayEquals(iSorted, i); + + const j = [1]; + const jSorted = [1]; + googArray.sort(j); + assertArrayEquals(jSorted, j); + + const k = [1.1]; + const kSorted = [1.1]; + googArray.sort(k); + assertArrayEquals(kSorted, k); + + const l = [co3]; + const lSorted = [co3]; + googArray.sort(l); + assertArrayEquals(lSorted, l); + + const m = [co2]; + const mSorted = [co2]; + googArray.sort( + m, (a, b) => a.value > b.value ? 1 : a.value < b.value ? -1 : 0); + assertArrayEquals(mSorted, m); + }, + + testStableSort() { + // Test array with custom comparison function + const arr = [ + {key: 3, val: 'a'}, + {key: 2, val: 'b'}, + {key: 3, val: 'c'}, + {key: 4, val: 'd'}, + {key: 3, val: 'e'}, + ]; + const arrClone = googArray.clone(arr); + + function comparisonFn(obj1, obj2) { + return obj1.key - obj2.key; + } + googArray.stableSort(arr, comparisonFn); + const sortedValues = []; + for (let i = 0; i < arr.length; i++) { + sortedValues.push(arr[i].val); + } + const wantedSortedValues = ['b', 'a', 'c', 'e', 'd']; + assertArrayEquals(wantedSortedValues, sortedValues); + + // Test array without custom comparison function + const arr2 = []; + for (let i = 0; i < arrClone.length; i++) { + arr2.push({ + val: arrClone[i].val, + toString: goog.partial((index) => arrClone[index].key, i), + }); + } + googArray.stableSort(arr2); + const sortedValues2 = []; + for (let i = 0; i < arr2.length; i++) { + sortedValues2.push(arr2[i].val); + } + assertArrayEquals(wantedSortedValues, sortedValues2); + }, + + testSortByKey() { + /** @constructor @struct */ + function Item(value) { + this.getValue = () => value; + } + const keyFn = (item) => item.getValue(); + + // Test without custom key comparison function + const arr1 = + [new Item(3), new Item(2), new Item(1), new Item(5), new Item(4)]; + googArray.sortByKey(arr1, keyFn); + const wantedSortedValues1 = [1, 2, 3, 4, 5]; + for (let i = 0; i < arr1.length; i++) { + assertEquals(wantedSortedValues1[i], arr1[i].getValue()); + } + + // Test with custom key comparison function + const arr2 = + [new Item(3), new Item(2), new Item(1), new Item(5), new Item(4)]; + function comparisonFn(key1, key2) { + return -(key1 - key2); + } + googArray.sortByKey(arr2, keyFn, comparisonFn); + const wantedSortedValues2 = [5, 4, 3, 2, 1]; + for (let i = 0; i < arr2.length; i++) { + assertEquals(wantedSortedValues2[i], arr2[i].getValue()); + } + }, + + testArrayBucketModulus() { + // bucket things by modulus + const a = {}; + const b = []; + + function modFive(num) { + return num % 5; + } + + for (let i = 0; i < 20; i++) { + const mod = modFive(i); + a[mod] = a[mod] || []; + a[mod].push(i); + b.push(i); + } + + const buckets = googArray.bucket(b, modFive); + + for (let i = 0; i < 5; i++) { + // The order isn't defined, but they should be the same sorted. + googArray.sort(a[i]); + googArray.sort(buckets[i]); + assertArrayEquals(a[i], buckets[i]); + } + }, + + testArrayBucketEvenOdd() { + const a = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + + // test even/odd + function isEven(value, index, array) { + assertEquals(value, array[index]); + assertEquals('number', typeof index); + assertEquals(a, array); + return value % 2 == 0; + } + + const b = googArray.bucket(a, isEven); + + assertArrayEquals(b[true], [2, 4, 6, 8]); + assertArrayEquals(b[false], [1, 3, 5, 7, 9]); + }, + + testArrayBucketUsingThisObject() { + const a = [1, 2, 3, 4, 5]; + + const obj = {specialValue: 2}; + + /** + * @param {number} value + * @param {number} index + * @param {!IArrayLike} array + * @return {number} + * @this {?} + */ + function isSpecialValue(value, index, array) { + return value == this.specialValue ? 1 : 0; + } + + const b = googArray.bucket(a, isSpecialValue, obj); + assertArrayEquals(b[0], [1, 3, 4, 5]); + assertArrayEquals(b[1], [2]); + }, + + + testArrayBucketToMap() { + const a = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + const evenKey = {}; + const oddKey = {}; + + function isEven(value, index, array) { + assertEquals(value, array[index]); + assertEquals('number', typeof index); + assertEquals(a, array); + return (value % 2 == 0) ? evenKey : oddKey; + } + + const map = googArray.bucketToMap(a, isEven); + assertEquals(2, map.size); + assertArrayEquals(map.get(evenKey), [2, 4, 6, 8]); + assertArrayEquals(map.get(oddKey), [1, 3, 5, 7, 9]); + }, + + + testArrayToObject() { + const a = [{name: 'a'}, {name: 'b'}, {name: 'c'}, {name: 'd'}]; + + function getName(value, index, array) { + assertEquals(value, array[index]); + assertEquals('number', typeof index); + assertEquals(a, array); + return value.name; + } + + const b = googArray.toObject(a, getName); + + for (let i = 0; i < a.length; i++) { + assertEquals(a[i], b[a[i].name]); + } + }, + + testArrayToMap() { + const a = [{id: 0}, {id: 1}, {id: 2}, {id: NaN}]; + + function getId(value, index, array) { + assertEquals(value, array[index]); + assertEquals('number', typeof index); + assertEquals(a, array); + return value.id; + } + + const map = googArray.toMap(a, getId); + assertEquals(map.size, a.length); + for (const e of a) { + assertEquals(e, map.get(e.id)); + } + }, + + testRange() { + assertArrayEquals([], googArray.range(0)); + assertArrayEquals([], googArray.range(5, 5, 5)); + assertArrayEquals([], googArray.range(-3, -3)); + assertArrayEquals([], googArray.range(10, undefined, -1)); + assertArrayEquals([], googArray.range(8, 0)); + assertArrayEquals([], googArray.range(-5, -10, 3)); + + assertArrayEquals([0], googArray.range(1)); + assertArrayEquals([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], googArray.range(10)); + + assertArrayEquals([1], googArray.range(1, 2)); + assertArrayEquals([-3, -2, -1, 0, 1, 2], googArray.range(-3, 3)); + + assertArrayEquals([4], googArray.range(4, 40, 400)); + assertArrayEquals([5, 8, 11, 14], googArray.range(5, 15, 3)); + assertArrayEquals([1, -1, -3], googArray.range(1, -5, -2)); + assertElementsRoughlyEqual( + [.2, .3, .4], googArray.range(.2, .5, .1), 0.001); + + assertArrayEquals([0], googArray.range(7, undefined, 9)); + assertArrayEquals([0, 2, 4, 6], googArray.range(8, undefined, 2)); + }, + + testArrayRepeat() { + assertArrayEquals([], googArray.repeat(3, 0)); + assertArrayEquals([], googArray.repeat(3, -1)); + assertArrayEquals([3], googArray.repeat(3, 1)); + assertArrayEquals([3, 3, 3], googArray.repeat(3, 3)); + assertArrayEquals([null, null], googArray.repeat(null, 2)); + }, + + testArrayFlatten() { + assertArrayEquals([1, 2, 3, 4, 5], googArray.flatten(1, 2, 3, 4, 5)); + assertArrayEquals([1, 2, 3, 4, 5], googArray.flatten(1, [2, [3, [4, 5]]])); + assertArrayEquals([1, 2, 3, 4], googArray.flatten(1, [2, [3, [4]]])); + assertArrayEquals([1, 2, 3, 4], googArray.flatten([[[1], 2], 3], 4)); + assertArrayEquals([1], googArray.flatten([[1]])); + assertArrayEquals([], googArray.flatten()); + assertArrayEquals([], googArray.flatten([])); + assertArrayEquals( + googArray.repeat(3, 180002), + googArray.flatten(3, googArray.repeat(3, 180000), 3)); + assertArrayEquals( + googArray.repeat(3, 180000), + googArray.flatten([googArray.repeat(3, 180000)])); + }, + + testSortObjectsByKey() { + const sortedArray = buildSortedObjectArray(4); + const objects = + [sortedArray[1], sortedArray[2], sortedArray[0], sortedArray[3]]; + + googArray.sortObjectsByKey(objects, 'name'); + assertArrayEquals(sortedArray, objects); + }, + + testSortObjectsByKeyWithCompareFunction() { + const sortedArray = buildSortedObjectArray(4); + const objects = + [sortedArray[1], sortedArray[2], sortedArray[0], sortedArray[3]]; + const descSortedArray = + [sortedArray[3], sortedArray[2], sortedArray[1], sortedArray[0]]; + + function descCompare(a, b) { + return a < b ? 1 : a > b ? -1 : 0; + } + + googArray.sortObjectsByKey(objects, 'name', descCompare); + assertArrayEquals(descSortedArray, objects); + }, + + testIsSorted() { + assertTrue(googArray.isSorted([1, 2, 3])); + assertTrue(googArray.isSorted([1, 2, 2])); + assertFalse(googArray.isSorted([1, 2, 1])); + + assertTrue(googArray.isSorted([1, 2, 3], null, true)); + assertFalse(googArray.isSorted([1, 2, 2], null, true)); + assertFalse(googArray.isSorted([1, 2, 1], null, true)); + + function compare(a, b) { + return b - a; + } + + assertFalse(googArray.isSorted([1, 2, 3], compare)); + assertTrue(googArray.isSorted([3, 2, 2], compare)); + }, + + testRotate() { + assertRotated([], [], 3); + assertRotated([1], [1], 3); + assertRotated([1, 2, 3, 4, 0], [0, 1, 2, 3, 4], -6); + assertRotated([0, 1, 2, 3, 4], [0, 1, 2, 3, 4], -5); + assertRotated([4, 0, 1, 2, 3], [0, 1, 2, 3, 4], -4); + assertRotated([3, 4, 0, 1, 2], [0, 1, 2, 3, 4], -3); + assertRotated([2, 3, 4, 0, 1], [0, 1, 2, 3, 4], -2); + assertRotated([1, 2, 3, 4, 0], [0, 1, 2, 3, 4], -1); + assertRotated([0, 1, 2, 3, 4], [0, 1, 2, 3, 4], 0); + assertRotated([4, 0, 1, 2, 3], [0, 1, 2, 3, 4], 1); + assertRotated([3, 4, 0, 1, 2], [0, 1, 2, 3, 4], 2); + assertRotated([2, 3, 4, 0, 1], [0, 1, 2, 3, 4], 3); + assertRotated([1, 2, 3, 4, 0], [0, 1, 2, 3, 4], 4); + assertRotated([0, 1, 2, 3, 4], [0, 1, 2, 3, 4], 5); + assertRotated([4, 0, 1, 2, 3], [0, 1, 2, 3, 4], 6); + }, + + testMoveItemWithArray() { + const arr = [0, 1, 2, 3]; + googArray.moveItem(arr, 1, 3); // toIndex > fromIndex + assertArrayEquals([0, 2, 3, 1], arr); + googArray.moveItem(arr, 2, 0); // toIndex < fromIndex + assertArrayEquals([3, 0, 2, 1], arr); + googArray.moveItem(arr, 1, 1); // toIndex == fromIndex + assertArrayEquals([3, 0, 2, 1], arr); + // Out-of-bounds indexes throw assertion errors. + assertThrows(() => { + googArray.moveItem(arr, -1, 1); + }); + assertThrows(() => { + googArray.moveItem(arr, 4, 1); + }); + assertThrows(() => { + googArray.moveItem(arr, 1, -1); + }); + assertThrows(() => { + googArray.moveItem(arr, 1, 4); + }); + // The array should not be modified by the out-of-bound calls. + assertArrayEquals([3, 0, 2, 1], arr); + }, + + testMoveItemWithArgumentsObject() { + const f = function(var_args) { + googArray.moveItem(arguments, 0, 1); + return arguments; + }; + assertArrayEquals([1, 0], googArray.toArray(f(0, 1))); + }, + + testConcat() { + const a1 = [1, 2, 3]; + const a2 = [4, 5, 6]; + const a3 = googArray.concat(a1, a2); + a1.push(1); + a2.push(5); + assertArrayEquals([1, 2, 3, 4, 5, 6], a3); + }, + + testConcatWithNoSecondArg() { + const a1 = [1, 2, 3, 4]; + const a2 = googArray.concat(a1); + a1.push(5); + assertArrayEquals([1, 2, 3, 4], a2); + }, + + testConcatWithNonArrayArgs() { + const a1 = [1, 2, 3, 4]; + const o = {0: 'a', 1: 'b', length: 2}; + const a2 = googArray.concat(a1, 5, '10', o); + assertArrayEquals([1, 2, 3, 4, 5, '10', o], a2); + }, + + testConcatWithNull() { + const a1 = googArray.concat(null, [1, 2, 3]); + const a2 = googArray.concat([1, 2, 3], null); + assertArrayEquals([null, 1, 2, 3], a1); + assertArrayEquals([1, 2, 3, null], a2); + }, + + testZip() { + const a1 = googArray.zip([1, 2, 3], [3, 2, 1]); + const a2 = googArray.zip([1, 2], [3, 2, 1]); + const a3 = googArray.zip(); + assertArrayEquals([[1, 3], [2, 2], [3, 1]], a1); + assertArrayEquals([[1, 3], [2, 2]], a2); + assertArrayEquals([], a3); + }, + + testShuffle() { + // Test array. This array should have unique values for the purposes of this + // test case. + const testArray = [1, 2, 3, 4, 5]; + const testArrayCopy = googArray.clone(testArray); + + // Custom random function, which always returns a value approaching 1, + // resulting in a "shuffle" that preserves the order of original array + // (for array sizes that we work with here). + const noChangeShuffleFunction = () => .999999; + googArray.shuffle(testArray, noChangeShuffleFunction); + assertArrayEquals(testArrayCopy, testArray); + + // Custom random function, which always returns 0, resulting in a + // deterministic "shuffle" that is predictable but differs from the + // original order of the array. + const testShuffleFunction = () => 0; + googArray.shuffle(testArray, testShuffleFunction); + assertArrayEquals([2, 3, 4, 5, 1], testArray); + + // Test the use of a real random function(no optional RNG is specified). + googArray.shuffle(testArray); + + // Ensure the shuffled array comprises the same elements (without regard to + // order). + assertSameElements(testArrayCopy, testArray); + }, + + testRemoveAllIf() { + const testArray = [9, 1, 9, 2, 9, 3, 4, 9, 9, 9, 5]; + const expectedArray = [1, 2, 3, 4, 5]; + + const actualOutput = googArray.removeAllIf(testArray, (el) => el == 9); + + assertEquals(6, actualOutput); + assertArrayEquals(expectedArray, testArray); + }, + + testRemoveAllIf_noMatches() { + const testArray = [1]; + const expectedArray = [1]; + + const actualOutput = googArray.removeAllIf(testArray, (el) => false); + + assertEquals(0, actualOutput); + assertArrayEquals(expectedArray, testArray); + }, + + testCopyByIndex() { + const testArray = [1, 2, 'a', 'b', 'c', 'd']; + const copyIndexes = [1, 3, 0, 0, 2]; + const expectedArray = [2, 'b', 1, 1, 'a']; + + const actualOutput = googArray.copyByIndex(testArray, copyIndexes); + + assertArrayEquals(expectedArray, actualOutput); + }, + + testComparators() { + const greater = 42; + const smaller = 13; + + assertTrue(googArray.defaultCompare(smaller, greater) < 0); + assertEquals(0, googArray.defaultCompare(smaller, smaller)); + assertTrue(googArray.defaultCompare(greater, smaller) > 0); + + assertTrue(googArray.inverseDefaultCompare(greater, smaller) < 0); + assertEquals(0, googArray.inverseDefaultCompare(greater, greater)); + assertTrue(googArray.inverseDefaultCompare(smaller, greater) > 0); + }, + + testConcatMap() { + const a = [0, 1, 2, 0]; + const context = {}; + const arraysToReturn = [['x', 'y', 'z'], [], ['a', 'b']]; + let timesCalled = 0; + const result = googArray.concatMap(a, function(val, index, a2) { + assertEquals(a, a2); + assertEquals(context, this); + assertEquals(timesCalled++, index); + assertEquals(a[index], val); + return arraysToReturn[val]; + }, context); + assertArrayEquals(['x', 'y', 'z', 'a', 'b', 'x', 'y', 'z'], result); + }, +}); diff --git a/closure/goog/array/array_test_dom.html b/closure/goog/array/array_test_dom.html new file mode 100644 index 0000000000..22100669ce --- /dev/null +++ b/closure/goog/array/array_test_dom.html @@ -0,0 +1,8 @@ + +
+
\ No newline at end of file diff --git a/closure/goog/asserts/BUILD b/closure/goog/asserts/BUILD new file mode 100644 index 0000000000..e34ab95a0f --- /dev/null +++ b/closure/goog/asserts/BUILD @@ -0,0 +1,26 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "asserts", + srcs = ["asserts.js"], + lenient = True, + deps = [ + "//closure/goog/debug:error", + "//closure/goog/dom:nodetype", + ], +) + +closure_js_library( + name = "dom", + srcs = ["dom.js"], + lenient = True, + deps = [ + ":asserts", + "//closure/goog/dom:element", + "//closure/goog/dom:tagname", + ], +) diff --git a/closure/goog/asserts/asserts.js b/closure/goog/asserts/asserts.js new file mode 100644 index 0000000000..2fd830b358 --- /dev/null +++ b/closure/goog/asserts/asserts.js @@ -0,0 +1,450 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities to check the preconditions, postconditions and + * invariants runtime. + * + * Methods in this package are given special treatment by the compiler + * for type-inference. For example, goog.asserts.assert(foo) + * will make the compiler treat foo as non-nullable. Similarly, + * goog.asserts.assertNumber(foo) informs the compiler about the + * type of foo. Where applicable, such assertions are preferable to + * casts by jsdoc with @type. + * + * The compiler has an option to disable asserts. So code like: + * + * var x = goog.asserts.assert(foo()); + * goog.asserts.assert(bar()); + * + * will be transformed into: + * + * var x = foo(); + * + * The compiler will leave in foo() (because its return value is used), + * but it will remove bar() because it assumes it does not have side-effects. + * + * Additionally, note the compiler will consider the type to be "tightened" for + * all statements after the assertion. For example: + * + * const /** ?Object &#ast;/ value = foo(); + * goog.asserts.assert(value); + * // "value" is of type {!Object} at this point. + * + */ + +goog.module('goog.asserts'); +goog.module.declareLegacyNamespace(); + +const DebugError = goog.require('goog.debug.Error'); +const NodeType = goog.require('goog.dom.NodeType'); + + +// NOTE: this needs to be exported directly and referenced via the exports +// object because unit tests stub it out. +/** + * @define {boolean} Whether to strip out asserts or to leave them in. + */ +exports.ENABLE_ASSERTS = goog.define('goog.asserts.ENABLE_ASSERTS', goog.DEBUG); + + + +/** + * Error object for failed assertions. + * @param {string} messagePattern The pattern that was used to form message. + * @param {!Array<*>} messageArgs The items to substitute into the pattern. + * @constructor + * @extends {DebugError} + * @final + */ +function AssertionError(messagePattern, messageArgs) { + DebugError.call(this, subs(messagePattern, messageArgs)); + + /** + * The message pattern used to format the error message. Error handlers can + * use this to uniquely identify the assertion. + * @type {string} + */ + this.messagePattern = messagePattern; +} +goog.inherits(AssertionError, DebugError); +exports.AssertionError = AssertionError; + +/** @override @type {string} */ +AssertionError.prototype.name = 'AssertionError'; + + +/** + * The default error handler. + * @param {!AssertionError} e The exception to be handled. + * @return {void} + */ +exports.DEFAULT_ERROR_HANDLER = function(e) { + throw e; +}; + + +/** + * The handler responsible for throwing or logging assertion errors. + * @type {function(!AssertionError)} + */ +let errorHandler_ = exports.DEFAULT_ERROR_HANDLER; + + +/** + * Does simple python-style string substitution. + * subs("foo%s hot%s", "bar", "dog") becomes "foobar hotdog". + * @param {string} pattern The string containing the pattern. + * @param {!Array<*>} subs The items to substitute into the pattern. + * @return {string} A copy of `str` in which each occurrence of + * `%s` has been replaced an argument from `var_args`. + */ +function subs(pattern, subs) { + const splitParts = pattern.split('%s'); + let returnString = ''; + + // Replace up to the last split part. We are inserting in the + // positions between split parts. + const subLast = splitParts.length - 1; + for (let i = 0; i < subLast; i++) { + // keep unsupplied as '%s' + const sub = (i < subs.length) ? subs[i] : '%s'; + returnString += splitParts[i] + sub; + } + return returnString + splitParts[subLast]; +} + + +/** + * Throws an exception with the given message and "Assertion failed" prefixed + * onto it. + * @param {string} defaultMessage The message to use if givenMessage is empty. + * @param {?Array<*>} defaultArgs The substitution arguments for defaultMessage. + * @param {string|undefined} givenMessage Message supplied by the caller. + * @param {!Array<*>} givenArgs The substitution arguments for givenMessage. + * @throws {AssertionError} When the value is not a number. + */ +function doAssertFailure(defaultMessage, defaultArgs, givenMessage, givenArgs) { + let message = 'Assertion failed'; + let args; + if (givenMessage) { + message += ': ' + givenMessage; + args = givenArgs; + } else if (defaultMessage) { + message += ': ' + defaultMessage; + args = defaultArgs; + } + // The '' + works around an Opera 10 bug in the unit tests. Without it, + // a stack trace is added to var message above. With this, a stack trace is + // not added until this line (it causes the extra garbage to be added after + // the assertion message instead of in the middle of it). + const e = new AssertionError('' + message, args || []); + errorHandler_(e); +} + + +/** + * Sets a custom error handler that can be used to customize the behavior of + * assertion failures, for example by turning all assertion failures into log + * messages. + * @param {function(!AssertionError)} errorHandler + * @return {void} + */ +exports.setErrorHandler = function(errorHandler) { + if (exports.ENABLE_ASSERTS) { + errorHandler_ = errorHandler; + } +}; + + +/** + * Checks if the condition evaluates to true if ENABLE_ASSERTS is + * true. + * @template T + * @param {T} condition The condition to check. + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @return {T} The value of the condition. + * @throws {AssertionError} When the condition evaluates to false. + * @closurePrimitive {asserts.truthy} + */ +exports.assert = function(condition, opt_message, var_args) { + if (exports.ENABLE_ASSERTS && !condition) { + doAssertFailure( + '', null, opt_message, Array.prototype.slice.call(arguments, 2)); + } + return condition; +}; + + +/** + * Checks if `value` is `null` or `undefined` if goog.asserts.ENABLE_ASSERTS is + * true. + * + * @param {T} value The value to check. + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @return {R} `value` with its type narrowed to exclude `null` and `undefined`. + * + * @template T + * @template R := + * mapunion(T, (V) => + * cond(eq(V, 'null'), + * none(), + * cond(eq(V, 'undefined'), + * none(), + * V))) + * =: + * + * @throws {!AssertionError} When `value` is `null` or `undefined`. + * @closurePrimitive {asserts.matchesReturn} + */ +exports.assertExists = function(value, opt_message, var_args) { + if (exports.ENABLE_ASSERTS && value == null) { + doAssertFailure( + 'Expected to exist: %s.', [value], opt_message, + Array.prototype.slice.call(arguments, 2)); + } + return value; +}; + + +/** + * Fails if goog.asserts.ENABLE_ASSERTS is true. This function is useful in case + * when we want to add a check in the unreachable area like switch-case + * statement: + * + *
+ *  switch(type) {
+ *    case FOO: doSomething(); break;
+ *    case BAR: doSomethingElse(); break;
+ *    default: goog.asserts.fail('Unrecognized type: ' + type);
+ *      // We have only 2 types - "default:" section is unreachable code.
+ *  }
+ * 
+ * + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @return {void} + * @throws {AssertionError} Failure. + * @closurePrimitive {asserts.fail} + */ +exports.fail = function(opt_message, var_args) { + if (exports.ENABLE_ASSERTS) { + errorHandler_(new AssertionError( + 'Failure' + (opt_message ? ': ' + opt_message : ''), + Array.prototype.slice.call(arguments, 1))); + } +}; + + +/** + * Checks if the value is a number if goog.asserts.ENABLE_ASSERTS is true. + * @param {*} value The value to check. + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @return {number} The value, guaranteed to be a number when asserts enabled. + * @throws {AssertionError} When the value is not a number. + * @closurePrimitive {asserts.matchesReturn} + */ +exports.assertNumber = function(value, opt_message, var_args) { + if (exports.ENABLE_ASSERTS && typeof value !== 'number') { + doAssertFailure( + 'Expected number but got %s: %s.', [goog.typeOf(value), value], + opt_message, Array.prototype.slice.call(arguments, 2)); + } + return /** @type {number} */ (value); +}; + + +/** + * Checks if the value is a string if goog.asserts.ENABLE_ASSERTS is true. + * @param {*} value The value to check. + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @return {string} The value, guaranteed to be a string when asserts enabled. + * @throws {AssertionError} When the value is not a string. + * @closurePrimitive {asserts.matchesReturn} + */ +exports.assertString = function(value, opt_message, var_args) { + if (exports.ENABLE_ASSERTS && typeof value !== 'string') { + doAssertFailure( + 'Expected string but got %s: %s.', [goog.typeOf(value), value], + opt_message, Array.prototype.slice.call(arguments, 2)); + } + return /** @type {string} */ (value); +}; + + +/** + * Checks if the value is a function if goog.asserts.ENABLE_ASSERTS is true. + * @param {*} value The value to check. + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @return {!Function} The value, guaranteed to be a function when asserts + * enabled. + * @throws {AssertionError} When the value is not a function. + * @closurePrimitive {asserts.matchesReturn} + */ +exports.assertFunction = function(value, opt_message, var_args) { + if (exports.ENABLE_ASSERTS && typeof value !== 'function') { + doAssertFailure( + 'Expected function but got %s: %s.', [goog.typeOf(value), value], + opt_message, Array.prototype.slice.call(arguments, 2)); + } + return /** @type {!Function} */ (value); +}; + + +/** + * Checks if the value is an Object if goog.asserts.ENABLE_ASSERTS is true. + * @param {*} value The value to check. + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @return {!Object} The value, guaranteed to be a non-null object. + * @throws {AssertionError} When the value is not an object. + * @closurePrimitive {asserts.matchesReturn} + */ +exports.assertObject = function(value, opt_message, var_args) { + if (exports.ENABLE_ASSERTS && !goog.isObject(value)) { + doAssertFailure( + 'Expected object but got %s: %s.', [goog.typeOf(value), value], + opt_message, Array.prototype.slice.call(arguments, 2)); + } + return /** @type {!Object} */ (value); +}; + + +/** + * Checks if the value is an Array if ENABLE_ASSERTS is true. + * @param {*} value The value to check. + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @return {!Array} The value, guaranteed to be a non-null array. + * @throws {AssertionError} When the value is not an array. + * @closurePrimitive {asserts.matchesReturn} + */ +exports.assertArray = function(value, opt_message, var_args) { + if (exports.ENABLE_ASSERTS && !Array.isArray(value)) { + doAssertFailure( + 'Expected array but got %s: %s.', [goog.typeOf(value), value], + opt_message, Array.prototype.slice.call(arguments, 2)); + } + return /** @type {!Array} */ (value); +}; + + +/** + * Checks if the value is a boolean if goog.asserts.ENABLE_ASSERTS is true. + * @param {*} value The value to check. + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @return {boolean} The value, guaranteed to be a boolean when asserts are + * enabled. + * @throws {AssertionError} When the value is not a boolean. + * @closurePrimitive {asserts.matchesReturn} + */ +exports.assertBoolean = function(value, opt_message, var_args) { + if (exports.ENABLE_ASSERTS && typeof value !== 'boolean') { + doAssertFailure( + 'Expected boolean but got %s: %s.', [goog.typeOf(value), value], + opt_message, Array.prototype.slice.call(arguments, 2)); + } + return /** @type {boolean} */ (value); +}; + + +/** + * Checks if the value is a DOM Element if goog.asserts.ENABLE_ASSERTS is true. + * @param {*} value The value to check. + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @return {!Element} The value, likely to be a DOM Element when asserts are + * enabled. + * @throws {AssertionError} When the value is not an Element. + * @closurePrimitive {asserts.matchesReturn} + * @deprecated Use goog.asserts.dom.assertIsElement instead. + */ +exports.assertElement = function(value, opt_message, var_args) { + if (exports.ENABLE_ASSERTS && + (!goog.isObject(value) || + /** @type {!Node} */ (value).nodeType != NodeType.ELEMENT)) { + doAssertFailure( + 'Expected Element but got %s: %s.', [goog.typeOf(value), value], + opt_message, Array.prototype.slice.call(arguments, 2)); + } + return /** @type {!Element} */ (value); +}; + + +/** + * Checks if the value is an instance of the user-defined type if + * goog.asserts.ENABLE_ASSERTS is true. + * + * The compiler may tighten the type returned by this function. + * + * Do not use this to ensure a value is an HTMLElement or a subclass! Cross- + * document DOM inherits from separate - though identical - browser classes, and + * such a check will unexpectedly fail. Please use the methods in + * goog.asserts.dom for these purposes. + * + * @param {?} value The value to check. + * @param {function(new: T, ...)} type A user-defined constructor. + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @throws {AssertionError} When the value is not an instance of + * type. + * @return {T} + * @template T + * @closurePrimitive {asserts.matchesReturn} + */ +exports.assertInstanceof = function(value, type, opt_message, var_args) { + if (exports.ENABLE_ASSERTS && !(value instanceof type)) { + doAssertFailure( + 'Expected instanceof %s but got %s.', [getType(type), getType(value)], + opt_message, Array.prototype.slice.call(arguments, 3)); + } + return value; +}; + + +/** + * Checks whether the value is a finite number, if ENABLE_ASSERTS + * is true. + * + * @param {*} value The value to check. + * @param {string=} opt_message Error message in case of failure. + * @param {...*} var_args The items to substitute into the failure message. + * @throws {AssertionError} When the value is not a number, or is + * a non-finite number such as NaN, Infinity or -Infinity. + * @return {number} The value initially passed in. + */ +exports.assertFinite = function(value, opt_message, var_args) { + if (exports.ENABLE_ASSERTS && + (typeof value != 'number' || !isFinite(value))) { + doAssertFailure( + 'Expected %s to be a finite number but it is not.', [value], + opt_message, Array.prototype.slice.call(arguments, 2)); + } + return /** @type {number} */ (value); +}; + +/** + * Returns the type of a value. If a constructor is passed, and a suitable + * string cannot be found, 'unknown type name' will be returned. + * @param {*} value A constructor, object, or primitive. + * @return {string} The best display name for the value, or 'unknown type name'. + */ +function getType(value) { + if (value instanceof Function) { + return value.displayName || value.name || 'unknown type name'; + } else if (value instanceof Object) { + return /** @type {string} */ (value.constructor.displayName) || + value.constructor.name || Object.prototype.toString.call(value); + } else { + return value === null ? 'null' : typeof value; + } +} diff --git a/closure/goog/asserts/asserts_test.js b/closure/goog/asserts/asserts_test.js new file mode 100644 index 0000000000..b38b20c3ed --- /dev/null +++ b/closure/goog/asserts/asserts_test.js @@ -0,0 +1,311 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.assertsTest'); +goog.setTestOnly(); + +const TagName = goog.require('goog.dom.TagName'); +const asserts = goog.require('goog.asserts'); +const dom = goog.require('goog.dom'); +const googString = goog.require('goog.string'); +const reflect = goog.require('goog.reflect'); +const testSuite = goog.require('goog.testing.testSuite'); +const userAgent = goog.require('goog.userAgent'); +const {AssertionError} = goog.require('goog.asserts'); + +/** + * Test that the function throws an error with the given message. + * @param {function()} failFunc + * @param {string} expectedMsg + */ +function doTestMessage(failFunc, expectedMsg) { + const error = assertThrows('failFunc should throw.', failFunc); + // Test error message. + assertEquals(expectedMsg, error.message); +} + +testSuite({ + testAssert() { + // None of them may throw exception + asserts.assert(true); + asserts.assert(1); + asserts.assert([]); + asserts.assert({}); + + assertThrows('assert(false)', goog.partial(asserts.assert, false)); + assertThrows('assert(0)', goog.partial(asserts.assert, 0)); + assertThrows('assert(null)', goog.partial(asserts.assert, null)); + assertThrows('assert(undefined)', goog.partial(asserts.assert, undefined)); + + // Test error messages. + doTestMessage(goog.partial(asserts.assert, false), 'Assertion failed'); + doTestMessage( + goog.partial(asserts.assert, false, 'ouch %s', 1), + 'Assertion failed: ouch 1'); + }, + + testAssertExists() { + // None of them may throw exception + asserts.assertExists(true); + asserts.assertExists(false); + asserts.assertExists(1); + asserts.assertExists(0); + asserts.assertExists(NaN); + asserts.assertExists('Hello'); + asserts.assertExists(''); + asserts.assertExists(/Hello/); + asserts.assertExists([]); + asserts.assertExists({}); + + assertThrows( + 'assertExists(null)', goog.partial(asserts.assertExists, null)); + assertThrows( + 'assertExists(undefined)', + goog.partial(asserts.assertExists, undefined)); + + // Test error messages. + doTestMessage( + goog.partial(asserts.assertExists, null), + 'Assertion failed: Expected to exist: null.'); + doTestMessage( + goog.partial(asserts.assertExists, null, 'ouch %s', 1), + 'Assertion failed: ouch 1'); + }, + + testAssertExists_narrowing() { + const /** number|null|undefined */ wideValue = 0; + + const /** number */ narrowReturn = asserts.assertExists(wideValue); + const /** number */ narrowInScope = wideValue; + + reflect.sinkValue(narrowReturn); + reflect.sinkValue(narrowInScope); + }, + + testFail() { + assertThrows('fail()', asserts.fail); + // Test error messages. + doTestMessage(goog.partial(asserts.fail, false), 'Failure'); + doTestMessage(goog.partial(asserts.fail, 'ouch %s', 1), 'Failure: ouch 1'); + }, + + testNumber() { + asserts.assertNumber(1); + assertThrows( + 'assertNumber(null)', goog.partial(asserts.assertNumber, null)); + // Test error messages. + doTestMessage( + goog.partial(asserts.assertNumber, null), + 'Assertion failed: Expected number but got null: null.'); + doTestMessage( + goog.partial(asserts.assertNumber, '1234'), + 'Assertion failed: Expected number but got string: 1234.'); + doTestMessage( + goog.partial(asserts.assertNumber, null, 'ouch %s', 1), + 'Assertion failed: ouch 1'); + }, + + testString() { + assertEquals('1', asserts.assertString('1')); + assertThrows( + 'assertString(null)', goog.partial(asserts.assertString, null)); + // Test error messages. + doTestMessage( + goog.partial(asserts.assertString, null), + 'Assertion failed: Expected string but got null: null.'); + doTestMessage( + goog.partial(asserts.assertString, 1234), + 'Assertion failed: Expected string but got number: 1234.'); + doTestMessage( + goog.partial(asserts.assertString, null, 'ouch %s', 1), + 'Assertion failed: ouch 1'); + }, + + // jslint:ignore start + testFunction() { + function f() {} + assertEquals(f, asserts.assertFunction(f)); + assertThrows( + 'assertFunction(null)', goog.partial(asserts.assertFunction, null)); + // Test error messages. + doTestMessage( + goog.partial(asserts.assertFunction, null), + 'Assertion failed: Expected function but got null: null.'); + doTestMessage( + goog.partial(asserts.assertFunction, 1234), + 'Assertion failed: Expected function but got number: 1234.'); + doTestMessage( + goog.partial(asserts.assertFunction, null, 'ouch %s', 1), + 'Assertion failed: ouch 1'); + }, + + // jslint:ignore end + testObject() { + const o = {}; + assertEquals(o, asserts.assertObject(o)); + assertThrows( + 'assertObject(null)', goog.partial(asserts.assertObject, null)); + // Test error messages. + doTestMessage( + goog.partial(asserts.assertObject, null), + 'Assertion failed: Expected object but got null: null.'); + doTestMessage( + goog.partial(asserts.assertObject, 1234), + 'Assertion failed: Expected object but got number: 1234.'); + doTestMessage( + goog.partial(asserts.assertObject, null, 'ouch %s', 1), + 'Assertion failed: ouch 1'); + }, + + testArray() { + const a = []; + assertEquals(a, asserts.assertArray(a)); + assertThrows('assertArray({})', goog.partial(asserts.assertArray, {})); + // Test error messages. + doTestMessage( + goog.partial(asserts.assertArray, null), + 'Assertion failed: Expected array but got null: null.'); + doTestMessage( + goog.partial(asserts.assertArray, 1234), + 'Assertion failed: Expected array but got number: 1234.'); + doTestMessage( + goog.partial(asserts.assertArray, null, 'ouch %s', 1), + 'Assertion failed: ouch 1'); + }, + + testBoolean() { + assertEquals(true, asserts.assertBoolean(true)); + assertEquals(false, asserts.assertBoolean(false)); + assertThrows(goog.partial(asserts.assertBoolean, null)); + assertThrows(goog.partial(asserts.assertBoolean, 'foo')); + + // Test error messages. + doTestMessage( + goog.partial(asserts.assertBoolean, null), + 'Assertion failed: Expected boolean but got null: null.'); + doTestMessage( + goog.partial(asserts.assertBoolean, 1234), + 'Assertion failed: Expected boolean but got number: 1234.'); + doTestMessage( + goog.partial(asserts.assertBoolean, null, 'ouch %s', 1), + 'Assertion failed: ouch 1'); + }, + + testElement() { + assertThrows(goog.partial(asserts.assertElement, null)); + assertThrows(goog.partial(asserts.assertElement, 'foo')); + assertThrows( + goog.partial(asserts.assertElement, dom.createTextNode('foo'))); + const elem = dom.createElement(TagName.DIV); + assertEquals(elem, asserts.assertElement(elem)); + }, + + testInstanceof() { + /** @constructor */ + let F = function() {}; + asserts.assertInstanceof(new F(), F); + const error = assertThrows( + 'assertInstanceof({}, F)', + goog.partial(asserts.assertInstanceof, {}, F)); + // IE lacks support for function.name and will fallback to toString(). + const object = /object/.test(error.message) ? '[object Object]' : 'Object'; + const name = /F/.test(error.message) ? 'F' : 'unknown type name'; + + // Test error messages. + doTestMessage( + goog.partial(asserts.assertInstanceof, {}, F), + `Assertion failed: Expected instanceof ${name} but got ${object}` + + '.'); + doTestMessage( + goog.partial(asserts.assertInstanceof, {}, F, 'a %s', 1), + 'Assertion failed: a 1'); + doTestMessage( + goog.partial(asserts.assertInstanceof, null, F), + `Assertion failed: Expected instanceof ${name} but got null.`); + doTestMessage( + goog.partial(asserts.assertInstanceof, 5, F), + 'Assertion failed: ' + + 'Expected instanceof ' + name + ' but got number.'); + + // Test a constructor a with a name (IE does not support function.name). + if (!userAgent.IE) { + F = function foo() {}; + doTestMessage( + goog.partial(asserts.assertInstanceof, {}, F), + `Assertion failed: Expected instanceof foo but got ${object}.`); + } + + // Test a constructor with a displayName. + F.displayName = 'bar'; + doTestMessage( + goog.partial(asserts.assertInstanceof, {}, F), + `Assertion failed: Expected instanceof bar but got ${object}.`); + }, + + testAssertionError() { + const error = new AssertionError('foo %s %s', [1, 'two']); + assertEquals('Wrong message', 'foo 1 two', error.message); + assertEquals('Wrong messagePattern', 'foo %s %s', error.messagePattern); + }, + + testFailWithCustomErrorHandler() { + try { + let handledException; + asserts.setErrorHandler((e) => { + handledException = e; + }); + + const expectedMessage = 'Failure: Gevalt!'; + + asserts.fail('Gevalt!'); + assertTrue('handledException is null.', handledException != null); + assertTrue( + `Message check failed. Expected: ${expectedMessage} Actual: ` + + handledException.message, + googString.startsWith(expectedMessage, handledException.message)); + } finally { + asserts.setErrorHandler(asserts.DEFAULT_ERROR_HANDLER); + } + }, + + testAssertWithCustomErrorHandler() { + try { + let handledException; + asserts.setErrorHandler((e) => { + handledException = e; + }); + + const expectedMessage = 'Assertion failed: Gevalt!'; + + asserts.assert(false, 'Gevalt!'); + assertTrue('handledException is null.', handledException != null); + assertTrue( + `Message check failed. Expected: ${expectedMessage} Actual: ` + + handledException.message, + googString.startsWith(expectedMessage, handledException.message)); + } finally { + asserts.setErrorHandler(asserts.DEFAULT_ERROR_HANDLER); + } + }, + + testAssertFinite() { + assertEquals(9, asserts.assertFinite(9)); + assertEquals(0, asserts.assertFinite(0)); + assertThrows(goog.partial(asserts.assertFinite, NaN)); + assertThrows(goog.partial(asserts.assertFinite, Infinity)); + assertThrows(goog.partial(asserts.assertFinite, -Infinity)); + assertThrows(goog.partial(asserts.assertFinite, 'foo')); + assertThrows(goog.partial(asserts.assertFinite, true)); + + // Test error messages. + doTestMessage( + goog.partial(asserts.assertFinite, NaN), + 'Assertion failed: Expected NaN to be a finite number but it is not.'); + doTestMessage( + goog.partial(asserts.assertFinite, NaN, 'ouch %s', 1), + 'Assertion failed: ouch 1'); + }, +}); diff --git a/closure/goog/asserts/dom.js b/closure/goog/asserts/dom.js new file mode 100644 index 0000000000..2355df206a --- /dev/null +++ b/closure/goog/asserts/dom.js @@ -0,0 +1,288 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.asserts.dom'); +goog.module.declareLegacyNamespace(); + +const TagName = goog.require('goog.dom.TagName'); +const asserts = goog.require('goog.asserts'); +const element = goog.require('goog.dom.element'); + +/** + * Checks if the value is a DOM Element if goog.asserts.ENABLE_ASSERTS is true. + * @param {*} value The value to check. + * @return {!Element} The value, likely to be a DOM Element when asserts are + * enabled. + * @throws {!asserts.AssertionError} When the value is not an Element. + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsElement = (value) => { + if (asserts.ENABLE_ASSERTS && !element.isElement(value)) { + asserts.fail( + `Argument is not an Element; got: ${debugStringForType(value)}`); + } + return /** @type {!Element} */ (value); +}; + +/** + * Checks if the value is a DOM HTMLElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value The value to check. + * @return {!HTMLElement} The value, likely to be a DOM HTMLElement when asserts + * are enabled. + * @throws {!asserts.AssertionError} When the value is not an HTMLElement. + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlElement = (value) => { + if (asserts.ENABLE_ASSERTS && !element.isHtmlElement(value)) { + asserts.fail( + `Argument is not an HTML Element; got: ${debugStringForType(value)}`); + } + return /** @type {!HTMLElement} */ (value); +}; + +/** + * Checks if the value is a DOM HTMLElement of the specified tag name / subclass + * if goog.asserts.ENABLE_ASSERTS is true. + * @param {*} value The value to check. + * @param {!TagName} tagName The element tagName to verify the value against. + * @return {T} The value, likely to be a DOM HTMLElement when asserts are + * enabled. The exact return type will match the parameterized type + * of the tagName as specified in goog.dom.TagName. + * @throws {!asserts.AssertionError} When the value is not an HTMLElement with + * the appropriate tagName. + * @template T + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlElementOfType = (value, tagName) => { + if (asserts.ENABLE_ASSERTS && !element.isHtmlElementOfType(value, tagName)) { + asserts.fail( + `Argument is not an HTML Element with tag name ` + + `${tagName.toString()}; got: ${debugStringForType(value)}`); + } + return /** @type {T} */ (value); +}; + +/** + * Checks if the value is an HTMLAnchorElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLAnchorElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlAnchorElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.A); +}; + +/** + * Checks if the value is an HTMLButtonElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLButtonElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlButtonElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.BUTTON); +}; + +/** + * Checks if the value is an HTMLLinkElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLLinkElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlLinkElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.LINK); +}; + +/** + * Checks if the value is an HTMLImageElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLImageElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlImageElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.IMG); +}; + +/** + * Checks if the value is an HTMLAudioElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLAudioElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlAudioElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.AUDIO); +}; + +/** + * Checks if the value is an HTMLVideoElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLVideoElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlVideoElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.VIDEO); +}; + +/** + * Checks if the value is an HTMLInputElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLInputElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlInputElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.INPUT); +}; + +/** + * Checks if the value is an HTMLTextAreaElement if goog.asserts.ENABLE_ASSERTS + * is true. + * @param {*} value + * @return {!HTMLTextAreaElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlTextAreaElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.TEXTAREA); +}; + +/** + * Checks if the value is an HTMLCanvasElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLCanvasElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlCanvasElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.CANVAS); +}; + +/** + * Checks if the value is an HTMLEmbedElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLEmbedElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlEmbedElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.EMBED); +}; + +/** + * Checks if the value is an HTMLFormElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLFormElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlFormElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.FORM); +}; + +/** + * Checks if the value is an HTMLFrameElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLFrameElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlFrameElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.FRAME); +}; + +/** + * Checks if the value is an HTMLIFrameElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLIFrameElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlIFrameElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.IFRAME); +}; + +/** + * Checks if the value is an HTMLObjectElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLObjectElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlObjectElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.OBJECT); +}; + +/** + * Checks if the value is an HTMLScriptElement if goog.asserts.ENABLE_ASSERTS is + * true. + * @param {*} value + * @return {!HTMLScriptElement} + * @throws {!asserts.AssertionError} + * @closurePrimitive {asserts.matchesReturn} + */ +const assertIsHtmlScriptElement = (value) => { + return assertIsHtmlElementOfType(value, TagName.SCRIPT); +}; + +/** + * Returns a string representation of a value's type. + * @param {*} value An object, or primitive. + * @return {string} The best display name for the value. + */ +const debugStringForType = (value) => { + if (goog.isObject(value)) { + try { + return /** @type {string|undefined} */ (value.constructor.displayName) || + value.constructor.name || + Object.prototype.toString.call(value); + } catch (e) { + return ''; + } + } else { + return value === undefined ? 'undefined' : + value === null ? 'null' : typeof value; + } +}; + +exports = { + assertIsElement, + assertIsHtmlElement, + assertIsHtmlElementOfType, + assertIsHtmlAnchorElement, + assertIsHtmlButtonElement, + assertIsHtmlLinkElement, + assertIsHtmlImageElement, + assertIsHtmlAudioElement, + assertIsHtmlVideoElement, + assertIsHtmlInputElement, + assertIsHtmlTextAreaElement, + assertIsHtmlCanvasElement, + assertIsHtmlEmbedElement, + assertIsHtmlFormElement, + assertIsHtmlFrameElement, + assertIsHtmlIFrameElement, + assertIsHtmlObjectElement, + assertIsHtmlScriptElement, +}; diff --git a/closure/goog/asserts/dom_test.js b/closure/goog/asserts/dom_test.js new file mode 100644 index 0000000000..6f6dd798ea --- /dev/null +++ b/closure/goog/asserts/dom_test.js @@ -0,0 +1,112 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.asserts.domTest'); +goog.setTestOnly(); + +const DomHelper = goog.require('goog.dom.DomHelper'); +const TagName = goog.require('goog.dom.TagName'); +const dom = goog.require('goog.asserts.dom'); +const testSuite = goog.require('goog.testing.testSuite'); + +/** + * @param {function():!Document} getDocument + * @return {!Object} + */ +const getTestsObject = (getDocument) => { + let domHelper; + let text; + let div; + let a; + let table; + let svg; + + return { + setUp() { + const doc = getDocument(); + domHelper = new DomHelper(doc); + text = domHelper.createTextNode('foo'); + div = domHelper.createElement(TagName.DIV); + a = domHelper.createElement(TagName.A); + table = domHelper.createElement(TagName.TABLE); + if (doc.createElementNS) { + svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg'); + } + }, + + testAssertIsElement() { + assertThrows(dom.assertIsElement.bind(null, text)); + + assertEquals(div, dom.assertIsElement(div)); + assertEquals(a, dom.assertIsElement(a)); + assertEquals(table, dom.assertIsElement(table)); + + if (svg) { + assertEquals(svg, dom.assertIsElement(svg)); + } + }, + + testAssertIsHtmlElement() { + assertThrows(dom.assertIsHtmlElement.bind(null, text)); + + assertEquals(div, dom.assertIsHtmlElement(div)); + assertEquals(a, dom.assertIsHtmlElement(a)); + assertEquals(table, dom.assertIsHtmlElement(table)); + + if (svg) { + assertThrows(dom.assertIsHtmlElement.bind(null, svg)); + } + }, + + testAssertIsHtmlElementOfType() { + assertThrows(dom.assertIsHtmlElementOfType.bind(null, text, TagName.DIV)); + + assertEquals(div, dom.assertIsHtmlElementOfType(div, TagName.DIV)); + assertThrows(dom.assertIsHtmlElementOfType.bind(null, div, TagName.A)); + + assertEquals(a, dom.assertIsHtmlElementOfType(a, TagName.A)); + assertThrows(dom.assertIsHtmlElementOfType.bind(null, a, TagName.DIV)); + + assertEquals(table, dom.assertIsHtmlElementOfType(table, TagName.TABLE)); + assertThrows( + dom.assertIsHtmlElementOfType.bind(null, table, TagName.DIV)); + + if (svg) { + assertThrows( + dom.assertIsHtmlElementOfType.bind(null, svg, TagName.DIV)); + } + }, + + testAssertIsHtmlAnchorElement() { + assertEquals(a, dom.assertIsHtmlAnchorElement(a)); + + assertThrows(dom.assertIsHtmlAnchorElement.bind(null, div)); + assertThrows(dom.assertIsHtmlAnchorElement.bind(null, table)); + + if (svg) { + assertThrows(dom.assertIsHtmlAnchorElement.bind(null, svg)); + } + }, + }; +}; + +/** + * Gets a secondary document to help expose differences in DOM ownership. + * @return {!Document} + */ +const getRemoteDocument = () => { + const domHelper = new DomHelper(); + const iframe = domHelper.createElement(TagName.IFRAME); + domHelper.appendChild(document.body, iframe); + const doc = iframe.contentWindow.document; + domHelper.removeNode(iframe); + return doc; +}; + +testSuite({ + testLocalDocument: getTestsObject(() => document), + testRemoteDocument: getTestsObject(getRemoteDocument), +}); diff --git a/closure/goog/async/BUILD b/closure/goog/async/BUILD new file mode 100644 index 0000000000..9653b3557c --- /dev/null +++ b/closure/goog/async/BUILD @@ -0,0 +1,119 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "animationdelay", + srcs = ["animationdelay.js"], + lenient = True, + deps = [ + "//closure/goog/disposable", + "//closure/goog/events", + "//closure/goog/functions", + ], +) + +closure_js_library( + name = "conditionaldelay", + srcs = ["conditionaldelay.js"], + lenient = True, + deps = [ + ":delay", + "//closure/goog/disposable", + ], +) + +closure_js_library( + name = "debouncer", + srcs = ["debouncer.js"], + lenient = True, + deps = [ + "//closure/goog/disposable", + "//closure/goog/timer", + ], +) + +closure_js_library( + name = "delay", + srcs = ["delay.js"], + lenient = True, + deps = [ + "//closure/goog/disposable", + "//closure/goog/timer", + ], +) + +closure_js_library( + name = "freelist", + srcs = ["freelist.js"], + lenient = True, +) + +closure_js_library( + name = "nexttick", + srcs = [ + "nexttick.js", + ], + lenient = True, + deps = [ + "//closure/goog/debug:entrypointregistry", + "//closure/goog/dom", + "//closure/goog/dom:tagname", + "//closure/goog/functions", + "//closure/goog/labs/useragent:browser", + "//closure/goog/labs/useragent:engine", + ], +) + +closure_js_library( + name = "throwexception", + srcs = [ + "throwexception.js", + ], + lenient = True, +) + +closure_js_library( + name = "promises", + srcs = ["promises.js"], + lenient = True, + deps = ["//closure/goog/promise"], +) + +closure_js_library( + name = "run", + srcs = ["run.js"], + lenient = True, + deps = [ + ":nexttick", + ":throwexception", + ":workqueue", + "//closure/goog/asserts", + "//closure/goog/debug:asyncstacktag", + ], +) + +closure_js_library( + name = "throttle", + srcs = [ + "legacy_throttle.js", + "throttle.js", + ], + lenient = True, + deps = [ + "//closure/goog/disposable", + "//closure/goog/timer", + ], +) + +closure_js_library( + name = "workqueue", + srcs = ["workqueue.js"], + lenient = True, + deps = [ + ":freelist", + "//closure/goog/asserts", + ], +) diff --git a/closure/goog/async/animationdelay.js b/closure/goog/async/animationdelay.js new file mode 100644 index 0000000000..09e0a1c310 --- /dev/null +++ b/closure/goog/async/animationdelay.js @@ -0,0 +1,273 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A delayed callback that pegs to the next animation frame + * instead of a user-configurable timeout. + */ + +goog.provide('goog.async.AnimationDelay'); + +goog.require('goog.Disposable'); +goog.require('goog.events'); +goog.require('goog.functions'); + + + +// TODO(nicksantos): Should we factor out the common code between this and +// goog.async.Delay? I'm not sure if there's enough code for this to really +// make sense. Subclassing seems like the wrong approach for a variety of +// reasons. Maybe there should be a common interface? + + + +/** + * A delayed callback that pegs to the next animation frame + * instead of a user configurable timeout. By design, this should have + * the same interface as goog.async.Delay. + * + * Uses requestAnimationFrame and friends when available, but falls + * back to a timeout of goog.async.AnimationDelay.TIMEOUT. + * + * For more on requestAnimationFrame and how you can use it to create smoother + * animations, see: + * @see http://paulirish.com/2011/requestanimationframe-for-smart-animating/ + * + * @param {function(this: THIS, number): void} listener Function to call + * when the delay completes. Will be passed the timestamp when it's called, + * in unix ms. + * @param {Window=} opt_window The window object to execute the delay in. + * Defaults to the global object. + * @param {THIS=} opt_handler The object scope to invoke the function in. + * @template THIS + * @constructor + * @struct + * @extends {goog.Disposable} + * @final + */ +goog.async.AnimationDelay = function(listener, opt_window, opt_handler) { + 'use strict'; + goog.async.AnimationDelay.base(this, 'constructor'); + + /** + * Identifier of the active delay timeout, or event listener, + * or null when inactive. + * @private {?goog.events.Key|number} + */ + this.id_ = null; + + /** + * If we're using dom listeners. + * @private {?boolean} + */ + this.usingListeners_ = false; + + /** + * The function that will be invoked after a delay. + * @const + * @private {function(this: THIS, number): void} + */ + this.listener_ = listener; + + /** + * The object context to invoke the callback in. + * @const + * @private {(THIS|undefined)} + */ + this.handler_ = opt_handler; + + /** + * @private {Window} + */ + this.win_ = opt_window || window; + + /** + * Cached callback function invoked when the delay finishes. + * @private {function()} + */ + this.callback_ = goog.bind(this.doAction_, this); +}; +goog.inherits(goog.async.AnimationDelay, goog.Disposable); + + +/** + * Default wait timeout for animations (in milliseconds). Only used for timed + * animation, which uses a timer (setTimeout) to schedule animation. + * + * @type {number} + * @const + */ +goog.async.AnimationDelay.TIMEOUT = 20; + + +/** + * Name of event received from the requestAnimationFrame in Firefox. + * + * @type {string} + * @const + * @private + */ +goog.async.AnimationDelay.MOZ_BEFORE_PAINT_EVENT_ = 'MozBeforePaint'; + + +/** + * Starts the delay timer. The provided listener function will be called + * before the next animation frame. + */ +goog.async.AnimationDelay.prototype.start = function() { + 'use strict'; + this.stop(); + this.usingListeners_ = false; + + var raf = this.getRaf_(); + var cancelRaf = this.getCancelRaf_(); + if (raf && !cancelRaf && this.win_.mozRequestAnimationFrame) { + // Because Firefox (Gecko) runs animation in separate threads, it also saves + // time by running the requestAnimationFrame callbacks in that same thread. + // Sadly this breaks the assumption of implicit thread-safety in JS, and can + // thus create thread-based inconsistencies on counters etc. + // + // Calling cycleAnimations_ using the MozBeforePaint event instead of as + // callback fixes this. + // + // Trigger this condition only if the mozRequestAnimationFrame is available, + // but not the W3C requestAnimationFrame function (as in draft) or the + // equivalent cancel functions. + this.id_ = goog.events.listen( + this.win_, goog.async.AnimationDelay.MOZ_BEFORE_PAINT_EVENT_, + this.callback_); + this.win_.mozRequestAnimationFrame(null); + this.usingListeners_ = true; + } else if (raf && cancelRaf) { + this.id_ = raf.call(this.win_, this.callback_); + } else { + this.id_ = this.win_.setTimeout( + // Prior to Firefox 13, Gecko passed a non-standard parameter + // to the callback that we want to ignore. + goog.functions.lock(this.callback_), goog.async.AnimationDelay.TIMEOUT); + } +}; + + +/** + * Starts the delay timer if it's not already active. + */ +goog.async.AnimationDelay.prototype.startIfNotActive = function() { + 'use strict'; + if (!this.isActive()) { + this.start(); + } +}; + + +/** + * Stops the delay timer if it is active. No action is taken if the timer is not + * in use. + */ +goog.async.AnimationDelay.prototype.stop = function() { + 'use strict'; + if (this.isActive()) { + var raf = this.getRaf_(); + var cancelRaf = this.getCancelRaf_(); + if (raf && !cancelRaf && this.win_.mozRequestAnimationFrame) { + goog.events.unlistenByKey(this.id_); + } else if (raf && cancelRaf) { + cancelRaf.call(this.win_, /** @type {number} */ (this.id_)); + } else { + this.win_.clearTimeout(/** @type {number} */ (this.id_)); + } + } + this.id_ = null; +}; + + +/** + * Fires delay's action even if timer has already gone off or has not been + * started yet; guarantees action firing. Stops the delay timer. + */ +goog.async.AnimationDelay.prototype.fire = function() { + 'use strict'; + this.stop(); + this.doAction_(); +}; + + +/** + * Fires delay's action only if timer is currently active. Stops the delay + * timer. + */ +goog.async.AnimationDelay.prototype.fireIfActive = function() { + 'use strict'; + if (this.isActive()) { + this.fire(); + } +}; + + +/** + * @return {boolean} True if the delay is currently active, false otherwise. + */ +goog.async.AnimationDelay.prototype.isActive = function() { + 'use strict'; + return this.id_ != null; +}; + + +/** + * Invokes the callback function after the delay successfully completes. + * @private + */ +goog.async.AnimationDelay.prototype.doAction_ = function() { + 'use strict'; + if (this.usingListeners_ && this.id_) { + goog.events.unlistenByKey(this.id_); + } + this.id_ = null; + + // We are not using the timestamp returned by requestAnimationFrame + // because it may be either a Date.now-style time or a + // high-resolution time (depending on browser implementation). Using + // goog.now() will ensure that the timestamp used is consistent and + // compatible with goog.fx.Animation. + this.listener_.call(this.handler_, goog.now()); +}; + + +/** @override */ +goog.async.AnimationDelay.prototype.disposeInternal = function() { + 'use strict'; + this.stop(); + goog.async.AnimationDelay.base(this, 'disposeInternal'); +}; + + +/** + * @return {?function(function(number)): number} The requestAnimationFrame + * function, or null if not available on this browser. + * @private + */ +goog.async.AnimationDelay.prototype.getRaf_ = function() { + 'use strict'; + var win = this.win_; + return win.requestAnimationFrame || win.webkitRequestAnimationFrame || + win.mozRequestAnimationFrame || win.oRequestAnimationFrame || + win.msRequestAnimationFrame || null; +}; + + +/** + * @return {?function(number): undefined} The cancelAnimationFrame function, + * or null if not available on this browser. + * @private + */ +goog.async.AnimationDelay.prototype.getCancelRaf_ = function() { + 'use strict'; + var win = this.win_; + return win.cancelAnimationFrame || win.cancelRequestAnimationFrame || + win.webkitCancelRequestAnimationFrame || + win.mozCancelRequestAnimationFrame || win.oCancelRequestAnimationFrame || + win.msCancelRequestAnimationFrame || null; +}; diff --git a/closure/goog/async/animationdelay_test.js b/closure/goog/async/animationdelay_test.js new file mode 100644 index 0000000000..e99202b6a6 --- /dev/null +++ b/closure/goog/async/animationdelay_test.js @@ -0,0 +1,84 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.async.AnimationDelayTest'); +goog.setTestOnly('goog.async.AnimationDelayTest'); + +const AnimationDelay = goog.require('goog.async.AnimationDelay'); +const Promise = goog.require('goog.Promise'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const Timer = goog.require('goog.Timer'); +const testSuite = goog.require('goog.testing.testSuite'); + +const TEST_DELAY = 50; +const stubs = new PropertyReplacer(); + +testSuite({ + tearDown: function() { + stubs.reset(); + }, + + testStart: function() { + let resolver = Promise.withResolver(); + const start = Date.now(); + const delay = new AnimationDelay(function(end) { + assertNotNull(resolver); // fail if called multiple times + resolver.resolve(); + resolver = null; + }); + + delay.start(); + + return resolver.promise; + }, + + testStop: function() { + const resolver = Promise.withResolver(); + const start = Date.now(); + const delay = new AnimationDelay(function(end) { + resolver.reject(); + }); + + delay.start(); + delay.stop(); + + return Timer.promise(TEST_DELAY).then(function() { + resolver.resolve(); + return resolver.promise; + }); + }, + + testAlwaysUseGoogNowForHandlerTimestamp: function() { + const resolver = Promise.withResolver(); + const expectedValue = 12345.1; + stubs.set(Date, 'now', function() { return expectedValue; }); + + const delay = new AnimationDelay(function(timestamp) { + assertEquals(expectedValue, timestamp); + resolver.resolve(); + }); + + delay.start(); + + return resolver.promise; + }, + + testStartIfActive: function() { + const delay = new AnimationDelay(() => {}); + delay.start(); + + let startWasCalled = false; + stubs.set(AnimationDelay.prototype, 'start', function() { + startWasCalled = true; + }); + + delay.startIfNotActive(); + assertEquals(startWasCalled, false); + delay.stop(); + delay.startIfNotActive(); + assertEquals(startWasCalled, true); + } +}); diff --git a/closure/goog/async/conditionaldelay.js b/closure/goog/async/conditionaldelay.js new file mode 100644 index 0000000000..c0f2f0d4e1 --- /dev/null +++ b/closure/goog/async/conditionaldelay.js @@ -0,0 +1,225 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Defines a class useful for handling functions that must be + * invoked later when some condition holds. Examples include deferred function + * calls that return a boolean flag whether it succedeed or not. + * + * Example: + * + * function deferred() { + * var succeeded = false; + * // ... custom code + * return succeeded; + * } + * + * var deferredCall = new goog.async.ConditionalDelay(deferred); + * deferredCall.onSuccess = function() { + * alert('Success: The deferred function has been successfully executed.'); + * } + * deferredCall.onFailure = function() { + * alert('Failure: Time limit exceeded.'); + * } + * + * // Call the deferred() every 100 msec until it returns true, + * // or 5 seconds pass. + * deferredCall.start(100, 5000); + * + * // Stop the deferred function call (does nothing if it's not active). + * deferredCall.stop(); + */ + + +goog.provide('goog.async.ConditionalDelay'); + +goog.require('goog.Disposable'); +goog.require('goog.async.Delay'); + + + +/** + * A ConditionalDelay object invokes the associated function after a specified + * interval delay and checks its return value. If the function returns + * `true` the conditional delay is cancelled and {@see #onSuccess} + * is called. Otherwise this object keeps to invoke the deferred function until + * either it returns `true` or the timeout is exceeded. In the latter case + * the {@see #onFailure} method will be called. + * + * The interval duration and timeout can be specified each time the delay is + * started. Calling start on an active delay will reset the timer. + * + * @param {function():boolean} listener Function to call when the delay + * completes. Should return a value that type-converts to `true` if + * the call succeeded and this delay should be stopped. + * @param {Object=} opt_handler The object scope to invoke the function in. + * @constructor + * @struct + * @extends {goog.Disposable} + */ +goog.async.ConditionalDelay = function(listener, opt_handler) { + 'use strict'; + goog.async.ConditionalDelay.base(this, 'constructor'); + + /** + * The delay interval in milliseconds to between the calls to the callback. + * Note, that the callback may be invoked earlier than this interval if the + * timeout is exceeded. + * @private {number} + */ + this.interval_ = 0; + + /** + * The timeout timestamp until which the delay is to be executed. + * A negative value means no timeout. + * @private {number} + */ + this.runUntil_ = 0; + + /** + * True if the listener has been executed, and it returned `true`. + * @private {boolean} + */ + this.isDone_ = false; + + /** + * The function that will be invoked after a delay. + * @private {function():boolean} + */ + this.listener_ = listener; + + /** + * The object context to invoke the callback in. + * @private {Object|undefined} + */ + this.handler_ = opt_handler; + + /** + * The underlying goog.async.Delay delegate object. + * @private {goog.async.Delay} + */ + this.delay_ = new goog.async.Delay( + goog.bind(this.onTick_, this), 0 /*interval*/, this /*scope*/); +}; +goog.inherits(goog.async.ConditionalDelay, goog.Disposable); + + +/** @override */ +goog.async.ConditionalDelay.prototype.disposeInternal = function() { + 'use strict'; + this.delay_.dispose(); + delete this.listener_; + delete this.handler_; + goog.async.ConditionalDelay.superClass_.disposeInternal.call(this); +}; + + +/** + * Starts the delay timer. The provided listener function will be called + * repeatedly after the specified interval until the function returns + * `true` or the timeout is exceeded. Calling start on an active timer + * will stop the timer first. + * @param {number=} opt_interval The time interval between the function + * invocations (in milliseconds). Default is 0. + * @param {number=} opt_timeout The timeout interval (in milliseconds). Takes + * precedence over the `opt_interval`, i.e. if the timeout is less + * than the invocation interval, the function will be called when the + * timeout is exceeded. A negative value means no timeout. Default is 0. + */ +goog.async.ConditionalDelay.prototype.start = function( + opt_interval, opt_timeout) { + 'use strict'; + this.stop(); + this.isDone_ = false; + + var timeout = opt_timeout || 0; + this.interval_ = Math.max(opt_interval || 0, 0); + this.runUntil_ = timeout < 0 ? -1 : (goog.now() + timeout); + this.delay_.start( + timeout < 0 ? this.interval_ : Math.min(this.interval_, timeout)); +}; + + +/** + * Stops the delay timer if it is active. No action is taken if the timer is not + * in use. + */ +goog.async.ConditionalDelay.prototype.stop = function() { + 'use strict'; + this.delay_.stop(); +}; + + +/** + * @return {boolean} True if the delay is currently active, false otherwise. + */ +goog.async.ConditionalDelay.prototype.isActive = function() { + 'use strict'; + return this.delay_.isActive(); +}; + + +/** + * @return {boolean} True if the listener has been executed and returned + * `true` since the last call to {@see #start}. + */ +goog.async.ConditionalDelay.prototype.isDone = function() { + 'use strict'; + return this.isDone_; +}; + + +/** + * Called when the listener has been successfully executed and returned + * `true`. The {@see #isDone} method should return `true` by now. + * Designed for inheritance, should be overridden by subclasses or on the + * instances if they care. + */ +goog.async.ConditionalDelay.prototype.onSuccess = function() { + // Do nothing by default. +}; + + +/** + * Called when this delayed call is cancelled because the timeout has been + * exceeded, and the listener has never returned `true`. + * Designed for inheritance, should be overridden by subclasses or on the + * instances if they care. + */ +goog.async.ConditionalDelay.prototype.onFailure = function() { + // Do nothing by default. +}; + + +/** + * A callback function for the underlying `goog.async.Delay` object. When + * executed the listener function is called, and if it returns `true` + * the delay is stopped and the {@see #onSuccess} method is invoked. + * If the timeout is exceeded the delay is stopped and the + * {@see #onFailure} method is called. + * @private + */ +goog.async.ConditionalDelay.prototype.onTick_ = function() { + 'use strict'; + var successful = this.listener_.call(this.handler_); + if (successful) { + this.isDone_ = true; + this.onSuccess(); + } else { + // Try to reschedule the task. + if (this.runUntil_ < 0) { + // No timeout. + this.delay_.start(this.interval_); + } else { + var timeLeft = this.runUntil_ - goog.now(); + if (timeLeft <= 0) { + this.onFailure(); + } else { + this.delay_.start(Math.min(this.interval_, timeLeft)); + } + } + } +}; diff --git a/closure/goog/async/conditionaldelay_test.js b/closure/goog/async/conditionaldelay_test.js new file mode 100644 index 0000000000..c15d959d34 --- /dev/null +++ b/closure/goog/async/conditionaldelay_test.js @@ -0,0 +1,205 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ +goog.module('goog.async.ConditionalDelayTest'); +goog.setTestOnly(); + +const ConditionalDelay = goog.require('goog.async.ConditionalDelay'); +const MockClock = goog.require('goog.testing.MockClock'); +const testSuite = goog.require('goog.testing.testSuite'); + +let invoked = false; +let delay = null; +let clock = null; +let returnValue = true; +let onSuccessCalled = false; +let onFailureCalled = false; + +function callback() { + invoked = true; + return returnValue; +} + +testSuite({ + setUp() { + clock = new MockClock(true); + invoked = false; + returnValue = true; + onSuccessCalled = false; + onFailureCalled = false; + delay = new ConditionalDelay(callback); + delay.onSuccess = () => { + onSuccessCalled = true; + }; + delay.onFailure = () => { + onFailureCalled = true; + }; + }, + + tearDown() { + clock.dispose(); + delay.dispose(); + }, + + testDelay() { + delay.start(200, 200); + assertFalse(invoked); + + clock.tick(100); + assertFalse(invoked); + + clock.tick(100); + assertTrue(invoked); + }, + + testStop() { + delay.start(200, 500); + assertTrue(delay.isActive()); + + clock.tick(100); + assertFalse(invoked); + + delay.stop(); + clock.tick(100); + assertFalse(invoked); + + assertFalse(delay.isActive()); + }, + + testIsActive() { + assertFalse(delay.isActive()); + delay.start(200, 200); + assertTrue(delay.isActive()); + clock.tick(200); + assertFalse(delay.isActive()); + }, + + testRestart() { + delay.start(200, 50000); + clock.tick(100); + + delay.stop(); + assertFalse(invoked); + + delay.start(200, 50000); + clock.tick(199); + assertFalse(invoked); + + clock.tick(1); + assertTrue(invoked); + + invoked = false; + delay.start(200, 200); + clock.tick(200); + assertTrue(invoked); + + assertFalse(delay.isActive()); + }, + + testDispose() { + delay.start(200, 200); + delay.dispose(); + assertTrue(delay.isDisposed()); + + clock.tick(500); + assertFalse(invoked); + }, + + testConditionalDelay_Success() { + returnValue = false; + delay.start(100, 300); + + clock.tick(99); + assertFalse(invoked); + clock.tick(1); + assertTrue(invoked); + + assertTrue(delay.isActive()); + assertFalse(delay.isDone()); + assertFalse(onSuccessCalled); + assertFalse(onFailureCalled); + + returnValue = true; + + invoked = false; + clock.tick(100); + assertTrue(invoked); + + assertFalse(delay.isActive()); + assertTrue(delay.isDone()); + assertTrue(onSuccessCalled); + assertFalse(onFailureCalled); + + invoked = false; + clock.tick(200); + assertFalse(invoked); + }, + + testConditionalDelay_Failure() { + returnValue = false; + delay.start(100, 300); + + clock.tick(99); + assertFalse(invoked); + clock.tick(1); + assertTrue(invoked); + + assertTrue(delay.isActive()); + assertFalse(delay.isDone()); + assertFalse(onSuccessCalled); + assertFalse(onFailureCalled); + + invoked = false; + clock.tick(100); + assertTrue(invoked); + assertFalse(onSuccessCalled); + assertFalse(onFailureCalled); + + invoked = false; + clock.tick(90); + assertFalse(invoked); + clock.tick(10); + assertTrue(invoked); + + assertFalse(delay.isActive()); + assertFalse(delay.isDone()); + assertFalse(onSuccessCalled); + assertTrue(onFailureCalled); + }, + + testInfiniteDelay() { + returnValue = false; + delay.start(100, -1); + + // Test in a big enough loop. + for (let i = 0; i < 1000; ++i) { + clock.tick(80); + assertTrue(delay.isActive()); + assertFalse(delay.isDone()); + assertFalse(onSuccessCalled); + assertFalse(onFailureCalled); + } + + delay.stop(); + assertFalse(delay.isActive()); + assertFalse(delay.isDone()); + assertFalse(onSuccessCalled); + assertFalse(onFailureCalled); + }, + + testCallbackScope() { + let callbackCalled = false; + const scopeObject = {}; + function internalCallback() { + assertEquals(this, scopeObject); + callbackCalled = true; + return true; + } + delay = new ConditionalDelay(internalCallback, scopeObject); + delay.start(200, 200); + clock.tick(201); + assertTrue(callbackCalled); + }, +}); diff --git a/closure/goog/async/debouncer.js b/closure/goog/async/debouncer.js new file mode 100644 index 0000000000..8214d3ee15 --- /dev/null +++ b/closure/goog/async/debouncer.js @@ -0,0 +1,216 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Definition of the goog.async.Debouncer class. + * + * @see ../demos/timers.html + */ + +goog.provide('goog.async.Debouncer'); + +goog.require('goog.Disposable'); +goog.require('goog.Timer'); + + + +/** + * Debouncer will perform a specified action exactly once for any sequence of + * signals fired repeatedly so long as they are fired less than a specified + * interval apart (in milliseconds). Whether it receives one signal or multiple, + * it will always wait until a full interval has elapsed since the last signal + * before performing the action. + * @param {function(this: T, ...?)} listener Function to callback when the + * action is triggered. + * @param {number} interval Interval over which to debounce. The listener will + * only be called after the full interval has elapsed since the last signal. + * @param {T=} opt_handler Object in whose scope to call the listener. + * @constructor + * @struct + * @extends {goog.Disposable} + * @final + * @template T + */ +goog.async.Debouncer = function(listener, interval, opt_handler) { + 'use strict'; + goog.async.Debouncer.base(this, 'constructor'); + + /** + * Function to callback + * @const @private {function(this: T, ...?)} + */ + this.listener_ = + opt_handler != null ? goog.bind(listener, opt_handler) : listener; + + /** + * Interval for the debounce time + * @const @private {number} + */ + this.interval_ = interval; + + /** + * Cached callback function invoked after the debounce timeout completes + * @const @private {!Function} + */ + this.callback_ = goog.bind(this.onTimer_, this); + + /** + * Indicates that the action is pending and needs to be fired. + * @private {boolean} + */ + this.shouldFire_ = false; + + /** + * Indicates the count of nested pauses currently in effect on the debouncer. + * When this count is not zero, fired actions will be postponed until the + * debouncer is resumed enough times to drop the pause count to zero. + * @private {number} + */ + this.pauseCount_ = 0; + + /** + * Timer for scheduling the next callback + * @private {?number} + */ + this.timer_ = null; + + /** + * When set this is a timestamp. On the onfire we want to reschedule the + * callback so it ends up at this time. + * @private {?number} + */ + this.refireAt_ = null; + + /** + * The last arguments passed into `fire`. + * @private {!IArrayLike} + */ + this.args_ = []; +}; +goog.inherits(goog.async.Debouncer, goog.Disposable); + + +/** + * Notifies the debouncer that the action has happened. It will debounce the + * call so that the callback is only called after the last action in a sequence + * of actions separated by periods less the interval parameter passed to the + * constructor, passing the arguments from the last call of this function into + * the debounced function. + * @param {...?} var_args Arguments to pass on to the debounced function. + */ +goog.async.Debouncer.prototype.fire = function(var_args) { + 'use strict'; + this.args_ = arguments; + // When this method is called, we need to prevent fire() calls from within the + // previous interval from calling the callback. The simplest way of doing this + // is to call this.stop() which calls clearTimeout, and then reschedule the + // timeout. However clearTimeout and setTimeout are expensive, so we just + // leave them untouched and when they do happen we potentially reschedule. + this.shouldFire_ = false; + if (this.timer_) { + this.refireAt_ = goog.now() + this.interval_; + return; + } + this.timer_ = goog.Timer.callOnce(this.callback_, this.interval_); +}; + + +/** + * Cancels any pending action callback. The debouncer can be restarted by + * calling {@link #fire}. + */ +goog.async.Debouncer.prototype.stop = function() { + 'use strict'; + this.clearTimer_(); + this.refireAt_ = null; + this.shouldFire_ = false; + this.args_ = []; +}; + + +/** + * Pauses the debouncer. All pending and future action callbacks will be delayed + * until the debouncer is resumed. Pauses can be nested. + */ +goog.async.Debouncer.prototype.pause = function() { + 'use strict'; + ++this.pauseCount_; +}; + + +/** + * Resumes the debouncer. If doing so drops the pausing count to zero, pending + * action callbacks will be executed as soon as possible, but still no sooner + * than an interval's delay after the previous call. Future action callbacks + * will be executed as normal. + */ +goog.async.Debouncer.prototype.resume = function() { + 'use strict'; + if (!this.pauseCount_) { + return; + } + + --this.pauseCount_; + if (!this.pauseCount_ && this.shouldFire_) { + this.doAction_(); + } +}; + + +/** @override */ +goog.async.Debouncer.prototype.disposeInternal = function() { + 'use strict'; + this.stop(); + goog.async.Debouncer.base(this, 'disposeInternal'); +}; + + +/** + * Handler for the timer to fire the debouncer. + * @private + */ +goog.async.Debouncer.prototype.onTimer_ = function() { + 'use strict'; + this.clearTimer_(); + // There is a newer call to fire() within the debounce interval. + // Reschedule the callback and return. + if (this.refireAt_) { + this.timer_ = + goog.Timer.callOnce(this.callback_, this.refireAt_ - goog.now()); + this.refireAt_ = null; + return; + } + + if (!this.pauseCount_) { + this.doAction_(); + } else { + this.shouldFire_ = true; + } +}; + + +/** + * Cleans the initialized timer. + * @private + */ +goog.async.Debouncer.prototype.clearTimer_ = function() { + 'use strict'; + if (this.timer_) { + goog.Timer.clear(this.timer_); + this.timer_ = null; + } +}; + + +/** + * Calls the callback. + * @private + */ +goog.async.Debouncer.prototype.doAction_ = function() { + 'use strict'; + this.shouldFire_ = false; + this.listener_.apply(null, this.args_); +}; diff --git a/closure/goog/async/debouncer_test.js b/closure/goog/async/debouncer_test.js new file mode 100644 index 0000000000..241e47cd96 --- /dev/null +++ b/closure/goog/async/debouncer_test.js @@ -0,0 +1,177 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.async.DebouncerTest'); +goog.setTestOnly(); + +const Debouncer = goog.require('goog.async.Debouncer'); +const MockClock = goog.require('goog.testing.MockClock'); +const recordFunction = goog.require('goog.testing.recordFunction'); +const testSuite = goog.require('goog.testing.testSuite'); + +testSuite({ + testDebouncerCommandSequences() { + // Encoded sequences of commands to perform mapped to expected # of calls. + // f: fire + // w: wait (for the debouncing timer to elapse) + // p: pause + // r: resume + // s: stop + const expectedCommandSequenceCalls = { + 'f': 0, + 'ff': 0, + 'fw': 1, + 'ffw': 1, + 'fpr': 0, + 'fsf': 0, + 'fsw': 0, + 'fprw': 1, + 'fpwr': 1, + 'fsfw': 1, + 'fswf': 0, + 'fprfw': 1, + 'fprsw': 0, + 'fpswr': 0, + 'fpwfr': 0, + 'fpwsr': 0, + 'fswfw': 1, + 'fpswrw': 0, + 'fpwfrw': 1, + 'fpwsfr': 0, + 'fpwsrw': 0, + 'fspwrw': 0, + 'fpwsfrw': 1, + 'ffwfwfffw': 3, + }; + const interval = 500; + + const mockClock = new MockClock(true); + for (let commandSequence in expectedCommandSequenceCalls) { + const recordFn = recordFunction(); + const debouncer = new Debouncer(recordFn, interval); + + for (let i = 0; i < commandSequence.length; ++i) { + switch (commandSequence[i]) { + case 'f': + debouncer.fire(); + break; + case 'w': + mockClock.tick(interval); + break; + case 'p': + debouncer.pause(); + break; + case 'r': + debouncer.resume(); + break; + case 's': + debouncer.stop(); + break; + } + } + + const expectedCalls = expectedCommandSequenceCalls[commandSequence]; + assertEquals( + `Expected ${expectedCalls} calls for command sequence "` + + commandSequence + '" (' + + Array.prototype.map + .call( + commandSequence, + command => { + switch (command) { + case 'f': + return 'fire'; + case 'w': + return 'wait'; + case 'p': + return 'pause'; + case 'r': + return 'resume'; + case 's': + return 'stop'; + } + }) + .join(' -> ') + + ')', + expectedCalls, recordFn.getCallCount()); + debouncer.dispose(); + } + mockClock.uninstall(); + }, + + testDebouncerScopeBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + const x = {'y': 0}; + const debouncer = new Debouncer(function() { + ++this['y']; + }, interval, x); + debouncer.fire(); + assertEquals(0, x['y']); + + mockClock.tick(interval); + assertEquals(1, x['y']); + + mockClock.uninstall(); + }, + + testDebouncerArgumentBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + let calls = 0; + const debouncer = new Debouncer((a, b, c) => { + ++calls; + assertEquals(3, a); + assertEquals('string', b); + assertEquals(false, c); + }, interval); + + debouncer.fire(3, 'string', false); + mockClock.tick(interval); + assertEquals(1, calls); + + // fire should always pass the last arguments passed to it into the + // decorated function, even if called multiple times. + debouncer.fire(); + mockClock.tick(interval / 2); + debouncer.fire(8, null, true); + debouncer.fire(3, 'string', false); + mockClock.tick(interval); + assertEquals(2, calls); + + mockClock.uninstall(); + }, + + testDebouncerArgumentAndScopeBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + const x = {'calls': 0}; + const debouncer = new Debouncer(function(a, b, c) { + ++this['calls']; + assertEquals(3, a); + assertEquals('string', b); + assertEquals(false, c); + }, interval, x); + + debouncer.fire(3, 'string', false); + mockClock.tick(interval); + assertEquals(1, x['calls']); + + // fire should always pass the last arguments passed to it into the + // decorated function, even if called multiple times. + debouncer.fire(); + mockClock.tick(interval / 2); + debouncer.fire(8, null, true); + debouncer.fire(3, 'string', false); + mockClock.tick(interval); + assertEquals(2, x['calls']); + + mockClock.uninstall(); + }, +}); diff --git a/closure/goog/async/delay.js b/closure/goog/async/delay.js new file mode 100644 index 0000000000..f3a0cad4d3 --- /dev/null +++ b/closure/goog/async/delay.js @@ -0,0 +1,183 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Defines a class useful for handling functions that must be + * invoked after a delay, especially when that delay is frequently restarted. + * Examples include delaying before displaying a tooltip, menu hysteresis, + * idle timers, etc. + * @see ../demos/timers.html + */ + + +goog.provide('goog.async.Delay'); + +goog.require('goog.Disposable'); +goog.require('goog.Timer'); + + + +/** + * A Delay object invokes the associated function after a specified delay. The + * interval duration can be specified once in the constructor, or can be defined + * each time the delay is started. Calling start on an active delay will reset + * the timer. + * + * @param {function(this:THIS)} listener Function to call when the + * delay completes. + * @param {number=} opt_interval The default length of the invocation delay (in + * milliseconds). + * @param {THIS=} opt_handler The object scope to invoke the function in. + * @template THIS + * @constructor + * @struct + * @extends {goog.Disposable} + * @final + */ +goog.async.Delay = function(listener, opt_interval, opt_handler) { + 'use strict'; + goog.async.Delay.base(this, 'constructor'); + + /** + * The function that will be invoked after a delay. + * @private {function(this:THIS)} + */ + this.listener_ = listener; + + /** + * The default amount of time to delay before invoking the callback. + * @type {number} + * @private + */ + this.interval_ = opt_interval || 0; + + /** + * The object context to invoke the callback in. + * @type {Object|undefined} + * @private + */ + this.handler_ = opt_handler; + + + /** + * Cached callback function invoked when the delay finishes. + * @type {Function} + * @private + */ + this.callback_ = goog.bind(this.doAction_, this); +}; +goog.inherits(goog.async.Delay, goog.Disposable); + + +/** + * Identifier of the active delay timeout, or 0 when inactive. + * @type {number} + * @private + */ +goog.async.Delay.prototype.id_ = 0; + + +/** + * Disposes of the object, cancelling the timeout if it is still outstanding and + * removing all object references. + * @override + * @protected + */ +goog.async.Delay.prototype.disposeInternal = function() { + 'use strict'; + goog.async.Delay.base(this, 'disposeInternal'); + this.stop(); + delete this.listener_; + delete this.handler_; +}; + + +/** + * Starts the delay timer. The provided listener function will be called after + * the specified interval. Calling start on an active timer will reset the + * delay interval. + * @param {number=} opt_interval If specified, overrides the object's default + * interval with this one (in milliseconds). + */ +goog.async.Delay.prototype.start = function(opt_interval) { + 'use strict'; + this.stop(); + this.id_ = goog.Timer.callOnce( + this.callback_, + opt_interval !== undefined ? opt_interval : this.interval_); +}; + + +/** + * Starts the delay timer if it's not already active. + * @param {number=} opt_interval If specified and the timer is not already + * active, overrides the object's default interval with this one (in + * milliseconds). + */ +goog.async.Delay.prototype.startIfNotActive = function(opt_interval) { + 'use strict'; + if (!this.isActive()) { + this.start(opt_interval); + } +}; + + +/** + * Stops the delay timer if it is active. No action is taken if the timer is not + * in use. + */ +goog.async.Delay.prototype.stop = function() { + 'use strict'; + if (this.isActive()) { + goog.Timer.clear(this.id_); + } + this.id_ = 0; +}; + + +/** + * Fires delay's action even if timer has already gone off or has not been + * started yet; guarantees action firing. Stops the delay timer. + */ +goog.async.Delay.prototype.fire = function() { + 'use strict'; + this.stop(); + this.doAction_(); +}; + + +/** + * Fires delay's action only if timer is currently active. Stops the delay + * timer. + */ +goog.async.Delay.prototype.fireIfActive = function() { + 'use strict'; + if (this.isActive()) { + this.fire(); + } +}; + + +/** + * @return {boolean} True if the delay is currently active, false otherwise. + */ +goog.async.Delay.prototype.isActive = function() { + 'use strict'; + return this.id_ != 0; +}; + + +/** + * Invokes the callback function after the delay successfully completes. + * @private + */ +goog.async.Delay.prototype.doAction_ = function() { + 'use strict'; + this.id_ = 0; + if (this.listener_) { + this.listener_.call(this.handler_); + } +}; diff --git a/closure/goog/async/delay_test.js b/closure/goog/async/delay_test.js new file mode 100644 index 0000000000..cf62eb88b0 --- /dev/null +++ b/closure/goog/async/delay_test.js @@ -0,0 +1,157 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ +goog.module('goog.async.DelayTest'); +goog.setTestOnly(); + +const Delay = goog.require('goog.async.Delay'); +const MockClock = goog.require('goog.testing.MockClock'); +const testSuite = goog.require('goog.testing.testSuite'); + +let invoked = false; +let delay = null; +let clock = null; + +function callback() { + invoked = true; +} + +testSuite({ + setUp() { + clock = new MockClock(true); + invoked = false; + delay = new Delay(callback, 200); + }, + + tearDown() { + clock.dispose(); + delay.dispose(); + }, + + testDelay() { + delay.start(); + assertFalse(invoked); + + clock.tick(100); + assertFalse(invoked); + + clock.tick(100); + assertTrue(invoked); + }, + + testStop() { + delay.start(); + + clock.tick(100); + assertFalse(invoked); + + delay.stop(); + clock.tick(100); + assertFalse(invoked); + }, + + testIsActive() { + assertFalse(delay.isActive()); + delay.start(); + assertTrue(delay.isActive()); + clock.tick(200); + assertFalse(delay.isActive()); + }, + + testRestart() { + delay.start(); + clock.tick(100); + + delay.stop(); + assertFalse(invoked); + + delay.start(); + clock.tick(199); + assertFalse(invoked); + + clock.tick(1); + assertTrue(invoked); + + invoked = false; + delay.start(); + clock.tick(200); + assertTrue(invoked); + }, + + testStartIfNotActive() { + delay.startIfNotActive(); + clock.tick(100); + + delay.stop(); + assertFalse(invoked); + + delay.startIfNotActive(); + clock.tick(199); + assertFalse(invoked); + + clock.tick(1); + assertTrue(invoked); + + invoked = false; + delay.start(); + clock.tick(199); + + assertFalse(invoked); + + delay.startIfNotActive(); + clock.tick(1); + + assertTrue(invoked); + }, + + testOverride() { + delay.start(50); + clock.tick(49); + assertFalse(invoked); + + clock.tick(1); + assertTrue(invoked); + }, + + testDispose() { + delay.start(); + delay.dispose(); + assertTrue(delay.isDisposed()); + + clock.tick(500); + assertFalse(invoked); + }, + + testFire() { + delay.start(); + + clock.tick(50); + delay.fire(); + assertTrue(invoked); + assertFalse(delay.isActive()); + + invoked = false; + clock.tick(200); + assertFalse( + 'Delay fired early with fire call, timeout should have been ' + + 'cleared', + invoked); + }, + + testFireIfActive() { + delay.fireIfActive(); + assertFalse(invoked); + + delay.start(); + delay.fireIfActive(); + assertTrue(invoked); + invoked = false; + clock.tick(300); + assertFalse( + 'Delay fired early with fireIfActive, timeout should have been ' + + 'cleared', + invoked); + }, +}); diff --git a/closure/goog/async/freelist.js b/closure/goog/async/freelist.js new file mode 100644 index 0000000000..24abeb03c8 --- /dev/null +++ b/closure/goog/async/freelist.js @@ -0,0 +1,72 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Simple freelist. + * + * An anterative to goog.structs.SimplePool, it imposes the requirement that the + * objects in the list contain a "next" property that can be used to maintain + * the pool. + */ +goog.module('goog.async.FreeList'); +goog.module.declareLegacyNamespace(); + +/** @template ITEM */ +class FreeList { + /** + * @param {function():ITEM} create + * @param {function(ITEM):void} reset + * @param {number} limit + */ + constructor(create, reset, limit) { + /** @private @const {number} */ + this.limit_ = limit; + /** @private @const {function()} */ + this.create_ = create; + /** @private @const {function(ITEM):void} */ + this.reset_ = reset; + + /** @private {number} */ + this.occupants_ = 0; + /** @private {ITEM} */ + this.head_ = null; + } + + /** @return {ITEM} */ + get() { + let item; + if (this.occupants_ > 0) { + this.occupants_--; + item = this.head_; + this.head_ = item.next; + item.next = null; + } else { + item = this.create_(); + } + return item; + } + + /** @param {ITEM} item An item available for possible future reuse. */ + put(item) { + this.reset_(item); + if (this.occupants_ < this.limit_) { + this.occupants_++; + item.next = this.head_; + this.head_ = item; + } + } + + /** + * Visible for testing. + * @return {number} + * @package + */ + occupants() { + return this.occupants_; + } +} + +exports = FreeList; diff --git a/closure/goog/async/freelist_test.js b/closure/goog/async/freelist_test.js new file mode 100644 index 0000000000..f80038b458 --- /dev/null +++ b/closure/goog/async/freelist_test.js @@ -0,0 +1,72 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.async.FreeListTest'); +goog.setTestOnly(); + +const FreeList = goog.require('goog.async.FreeList'); +const testSuite = goog.require('goog.testing.testSuite'); + +let id = 0; +let list = null; + +testSuite({ + setUp() { + let id = 0; + let data = 1; + list = new FreeList( + () => { + data *= 2; + return {id: id++, data: data, next: null}; + }, + (item) => { + item.data = null; + }, + 2); // max occupancy + }, + + tearDown() { + list = null; + }, + + testItemsCreatedAsNeeded() { + assertEquals(0, list.occupants()); + const item1 = list.get(); + assertNotNullNorUndefined(item1); + const item2 = list.get(); + assertNotNullNorUndefined(item2); + assertNotEquals(item1, item2); + assertEquals(0, list.occupants()); + }, + + testMaxOccupancy() { + assertEquals(0, list.occupants()); + const item1 = list.get(); + const item2 = list.get(); + const item3 = list.get(); + + list.put(item1); + list.put(item2); + list.put(item3); + + assertEquals(2, list.occupants()); + }, + + testRecycling() { + assertEquals(0, list.occupants()); + const item1 = list.get(); + assertNotNull(item1.data); + + list.put(item1); + + const item2 = list.get(); + + // Item recycled + assertEquals(item1, item2); + // reset method called + assertNull(item2.data); + }, +}); diff --git a/closure/goog/async/legacy_throttle.js b/closure/goog/async/legacy_throttle.js new file mode 100644 index 0000000000..8046e58e9b --- /dev/null +++ b/closure/goog/async/legacy_throttle.js @@ -0,0 +1,16 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Provides a deprecated alias for goog.async.Throttle + * @deprecated Use goog.async.Throttle instead. + */ +goog.module('goog.Throttle'); +goog.module.declareLegacyNamespace(); + +const Throttle = goog.require('goog.async.Throttle'); + +exports = Throttle; diff --git a/closure/goog/async/nexttick.js b/closure/goog/async/nexttick.js new file mode 100644 index 0000000000..e82a23cab5 --- /dev/null +++ b/closure/goog/async/nexttick.js @@ -0,0 +1,236 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Provides a function to schedule running a function as soon + * as possible after the current JS execution stops and yields to the event + * loop. + */ + +goog.provide('goog.async.nextTick'); + +goog.require('goog.debug.entryPointRegistry'); +goog.require('goog.dom'); +goog.require('goog.dom.TagName'); +goog.require('goog.functions'); +goog.require('goog.labs.userAgent.browser'); +goog.require('goog.labs.userAgent.engine'); + + +/** + * Fires the provided callbacks as soon as possible after the current JS + * execution context. setTimeout(…, 0) takes at least 4ms when called from + * within another setTimeout(…, 0) for legacy reasons. + * + * This will not schedule the callback as a microtask (i.e. a task that can + * preempt user input or networking callbacks). It is meant to emulate what + * setTimeout(_, 0) would do if it were not throttled. If you desire microtask + * behavior, use {@see goog.Promise} instead. + * + * @param {function(this:SCOPE)} callback Callback function to fire as soon as + * possible. + * @param {SCOPE=} opt_context Object in whose scope to call the listener. + * @param {boolean=} opt_useSetImmediate Avoid the IE workaround that + * ensures correctness at the cost of speed. See comments for details. + * @template SCOPE + */ +goog.async.nextTick = function(callback, opt_context, opt_useSetImmediate) { + 'use strict'; + var cb = callback; + if (opt_context) { + cb = goog.bind(callback, opt_context); + } + cb = goog.async.nextTick.wrapCallback_(cb); + // Note we do allow callers to also request setImmediate if they are willing + // to accept the possible tradeoffs of incorrectness in exchange for speed. + // The IE fallback of readystate change is much slower. See useSetImmediate_ + // for details. + if (typeof goog.global.setImmediate === 'function' && + (opt_useSetImmediate || goog.async.nextTick.useSetImmediate_())) { + goog.global.setImmediate(cb); + return; + } + + // Look for and cache the custom fallback version of setImmediate. + if (!goog.async.nextTick.nextTickImpl) { + goog.async.nextTick.nextTickImpl = goog.async.nextTick.getNextTickImpl_(); + } + goog.async.nextTick.nextTickImpl(cb); +}; + + +/** + * Returns whether should use setImmediate implementation currently on window. + * + * window.setImmediate was introduced and currently only supported by IE10+, + * but due to a bug in the implementation it is not guaranteed that + * setImmediate is faster than setTimeout nor that setImmediate N is before + * setImmediate N+1. That is why we do not use the native version if + * available. We do, however, call setImmediate if it is a non-native function + * because that indicates that it has been replaced by goog.testing.MockClock + * which we do want to support. + * See + * http://connect.microsoft.com/IE/feedback/details/801823/setimmediate-and-messagechannel-are-broken-in-ie10 + * + * @return {boolean} Whether to use the implementation of setImmediate defined + * on Window. + * @private + * @suppress {missingProperties} For "Window.prototype.setImmediate" + */ +goog.async.nextTick.useSetImmediate_ = function() { + 'use strict'; + // Not a browser environment. + if (!goog.global.Window || !goog.global.Window.prototype) { + return true; + } + + // MS Edge has window.setImmediate natively, but it's not on Window.prototype. + // Also, there's no clean way to detect if the goog.global.setImmediate has + // been replaced by mockClock as its replacement also shows up as "[native + // code]" when using toString. Therefore, just always use + // goog.global.setImmediate for Edge. It's unclear if it suffers the same + // issues as IE10/11, but based on + // https://dev.modern.ie/testdrive/demos/setimmediatesorting/ + // it seems they've been working to ensure it's WAI. + if (goog.labs.userAgent.browser.isEdge() || + goog.global.Window.prototype.setImmediate != goog.global.setImmediate) { + // Something redefined setImmediate in which case we decide to use it (This + // is so that we use the mockClock setImmediate). + return true; + } + + return false; +}; + + +/** + * Cache for the nextTick implementation. Exposed so tests can replace it, + * if needed. + * @type {function(function())} + */ +goog.async.nextTick.nextTickImpl; + + +/** + * Determines the best possible implementation to run a function as soon as + * the JS event loop is idle. + * @return {function(function())} The "setImmediate" implementation. + * @private + */ +goog.async.nextTick.getNextTickImpl_ = function() { + 'use strict'; + // Create a private message channel and use it to postMessage empty messages + // to ourselves. + /** @type {!Function|undefined} */ + var Channel = goog.global['MessageChannel']; + // If MessageChannel is not available and we are in a browser, implement + // an iframe based polyfill in browsers that have postMessage and + // document.addEventListener. The latter excludes IE8 because it has a + // synchronous postMessage implementation. + if (typeof Channel === 'undefined' && typeof window !== 'undefined' && + window.postMessage && window.addEventListener && + // Presto (The old pre-blink Opera engine) has problems with iframes + // and contentWindow. + !goog.labs.userAgent.engine.isPresto()) { + /** @constructor */ + Channel = function() { + 'use strict'; + // Make an empty, invisible iframe. + var iframe = goog.dom.createElement(goog.dom.TagName.IFRAME); + iframe.style.display = 'none'; + document.documentElement.appendChild(iframe); + var win = iframe.contentWindow; + var doc = win.document; + doc.open(); + doc.close(); + // Do not post anything sensitive over this channel, as the workaround for + // pages with file: origin could allow that information to be modified or + // intercepted. + var message = 'callImmediate' + Math.random(); + // The same origin policy rejects attempts to postMessage from file: urls + // unless the origin is '*'. + var origin = win.location.protocol == 'file:' ? + '*' : + win.location.protocol + '//' + win.location.host; + var onmessage = goog.bind(function(e) { + 'use strict'; + // Validate origin and message to make sure that this message was + // intended for us. If the origin is set to '*' (see above) only the + // message needs to match since, for example, '*' != 'file://'. Allowing + // the wildcard is ok, as we are not concerned with security here. + if ((origin != '*' && e.origin != origin) || e.data != message) { + return; + } + this['port1'].onmessage(); + }, this); + win.addEventListener('message', onmessage, false); + this['port1'] = {}; + this['port2'] = { + postMessage: function() { + 'use strict'; + win.postMessage(message, origin); + } + }; + }; + } + if (typeof Channel !== 'undefined' && !goog.labs.userAgent.browser.isIE()) { + // Exclude all of IE due to + // http://codeforhire.com/2013/09/21/setimmediate-and-messagechannel-broken-on-internet-explorer-10/ + // which allows starving postMessage with a busy setTimeout loop. + // This currently affects IE10 and IE11 which would otherwise be able + // to use the postMessage based fallbacks. + var channel = new Channel(); + // Use a fifo linked list to call callbacks in the right order. + var head = {}; + var tail = head; + channel['port1'].onmessage = function() { + 'use strict'; + if (head.next !== undefined) { + head = head.next; + var cb = head.cb; + head.cb = null; + cb(); + } + }; + return function(cb) { + 'use strict'; + tail.next = {cb: cb}; + tail = tail.next; + channel['port2'].postMessage(0); + }; + } + // Fall back to setTimeout with 0. In browsers this creates a delay of 5ms + // or more. + // NOTE(user): This fallback is used for IE. + return function(cb) { + 'use strict'; + goog.global.setTimeout(/** @type {function()} */ (cb), 0); + }; +}; + + +/** + * Helper function that is overrided to protect callbacks with entry point + * monitor if the application monitors entry points. + * @param {function()} callback Callback function to fire as soon as possible. + * @return {function()} The wrapped callback. + * @private + */ +goog.async.nextTick.wrapCallback_ = goog.functions.identity; + + +// Register the callback function as an entry point, so that it can be +// monitored for exception handling, etc. This has to be done in this file +// since it requires special code to handle all browsers. +goog.debug.entryPointRegistry.register( + /** + * @param {function(!Function): !Function} transformer The transforming + * function. + */ + function(transformer) { + 'use strict'; + goog.async.nextTick.wrapCallback_ = transformer; + }); diff --git a/closure/goog/async/nexttick_test.js b/closure/goog/async/nexttick_test.js new file mode 100644 index 0000000000..f9adfc6db8 --- /dev/null +++ b/closure/goog/async/nexttick_test.js @@ -0,0 +1,241 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ +goog.module('goog.async.nextTickTest'); +goog.setTestOnly(); + +const ErrorHandler = goog.require('goog.debug.ErrorHandler'); +const GoogPromise = goog.require('goog.Promise'); +const MockClock = goog.require('goog.testing.MockClock'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const TagName = goog.require('goog.dom.TagName'); +const browser = goog.require('goog.labs.userAgent.browser'); +const dom = goog.require('goog.dom'); +const entryPointRegistry = goog.require('goog.debug.entryPointRegistry'); +const nextTick = goog.require('goog.async.nextTick'); +const testSuite = goog.require('goog.testing.testSuite'); + +let clock; +const propertyReplacer = new PropertyReplacer(); + +testSuite({ + setUp() { + clock = null; + }, + + /** @suppress {visibility} */ + tearDown() { + if (clock) { + clock.uninstall(); + } + // Unset the cached nextTickImpl behavior so it's re-evaluated for each + // test. + nextTick.nextTickImpl = /** @type {?} */ (undefined); + propertyReplacer.reset(); + }, + + testNextTick() { + return new GoogPromise((resolve, reject) => { + let c = 0; + const max = 100; + let async = true; + const counterStep = (i) => { + async = false; + assertEquals('Order correct', i, c); + c++; + if (c === max) { + resolve(); + } + }; + for (let i = 0; i < max; i++) { + nextTick(goog.partial(counterStep, i)); + } + assertTrue(async); + }); + }, + + testNextTickSetImmediate() { + return new GoogPromise((resolve, reject) => { + let c = 0; + const max = 100; + let async = true; + const counterStep = (i) => { + async = false; + assertEquals('Order correct', i, c); + c++; + if (c === max) { + resolve(); + } + }; + for (let i = 0; i < max; i++) { + nextTick( + goog.partial(counterStep, i), undefined, + /* opt_useSetImmediate */ true); + } + assertTrue(async); + }); + }, + + testNextTickContext() { + return new GoogPromise((resolve, reject) => { + const context = {}; + let c = 0; + const max = 10; + let async = true; + const counterStep = function(i) { + async = false; + assertEquals('Order correct', i, c); + assertEquals(context, this); + c++; + if (c === max) { + resolve(); + } + }; + for (let i = 0; i < max; i++) { + nextTick(goog.partial(counterStep, i), context); + } + assertTrue(async); + }); + }, + + testNextTickMockClock() { + clock = new MockClock(true); + let result = ''; + nextTick(() => { + result += 'a'; + }); + nextTick(() => { + result += 'b'; + }); + nextTick(() => { + result += 'c'; + }); + assertEquals('', result); + clock.tick(0); + assertEquals('abc', result); + }, + + testNextTickDoesntSwallowError() { + return new GoogPromise((resolve, reject) => { + const sentinel = 'sentinel'; + + propertyReplacer.replace(window, 'onerror', (e) => { + e = '' + e; + // Don't test for contents in IE7, which does not preserve the exception + // message. + if (e.indexOf('Exception thrown and not caught') == -1) { + assertContains(sentinel, e); + } + resolve(); + return false; + }); + + nextTick(() => { + throw sentinel; + }); + }); + }, + + testNextTickProtectEntryPoint() { + return new GoogPromise((resolve, reject) => { + let errorHandlerCallbackCalled = false; + const errorHandler = new ErrorHandler(() => { + errorHandlerCallbackCalled = true; + }); + + // MS Edge will always use globalThis.setImmediate, so ensure we get + // to setImmediate_ here. See useSetImmediate_ implementation for details + // on Edge special casing. + propertyReplacer.set(nextTick, 'useSetImmediate_', () => false); + + // This is only testing wrapping the callback with the protected entry + // point, so it's okay to replace this function with a fake. + propertyReplacer.set(nextTick, 'nextTickImpl', (cb) => { + try { + cb(); + fail('The callback should have thrown an error.'); + } catch (e) { + assertTrue(errorHandlerCallbackCalled); + assertTrue(e instanceof ErrorHandler.ProtectedFunctionError); + } finally { + // Restore setImmediate so it doesn't interfere with Promise behavior. + propertyReplacer.reset(); + } + resolve(); + }); + + entryPointRegistry.monitorAll(errorHandler); + nextTick(() => { + throw new Error('This should be caught by the protected function.'); + }); + }); + }, + + testNextTick_notStarvedBySetTimeout() { + // This test will timeout when affected by + // http://codeforhire.com/2013/09/21/setimmediate-and-messagechannel-broken-on-internet-explorer-10/ + // This test would fail without the fix introduced in cl/72472221 + // It keeps scheduling 0 timeouts and a single nextTick. If the nextTick + // ever fires, the IE specific problem does not occur. + let timeout; + function busy() { + timeout = setTimeout(() => { + busy(); + }, 0); + } + busy(); + + return new GoogPromise((resolve, reject) => { + nextTick(() => { + if (timeout) { + clearTimeout(timeout); + } + resolve(); + }); + }); + }, + + /** + * Test a scenario in which the iframe used by the postMessage polyfill gets a + * message that does not have match what is expected. In this case, the + * polyfill should not try to invoke a callback (which would result in an + * error because there would be no callbacks in the linked list). + * + * TODO(nickreid): Delete this test? It's testing for a an adversarial input + * case and depend on deep implementation details. + */ + testPostMessagePolyfillDoesNotPumpCallbackQueueIfMessageIsIncorrect() { + // EDGE/IE does not use the postMessage polyfill. + if (browser.isIE() || browser.isEdge()) { + return; + } + + // Force postMessage polyfill for setImmediate. + propertyReplacer.set(window, 'setImmediate', undefined); + propertyReplacer.set(window, 'MessageChannel', undefined); + + let atNextTick = new GoogPromise(nextTick); + + const frame = dom.getElementsByTagName(TagName.IFRAME)[0]; + frame.contentWindow.postMessage( + 'bogus message', + window.location.protocol + '//' + window.location.host); + + // The test passes if no error is ever reported by the + diff --git a/closure/goog/bootstrap/BUILD b/closure/goog/bootstrap/BUILD new file mode 100644 index 0000000000..9a544f0133 --- /dev/null +++ b/closure/goog/bootstrap/BUILD @@ -0,0 +1,15 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +closure_js_library( + name = "nodejs", + srcs = ["nodejs.js"], + lenient = True, +) + +closure_js_library( + name = "webworkers", + srcs = ["webworkers.js"], + lenient = True, +) diff --git a/closure/goog/bootstrap/nodejs.js b/closure/goog/bootstrap/nodejs.js new file mode 100644 index 0000000000..997f4e8f5a --- /dev/null +++ b/closure/goog/bootstrap/nodejs.js @@ -0,0 +1,107 @@ +// Copyright 2013 The Closure Library Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview A nodejs script for dynamically requiring Closure within + * nodejs. + * + * Example of usage: + * + * require('./bootstrap/nodejs') + * goog.require('goog.ui.Component') + * + * + * This loads goog.ui.Component in the global scope. + * + * If you want to load custom libraries, you can require the custom deps file + * directly. If your custom libraries introduce new globals, you may + * need to run goog.nodeGlobalRequire to get them to load correctly. + * + * + * require('./path/to/my/deps.js') + * goog.bootstrap.nodeJs.nodeGlobalRequire('./path/to/my/base.js') + * goog.require('my.Class') + * + * + * @nocompile + */ + + +var fs = require('fs'); +var path = require('path'); +var vm = require('vm'); + + +/** + * The goog namespace in the global scope. + */ +global.goog = {}; + +/** The runtime nodejs bootstrap relies on the Debug Loader being enabled. */ +global.CLOSURE_UNCOMPILED_DEFINES = global.CLOSURE_UNCOMPILED_DEFINES || {}; +global.CLOSURE_UNCOMPILED_DEFINES['goog.ENABLE_DEBUG_LOADER'] = true; + +/** + * Imports a script using Node's require() API. + * + * @param {string} src The script source. + * @param {string=} opt_sourceText The optional source text to evaluate. + * @return {boolean} True if the script was imported, false otherwise. + */ +global.CLOSURE_IMPORT_SCRIPT = function(src, opt_sourceText) { + // Sources are always expressed relative to closure's base.js, but + // require() is always relative to the current source. + if (opt_sourceText === undefined) { + require('./../' + src); + } else { + eval(opt_sourceText); + } + return true; +}; + + +/** + * Loads a file when using Closure's goog.require() API with goog.modules. + * + * @param {string} src The file source. + * @return {string} The file contents. + */ +global.CLOSURE_LOAD_FILE_SYNC = function(src) { + return fs.readFileSync( + path.resolve(__dirname, '..', src), {encoding: 'utf-8'}); +}; + + +// Declared here so it can be used to require base.js +function nodeGlobalRequire(file) { + vm.runInThisContext.call(global, fs.readFileSync(file), file); +} + + +// Load Closure's base.js into memory. It is assumed base.js is in the +// directory above this directory given this script's location in +// bootstrap/nodejs.js. +nodeGlobalRequire(path.resolve(__dirname, '..', 'base.js')); + + +/** + * Bootstraps a file into the global scope. + * + * This is strictly for cases where normal require() won't work, + * because the file declares global symbols with 'var' that need to + * be added to the global scope. + * + * @param {string} file The path to the file. + */ +goog.nodeGlobalRequire = nodeGlobalRequire; diff --git a/closure/goog/bootstrap/webworkers.js b/closure/goog/bootstrap/webworkers.js new file mode 100644 index 0000000000..cc73e20570 --- /dev/null +++ b/closure/goog/bootstrap/webworkers.js @@ -0,0 +1,41 @@ +// Copyright 2010 The Closure Library Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview A bootstrap for dynamically requiring Closure within an HTML5 + * Web Worker context. To use this, first set CLOSURE_BASE_PATH to the directory + * containing base.js (relative to the main script), then use importScripts to + * load this file and base.js (in that order). After this you can use + * goog.require for further imports. + * + * @nocompile + */ + + +/** + * Imports a script using the Web Worker importScript API. + * + * @param {string} src The script source. + * @return {boolean} True if the script was imported, false otherwise. + */ +this.CLOSURE_IMPORT_SCRIPT = (function(global) { + return function(src, opt_sourceText) { + if (opt_sourceText) { + eval(opt_sourceText) + } else { + global['importScripts'](src); + } + return true; + }; +})(this); diff --git a/closure/goog/collections/BUILD b/closure/goog/collections/BUILD new file mode 100644 index 0000000000..b0e447cc42 --- /dev/null +++ b/closure/goog/collections/BUILD @@ -0,0 +1,24 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "sets", + srcs = ["sets.js"], + lenient = True, + deps = ["//closure/goog/collections:iters"], +) + +closure_js_library( + name = "iters", + srcs = ["iters.js"], + lenient = True, +) + +closure_js_library( + name = "maps", + srcs = ["maps.js"], + lenient = True, +) diff --git a/closure/goog/collections/iters.js b/closure/goog/collections/iters.js new file mode 100644 index 0000000000..87160d3222 --- /dev/null +++ b/closure/goog/collections/iters.js @@ -0,0 +1,232 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for working with ES6 iterables. + * + * The goal is that this should be a replacement for goog.iter which uses + * a now non-standard approach to iterables. + * + * This module's API should track the TC39 proposal as closely as possible to + * allow for eventual deprecation and migrations. + * https://github.com/tc39/proposal-iterator-helpers + * + * @see go/closure-iters-labs + * @see https://goo.gl/Rok5YQ + */ + +goog.module('goog.collections.iters'); +goog.module.declareLegacyNamespace(); + +/** + * Get the iterator for an iterable. + * @param {!Iterable} iterable + * @return {!Iterator} + * @template VALUE + */ +function getIterator(iterable) { + return iterable[goog.global.Symbol.iterator](); +} +exports.getIterator = getIterator; + + +/** + * Call a function with every value of an iterable. + * + * Warning: this function will never halt if given an iterable that + * is never exhausted. + * + * @param {!Iterator} iterator + * @param {function(VALUE) : *} f + * @template VALUE + */ +function forEach(iterator, f) { + let result; + while (!(result = iterator.next()).done) { + f(result.value); + } +} +exports.forEach = forEach; + +/** + * An Iterable that wraps a child iterable, and maps every element of the child + * iterator to a new value, using a mapping function. Similar to Array.map, but + * for Iterable. + * @template TO,FROM + * @implements {IteratorIterable} + */ +class MapIterator { + /** + * @param {!Iterable} childIter + * @param {function(FROM): TO} mapFn + */ + constructor(childIter, mapFn) { + /** @private @const {!Iterator} */ + this.childIterator_ = getIterator(childIter); + + /** @private @const {function(FROM): TO} */ + this.mapFn_ = mapFn; + } + + [Symbol.iterator]() { + return this; + } + + /** @override */ + next() { + const childResult = this.childIterator_.next(); + // Always return a new object, even when childResult.done == true. This is + // so that we don't accidentally preserve generator return values, which + // are unlikely to be meaningful in the context of this MapIterator. + return { + value: childResult.done ? undefined : + this.mapFn_.call(undefined, childResult.value), + done: childResult.done, + }; + } +} + + +/** + * Maps the values of one iterable to create another iterable. + * + * When next() is called on the returned iterable, it will call the given + * function `f` with the next value of the given iterable + * `iterable` until the given iterable is exhausted. + * + * @param {!Iterable} iterable + * @param {function(VALUE): RESULT} f + * @return {!IteratorIterable} The created iterable that gives the + * mapped values. + * @template VALUE, RESULT + */ +exports.map = function(iterable, f) { + return new MapIterator(iterable, f); +}; + + +/** + * An Iterable that wraps a child Iterable and returns a subset of the child's + * items, based on a filter function. Similar to Array.filter, but for + * Iterable. + * @template T + * @implements {IteratorIterable} + */ +class FilterIterator { + /** + * @param {!Iterable} childIter + * @param {function(T): boolean} filterFn + */ + constructor(childIter, filterFn) { + /** @private @const {!Iterator} */ + this.childIter_ = getIterator(childIter); + + /** @private @const {function(T): boolean} */ + this.filterFn_ = filterFn; + } + + [Symbol.iterator]() { + return this; + } + + /** @override */ + next() { + while (true) { + const childResult = this.childIter_.next(); + if (childResult.done) { + // Don't return childResult directly, because that would preserve + // generator return values, and we want to ignore them. + return {done: true, value: undefined}; + } + const passesFilter = this.filterFn_.call(undefined, childResult.value); + if (passesFilter) { + return childResult; + } + } + } +} + + +/** + * Filter elements from one iterator to create another iterable. + * + * When next() is called on the returned iterator, it will call next() on the + * given iterator and call the given function `f` with that value until `true` + * is returned or the given iterator is exhausted. + * + * @param {!Iterable} iterable + * @param {function(VALUE): boolean} f + * @return {!IteratorIterable} The created iterable that gives the mapped + * values. + * @template VALUE + */ +exports.filter = function(iterable, f) { + return new FilterIterator(iterable, f); +}; + + +/** + * @template T + * @implements {IteratorIterable} + */ +class ConcatIterator { + /** @param {!Array>} iterators */ + constructor(iterators) { + /** @private @const {!Array>} */ + this.iterators_ = iterators; + + /** @private {number} */ + this.iterIndex_ = 0; + } + + [Symbol.iterator]() { + return this; + } + + /** @override */ + next() { + while (this.iterIndex_ < this.iterators_.length) { + const result = this.iterators_[this.iterIndex_].next(); + if (!result.done) { + return result; + } + this.iterIndex_++; + } + return /** @type {!IIterableResult} */ ({done: true}); + } +} + + +/** + * Concatenates multiple iterators to create a new iterable. + * + * When next() is called on the return iterator, it will call next() on the + * current passed iterator. When the current passed iterator is exhausted, it + * will move on to the next iterator until there are no more left. + * + * All generator return values will be ignored (i.e. when childIter.next() + * returns {done: true, value: notUndefined} it will be treated as just + * {done: true}). + * + * @param {...!Iterable} iterables + * @return {!IteratorIterable} + * @template VALUE + */ +exports.concat = function(...iterables) { + return new ConcatIterator(iterables.map(getIterator)); +}; + +/** + * Creates an array containing the values from the given iterator. + * @param {!Iterator} iterator + * @return {!Array} + * @template VALUE + */ +exports.toArray = function(iterator) { + const arr = []; + forEach(iterator, e => arr.push(e)); + return arr; +}; diff --git a/closure/goog/collections/iters_test.js b/closure/goog/collections/iters_test.js new file mode 100644 index 0000000000..6ecc8332ee --- /dev/null +++ b/closure/goog/collections/iters_test.js @@ -0,0 +1,308 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Tests for goog.labs.iterable + */ + +goog.module('goog.collections.iters.iterableTest'); +goog.setTestOnly('goog.collections.iters.iterableTest'); + +const iters = goog.require('goog.collections.iters'); +const recordFunction = goog.require('goog.testing.recordFunction'); +const testSuite = goog.require('goog.testing.testSuite'); + + +/** + * Create an Iterator starting at start and increments up to + * (but not including) stop. + * @param {number} start + * @param {number} stop + * @return {!Iterator} + */ +function createRangeIterator(start, stop) { + let value = start; + const next = () => { + if (value < stop) { + return {value: value++, done: false}; + } + + return {value: undefined, done: true}; + }; + + return /** @type {!Iterator} */ ({next}); +} + +/** + * Creates an Iterable starting at start and increments up to (but not + * including) stop. + * @param {number} start + * @param {number} stop + * @return {!Iterable} + */ +function createRangeIterable(start, stop) { + const obj = {}; + + // Refer to globalThis['Symbol'] because otherwise this + // is a parse error in earlier IEs. + obj[globalThis['Symbol'].iterator] = () => createRangeIterator(start, stop); + return /** @type {!Iterable} */ (obj); +} + +/** + * Creates a Generator object that yields the values start to stop-1 and returns + * the value stop. + * @param {number} start + * @param {number} stop + * @return {!Iterable} + */ +function* rangeGeneratorWithReturn(start, stop) { + for (let i = start; i < stop; i++) { + yield i; + } + return stop; +} + +/** @return {boolean} */ +function isSymbolDefined() { + return !!globalThis['Symbol']; +} + +testSuite({ + testCreateRangeIterable() { + // Do not run if Symbol does not exist in this browser. + if (!isSymbolDefined()) { + return; + } + + const rangeIterator = createRangeIterator(0, 3); + + for (let i = 0; i < 3; i++) { + assertObjectEquals({value: i, done: false}, rangeIterator.next()); + } + + for (let i = 0; i < 3; i++) { + assertObjectEquals({value: undefined, done: true}, rangeIterator.next()); + } + }, + + testForEach() { + // Do not run if Symbol does not exist in this browser. + if (!isSymbolDefined()) { + return; + } + + const range = createRangeIterator(0, 3); + + const callback = recordFunction(); + iters.forEach(range, callback); + + callback.assertCallCount(3); + + const calls = callback.getCalls(); + for (let i = 0; i < calls.length; i++) { + const call = calls[i]; + assertArrayEquals([i], call.getArguments()); + } + }, + + testMap() { + // Do not run if Symbol does not exist in this browser. + if (!isSymbolDefined()) { + return; + } + + const range = createRangeIterable(0, 3); + + function addTwo(i) { + return i + 2; + } + + const newIterable = iters.map(range, addTwo); + const newIterator = iters.getIterator(newIterable); + + let nextObj = newIterator.next(); + assertEquals(2, nextObj.value); + assertFalse(nextObj.done); + + nextObj = newIterator.next(); + assertEquals(3, nextObj.value); + assertFalse(nextObj.done); + + nextObj = newIterator.next(); + assertEquals(4, nextObj.value); + assertFalse(nextObj.done); + + // Check that the iterator repeatedly signals done. + for (let i = 0; i < 3; i++) { + nextObj = newIterator.next(); + assertUndefined(nextObj.value); + assertTrue(nextObj.done); + } + }, + + testMap_3Items() { + assertArrayEquals( + [0, 2, 4], [...iters.map(createRangeIterable(0, 3), (x) => x * 2)]); + }, + + // Make sure that generator return values are ignored + testMap_3ItemsWithReturn() { + // {value: 0, done: false} + // {value: 1, done: false} + // {value: 2, done: false} + // {value: 3, done: true} + const childIter = rangeGeneratorWithReturn(0, 3); + const iter = iters.map(childIter, (x) => x * 2); + + assertObjectEquals({value: 0, done: false}, iter.next()); + assertObjectEquals({value: 2, done: false}, iter.next()); + assertObjectEquals({value: 4, done: false}, iter.next()); + assertObjectEquals({value: undefined, done: true}, iter.next()); + assertObjectEquals({value: undefined, done: true}, iter.next()); + assertObjectEquals({value: undefined, done: true}, iter.next()); + }, + + testFilter() { + function isEven(val) { + return val % 2 == 0; + } + + const range = createRangeIterable(0, 6); + const newIterable = iters.filter(range, isEven); + const newIterator = iters.getIterator(newIterable); + + let nextObj = newIterator.next(); + assertEquals(0, nextObj.value); + assertFalse(nextObj.done); + + nextObj = newIterator.next(); + assertEquals(2, nextObj.value); + assertFalse(nextObj.done); + + nextObj = newIterator.next(); + assertEquals(4, nextObj.value); + assertFalse(nextObj.done); + + // Check that the iterator repeatedly signals done. + for (let i = 0; i < 3; i++) { + nextObj = newIterator.next(); + assertUndefined(nextObj.value); + assertTrue(nextObj.done); + } + }, + + testFilterLongGap() { + const tenNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].values(); + assertArrayEquals( + [1, 10], + [...iters.filter(tenNumbers, (n) => String(n).startsWith('1'))]); + }, + + // Make sure the implementation tests `childResult.done` and not + // `childResult.value != undefined` + testFilterUndefineds() { + const miscValues = [1, 'two', undefined, 4.4].values(); + let count = 0; + assertArrayEquals( + [1, 'two', undefined, 4.4], [...iters.filter(miscValues, () => { + count++; + return true; + })]); + assertEquals(4, count); + }, + + // Make sure generator return values are ignored + testFilterReturnValues() { + // {value: 0, done: false} + // {value: 1, done: false} + // {value: 2, done: false} + // {value: 3, done: true} + const childIter = rangeGeneratorWithReturn(0, 3); + const filterIter = iters.filter(childIter, () => true); + + assertObjectEquals({value: 0, done: false}, filterIter.next()); + assertObjectEquals({value: 1, done: false}, filterIter.next()); + assertObjectEquals({value: 2, done: false}, filterIter.next()); + assertObjectEquals({value: undefined, done: true}, filterIter.next()); + assertObjectEquals({value: undefined, done: true}, filterIter.next()); + assertObjectEquals({value: undefined, done: true}, filterIter.next()); + }, + + testConcat_2Iterators() { + const iter1 = createRangeIterable(0, 3); + const iter2 = createRangeIterable(3, 6); + const concatIter = iters.concat(iter1, iter2); + + assertObjectEquals({value: 0, done: false}, concatIter.next()); + assertObjectEquals({value: 1, done: false}, concatIter.next()); + assertObjectEquals({value: 2, done: false}, concatIter.next()); + assertObjectEquals({value: 3, done: false}, concatIter.next()); + assertObjectEquals({value: 4, done: false}, concatIter.next()); + assertObjectEquals({value: 5, done: false}, concatIter.next()); + assertObjectEquals({done: true}, concatIter.next()); + assertObjectEquals({done: true}, concatIter.next()); + assertObjectEquals({done: true}, concatIter.next()); + }, + + testConcat_3Iterators() { + const iter1 = createRangeIterable(0, 3); + const iter2 = createRangeIterable(3, 6); + const iter3 = createRangeIterable(6, 9); + const concatIter = iters.concat(iter1, iter2, iter3); + + assertObjectEquals({value: 0, done: false}, concatIter.next()); + assertObjectEquals({value: 1, done: false}, concatIter.next()); + assertObjectEquals({value: 2, done: false}, concatIter.next()); + assertObjectEquals({value: 3, done: false}, concatIter.next()); + assertObjectEquals({value: 4, done: false}, concatIter.next()); + assertObjectEquals({value: 5, done: false}, concatIter.next()); + assertObjectEquals({value: 6, done: false}, concatIter.next()); + assertObjectEquals({value: 7, done: false}, concatIter.next()); + assertObjectEquals({value: 8, done: false}, concatIter.next()); + assertObjectEquals({done: true}, concatIter.next()); + assertObjectEquals({done: true}, concatIter.next()); + assertObjectEquals({done: true}, concatIter.next()); + }, + + testConcat_generatorReturnValuesAreIgnored() { + // These generators will return 3 and 6. If the generator return values are + // not ignored, we'll see the sequence 0 1 2 3 3 4 5 6. In other words we're + // testing that 3 is only present once, and that 6 is not present at all. + const iter1 = rangeGeneratorWithReturn(0, 3); + const iter2 = rangeGeneratorWithReturn(3, 6); + const concatIter = iters.concat(iter1, iter2); + + assertObjectEquals({value: 0, done: false}, concatIter.next()); + assertObjectEquals({value: 1, done: false}, concatIter.next()); + assertObjectEquals({value: 2, done: false}, concatIter.next()); + assertObjectEquals({value: 3, done: false}, concatIter.next()); + assertObjectEquals({value: 4, done: false}, concatIter.next()); + assertObjectEquals({value: 5, done: false}, concatIter.next()); + assertObjectEquals({done: true}, concatIter.next()); + assertObjectEquals({done: true}, concatIter.next()); + assertObjectEquals({done: true}, concatIter.next()); + }, + + // Ensure that concat behaves the same as if you had used the array spread + // operator to concatenate the iterators (i.e. that generator return values + // are ignored). Also ensures that the Symbol.iterator property is present. + testConcat_arraySpread() { + const concat1 = rangeGeneratorWithReturn(0, 3); + const concat2 = rangeGeneratorWithReturn(3, 6); + const concatIter = iters.concat(concat1, concat2); + + const array1 = rangeGeneratorWithReturn(0, 3); + const array2 = rangeGeneratorWithReturn(3, 6); + + assertArrayEquals([...array1, ...array2], [...concatIter]); + }, + + testToArray() { + assertArrayEquals( + [0, 1, 2, 3, 4], iters.toArray(createRangeIterator(0, 5))); + } +}); diff --git a/closure/goog/collections/maps.js b/closure/goog/collections/maps.js new file mode 100644 index 0000000000..ed824bbf0a --- /dev/null +++ b/closure/goog/collections/maps.js @@ -0,0 +1,159 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Helper methods that operate on Map-like objects (e.g. ES6 + * Maps). + */ + +goog.module('goog.collections.maps'); +goog.module.declareLegacyNamespace(); + +/** + * A MapLike implements the same public interface as an ES6 Map, without tying + * the underlying code directly to the implementation. Any additions to this + * type should also be present on ES6 Maps. + * @template K,V + * @record + */ +class MapLike { + constructor() { + /** @const {number} The number of items in this map. */ + this.size; + } + + /** + * @param {K} key The key to set in the map. + * @param {V} val The value to set for the given key in the map. + */ + set(key, val) {}; + + /** + * @param {K} key The key to retrieve from the map. + * @return {V|undefined} The value for this key, or undefined if the key is + * not present in the map. + */ + get(key) {}; + + /** + * @return {!IteratorIterable} An ES6 Iterator that iterates over the keys + * in the map. + */ + keys() {}; + + /** + * @return {!IteratorIterable} An ES6 Iterator that iterates over the + * values in the map. + */ + values() {}; + + /** + * @param {K} key The key to check. + * @return {boolean} True iff this key is present in the map. + */ + has(key) {}; +} +exports.MapLike = MapLike; + +/** + * Iterates over each entry in the given entries and sets the entry in + * the map, overwriting any existing entries for the key. + * @param {!MapLike} map The map to set entries on. + * @param {?Iterable>} entries The iterable of entries. This + * iterable should really be of type Iterable>, but the tuple + * type is not representable in the Closure Type System. + * @template K,V + */ +function setAll(map, entries) { + if (!entries) return; + for (const [k, v] of entries) { + map.set(k, v); + } +} +exports.setAll = setAll; + +/** + * Determines if a given map contains the given value, optionally using + * a custom comparison function. + * @param {!MapLike} map The map whose values to check. + * @param {V2} val The value to check for. + * @param {(function(V1,V2): boolean)=} valueEqualityFn The comparison function + * used to determine if the given value is equivalent to any of the values + * in the map. If no function is provided, defaults to strict equality + * (===). + * @return {boolean} True iff the given map contains the given value according + * to the comparison function. + * @template V1,V2 + */ +function hasValue(map, val, valueEqualityFn = defaultEqualityFn) { + for (const v of map.values()) { + if (valueEqualityFn(v, val)) return true; + } + return false; +} +exports.hasValue = hasValue; + +/** @const {function(?,?): boolean} */ +const defaultEqualityFn = (a, b) => a === b; + +/** + * Compares two maps using their public APIs to determine if they have + * equal contents, optionally using a custom comparison function when comaring + * values. + * @param {!MapLike} map The first map + * @param {!MapLike} otherMap The other map + * @param {(function(V1,V2): boolean)=} valueEqualityFn The comparison function + * used to determine if the values obtained from each map are equivalent. If + * no function is provided, defaults to strict equality (===). + * @return {boolean} + * @template K,V1,V2 + */ +function equals(map, otherMap, valueEqualityFn = defaultEqualityFn) { + if (map === otherMap) return true; + if (map.size !== otherMap.size) return false; + for (const key of map.keys()) { + if (!otherMap.has(key)) return false; + if (!valueEqualityFn(map.get(key), otherMap.get(key))) return false; + } + return true; +} +exports.equals = equals; + +/** + * Returns a new ES6 Map in which all the keys and values from the + * given map are interchanged (keys become values and values become keys). If + * multiple keys in the given map to the same value, the resulting value in the + * transposed map is implementation-dependent. + * + * It acts very similarly to {goog.object.transpose(Object)}. + * @param {!MapLike} map The map to transpose. + * @return {!Map} A transposed version of the given map. + * @template K,V + */ +function transpose(map) { + const /** !Map */ transposed = new Map(); + for (const key of map.keys()) { + const val = map.get(key); + transposed.set(val, key); + } + return transposed; +} +exports.transpose = transpose; + +/** + * ToObject returns a new object whose properties are the keys from the Map. + * @param {!MapLike} map The map to convert into an object. + * @return {!Object} An object representation of the Map. + * @template K,V + */ +function toObject(map) { + const /** !Object */ obj = {}; + for (const key of map.keys()) { + obj[key] = map.get(key); + } + return obj; +} +exports.toObject = toObject; diff --git a/closure/goog/collections/maps_test.js b/closure/goog/collections/maps_test.js new file mode 100644 index 0000000000..04bf50f31d --- /dev/null +++ b/closure/goog/collections/maps_test.js @@ -0,0 +1,202 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Tests for collections.maps. These tests try to ensure that for + * all known common MapLike implementations they correctly implement the + * necessary public API for use with these functions. + */ +goog.module('goog.collections.mapsTest'); +goog.setTestOnly('goog.collections.mapsTest'); + +const StructsMap = goog.require('goog.structs.Map'); +const googIter = goog.require('goog.iter'); +const maps = goog.require('goog.collections.maps'); +const testSuite = goog.require('goog.testing.testSuite'); + + +/** + * @typedef {function(new:maps.MapLike)} + */ +let MapLikeCtor; + +/** + * The list of well-known MapLike constructors whose implementations should be + * equivalent under test. + * @const {!Array} + */ +const knownMapLikeImpls = [StructsMap, Map]; + +/** + * For a given test implementation (testImpl), this function will call the test + * implementation once for each well-known MapLike implementation. + * @param {function(!MapLikeCtor)} testImpl + */ +function testAllMapLikeImpls(testImpl) { + for (const mapLikeImpl of knownMapLikeImpls) { + testImpl(mapLikeImpl); + } +} + +/** + * For a given test implementation, this function calls the test implementation + * once for every permutation (order-matters) of 2 of the well-known test + * implementations. These tests are generally to ensure interoperability (e.g. + * when constructing a new Map from the contents of an existing Map). + * @param {function(!MapLikeCtor, !MapLikeCtor)} testImpl + */ +function testTwoMapLikeImplInterop(testImpl) { + googIter.forEach(googIter.permutations(knownMapLikeImpls, 2), (p) => { + testImpl(p[0], p[1]); + }); +} + +/** + * Creates a test map populated with basic data. + * @param {!MapLikeCtor} mapLikeCtor + * @return {!maps.MapLike} + */ +function createTestMap(mapLikeCtor) { + const m = new mapLikeCtor(); + m.set('a', 0); + m.set('b', 1); + m.set('c', 2); + m.set('d', 3); + return m; +} + +testSuite({ + + testSetAll() { + testAllMapLikeImpls((mapLikeCtor) => { + const m = new mapLikeCtor(); + maps.setAll(m, Object.entries({a: 0, b: 1, c: 2, d: 3})); + assertTrue('addAll so it should not be empty', m.size > 0); + assertTrue('addAll so it should have \'c\' key', m.has('c')); + }); + + testAllMapLikeImpls((mapLikeCtor) => { + const m = /** @type {!Map} */ (createTestMap(Map)); + const m2 = new mapLikeCtor(); + maps.setAll(m2, m.entries()); + assertTrue('addAll so it should not be empty', m2.size > 0); + assertTrue('addAll so it should have \'c\' key', m2.has('c')); + }); + }, + + /** @suppress {checkTypes} */ + testHasValue() { + testAllMapLikeImpls((mapLikeCtor) => { + const m = createTestMap(mapLikeCtor); + assertTrue(maps.hasValue(m, 3)); + assertFalse(maps.hasValue(m, 4)); + + // Emulate a lack of type-checking to ensure that objects are being + // compared using === when no comparison function is provided. + assertFalse(maps.hasValue(m, '3')); + }); + }, + + testHasValueWithCustomEquality() { + testAllMapLikeImpls((mapLikeCtor) => { + const m = createTestMap(mapLikeCtor); + + const equalsFn = (a, b) => a == b; + assertTrue(maps.hasValue(m, '3', equalsFn)); + assertFalse(maps.hasValue(m, '4', equalsFn)); + }); + }, + + testTranspose() { + testAllMapLikeImpls((mapLikeCtor) => { + const m = new mapLikeCtor(); + m.set('a', 1); + m.set('b', 2); + m.set('c', 3); + m.set('d', 4); + m.set('e', 5); + + const transposed = maps.transpose(m); + assertEquals( + 'Should contain the keys from the original map as values', 'abcde', + Array.from(transposed.values()).join('')); + assertEquals( + 'Should contain the values from the original map as keys', '12345', + Array.from(transposed.keys()).join('')); + }); + }, + + testToObject() { + testAllMapLikeImpls((mapLikeCtor) => { + Object.prototype.b = 0; + try { + const m = new mapLikeCtor(); + m.set('a', 0); + const obj = maps.toObject(m); + assertTrue( + 'object representation has key "a"', obj.hasOwnProperty('a')); + assertFalse( + 'object representation does not have key "b"', + obj.hasOwnProperty('b')); + assertEquals('value for key "a"', 0, obj['a']); + } finally { + delete Object.prototype.b; + } + }); + }, + + testEqualsWithSameObject() { + testAllMapLikeImpls((mapLikeCtor) => { + const map1 = createTestMap(mapLikeCtor); + assertTrue('maps are the same object', maps.equals(map1, map1)); + }); + }, + + testEqualsWithDifferentSizeMaps() { + testTwoMapLikeImplInterop((mapACtor, mapBCtor) => { + const map1 = createTestMap(mapACtor); + const map2 = new mapBCtor(); + + assertFalse('maps are different sizes', maps.equals(map1, map2)); + }); + }, + + /** @suppress {checkTypes} */ + testEqualsWithDefaultEqualityFn() { + testTwoMapLikeImplInterop((mapACtor, mapBCtor) => { + let map1 = new mapACtor(); + let map2 = new mapBCtor(); + + assertTrue('maps are both empty', maps.equals(map1, map2)); + + map1 = createTestMap(mapACtor); + map2 = createTestMap(mapBCtor); + assertTrue('maps are the same', maps.equals(map1, map2)); + + // Emulate a lack of type-checking to ensure that objects are being + // compared using === when no comparison function is provided. + map2.set('d', '3'); + assertFalse('maps have 3 and \'3\'', maps.equals(map1, map2)); + }); + }, + + testEqualsWithCustomEqualityFn() { + testTwoMapLikeImplInterop((mapACtor, mapBCtor) => { + const map1 = new mapACtor(); + const map2 = new mapBCtor(); + + map1.set('a', 0); + map1.set('b', 1); + + map2.set('a', '0'); + map2.set('b', '1'); + + const equalsFn = (a, b) => a == b; + + assertTrue('maps are equal with ==', maps.equals(map1, map2, equalsFn)); + }); + }, +}); diff --git a/closure/goog/collections/sets.js b/closure/goog/collections/sets.js new file mode 100644 index 0000000000..11d44d731f --- /dev/null +++ b/closure/goog/collections/sets.js @@ -0,0 +1,202 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Set operations for ES6 Sets. + * + * See design doc at go/closure-es6-set + */ + +goog.module('goog.collections.sets'); + +const iters = goog.require('goog.collections.iters'); + +// Note: Set operations are being proposed for EcmaScript. See proposal here: +// https://github.com/tc39/proposal-set-methods + +// When these methods become available in JS engines, they should be used in +// place of these utility methods and these methods will be deprecated. +// Call sites can be automatically migrated. For example, +// "iters.filter(a, b)" becomes "a.filter(b)". + +/** + * A SetLike implements the same public interface as an ES6 Set, without tying + * the underlying code directly to the implementation. Any additions to this + * type should also be present on ES6 Sets. + * @template T + * @extends {Iterable} + * @record + */ +class SetLike { + constructor() { + /** @const {number} The number of items in this set. */ + this.size; + } + + /** + * @param {T} val The value to add to the Set. + */ + add(val) {}; + + /** + * @param {T} val The value to remove from the Set. + * @return {boolean} Whether the value was removed from the set. + */ + delete(val) {}; + + /** + * @param {T} val The value to check. + * @return {boolean} True iff this value is present in the set. + */ + has(val) {}; +} +exports.SetLike = SetLike; + +/** + * Creates a new ES6 Set containing the elements that appear in both given + * collections. + * + * @param {!SetLike} a + * @param {!Iterable} b + * @returns {!Set} + * @template T + */ +exports.intersection = function(a, b) { + return new Set(iters.filter(b, elem => a.has(elem))); +}; + +/** + * Creates a new ES6 Set containing the elements that appear in both given + * collections. + * + * @param {!SetLike} a + * @param {!Iterable} b + * @return {!Set} + * @template T + */ +exports.union = function(a, b) { + const set = new Set(a); + iters.forEach(b[Symbol.iterator](), elem => set.add(elem)); + return set; +}; + + +/** + * Creates a new ES6 Set containing the elements that appear in the first + * collection but not in the second. + * + * @param {!SetLike} a + * @param {!Iterable} b + * @return {!Set} + * @template T + */ +exports.difference = function(a, b) { + const set = new Set(a); + iters.forEach(b[Symbol.iterator](), elem => set.delete(elem)); + return set; +}; + +/** + * Creates a new set containing the elements that appear in a or b but not + * both. + * + * @param {!Set} a + * @param {!Set} b + * @return {!Set} + * @template T + */ +// TODO(nnaze): Consider widening the type of b per discussion in +// https://github.com/tc39/proposal-set-methods/issues/56 +exports.symmetricDifference = function(a, b) { + const newSet = new Set(a); + for (const elem of b) { + if (a.has(elem)) { + newSet.delete(elem); + } else { + newSet.add(elem); + } + } + return newSet; +}; + +/** + * Adds all the values in the given iterable to the given set. + * @param {!SetLike} set The set to add items to. + * @param {!Iterable} col A collection containing items to add. + * @template T + */ +exports.addAll = function(set, col) { + for (const elem of col) { + set.add(elem); + } +}; + +/** + * Removes all values in the given collection from the given set. + * @param {!SetLike} set The set to remove items from. + * @param {!Iterable} col A collection containing the elements to remove. + * @template T + */ +exports.removeAll = function(set, col) { + for (const elem of col) { + set.delete(elem); + } +}; + +/** + * Checks the given set contains all members of the given collection. + * @param {!SetLike} set The set to check for item presence. + * @param {!Iterable} col The collection of items to check for. + * @return {boolean} True iff the given set contains all the elements in the + * given collection, false otherwise. + * @template T + */ +exports.hasAll = function(set, col) { + for (const elem of col) { + if (!set.has(elem)) return false; + } + return true; +}; + +/** + * Tests whether the given collection consists of the same elements as the + * given set, regardless of order, without repetition. This operation is O(n). + * @param {!SetLike} set The first set which might be equal to the given + * collection. + * @param {!SetLike|!Array} col The second collection of items. + * @return {boolean} True iff the given collections are equal (contain) contains + * all the elements in the given collection, false otherwise. + * @template T + */ +exports.equals = function(set, col) { + const colSize = Array.isArray(col) ? col.length : col.size; + if (set.size !== colSize) { + return false; + } + return exports.isSubsetOf(set, col); +}; + +/** + * Tests whether all elements in the set are contained in the given collection. + * This operation is O(n). + * @param {!SetLike} set The set which might be a subset of the given + * collection. + * @param {!SetLike|!Array} col The second collection of items. + * @return {boolean} True iff set A is a subset of collection B, false + * otherwise. + * @template T + */ +exports.isSubsetOf = function(set, col) { + if (Array.isArray(col) && set.size > col.length) return false; + const colSet = Array.isArray(col) ? new Set(col) : col; + if (set.size > colSet.size) { + return false; + } + for (const elem of set) { + if (!colSet.has(elem)) return false; + } + return true; +}; diff --git a/closure/goog/collections/sets_test.js b/closure/goog/collections/sets_test.js new file mode 100644 index 0000000000..0274042367 --- /dev/null +++ b/closure/goog/collections/sets_test.js @@ -0,0 +1,310 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Unit tests for goog.collections.set. + */ + +goog.module('goog.collections.setsTest'); +goog.setTestOnly('goog.collections.setsTest'); + +const StructsSet = goog.require('goog.structs.Set'); +const googIter = goog.require('goog.iter'); +const sets = goog.require('goog.collections.sets'); +const testSuite = goog.require('goog.testing.testSuite'); + + +/** + * @typedef {function(new:sets.SetLike,(!Iterable|!Array)=)} + */ +let SetLikeCtor; + +/** + * The list of well-known SetLike constructors whose implementations should be + * equivalent under test. + * @const {!Array} + */ +const knownSetLikeImpls = [StructsSet, Set]; + +/** + * For a given test implementation, this function calls the test implementation + * once for every permutation (order-matters) of 2 of the well-known test + * implementations. These tests are generally to ensure interoperability (e.g. + * when constructing a new Set from the contents of an existing Set). + * @param {function(!SetLikeCtor, !SetLikeCtor)} testImpl + */ +function testTwoSetLikeImplInterop(testImpl) { + googIter.forEach(googIter.permutations(knownSetLikeImpls, 2), (p) => { + testImpl(p[0], p[1]); + }); +} + +/** Yield of the given arguments in order. */ +function* yieldArguments(...args) { + for (const arg of args) { + yield arg; + } +} + +/** Produce an empty generator. */ +function* emptyGenerator() {} + +testSuite({ + testIntersection() { + testTwoSetLikeImplInterop((setACtor, setBCtor) => { + // arrays + assertSameElements([2], sets.intersection(new setACtor([1, 2]), [2, 3])); + assertSameElements([], sets.intersection(new setACtor([]), [])); + assertSameElements( + [1], sets.intersection(new setACtor([1, 1, 1]), [1, 1])); + + // generators + assertSameElements( + [2], sets.intersection(new setACtor([1, 2]), yieldArguments(2, 3))); + assertSameElements( + [], sets.intersection(new setACtor([]), emptyGenerator())); + assertSameElements( + [1], + sets.intersection(new setACtor([1, 1, 1]), yieldArguments(1, 1))); + + // sets + assertSameElements( + [2], sets.intersection(new setACtor([1, 2]), new setBCtor([2, 3]))); + assertSameElements( + [], sets.intersection(new setACtor([]), new setBCtor())); + assertSameElements( + [1], + sets.intersection(new setACtor([1, 1, 1]), new setBCtor([1, 1]))); + }); + }, + + testUnion() { + testTwoSetLikeImplInterop((setACtor, setBCtor) => { + // arrays + assertSameElements([1, 2, 3], sets.union(new setACtor([1, 2]), [2, 3])); + assertSameElements([], sets.union(new setACtor([]), [])); + assertSameElements([1], sets.union(new setACtor([1, 1, 1]), [1, 1])); + + // generators + assertSameElements( + [1, 2, 3], sets.union(new setACtor([1, 2]), yieldArguments(2, 3))); + assertSameElements([], sets.union(new setACtor([]), emptyGenerator())); + assertSameElements( + [1], sets.union(new setACtor([1, 1, 1]), yieldArguments(1, 1))); + + // sets + assertSameElements( + [1, 2, 3], sets.union(new setACtor([1, 2]), new setBCtor([2, 3]))); + assertSameElements([], sets.union(new setACtor([]), new setBCtor())); + assertSameElements( + [1], sets.union(new setACtor([1, 1, 1]), new setBCtor([1, 1]))); + }); + }, + + testDifference() { + testTwoSetLikeImplInterop((setACtor, setBCtor) => { + // arrays + assertSameElements([1], sets.difference(new setACtor([1, 2]), [2, 3])); + assertSameElements([], sets.difference(new setACtor([]), [])); + assertSameElements([], sets.difference(new setACtor([1, 1, 1]), [1, 1])); + + // generators + assertSameElements( + [1], sets.difference(new setACtor([1, 2]), yieldArguments(2, 3))); + assertSameElements( + [], sets.difference(new setACtor([]), emptyGenerator())); + assertSameElements( + [], sets.difference(new setACtor([1, 1, 1]), yieldArguments(1, 1))); + + // sets + assertSameElements( + [1], sets.difference(new setACtor([1, 2]), new setBCtor([2, 3]))); + assertSameElements([], sets.difference(new setACtor([]), new setBCtor())); + assertSameElements( + [], sets.difference(new setACtor([1, 1, 1]), new setBCtor([1, 1]))); + }); + }, + + testSymmetricDifference() { + // sets (only sets are accepted for symmetricDifference) + assertSameElements( + [1, 3], sets.symmetricDifference(new Set([1, 2]), new Set([2, 3]))); + assertSameElements([], sets.symmetricDifference(new Set([]), new Set())); + assertSameElements( + [], sets.symmetricDifference(new Set([1, 1, 1]), new Set([1, 1]))); + }, + + testAddAll() { + testTwoSetLikeImplInterop((setACtor, setBCtor) => { + const s = new setACtor(); + sets.addAll(s, ['a', 'b', 'c', 'd']); + assertTrue('addAll so it should not be empty', s.size > 0); + assertTrue('addAll so it should have \'c\' key', s.has('c')); + + const s2 = new setBCtor(); + sets.addAll(s2, s); + assertTrue('addAll so it should not be empty', s2.size > 0); + assertTrue('addAll so it should has \'c\' key', s2.has('c')); + }); + }, + + testRemoveAll() { + const testRemoveAllImpl = function( + msg, elements1, elements2, expectedResult) { + testTwoSetLikeImplInterop((setACtor, setBCtor) => { + const set1 = new setACtor(elements1); + const set2 = new setBCtor(elements2); + sets.removeAll(set1, set2); + + assertTrue( + `${msg}: set1 count increased after removeAll`, + elements1.length >= set1.size); + assertEquals( + `${msg}: set2 count changed after removeAll`, elements2.length, + set2.size); + assertTrue( + `${msg}: wrong set1 after removeAll`, + sets.equals(set1, expectedResult)); + assertTrue( + `${msg}: non-empty intersection after removeAll: set1->set2`, + sets.equals(sets.intersection(set1, set2), [])); + assertTrue( + `${msg}: non-empty intersection after removeAll: set2->set1`, + sets.equals(sets.intersection(set2, set1), [])); + }); + }; + testRemoveAllImpl('removeAll of empty set from empty set', [], [], []); + testRemoveAllImpl( + 'removeAll of empty set from populated set', ['a', 'b', 'c', 'd'], [], + ['a', 'b', 'c', 'd']); + testRemoveAllImpl( + 'removeAll of [a,d] from [a,b,c,d]', ['a', 'b', 'c', 'd'], ['a', 'd'], + ['b', 'c']); + testRemoveAllImpl( + 'removeAll of [b,c] from [a,b,c,d]', ['a', 'b', 'c', 'd'], ['b', 'c'], + ['a', 'd']); + testRemoveAllImpl( + 'removeAll of [b,c,e] from [a,b,c,d]', ['a', 'b', 'c', 'd'], + ['b', 'c', 'e'], ['a', 'd']); + testRemoveAllImpl( + 'removeAll of [a,b,c,d] from [a,d]', ['a', 'd'], ['a', 'b', 'c', 'd'], + []); + testRemoveAllImpl( + 'removeAll of [a,b,c,d] from [b,c]', ['b', 'c'], ['a', 'b', 'c', 'd'], + []); + testRemoveAllImpl( + 'removeAll of [a,b,c,d] from [b,c,e]', ['b', 'c', 'e'], + ['a', 'b', 'c', 'd'], ['e']); + }, + + testHasAll() { + testTwoSetLikeImplInterop((setACtor, setBCtor) => { + const s = new setACtor([1, 2, 3]); + + assertTrue('{1, 2, 3} contains []', sets.hasAll(s, [])); + assertTrue('{1, 2, 3} contains [1]', sets.hasAll(s, [1])); + assertTrue('{1, 2, 3} contains [1, 1]', sets.hasAll(s, [1, 1])); + assertTrue('{1, 2, 3} contains [3, 2, 1]', sets.hasAll(s, [3, 2, 1])); + assertFalse('{1, 2, 3} doesn\'t contain [4]', sets.hasAll(s, [4])); + assertFalse('{1, 2, 3} doesn\'t contain [1, 4]', sets.hasAll(s, [1, 4])); + + assertTrue( + '{1, 2, 3} contains {a: 1}', sets.hasAll(s, Object.values({a: 1}))); + assertFalse( + '{1, 2, 3} doesn\'t contain {a: 4}', + sets.hasAll(s, Object.values({a: 4}))); + + assertTrue('{1, 2, 3} contains {1}', sets.hasAll(s, new setBCtor([1]))); + assertFalse( + '{1, 2, 3} doesn\'t contain {4}', sets.hasAll(s, new setBCtor([4]))); + }); + }, + + testEquals() { + /** + * Helper method for testEquals(). + * @param {?} a First element to use in the tests. + * @param {?} b Second element to use in the tests. + * @param {?} c Third element to use in the tests. + * @param {?} d Fourth element to use in the tests. + */ + const testEqualsImpl = function(a, b, c, d) { + testTwoSetLikeImplInterop((setACtor, setBCtor) => { + const s = new setACtor([a, b, c]); + + assertTrue('set == itself', sets.equals(s, s)); + assertTrue('set == same set', sets.equals(s, new setBCtor([a, b, c]))); + assertTrue('set == its clone', sets.equals(s, new setBCtor(s))); + assertTrue('set == array of same elements', sets.equals(s, [a, b, c])); + assertTrue( + 'set == array of same elements in different order', + sets.equals(s, [c, b, a])); + + assertFalse('set != empty set', sets.equals(s, new setBCtor)); + assertFalse('set != its subset', sets.equals(s, new setBCtor([a, c]))); + assertFalse( + 'set != its superset', sets.equals(s, new setBCtor([a, b, c, d]))); + assertFalse( + 'set != different set', sets.equals(s, new setBCtor([b, c, d]))); + assertFalse('set != its subset as array', sets.equals(s, [a, c])); + assertFalse( + 'set != its superset as array', sets.equals(s, [a, b, c, d])); + assertFalse('set != different set as array', sets.equals(s, [b, c, d])); + assertFalse('set != [a, b, c, c]', sets.equals(s, [a, b, c, c])); + assertFalse('set != [a, b, b]', sets.equals(s, [a, b, b])); + assertFalse('set != [a, a]', sets.equals(s, [a, a])); + }); + }; + testEqualsImpl(1, 2, 3, 4); + testEqualsImpl('a', 'b', 'c', 'd'); + }, + + testIsSubsetOf() { + /** + * Helper method for testIsSubsetOf(). + * @param {?} a First element to use in the tests. + * @param {?} b Second element to use in the tests. + * @param {?} c Third element to use in the tests. + * @param {?} d Fourth element to use in the tests. + */ + const testSubsetOfImpl = function(a, b, c, d) { + testTwoSetLikeImplInterop((setACtor, setBCtor) => { + const s = new setACtor([a, b, c]); + + assertTrue('set <= itself', sets.isSubsetOf(s, s)); + assertTrue( + 'set <= same set', sets.isSubsetOf(s, new setBCtor([a, b, c]))); + assertTrue('set <= its clone', sets.isSubsetOf(s, new setBCtor(s))); + assertTrue( + 'set <= array of same elements', sets.isSubsetOf(s, [a, b, c])); + assertTrue( + 'set <= array of same elements in different order', + sets.equals(s, [c, b, a])); + + assertTrue( + 'set <= Set([a, b, c, d])', + sets.isSubsetOf(s, new setBCtor([a, b, c, d]))); + assertTrue('set <= [a, b, c, d]', sets.isSubsetOf(s, [a, b, c, d])); + assertTrue('set <= [a, b, c, c]', sets.isSubsetOf(s, [a, b, c, c])); + + assertFalse( + 'set !<= Set([a, b])', sets.isSubsetOf(s, new setBCtor([a, b]))); + assertFalse('set !<= [a, b]', sets.isSubsetOf(s, [a, b])); + assertFalse( + 'set !<= Set([c, d])', sets.isSubsetOf(s, new setBCtor([c, d]))); + assertFalse('set !<= [c, d]', sets.isSubsetOf(s, [c, d])); + assertFalse( + 'set !<= Set([a, c, d])', + sets.isSubsetOf(s, new setBCtor([a, c, d]))); + assertFalse('set !<= [a, c, d]', sets.isSubsetOf(s, [a, c, d])); + assertFalse('set !<= [a, a, b]', sets.isSubsetOf(s, [a, a, b])); + assertFalse('set !<= [a, a, b, b]', sets.isSubsetOf(s, [a, a, b, b])); + }); + }; + testSubsetOfImpl(1, 2, 3, 4); + testSubsetOfImpl('a', 'b', 'c', 'd'); + }, +}); diff --git a/closure/goog/color/BUILD b/closure/goog/color/BUILD new file mode 100644 index 0000000000..d41c8c16bc --- /dev/null +++ b/closure/goog/color/BUILD @@ -0,0 +1,28 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "alpha", + srcs = ["alpha.js"], + lenient = True, + deps = [":color"], +) + +closure_js_library( + name = "color", + srcs = ["color.js"], + lenient = True, + deps = [ + ":names", + "//closure/goog/math", + ], +) + +closure_js_library( + name = "names", + srcs = ["names.js"], + lenient = True, +) diff --git a/closure/goog/color/alpha.js b/closure/goog/color/alpha.js new file mode 100644 index 0000000000..6f38ee839f --- /dev/null +++ b/closure/goog/color/alpha.js @@ -0,0 +1,519 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities related to alpha/transparent colors and alpha color + * conversion. + */ + +goog.provide('goog.color.alpha'); + +goog.require('goog.color'); + + +/** + * Parses an alpha color out of a string. + * @param {string} str Color in some format. + * @return {{hex: string, type: string}} 'hex' is a string containing + * a hex representation of the color, and 'type' is a string + * containing the type of color format passed in ('hex', 'rgb', 'named'). + */ +goog.color.alpha.parse = function(str) { + 'use strict'; + const result = {}; + str = String(str); + + const maybeHex = goog.color.prependHashIfNecessaryHelper(str); + if (goog.color.alpha.isValidAlphaHexColor_(maybeHex)) { + result.hex = goog.color.alpha.normalizeAlphaHex_(maybeHex); + result.type = 'hex'; + return result; + } else { + const rgba = goog.color.alpha.isValidRgbaColor_(str); + if (rgba.length) { + result.hex = goog.color.alpha.rgbaArrayToHex(rgba); + result.type = 'rgba'; + return result; + } else { + const hsla = goog.color.alpha.isValidHslaColor_(str); + if (hsla.length) { + result.hex = goog.color.alpha.hslaArrayToHex(hsla); + result.type = 'hsla'; + return result; + } + } + } + throw new Error(str + ' is not a valid color string'); +}; + + +/** + * Converts a hex representation of a color to RGBA. + * @param {string} hexColor Color to convert. + * @return {string} string of the form 'rgba(R,G,B,A)' which can be used in + * styles. + */ +goog.color.alpha.hexToRgbaStyle = function(hexColor) { + 'use strict'; + return goog.color.alpha.rgbaStyle_(goog.color.alpha.hexToRgba(hexColor)); +}; + + +/** + * Extracts a substring, from startIdx to endIdx, of the normalized (lowercase + * #rrggbbaa) form of a hex-with-alpha color. + * @param {string} colorWithAlpha The alpha hex color to get the hex color from. + * This may be four or eight digits. + * @param {number} startIdx The start index within the #rrggbbaa color. + * @param {number} endIdx The end index within the #rrggbbbaa color. + * @return {string} The requested startIdx-to-endIdx substring from the color. + * @private + */ +goog.color.alpha.extractColor_ = function(colorWithAlpha, startIdx, endIdx) { + 'use strict'; + if (goog.color.alpha.isValidAlphaHexColor_(colorWithAlpha)) { + const fullColor = goog.color.prependHashIfNecessaryHelper(colorWithAlpha); + const normalizedColor = goog.color.alpha.normalizeAlphaHex_(fullColor); + return normalizedColor.substring(startIdx, endIdx); + } else { + throw new Error(colorWithAlpha + ' is not a valid 8-hex color string'); + } +}; + + +/** + * Gets the hex color part of an alpha hex color. For example, both '#abcd' and + * '#AABBCC12' return '#aabbcc'. + * @param {string} colorWithAlpha The alpha hex color to get the hex color from. + * @return {string} The hex color where the alpha part has been stripped off. + */ +goog.color.alpha.extractHexColor = function(colorWithAlpha) { + 'use strict'; + return goog.color.alpha.extractColor_(colorWithAlpha, 0, 7); +}; + + +/** + * Gets the alpha color part of an alpha hex color. For example, both '#123A' + * and '#123456aa' return 'aa'. The result is always two characters long. + * @param {string} colorWithAlpha The alpha hex color to get the hex color from. + * @return {string} The two-character alpha from the given color. + */ +goog.color.alpha.extractAlpha = function(colorWithAlpha) { + 'use strict'; + return goog.color.alpha.extractColor_(colorWithAlpha, 7, 9); +}; + + +/** + * Regular expression for extracting the digits in a hex color quadruplet. + * @const {!RegExp} + * @private + */ +goog.color.alpha.hexQuadrupletRe_ = /#(.)(.)(.)(.)/; + + +/** + * Normalize a hex representation of an alpha color. + * @param {string} hexColor an alpha hex color string. + * @return {string} hex color in the format '#rrggbbaa' with all lowercase + * literals. + * @private + */ +goog.color.alpha.normalizeAlphaHex_ = function(hexColor) { + 'use strict'; + if (!goog.color.alpha.isValidAlphaHexColor_(hexColor)) { + throw new Error('\'' + hexColor + '\' is not a valid alpha hex color'); + } + if (hexColor.length == 5) { // of the form #RGBA + hexColor = hexColor.replace( + goog.color.alpha.hexQuadrupletRe_, '#$1$1$2$2$3$3$4$4'); + } + return hexColor.toLowerCase(); +}; + + +/** + * Converts an 8-hex representation of a color to RGBA. + * @param {string} hexColor Color to convert. + * @return {!Array} array containing [r, g, b, a]. + * r, g, b are ints between 0 + * and 255, and a is a value between 0 and 1. + */ +goog.color.alpha.hexToRgba = function(hexColor) { + 'use strict'; + // TODO(user): Enhance code sharing with goog.color, for example by + // adding a goog.color.genericHexToRgb method. + hexColor = goog.color.alpha.normalizeAlphaHex_(hexColor); + const r = parseInt(hexColor.slice(1, 3), 16); + const g = parseInt(hexColor.slice(3, 5), 16); + const b = parseInt(hexColor.slice(5, 7), 16); + const a = parseInt(hexColor.slice(7, 9), 16); + + return [r, g, b, a / 255]; +}; + + +/** + * Converts a color from RGBA to hex representation. + * @param {number} r Amount of red, int between 0 and 255. + * @param {number} g Amount of green, int between 0 and 255. + * @param {number} b Amount of blue, int between 0 and 255. + * @param {number} a Amount of alpha, float between 0 and 1. + * @return {string} hex representation of the color. + */ +goog.color.alpha.rgbaToHex = function(r, g, b, a) { + 'use strict'; + const intAlpha = Math.floor(a * 255); + if (isNaN(intAlpha) || intAlpha < 0 || intAlpha > 255) { + // TODO(user): The CSS spec says the value should be clamped. + throw new Error( + '"(' + r + ',' + g + ',' + b + ',' + a + + '") is not a valid RGBA color'); + } + const hexA = goog.color.prependZeroIfNecessaryHelper(intAlpha.toString(16)); + return goog.color.rgbToHex(r, g, b) + hexA; +}; + + +/** + * Converts a color from HSLA to hex representation. + * @param {number} h Amount of hue, int between 0 and 360. + * @param {number} s Amount of saturation, int between 0 and 100. + * @param {number} l Amount of lightness, int between 0 and 100. + * @param {number} a Amount of alpha, float between 0 and 1. + * @return {string} hex representation of the color. + */ +goog.color.alpha.hslaToHex = function(h, s, l, a) { + 'use strict'; + const intAlpha = Math.floor(a * 255); + if (isNaN(intAlpha) || intAlpha < 0 || intAlpha > 255) { + // TODO(user): The CSS spec says the value should be clamped. + throw new Error( + '"(' + h + ',' + s + ',' + l + ',' + a + + '") is not a valid HSLA color'); + } + const hexA = goog.color.prependZeroIfNecessaryHelper(intAlpha.toString(16)); + return goog.color.hslToHex(h, s / 100, l / 100) + hexA; +}; + + +/** + * Converts a color from RGBA to hex representation. + * @param {!Array} rgba Array of [r, g, b, a], with r, g, b in [0, 255] + * and a in [0, 1]. + * @return {string} hex representation of the color. + */ +goog.color.alpha.rgbaArrayToHex = function(rgba) { + 'use strict'; + return goog.color.alpha.rgbaToHex(rgba[0], rgba[1], rgba[2], rgba[3]); +}; + + +/** + * Converts a color from RGBA to an RGBA style string. + * @param {number} r Value of red, in [0, 255]. + * @param {number} g Value of green, in [0, 255]. + * @param {number} b Value of blue, in [0, 255]. + * @param {number} a Value of alpha, in [0, 1]. + * @return {string} An 'rgba(r,g,b,a)' string ready for use in a CSS rule. + */ +goog.color.alpha.rgbaToRgbaStyle = function(r, g, b, a) { + 'use strict'; + if (isNaN(r) || r < 0 || r > 255 || isNaN(g) || g < 0 || g > 255 || + isNaN(b) || b < 0 || b > 255 || isNaN(a) || a < 0 || a > 1) { + throw new Error( + '"(' + r + ',' + g + ',' + b + ',' + a + + ')" is not a valid RGBA color'); + } + return goog.color.alpha.rgbaStyle_([r, g, b, a]); +}; + + +/** + * Converts a color from RGBA to an RGBA style string. + * @param {(!Array|!Float32Array)} rgba Array of [r, g, b, a], + * with r, g, b in [0, 255] and a in [0, 1]. + * @return {string} An 'rgba(r,g,b,a)' string ready for use in a CSS rule. + */ +goog.color.alpha.rgbaArrayToRgbaStyle = function(rgba) { + 'use strict'; + return goog.color.alpha.rgbaToRgbaStyle(rgba[0], rgba[1], rgba[2], rgba[3]); +}; + + +/** + * Converts a color from HSLA to hex representation. + * @param {!Array} hsla Array of [h, s, l, a], where h is an integer in + * [0, 360], s and l are integers in [0, 100], and a is in [0, 1]. + * @return {string} hex representation of the color, such as '#af457eff'. + */ +goog.color.alpha.hslaArrayToHex = function(hsla) { + 'use strict'; + return goog.color.alpha.hslaToHex(hsla[0], hsla[1], hsla[2], hsla[3]); +}; + + +/** + * Converts a color from HSLA to an RGBA style string. + * @param {!Array} hsla Array of [h, s, l, a], where h is and integer in + * [0, 360], s and l are integers in [0, 100], and a is in [0, 1]. + * @return {string} An 'rgba(r,g,b,a)' string ready for use in a CSS rule. + */ +goog.color.alpha.hslaArrayToRgbaStyle = function(hsla) { + 'use strict'; + return goog.color.alpha.hslaToRgbaStyle(hsla[0], hsla[1], hsla[2], hsla[3]); +}; + + +/** + * Converts a color from HSLA to an RGBA style string. + * @param {number} h Amount of hue, int between 0 and 360. + * @param {number} s Amount of saturation, int between 0 and 100. + * @param {number} l Amount of lightness, int between 0 and 100. + * @param {number} a Amount of alpha, float between 0 and 1. + * @return {string} An 'rgba(r,g,b,a)' string ready for use in a CSS rule. + * styles. + */ +goog.color.alpha.hslaToRgbaStyle = function(h, s, l, a) { + 'use strict'; + return goog.color.alpha.rgbaStyle_(goog.color.alpha.hslaToRgba(h, s, l, a)); +}; + + +/** + * Converts a color from HSLA color space to RGBA color space. + * @param {number} h Amount of hue, int between 0 and 360. + * @param {number} s Amount of saturation, int between 0 and 100. + * @param {number} l Amount of lightness, int between 0 and 100. + * @param {number} a Amount of alpha, float between 0 and 1. + * @return {!Array} [r, g, b, a] values for the color, where r, g, b + * are integers in [0, 255] and a is a float in [0, 1]. + */ +goog.color.alpha.hslaToRgba = function(h, s, l, a) { + 'use strict'; + return goog.color.hslToRgb(h, s / 100, l / 100).concat(a); +}; + + +/** + * Converts a color from RGBA color space to HSLA color space. + * Modified from {@link http://en.wikipedia.org/wiki/HLS_color_space}. + * @param {number} r Value of red, in [0, 255]. + * @param {number} g Value of green, in [0, 255]. + * @param {number} b Value of blue, in [0, 255]. + * @param {number} a Value of alpha, in [0, 255]. + * @return {!Array} [h, s, l, a] values for the color, with h an int in + * [0, 360] and s, l and a in [0, 1]. + */ +goog.color.alpha.rgbaToHsla = function(r, g, b, a) { + 'use strict'; + return goog.color.rgbToHsl(r, g, b).concat(a); +}; + + +/** + * Converts a color from RGBA color space to HSLA color space. + * @param {!Array} rgba [r, g, b, a] values for the color, each in + * [0, 255]. + * @return {!Array} [h, s, l, a] values for the color, with h in + * [0, 360] and s, l and a in [0, 1]. + */ +goog.color.alpha.rgbaArrayToHsla = function(rgba) { + 'use strict'; + return goog.color.alpha.rgbaToHsla(rgba[0], rgba[1], rgba[2], rgba[3]); +}; + + +/** + * Helper for isValidAlphaHexColor_. + * @const {!RegExp} + * @private + */ +goog.color.alpha.validAlphaHexColorRe_ = /^#(?:[0-9a-f]{4}){1,2}$/i; + + +/** + * Checks if a string is a valid alpha hex color. We expect strings of the + * format #RRGGBBAA (ex: #1b3d5f5b) or #RGBA (ex: #3CAF == #33CCAAFF). + * @param {string} str String to check. + * @return {boolean} Whether the string is a valid alpha hex color. + * @private + */ +// TODO(user): Support percentages when goog.color also supports them. +goog.color.alpha.isValidAlphaHexColor_ = function(str) { + 'use strict'; + return goog.color.alpha.validAlphaHexColorRe_.test(str); +}; + + +/** + * Helper for isNormalizedAlphaHexColor_. + * @const {!RegExp} + * @private + */ +goog.color.alpha.normalizedAlphaHexColorRe_ = /^#[0-9a-f]{8}$/; + + +/** + * Checks if a string is a normalized alpha hex color. + * We expect strings of the format #RRGGBBAA (ex: #1b3d5f5b) + * using only lowercase letters. + * @param {string} str String to check. + * @return {boolean} Whether the string is a normalized hex color. + * @private + */ +goog.color.alpha.isNormalizedAlphaHexColor_ = function(str) { + 'use strict'; + return goog.color.alpha.normalizedAlphaHexColorRe_.test(str); +}; + + +/** + * A pattern capturing any 3-digit number (without leading 0s). + * @const {!RegExp} + * @private + */ +goog.color.alpha.re0_999_ = /(0|[1-9]\d{0,2})/; + +/** + * A pattern capturing [0.0000000000...1.0000000000]. + * Number before dot is optional. + * @const {!RegExp} + * @private + */ +goog.color.alpha.re0_1_ = /(0|1|0?\.\d{1,10}|1\.0{1,10})/; + +/** + * Regular expression for matching and capturing RGBA style strings. Helper for + * isValidRgbaColor_. + * @type {!RegExp} + * @private + */ +goog.color.alpha.rgbaColorRe_ = new RegExp( + '^\\s*(?:rgba)?\\(' + // + goog.color.alpha.re0_999_.source + ',\\s*' + // + goog.color.alpha.re0_999_.source + ',\\s*' + // + goog.color.alpha.re0_999_.source + ',\\s*' + // + goog.color.alpha.re0_1_.source + '\\)\\s*$', + 'i'); + + +/** + * Regular expression for matching and capturing HSLA style strings. Helper for + * isValidHslaColor_. + * @type {!RegExp} + * @private + */ +goog.color.alpha.hslaColorRe_ = new RegExp( + '^\\s*(?:hsla)?\\(' + // + goog.color.alpha.re0_999_.source + ',\\s*' + // + goog.color.alpha.re0_999_.source + '%,\\s*' + // + goog.color.alpha.re0_999_.source + '%,\\s*' + // + goog.color.alpha.re0_1_.source + '\\)\\s*$', + 'i'); + +/** + * Checks if a string is a valid rgba color. We expect strings of the format + * '(r, g, b, a)', or 'rgba(r, g, b, a)', where r, g, b are ints in [0, 255] + * and a is a float in [0, 1]. + * @param {string} str String to check. + * @return {!Array} the integers [r, g, b, a] for valid colors or the + * empty array for invalid colors. + * @private + */ +goog.color.alpha.isValidRgbaColor_ = function(str) { + 'use strict'; + // Each component is separate (rather than using a repeater) so we can + // capture the match. Also, we explicitly set each component to be either 0, + // or start with a non-zero, to prevent octal numbers from slipping through. + const regExpResultArray = str.match(goog.color.alpha.rgbaColorRe_); + if (regExpResultArray) { + const r = Number(regExpResultArray[1]); + const g = Number(regExpResultArray[2]); + const b = Number(regExpResultArray[3]); + const a = Number(regExpResultArray[4]); + if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255 && + a >= 0 && a <= 1) { + return [r, g, b, a]; + } + } + return []; +}; + + +/** + * Checks if a string is a valid hsla color. We expect strings of the format + * 'hsla(h, s, l, a)', where s in an int in [0, 360], s and l are percentages + * between 0 and 100 such as '50%' or '70%', and a is a float in [0, 1]. + * @param {string} str String to check. + * @return {!Array} the integers [h, s, l, a] for valid colors or the + * empty array for invalid colors. + * @private + */ +goog.color.alpha.isValidHslaColor_ = function(str) { + 'use strict'; + // Each component is separate (rather than using a repeater) so we can + // capture the match. Also, we explicitly set each component to be either 0, + // or start with a non-zero, to prevent octal numbers from slipping through. + const regExpResultArray = str.match(goog.color.alpha.hslaColorRe_); + if (regExpResultArray) { + const h = Number(regExpResultArray[1]); + const s = Number(regExpResultArray[2]); + const l = Number(regExpResultArray[3]); + const a = Number(regExpResultArray[4]); + if (h >= 0 && h <= 360 && s >= 0 && s <= 100 && l >= 0 && l <= 100 && + a >= 0 && a <= 1) { + return [h, s, l, a]; + } + } + return []; +}; + + +/** + * Takes an array of [r, g, b, a] and converts it into a string appropriate for + * CSS styles. The alpha channel value is rounded to 3 decimal places to make + * sure the produced string is not too long. + * @param {!Array} rgba [r, g, b, a] with r, g, b in [0, 255] and a + * in [0, 1]. + * @return {string} string of the form 'rgba(r,g,b,a)'. + * @private + */ +goog.color.alpha.rgbaStyle_ = function(rgba) { + 'use strict'; + const roundedRgba = rgba.slice(0); + roundedRgba[3] = Math.round(rgba[3] * 1000) / 1000; + return 'rgba(' + roundedRgba.join(',') + ')'; +}; + + +/** + * Converts from h,s,v,a values to a hex string + * @param {number} h Hue, in [0, 1]. + * @param {number} s Saturation, in [0, 1]. + * @param {number} v Value, in [0, 255]. + * @param {number} a Alpha, in [0, 1]. + * @return {string} hex representation of the color. + */ +goog.color.alpha.hsvaToHex = function(h, s, v, a) { + 'use strict'; + const alpha = Math.floor(a * 255); + return goog.color.hsvArrayToHex([h, s, v]) + + goog.color.prependZeroIfNecessaryHelper(alpha.toString(16)); +}; + + +/** + * Converts from an HSVA array to a hex string + * @param {!Array} hsva Array of [h, s, v, a] in + * [[0, 1], [0, 1], [0, 255], [0, 1]]. + * @return {string} hex representation of the color. + */ +goog.color.alpha.hsvaArrayToHex = function(hsva) { + 'use strict'; + return goog.color.alpha.hsvaToHex(hsva[0], hsva[1], hsva[2], hsva[3]); +}; diff --git a/closure/goog/color/alpha_test.js b/closure/goog/color/alpha_test.js new file mode 100644 index 0000000000..d873276c70 --- /dev/null +++ b/closure/goog/color/alpha_test.js @@ -0,0 +1,390 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ +goog.module('goog.color.alphaTest'); +goog.setTestOnly(); + +const alpha = goog.require('goog.color.alpha'); +const testSuite = goog.require('goog.testing.testSuite'); + +testSuite({ + /** + * @suppress {visibility} accessing private properties + */ + testIsValidAlphaHexColor() { + const goodAlphaHexColors = [ + '#ffffffff', + '#ff781259', + '#01234567', + '#Ff003DaB', + '#3CAF', + '#abcdefab', + '#3CAB', + ]; + const badAlphaHexColors = + ['#xxxxxxxx', '88990077', 'not_color', '#123456789', 'fffffgfg']; + for (let i = 0; i < goodAlphaHexColors.length; i++) { + assertTrue( + goodAlphaHexColors[i], + alpha.isValidAlphaHexColor_(goodAlphaHexColors[i])); + } + for (let i = 0; i < badAlphaHexColors.length; i++) { + assertFalse( + badAlphaHexColors[i], + alpha.isValidAlphaHexColor_(badAlphaHexColors[i])); + } + }, + + /** + * @suppress {visibility} accessing private properties + */ + testIsValidRgbaColor() { + const goodRgbaColors = [ + 'rgba(1, 20, 234, 1)', + 'rgba(255,127, 0,1)', + 'rgba(0,0,255,0.5)', + '(255, 26, 75, 0.2)', + 'RGBA(0, 55, 0, 0.6)', + 'rGbA(0, 200, 0, 0.123456789)', + 'rgba(255, 0, 0, 1.0)', + ' rgba(1,\t2,\n3,\r0.2) ', + 'rgba(255, 0, 0, .2)', + ]; + const badRgbaColors = [ + '(255, 0, 0)', + '(2555,0,0, 0)', + '(1,2,3,4,5)', + 'rgba(1,20,)', + 'RGBA(20,20,20,)', + 'RGBA', + 'rgba(255, 0, 0, 1.1)', + 'rgba(255, 0, 0, 1.00001)', + 'rgba(255, 0, 0, 1.)', + 'rgba(01, 0, 0, 1)', + ]; + for (let i = 0; i < goodRgbaColors.length; i++) { + assertEquals( + goodRgbaColors[i], 4, + alpha.isValidRgbaColor_(goodRgbaColors[i]).length); + } + for (let i = 0; i < badRgbaColors.length; i++) { + assertEquals( + badRgbaColors[i], 0, + alpha.isValidRgbaColor_(badRgbaColors[i]).length); + } + }, + + /** + * @suppress {visibility} accessing private properties + */ + testIsValidHslaColor() { + const goodHslaColors = [ + 'hsla(120, 0%, 0%, 1)', + 'hsla(360,20%,0%,1)', + 'hsla(0,0%,50%,0.5)', + 'HSLA(0, 55%, 0%, 0.6)', + 'hsla(0, 85%, 0%, 0.123456789)', + 'hsla(120, 0%, 0%, 1.0)', + ' hsla(120,\t0%,\n0%,\r0.2) ', + ]; + const badHslaColors = [ + '(255, 0, 0, 0)', + 'hsla(2555,0,0, 0)', + 'hsla(1,2,3,4,5)', + 'hsla(1,20,)', + 'HSLA(20,20,20,)', + 'hsla(255, 0, 0, 1.1)', + 'hsla(255, 0, 0, 1.00001)', + 'HSLA', + 'hsla(255, 0, 0, 1.)', + ]; + for (let i = 0; i < goodHslaColors.length; i++) { + assertEquals( + goodHslaColors[i], 4, + alpha.isValidHslaColor_(goodHslaColors[i]).length); + } + for (let i = 0; i < badHslaColors.length; i++) { + assertEquals( + badHslaColors[i], 0, + alpha.isValidHslaColor_(badHslaColors[i]).length); + } + }, + + testParse() { + const colors = [ + 'rgba(15, 250, 77, 0.5)', + '(127, 127, 127, 0.8)', + '#ffeeddaa', + '12345678', + 'hsla(160, 50%, 90%, 0.2)', + ]; + const parsed = colors.map(alpha.parse); + assertEquals('rgba', parsed[0].type); + assertEquals(alpha.rgbaToHex(15, 250, 77, 0.5), parsed[0].hex); + assertEquals('rgba', parsed[1].type); + assertEquals(alpha.rgbaToHex(127, 127, 127, 0.8), parsed[1].hex); + assertEquals('hex', parsed[2].type); + assertEquals('#ffeeddaa', parsed[2].hex); + assertEquals('hex', parsed[3].type); + assertEquals('#12345678', parsed[3].hex); + assertEquals('hsla', parsed[4].type); + assertEquals('#d9f2ea33', parsed[4].hex); + + const e = assertThrows( + 'not_color is not a valid color string', + goog.partial(alpha.parse, 'not_color')); + assertContains( + 'Error processing not_color', 'is not a valid color string', e.message); + }, + + testHexToRgba() { + const testColors = [ + ['#B0FF2D66', [176, 255, 45, 0.4]], + ['#b26e5fcc', [178, 110, 95, 0.8]], + ['#66f3', [102, 102, 255, 0.2]], + ]; + + for (let i = 0; i < testColors.length; i++) { + const r = alpha.hexToRgba(testColors[i][0]); + const t = testColors[i][1]; + + assertEquals('Red channel should match.', t[0], r[0]); + assertEquals('Green channel should match.', t[1], r[1]); + assertEquals('Blue channel should match.', t[2], r[2]); + assertEquals('Alpha channel should match.', t[3], r[3]); + } + + const badColors = ['', '#g00', 'some words']; + for (let i = 0; i < badColors.length; i++) { + const e = assertThrows(goog.partial(alpha.hexToRgba, badColors[i])); + assertEquals( + '\'' + badColors[i] + '\' is not a valid alpha hex color', e.message); + } + }, + + testHexToRgbaStyle() { + assertEquals('rgba(255,0,0,1)', alpha.hexToRgbaStyle('#ff0000ff')); + assertEquals('rgba(206,206,206,0.8)', alpha.hexToRgbaStyle('#cecececc')); + assertEquals('rgba(51,204,170,0.2)', alpha.hexToRgbaStyle('#3CA3')); + assertEquals('rgba(1,2,3,0.016)', alpha.hexToRgbaStyle('#01020304')); + assertEquals('rgba(255,255,0,0.333)', alpha.hexToRgbaStyle('#FFFF0055')); + + const badHexColors = ['#12345', null, undefined, '#.1234567890']; + for (let i = 0; i < badHexColors.length; ++i) { + const e = assertThrows( + badHexColors[i] + ' is an invalid hex color', + goog.partial(alpha.hexToRgbaStyle, badHexColors[i])); + assertEquals( + '\'' + badHexColors[i] + '\' is not a valid alpha hex color', + e.message); + } + }, + + testRgbaToHex() { + assertEquals('#af13ffff', alpha.rgbaToHex(175, 19, 255, 1)); + assertEquals('#357cf099', alpha.rgbaToHex(53, 124, 240, 0.6)); + const badRgba = [ + [-1, -1, -1, -1], + [0, 0, 0, 2], + ['a', 'b', 'c', 'd'], + [undefined, 5, 5, 5], + ]; + for (let i = 0; i < badRgba.length; ++i) { + const e = assertThrows( + badRgba[i] + ' is not a valid rgba color', + goog.partial(alpha.rgbaArrayToHex, badRgba[i])); + assertContains('is not a valid RGBA color', e.message); + } + }, + + testRgbaToRgbaStyle() { + const testColors = [ + [[175, 19, 255, 1], 'rgba(175,19,255,1)'], + [[53, 124, 240, .6], 'rgba(53,124,240,0.6)'], + [[10, 20, 30, .1234567], 'rgba(10,20,30,0.123)'], + [[20, 30, 40, 1 / 3], 'rgba(20,30,40,0.333)'], + ]; + + for (let i = 0; i < testColors.length; ++i) { + const r = alpha.rgbaToRgbaStyle( + testColors[i][0][0], testColors[i][0][1], testColors[i][0][2], + testColors[i][0][3]); + assertEquals(testColors[i][1], r); + } + + const badColors = [[0, 0, 0, 2]]; + for (let i = 0; i < badColors.length; ++i) { + const e = assertThrows(goog.partial( + alpha.rgbaToRgbaStyle, badColors[i][0], badColors[i][1], + badColors[i][2], badColors[i][3])); + + assertContains('is not a valid RGBA color', e.message); + } + + // Loop through all bad color values and ensure they fail in each channel. + const badValues = [-1, 300, 'a', undefined, null, NaN]; + const color = [0, 0, 0, 0]; + for (let i = 0; i < badValues.length; ++i) { + for (let channel = 0; channel < color.length; ++channel) { + color[channel] = badValues[i]; + const e = assertThrows( + `${color} is not a valid rgba color`, + goog.partial(alpha.rgbaToRgbaStyle, color)); + assertContains('is not a valid RGBA color', e.message); + + color[channel] = 0; + } + } + }, + + testRgbaArrayToRgbaStyle() { + const testColors = [ + [[175, 19, 255, 1], 'rgba(175,19,255,1)'], + [[53, 124, 240, .6], 'rgba(53,124,240,0.6)'], + ]; + + for (let i = 0; i < testColors.length; ++i) { + const r = alpha.rgbaArrayToRgbaStyle(testColors[i][0]); + assertEquals(testColors[i][1], r); + } + + const badColors = [[0, 0, 0, 2]]; + for (let i = 0; i < badColors.length; ++i) { + const e = + assertThrows(goog.partial(alpha.rgbaArrayToRgbaStyle, badColors[i])); + + assertContains('is not a valid RGBA color', e.message); + } + + // Loop through all bad color values and ensure they fail in each channel. + const badValues = [-1, 300, 'a', undefined, null, NaN]; + const color = [0, 0, 0, 0]; + for (let i = 0; i < badValues.length; ++i) { + for (let channel = 0; channel < color.length; ++channel) { + color[channel] = badValues[i]; + const e = assertThrows( + `${color} is not a valid rgba color`, + goog.partial(alpha.rgbaToRgbaStyle, color)); + assertContains('is not a valid RGBA color', e.message); + + color[channel] = 0; + } + } + }, + + testRgbaArrayToHsla() { + const opaqueBlueRgb = [0, 0, 255, 1]; + const opaqueBlueHsl = alpha.rgbaArrayToHsla(opaqueBlueRgb); + assertArrayEquals( + 'Conversion from RGBA to HSLA should be as expected', [240, 1, 0.5, 1], + opaqueBlueHsl); + + const nearlyOpaqueYellowRgb = [255, 190, 0, 0.7]; + const nearlyOpaqueYellowHsl = alpha.rgbaArrayToHsla(nearlyOpaqueYellowRgb); + assertArrayEquals( + 'Conversion from RGBA to HSLA should be as expected', [45, 1, 0.5, 0.7], + nearlyOpaqueYellowHsl); + + const transparentPurpleRgb = [180, 0, 255, 0]; + const transparentPurpleHsl = alpha.rgbaArrayToHsla(transparentPurpleRgb); + assertArrayEquals( + 'Conversion from RGBA to HSLA should be as expected', [282, 1, 0.5, 0], + transparentPurpleHsl); + }, + + /** + * @suppress {visibility} accessing private properties + */ + testNormalizeAlphaHex() { + const compactColor = '#abcd'; + const normalizedCompactColor = alpha.normalizeAlphaHex_(compactColor); + assertEquals( + 'The color should have been normalized to the right length', + '#aabbccdd', normalizedCompactColor); + + const uppercaseColor = '#ABCDEF01'; + const normalizedUppercaseColor = alpha.normalizeAlphaHex_(uppercaseColor); + assertEquals( + 'The color should have been normalized to lowercase', '#abcdef01', + normalizedUppercaseColor); + }, + + testHsvaArrayToHex() { + const opaqueSkyBlueHsv = [190, 1, 255, 1]; + const opaqueSkyBlueHex = alpha.hsvaArrayToHex(opaqueSkyBlueHsv); + assertEquals( + 'The HSVA array should have been properly converted to hex', + '#00d5ffff', opaqueSkyBlueHex); + + const halfTransparentPinkHsv = [300, 1, 255, 0.5]; + const halfTransparentPinkHex = alpha.hsvaArrayToHex(halfTransparentPinkHsv); + assertEquals( + 'The HSVA array should have been properly converted to hex', + '#ff00ff7f', halfTransparentPinkHex); + + const transparentDarkTurquoiseHsv = [175, 1, 127, 0.5]; + const transparentDarkTurquoiseHex = + alpha.hsvaArrayToHex(transparentDarkTurquoiseHsv); + assertEquals( + 'The HSVA array should have been properly converted to hex', + '#007f747f', transparentDarkTurquoiseHex); + }, + + testExtractHexColor() { + const opaqueRed = '#ff0000ff'; + const red = alpha.extractHexColor(opaqueRed); + assertEquals( + 'The hex part of the color should have been extracted correctly', + '#ff0000', red); + + const halfOpaqueDarkGreenCompact = '#0507'; + const darkGreen = alpha.extractHexColor(halfOpaqueDarkGreenCompact); + assertEquals( + 'The hex part of the color should have been extracted correctly', + '#005500', darkGreen); + }, + + testExtractAlpha() { + const colors = ['#ff0000ff', '#0507', '#ff000005']; + const expectedOpacities = ['ff', '77', '05']; + + for (let i = 0; i < colors.length; i++) { + const opacity = alpha.extractAlpha(colors[i]); + assertEquals( + 'The alpha transparency should have been extracted correctly', + expectedOpacities[i], opacity); + } + }, + + testHslaArrayToRgbaStyle() { + assertEquals( + 'rgba(102,255,102,0.5)', + alpha.hslaArrayToRgbaStyle([120, 100, 70, 0.5])); + assertEquals( + 'rgba(28,23,23,0.9)', alpha.hslaArrayToRgbaStyle([0, 10, 10, 0.9])); + }, + + /** + * @suppress {visibility} accessing private properties + */ + testRgbaStyleParsableResult() { + const testColors = [ + [175, 19, 255, 1], + [53, 124, 240, .6], + [20, 30, 40, 0.3333333], + [255, 255, 255, 0.7071067811865476], + ]; + + for (let i = 0, testColor; testColor = testColors[i]; i++) { + const rgbaStyle = alpha.rgbaStyle_(testColor); + const parsedColor = alpha.hexToRgba(alpha.parse(rgbaStyle).hex); + assertEquals(testColor[0], parsedColor[0]); + assertEquals(testColor[1], parsedColor[1]); + assertEquals(testColor[2], parsedColor[2]); + // Parsing keeps a 1/255 accuracy on the alpha channel. + assertRoughlyEquals(testColor[3], parsedColor[3], 0.005); + } + }, +}); diff --git a/closure/goog/color/color.js b/closure/goog/color/color.js new file mode 100644 index 0000000000..1357efea68 --- /dev/null +++ b/closure/goog/color/color.js @@ -0,0 +1,784 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities related to color and color conversion. + */ + +goog.provide('goog.color'); +goog.provide('goog.color.Hsl'); +goog.provide('goog.color.Hsv'); +goog.provide('goog.color.Rgb'); + +goog.require('goog.color.names'); +goog.require('goog.math'); + + +/** + * RGB color representation. An array containing three elements [r, g, b], + * each an integer in [0, 255], representing the red, green, and blue components + * of the color respectively. + * @typedef {Array} + */ +goog.color.Rgb; + + +/** + * HSV color representation. An array containing three elements [h, s, v]: + * h (hue) must be an integer in [0, 360], cyclic. + * s (saturation) must be a number in [0, 1]. + * v (value/brightness) must be an integer in [0, 255]. + * @typedef {Array} + */ +goog.color.Hsv; + + +/** + * HSL color representation. An array containing three elements [h, s, l]: + * h (hue) must be an integer in [0, 360], cyclic. + * s (saturation) must be a number in [0, 1]. + * l (lightness) must be a number in [0, 1]. + * @typedef {Array} + */ +goog.color.Hsl; + + +/** + * Parses a color out of a string. + * @param {string} str Color in some format. + * @return {{hex: string, type: string}} 'hex' is a string containing a hex + * representation of the color, 'type' is a string containing the type + * of color format passed in ('hex', 'rgb', 'named'). + */ +goog.color.parse = function(str) { + 'use strict'; + const result = {}; + str = String(str); + + const maybeHex = goog.color.prependHashIfNecessaryHelper(str); + if (goog.color.isValidHexColor_(maybeHex)) { + result.hex = goog.color.normalizeHex(maybeHex); + result.type = 'hex'; + return result; + } else { + const rgb = goog.color.isValidRgbColor_(str); + if (rgb.length) { + result.hex = goog.color.rgbArrayToHex(rgb); + result.type = 'rgb'; + return result; + } else if (goog.color.names) { + const hex = goog.color.names[str.toLowerCase()]; + if (hex) { + result.hex = hex; + result.type = 'named'; + return result; + } + } + } + throw Error(str + ' is not a valid color string'); +}; + + +/** + * Determines if the given string can be parsed as a color. + * {@see goog.color.parse}. + * @param {string} str Potential color string. + * @return {boolean} True if str is in a format that can be parsed to a color. + */ +goog.color.isValidColor = function(str) { + 'use strict'; + const maybeHex = goog.color.prependHashIfNecessaryHelper(str); + return !!( + goog.color.isValidHexColor_(maybeHex) || + goog.color.isValidRgbColor_(str).length || + goog.color.names && goog.color.names[str.toLowerCase()]); +}; + + +/** + * Parses red, green, blue components out of a valid rgb color string. + * Throws Error if the color string is invalid. + * @param {string} str RGB representation of a color. + * {@see goog.color.isValidRgbColor_}. + * @return {!goog.color.Rgb} rgb representation of the color. + */ +goog.color.parseRgb = function(str) { + 'use strict'; + const rgb = goog.color.isValidRgbColor_(str); + if (!rgb.length) { + throw Error(str + ' is not a valid RGB color'); + } + return rgb; +}; + + +/** + * Converts a hex representation of a color to RGB. + * @param {string} hexColor Color to convert. + * @return {string} string of the form 'rgb(R,G,B)' which can be used in + * styles. + */ +goog.color.hexToRgbStyle = function(hexColor) { + 'use strict'; + return goog.color.rgbStyle_(goog.color.hexToRgb(hexColor)); +}; + + +/** + * Regular expression for extracting the digits in a hex color triplet. + * @type {!RegExp} + * @private + */ +goog.color.hexTripletRe_ = /#(.)(.)(.)/; + + +/** + * Normalize an hex representation of a color + * @param {string} hexColor an hex color string. + * @return {string} hex color in the format '#rrggbb' with all lowercase + * literals. + */ +goog.color.normalizeHex = function(hexColor) { + 'use strict'; + if (!goog.color.isValidHexColor_(hexColor)) { + throw Error("'" + hexColor + "' is not a valid hex color"); + } + if (hexColor.length == 4) { // of the form #RGB + hexColor = hexColor.replace(goog.color.hexTripletRe_, '#$1$1$2$2$3$3'); + } + return hexColor.toLowerCase(); +}; + + +/** + * Converts a hex representation of a color to RGB. + * @param {string} hexColor Color to convert. + * @return {!goog.color.Rgb} rgb representation of the color. + */ +goog.color.hexToRgb = function(hexColor) { + 'use strict'; + hexColor = goog.color.normalizeHex(hexColor); + const rgb = parseInt(hexColor.slice(1), 16); + const r = rgb >> 16; + const g = (rgb >> 8) & 255; + const b = rgb & 255; + + return [r, g, b]; +}; + + +/** + * Converts a color from RGB to hex representation. + * @param {number} r Amount of red, int between 0 and 255. + * @param {number} g Amount of green, int between 0 and 255. + * @param {number} b Amount of blue, int between 0 and 255. + * @return {string} hex representation of the color. + */ +goog.color.rgbToHex = function(r, g, b) { + 'use strict'; + r = Number(r); + g = Number(g); + b = Number(b); + if (r != (r & 255) || g != (g & 255) || b != (b & 255)) { + throw Error('"(' + r + ',' + g + ',' + b + '") is not a valid RGB color'); + } + const rgb = (r << 16) | (g << 8) | b; + if (r < 0x10) { + return '#' + (0x1000000 | rgb).toString(16).slice(1); + } + return '#' + rgb.toString(16); +}; + + +/** + * Converts a color from RGB to hex representation. + * @param {goog.color.Rgb} rgb rgb representation of the color. + * @return {string} hex representation of the color. + */ +goog.color.rgbArrayToHex = function(rgb) { + 'use strict'; + return goog.color.rgbToHex(rgb[0], rgb[1], rgb[2]); +}; + + +/** + * Converts a color from RGB color space to HSL color space. + * Modified from {@link http://en.wikipedia.org/wiki/HLS_color_space}. + * @param {number} r Value of red, in [0, 255]. + * @param {number} g Value of green, in [0, 255]. + * @param {number} b Value of blue, in [0, 255]. + * @return {!goog.color.Hsl} hsl representation of the color. + */ +goog.color.rgbToHsl = function(r, g, b) { + 'use strict'; + // First must normalize r, g, b to be between 0 and 1. + const normR = r / 255; + const normG = g / 255; + const normB = b / 255; + const max = Math.max(normR, normG, normB); + const min = Math.min(normR, normG, normB); + let h = 0; + let s = 0; + + // Luminosity is the average of the max and min rgb color intensities. + const l = 0.5 * (max + min); + + // The hue and saturation are dependent on which color intensity is the max. + // If max and min are equal, the color is gray and h and s should be 0. + if (max != min) { + if (max == normR) { + h = 60 * (normG - normB) / (max - min); + } else if (max == normG) { + h = 60 * (normB - normR) / (max - min) + 120; + } else if (max == normB) { + h = 60 * (normR - normG) / (max - min) + 240; + } + + if (0 < l && l <= 0.5) { + s = (max - min) / (2 * l); + } else { + s = (max - min) / (2 - 2 * l); + } + } + + // Make sure the hue falls between 0 and 360. + return [Math.round(h + 360) % 360, s, l]; +}; + + +/** + * Converts a color from RGB color space to HSL color space. + * @param {goog.color.Rgb} rgb rgb representation of the color. + * @return {!goog.color.Hsl} hsl representation of the color. + */ +goog.color.rgbArrayToHsl = function(rgb) { + 'use strict'; + return goog.color.rgbToHsl(rgb[0], rgb[1], rgb[2]); +}; + + +/** + * Helper for hslToRgb. + * @param {number} v1 Helper variable 1. + * @param {number} v2 Helper variable 2. + * @param {number} vH Helper variable 3. + * @return {number} Appropriate RGB value, given the above. + * @private + */ +goog.color.hueToRgb_ = function(v1, v2, vH) { + 'use strict'; + if (vH < 0) { + vH += 1; + } else if (vH > 1) { + vH -= 1; + } + if ((6 * vH) < 1) { + return (v1 + (v2 - v1) * 6 * vH); + } else if (2 * vH < 1) { + return v2; + } else if (3 * vH < 2) { + return (v1 + (v2 - v1) * ((2 / 3) - vH) * 6); + } + return v1; +}; + + +/** + * Converts a color from HSL color space to RGB color space. + * Modified from {@link http://www.easyrgb.com/math.html} + * @param {number} h Hue, in [0, 360]. + * @param {number} s Saturation, in [0, 1]. + * @param {number} l Luminosity, in [0, 1]. + * @return {!goog.color.Rgb} rgb representation of the color. + */ +goog.color.hslToRgb = function(h, s, l) { + 'use strict'; + let r = 0; + let g = 0; + let b = 0; + const normH = h / 360; // normalize h to fall in [0, 1] + + if (s == 0) { + r = g = b = l * 255; + } else { + let temp1 = 0; + let temp2 = 0; + if (l < 0.5) { + temp2 = l * (1 + s); + } else { + temp2 = l + s - (s * l); + } + temp1 = 2 * l - temp2; + r = 255 * goog.color.hueToRgb_(temp1, temp2, normH + (1 / 3)); + g = 255 * goog.color.hueToRgb_(temp1, temp2, normH); + b = 255 * goog.color.hueToRgb_(temp1, temp2, normH - (1 / 3)); + } + + return [Math.round(r), Math.round(g), Math.round(b)]; +}; + + +/** + * Converts a color from HSL color space to RGB color space. + * @param {goog.color.Hsl} hsl hsl representation of the color. + * @return {!goog.color.Rgb} rgb representation of the color. + */ +goog.color.hslArrayToRgb = function(hsl) { + 'use strict'; + return goog.color.hslToRgb(hsl[0], hsl[1], hsl[2]); +}; + + +/** + * Helper for isValidHexColor_. + * @type {!RegExp} + * @private + */ +goog.color.validHexColorRe_ = /^#(?:[0-9a-f]{3}){1,2}$/i; + + +/** + * Checks if a string is a valid hex color. We expect strings of the format + * #RRGGBB (ex: #1b3d5f) or #RGB (ex: #3CA == #33CCAA). + * @param {string} str String to check. + * @return {boolean} Whether the string is a valid hex color. + * @private + */ +goog.color.isValidHexColor_ = function(str) { + 'use strict'; + return goog.color.validHexColorRe_.test(str); +}; + + +/** + * Regular expression for matching and capturing RGB style strings. Helper for + * isValidRgbColor_. + * @type {!RegExp} + * @private + */ +goog.color.rgbColorRe_ = + /^(?:rgb)?\((0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2})\)$/i; + + +/** + * Checks if a string is a valid rgb color. We expect strings of the format + * '(r, g, b)', or 'rgb(r, g, b)', where each color component is an int in + * [0, 255]. + * @param {string} str String to check. + * @return {!goog.color.Rgb} the rgb representation of the color if it is + * a valid color, or the empty array otherwise. + * @private + */ +goog.color.isValidRgbColor_ = function(str) { + 'use strict'; + // Each component is separate (rather than using a repeater) so we can + // capture the match. Also, we explicitly set each component to be either 0, + // or start with a non-zero, to prevent octal numbers from slipping through. + const regExpResultArray = str.match(goog.color.rgbColorRe_); + if (regExpResultArray) { + const r = Number(regExpResultArray[1]); + const g = Number(regExpResultArray[2]); + const b = Number(regExpResultArray[3]); + if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) { + return [r, g, b]; + } + } + return []; +}; + + +/** + * Takes a hex value and prepends a zero if it's a single digit. + * Small helper method for use by goog.color and friends. + * @param {string} hex Hex value to prepend if single digit. + * @return {string} hex value prepended with zero if it was single digit, + * otherwise the same value that was passed in. + */ +goog.color.prependZeroIfNecessaryHelper = function(hex) { + 'use strict'; + return hex.length == 1 ? '0' + hex : hex; +}; + + +/** + * Takes a string a prepends a '#' sign if one doesn't exist. + * Small helper method for use by goog.color and friends. + * @param {string} str String to check. + * @return {string} The value passed in, prepended with a '#' if it didn't + * already have one. + */ +goog.color.prependHashIfNecessaryHelper = function(str) { + 'use strict'; + return str.charAt(0) == '#' ? str : '#' + str; +}; + + +/** + * Takes an array of [r, g, b] and converts it into a string appropriate for + * CSS styles. + * @param {goog.color.Rgb} rgb rgb representation of the color. + * @return {string} string of the form 'rgb(r,g,b)'. + * @private + */ +goog.color.rgbStyle_ = function(rgb) { + 'use strict'; + return 'rgb(' + rgb.join(',') + ')'; +}; + + +/** + * Converts an HSV triplet to an RGB array. V is brightness because b is + * reserved for blue in RGB. + * @param {number} h Hue value in [0, 360]. + * @param {number} s Saturation value in [0, 1]. + * @param {number} brightness brightness in [0, 255]. + * @return {!goog.color.Rgb} rgb representation of the color. + */ +goog.color.hsvToRgb = function(h, s, brightness) { + 'use strict'; + let red = 0; + let green = 0; + let blue = 0; + if (s == 0) { + red = brightness; + green = brightness; + blue = brightness; + } else { + const sextant = Math.floor(h / 60); + const remainder = (h / 60) - sextant; + const val1 = brightness * (1 - s); + const val2 = brightness * (1 - (s * remainder)); + const val3 = brightness * (1 - (s * (1 - remainder))); + switch (sextant) { + case 1: + red = val2; + green = brightness; + blue = val1; + break; + case 2: + red = val1; + green = brightness; + blue = val3; + break; + case 3: + red = val1; + green = val2; + blue = brightness; + break; + case 4: + red = val3; + green = val1; + blue = brightness; + break; + case 5: + red = brightness; + green = val1; + blue = val2; + break; + case 6: + case 0: + red = brightness; + green = val3; + blue = val1; + break; + } + } + + return [Math.round(red), Math.round(green), Math.round(blue)]; +}; + + +/** + * Converts from RGB values to an array of HSV values. + * @param {number} red Red value in [0, 255]. + * @param {number} green Green value in [0, 255]. + * @param {number} blue Blue value in [0, 255]. + * @return {!goog.color.Hsv} hsv representation of the color. + */ +goog.color.rgbToHsv = function(red, green, blue) { + 'use strict'; + const max = Math.max(Math.max(red, green), blue); + const min = Math.min(Math.min(red, green), blue); + let hue; + let saturation; + const value = max; + if (min == max) { + hue = 0; + saturation = 0; + } else { + const delta = (max - min); + saturation = delta / max; + + if (red == max) { + hue = (green - blue) / delta; + } else if (green == max) { + hue = 2 + ((blue - red) / delta); + } else { + hue = 4 + ((red - green) / delta); + } + hue *= 60; + if (hue < 0) { + hue += 360; + } + if (hue > 360) { + hue -= 360; + } + } + + return [hue, saturation, value]; +}; + + +/** + * Converts from an array of RGB values to an array of HSV values. + * @param {goog.color.Rgb} rgb rgb representation of the color. + * @return {!goog.color.Hsv} hsv representation of the color. + */ +goog.color.rgbArrayToHsv = function(rgb) { + 'use strict'; + return goog.color.rgbToHsv(rgb[0], rgb[1], rgb[2]); +}; + + +/** + * Converts an HSV triplet to an RGB array. + * @param {goog.color.Hsv} hsv hsv representation of the color. + * @return {!goog.color.Rgb} rgb representation of the color. + */ +goog.color.hsvArrayToRgb = function(hsv) { + 'use strict'; + return goog.color.hsvToRgb(hsv[0], hsv[1], hsv[2]); +}; + + +/** + * Converts a hex representation of a color to HSL. + * @param {string} hex Color to convert. + * @return {!goog.color.Hsl} hsl representation of the color. + */ +goog.color.hexToHsl = function(hex) { + 'use strict'; + const rgb = goog.color.hexToRgb(hex); + return goog.color.rgbToHsl(rgb[0], rgb[1], rgb[2]); +}; + + +/** + * Converts from h,s,l values to a hex string + * @param {number} h Hue, in [0, 360]. + * @param {number} s Saturation, in [0, 1]. + * @param {number} l Luminosity, in [0, 1]. + * @return {string} hex representation of the color. + */ +goog.color.hslToHex = function(h, s, l) { + 'use strict'; + return goog.color.rgbArrayToHex(goog.color.hslToRgb(h, s, l)); +}; + + +/** + * Converts from an hsl array to a hex string + * @param {goog.color.Hsl} hsl hsl representation of the color. + * @return {string} hex representation of the color. + */ +goog.color.hslArrayToHex = function(hsl) { + 'use strict'; + return goog.color.rgbArrayToHex(goog.color.hslToRgb(hsl[0], hsl[1], hsl[2])); +}; + + +/** + * Converts a hex representation of a color to HSV + * @param {string} hex Color to convert. + * @return {!goog.color.Hsv} hsv representation of the color. + */ +goog.color.hexToHsv = function(hex) { + 'use strict'; + return goog.color.rgbArrayToHsv(goog.color.hexToRgb(hex)); +}; + + +/** + * Converts from h,s,v values to a hex string + * @param {number} h Hue, in [0, 360]. + * @param {number} s Saturation, in [0, 1]. + * @param {number} v Value, in [0, 255]. + * @return {string} hex representation of the color. + */ +goog.color.hsvToHex = function(h, s, v) { + 'use strict'; + return goog.color.rgbArrayToHex(goog.color.hsvToRgb(h, s, v)); +}; + + +/** + * Converts from an HSV array to a hex string + * @param {goog.color.Hsv} hsv hsv representation of the color. + * @return {string} hex representation of the color. + */ +goog.color.hsvArrayToHex = function(hsv) { + 'use strict'; + return goog.color.hsvToHex(hsv[0], hsv[1], hsv[2]); +}; + + +/** + * Calculates the Euclidean distance between two color vectors on an HSL sphere. + * A demo of the sphere can be found at: + * http://en.wikipedia.org/wiki/HSL_color_space + * In short, a vector for color (H, S, L) in this system can be expressed as + * (S*L'*cos(2*PI*H), S*L'*sin(2*PI*H), L), where L' = abs(L - 0.5), and we + * simply calculate the 1-2 distance using these coordinates + * @param {goog.color.Hsl} hsl1 First color in hsl representation. + * @param {goog.color.Hsl} hsl2 Second color in hsl representation. + * @return {number} Distance between the two colors, in the range [0, 1]. + */ +goog.color.hslDistance = function(hsl1, hsl2) { + 'use strict'; + let sl1; + let sl2; + + if (hsl1[2] <= 0.5) { + sl1 = hsl1[1] * hsl1[2]; + } else { + sl1 = hsl1[1] * (1.0 - hsl1[2]); + } + + if (hsl2[2] <= 0.5) { + sl2 = hsl2[1] * hsl2[2]; + } else { + sl2 = hsl2[1] * (1.0 - hsl2[2]); + } + + const h1 = hsl1[0] / 360.0; + const h2 = hsl2[0] / 360.0; + const dh = (h1 - h2) * 2.0 * Math.PI; + return (hsl1[2] - hsl2[2]) * (hsl1[2] - hsl2[2]) + sl1 * sl1 + sl2 * sl2 - + 2 * sl1 * sl2 * Math.cos(dh); +}; + + +/** + * Blend two colors together, using the specified factor to indicate the weight + * given to the first color + * @param {goog.color.Rgb} rgb1 First color represented in rgb. + * @param {goog.color.Rgb} rgb2 Second color represented in rgb. + * @param {number} factor The weight to be given to rgb1 over rgb2. Values + * should be in the range [0, 1]. If less than 0, factor will be set to 0. + * If greater than 1, factor will be set to 1. + * @return {!goog.color.Rgb} Combined color represented in rgb. + */ +goog.color.blend = function(rgb1, rgb2, factor) { + 'use strict'; + factor = goog.math.clamp(factor, 0, 1); + + return [ + Math.round(rgb2[0] + factor * (rgb1[0] - rgb2[0])), + Math.round(rgb2[1] + factor * (rgb1[1] - rgb2[1])), + Math.round(rgb2[2] + factor * (rgb1[2] - rgb2[2])) + ]; +}; + + +/** + * Adds black to the specified color, darkening it + * @param {goog.color.Rgb} rgb rgb representation of the color. + * @param {number} factor Number in the range [0, 1]. 0 will do nothing, while + * 1 will return black. If less than 0, factor will be set to 0. If greater + * than 1, factor will be set to 1. + * @return {!goog.color.Rgb} Combined rgb color. + */ +goog.color.darken = function(rgb, factor) { + 'use strict'; + const black = [0, 0, 0]; + return goog.color.blend(black, rgb, factor); +}; + + +/** + * Adds white to the specified color, lightening it + * @param {goog.color.Rgb} rgb rgb representation of the color. + * @param {number} factor Number in the range [0, 1]. 0 will do nothing, while + * 1 will return white. If less than 0, factor will be set to 0. If greater + * than 1, factor will be set to 1. + * @return {!goog.color.Rgb} Combined rgb color. + */ +goog.color.lighten = function(rgb, factor) { + 'use strict'; + const white = [255, 255, 255]; + return goog.color.blend(white, rgb, factor); +}; + + +/** + * Find the "best" (highest-contrast) of the suggested colors for the prime + * color. Uses W3C formula for judging readability and visual accessibility: + * http://www.w3.org/TR/AERT#color-contrast + * @param {goog.color.Rgb} prime Color represented as a rgb array. + * @param {Array} suggestions Array of colors, + * each representing a rgb array. + * @return {!goog.color.Rgb} Highest-contrast color represented by an array.. + */ +goog.color.highContrast = function(prime, suggestions) { + 'use strict'; + const suggestionsWithDiff = []; + for (let i = 0; i < suggestions.length; i++) { + suggestionsWithDiff.push({ + color: suggestions[i], + diff: goog.color.yiqBrightnessDiff_(suggestions[i], prime) + + goog.color.colorDiff_(suggestions[i], prime) + }); + } + suggestionsWithDiff.sort(function(a, b) { + 'use strict'; + return b.diff - a.diff; + }); + return suggestionsWithDiff[0].color; +}; + + +/** + * Calculate brightness of a color according to YIQ formula (brightness is Y). + * More info on YIQ here: http://en.wikipedia.org/wiki/YIQ. Helper method for + * goog.color.highContrast() + * @param {goog.color.Rgb} rgb Color represented by a rgb array. + * @return {number} brightness (Y). + * @private + */ +goog.color.yiqBrightness_ = function(rgb) { + 'use strict'; + return Math.round((rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000); +}; + + +/** + * Calculate difference in brightness of two colors. Helper method for + * goog.color.highContrast() + * @param {goog.color.Rgb} rgb1 Color represented by a rgb array. + * @param {goog.color.Rgb} rgb2 Color represented by a rgb array. + * @return {number} Brightness difference. + * @private + */ +goog.color.yiqBrightnessDiff_ = function(rgb1, rgb2) { + 'use strict'; + return Math.abs( + goog.color.yiqBrightness_(rgb1) - goog.color.yiqBrightness_(rgb2)); +}; + + +/** + * Calculate color difference between two colors. Helper method for + * goog.color.highContrast() + * @param {goog.color.Rgb} rgb1 Color represented by a rgb array. + * @param {goog.color.Rgb} rgb2 Color represented by a rgb array. + * @return {number} Color difference. + * @private + */ +goog.color.colorDiff_ = function(rgb1, rgb2) { + 'use strict'; + return Math.abs(rgb1[0] - rgb2[0]) + Math.abs(rgb1[1] - rgb2[1]) + + Math.abs(rgb1[2] - rgb2[2]); +}; diff --git a/closure/goog/color/color_test.js b/closure/goog/color/color_test.js new file mode 100644 index 0000000000..52374dce6d --- /dev/null +++ b/closure/goog/color/color_test.js @@ -0,0 +1,676 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ +goog.module('goog.colorTest'); +goog.setTestOnly(); + +const googColor = goog.require('goog.color'); +const names = goog.require('goog.color.names'); +const testSuite = goog.require('goog.testing.testSuite'); + +// Tests accuracy of HSL to RGB conversion + +// Tests HSV to RGB conversion + +// Tests that HSV space is (0-360) for hue + +// Tests conversion between HSL and Hex + +// Tests conversion between HSV and Hex + +/** + * This helper method compares two RGB colors, checking that each color + * component is the same. + * @param {Array} rgb1 Color represented by a 3-element array with red, + * green, and blue values respectively, in the range [0, 255]. + * @param {Array} rgb2 Color represented by a 3-element array with red, + * green, and blue values respectively, in the range [0, 255]. + * @return {boolean} True if the colors are the same, false otherwise. + */ +function rgbColorsAreEqual(rgb1, rgb2) { + return (rgb1[0] == rgb2[0] && rgb1[1] == rgb2[1] && rgb1[2] == rgb2[2]); +} + +/** + * Helper function for color conversion functions between two colorspaces. + * @param {Function} funcOne Function that converts from 1st colorspace to 2nd + * @param {Function} funcTwo Function that converts from 2nd colorspace to 2nd + * @param {Array} color The color array passed to funcOne + * @param {number} DELTA Margin of error for each element in color + * @suppress {visibility} accessing private properties + */ +function colorConversionTestHelper(funcOne, funcTwo, color, DELTA) { + const temp = funcOne(color); + + if (!googColor.isValidHexColor_(temp)) { + assertTrue(`First conversion had a NaN: ${temp}`, !isNaN(temp[0])); + assertTrue(`First conversion had a NaN: ${temp}`, !isNaN(temp[1])); + assertTrue(`First conversion had a NaN: ${temp}`, !isNaN(temp[2])); + } + + const back = funcTwo(temp); + + if (!googColor.isValidHexColor_(temp)) { + assertTrue(`Second conversion had a NaN: ${back}`, !isNaN(back[0])); + assertTrue(`Second conversion had a NaN: ${back}`, !isNaN(back[1])); + assertTrue(`Second conversion had a NaN: ${back}`, !isNaN(back[2])); + } + + assertColorFuzzyEquals('Color was off', color, back, DELTA); +} + +/** + * Checks equivalence between two colors' respective values. Accepts +- delta + * for each pair of values + * @param {string} str + * @param {Array} expected + * @param {Array} actual + * @param {number} delta Margin of error for each element in color array + */ +function assertColorFuzzyEquals(str, expected, actual, delta) { + assertTrue( + `${str} Expected: ${expected} and got: ${actual} w/ delta: ` + delta, + (Math.abs(expected[0] - actual[0]) <= delta) && + (Math.abs(expected[1] - actual[1]) <= delta) && + (Math.abs(expected[2] - actual[2]) <= delta)); +} +testSuite({ + testIsValidColor() { + const goodColors = [ + '#ffffff', + '#ff7812', + '#012345', + '#Ff003D', + '#3CA', + '(255, 26, 75)', + 'RGB(2, 3, 4)', + '(0,0,0)', + 'white', + 'blue', + ]; + const badColors = [ + '#xxxxxx', + '8899000', + 'not_color', + '#1234567', + 'fffffg', + '(2555,0,0)', + '(1,2,3,4)', + 'rgb(1,20,)', + 'RGB(20,20,20,)', + 'omgwtfbbq', + ]; + for (let i = 0; i < goodColors.length; i++) { + assertTrue(goodColors[i], googColor.isValidColor(goodColors[i])); + } + for (let i = 0; i < badColors.length; i++) { + assertFalse(badColors[i], googColor.isValidColor(badColors[i])); + } + }, + + /** + * @suppress {visibility} accessing private properties + */ + testIsValidHexColor() { + const goodHexColors = ['#ffffff', '#ff7812', '#012345', '#Ff003D', '#3CA']; + const badHexColors = + ['#xxxxxx', '889900', 'not_color', '#1234567', 'fffffg']; + for (let i = 0; i < goodHexColors.length; i++) { + assertTrue( + goodHexColors[i], googColor.isValidHexColor_(goodHexColors[i])); + } + for (let i = 0; i < badHexColors.length; i++) { + assertFalse(badHexColors[i], googColor.isValidHexColor_(badHexColors[i])); + } + }, + + /** + * @suppress {visibility} accessing private properties + */ + testIsValidRgbColor() { + const goodRgbColors = + ['(255, 26, 75)', 'RGB(2, 3, 4)', '(0,0,0)', 'rgb(255,255,255)']; + const badRgbColors = + ['(2555,0,0)', '(1,2,3,4)', 'rgb(1,20,)', 'RGB(20,20,20,)']; + for (let i = 0; i < goodRgbColors.length; i++) { + assertEquals( + goodRgbColors[i], googColor.isValidRgbColor_(goodRgbColors[i]).length, + 3); + } + for (let i = 0; i < badRgbColors.length; i++) { + assertEquals( + badRgbColors[i], googColor.isValidRgbColor_(badRgbColors[i]).length, + 0); + } + }, + + testParse() { + const colors = + ['rgb(15, 250, 77)', '(127, 127, 127)', '#ffeedd', '123456', 'magenta']; + const parsed = colors.map(googColor.parse); + assertEquals('rgb', parsed[0].type); + assertEquals(googColor.rgbToHex(15, 250, 77), parsed[0].hex); + assertEquals('rgb', parsed[1].type); + assertEquals(googColor.rgbToHex(127, 127, 127), parsed[1].hex); + assertEquals('hex', parsed[2].type); + assertEquals('#ffeedd', parsed[2].hex); + assertEquals('hex', parsed[3].type); + assertEquals('#123456', parsed[3].hex); + assertEquals('named', parsed[4].type); + assertEquals('#ff00ff', parsed[4].hex); + + const badColors = ['rgb(01, 1, 23)', '(256, 256, 256)', '#ffeeddaa']; + for (let i = 0; i < badColors.length; i++) { + const e = assertThrows(goog.partial(googColor.parse, badColors[i])); + assertContains('is not a valid color string', e.message); + } + }, + + testHexToRgb() { + const testColors = [ + ['#B0FF2D', [176, 255, 45]], + ['#b26e5f', [178, 110, 95]], + ['#66f', [102, 102, 255]], + ]; + + for (let i = 0; i < testColors.length; i++) { + const r = googColor.hexToRgb(testColors[i][0]); + const t = testColors[i][1]; + + assertEquals('Red channel should match.', t[0], r[0]); + assertEquals('Green channel should match.', t[1], r[1]); + assertEquals('Blue channel should match.', t[2], r[2]); + } + + const badColors = ['', '#g00', 'some words']; + for (let i = 0; i < badColors.length; i++) { + const e = assertThrows(goog.partial(googColor.hexToRgb, badColors[i])); + assertEquals( + '\'' + badColors[i] + '\' is not a valid hex color', e.message); + } + }, + + testHexToRgbStyle() { + assertEquals('rgb(255,0,0)', googColor.hexToRgbStyle(names['red'])); + assertEquals('rgb(206,206,206)', googColor.hexToRgbStyle('#cecece')); + assertEquals('rgb(51,204,170)', googColor.hexToRgbStyle('#3CA')); + const badHexColors = ['#1234', null, undefined, '#.1234567890']; + for (let i = 0; i < badHexColors.length; ++i) { + const badHexColor = badHexColors[i]; + const e = + assertThrows(goog.partial(googColor.hexToRgbStyle, badHexColor)); + assertEquals(`'${badHexColor}' is not a valid hex color`, e.message); + } + }, + + testRgbToHex() { + assertEquals(names['red'], googColor.rgbToHex(255, 0, 0)); + assertEquals('#af13ff', googColor.rgbToHex(175, 19, 255)); + const badRgb = [ + [-1, -1, -1], + [256, 0, 0], + ['a', 'b', 'c'], + [undefined, 5, 5], + [1.2, 3, 4], + ]; + for (let i = 0; i < badRgb.length; ++i) { + const e = assertThrows(goog.partial(googColor.rgbArrayToHex, badRgb[i])); + assertContains('is not a valid RGB color', e.message); + } + }, + + testRgbToHsl() { + const rgb = [255, 171, 32]; + const hsl = googColor.rgbArrayToHsl(rgb); + assertEquals(37, hsl[0]); + assertTrue(1.0 - hsl[1] < 0.01); + assertTrue(hsl[2] - .5625 < 0.01); + }, + + testHslToRgb() { + const hsl = [60, 0.5, 0.1]; + const rgb = googColor.hslArrayToRgb(hsl); + assertEquals(38, rgb[0]); + assertEquals(38, rgb[1]); + assertEquals(13, rgb[2]); + }, + + testHSLBidiToRGB() { + const DELTA = 1; + + const color = [ + [100, 56, 200], + [255, 0, 0], + [0, 0, 255], + [0, 255, 0], + [255, 255, 255], + [0, 0, 0], + ]; + + for (let i = 0; i < color.length; i++) { + colorConversionTestHelper( + (color) => googColor.rgbToHsl(color[0], color[1], color[2]), + (color) => googColor.hslToRgb(color[0], color[1], color[2]), color[i], + DELTA); + + colorConversionTestHelper( + (color) => googColor.rgbArrayToHsl(color), + (color) => googColor.hslArrayToRgb(color), color[i], DELTA); + } + }, + + testHSVToRGB() { + const DELTA = 1; + + const color = [ + [100, 56, 200], + [255, 0, 0], + [0, 0, 255], + [0, 255, 0], + [255, 255, 255], + [0, 0, 0], + ]; + + for (let i = 0; i < color.length; i++) { + colorConversionTestHelper( + (color) => googColor.rgbToHsv(color[0], color[1], color[2]), + (color) => googColor.hsvToRgb(color[0], color[1], color[2]), color[i], + DELTA); + + colorConversionTestHelper( + (color) => googColor.rgbArrayToHsv(color), + (color) => googColor.hsvArrayToRgb(color), color[i], DELTA); + } + }, + + testHSVSpecRangeIsCorrect() { + const color = [0, 0, 255]; // Blue is in the middle of hue range + + const hsv = googColor.rgbToHsv(color[0], color[1], color[2]); + + assertTrue('H in HSV space looks like it\'s not 0-360', hsv[0] > 1); + }, + + testHslToHex() { + const DELTA = 1; + + const color = [[0, 0, 0], [20, 0.5, 0.5], [0, 0, 1], [255, .45, .76]]; + + for (let i = 0; i < color.length; i++) { + colorConversionTestHelper( + (hsl) => googColor.hslToHex(hsl[0], hsl[1], hsl[2]), + (hex) => googColor.hexToHsl(hex), color[i], DELTA); + + colorConversionTestHelper( + (hsl) => googColor.hslArrayToHex(hsl), + (hex) => googColor.hexToHsl(hex), color[i], DELTA); + } + }, + + testHsvToHex() { + const DELTA = 1; + + const color = [[0, 0, 0], [.5, 0.5, 155], [0, 0, 255], [.7, .45, 21]]; + + for (let i = 0; i < color.length; i++) { + colorConversionTestHelper( + (hsl) => googColor.hsvToHex(hsl[0], hsl[1], hsl[2]), + (hex) => googColor.hexToHsv(hex), color[i], DELTA); + + colorConversionTestHelper( + (hsl) => googColor.hsvArrayToHex(hsl), + (hex) => googColor.hexToHsv(hex), color[i], DELTA); + } + }, + + /** + * This method runs unit tests against googColor.blend(). Test cases include: + * blending arbitrary colors with factors of 0 and 1, blending the same colors + * using arbitrary factors, blending different colors of varying factors, + * and blending colors using factors outside the expected range. + */ + testColorBlend() { + // Define some RGB colors for our tests. + const black = [0, 0, 0]; + const blue = [0, 0, 255]; + const gray = [128, 128, 128]; + const green = [0, 255, 0]; + const purple = [128, 0, 128]; + const red = [255, 0, 0]; + const yellow = [255, 255, 0]; + const white = [255, 255, 255]; + + // Blend arbitrary colors, using 0 and 1 for factors. Using 0 should return + // the first color, while using 1 should return the second color. + const redWithNoGreen = googColor.blend(red, green, 1); + assertTrue('red + 0 * green = red', rgbColorsAreEqual(red, redWithNoGreen)); + const whiteWithAllBlue = googColor.blend(white, blue, 0); + assertTrue( + 'white + 1 * blue = blue', rgbColorsAreEqual(blue, whiteWithAllBlue)); + + // Blend the same colors using arbitrary factors. This should return the + // same colors. + const greenWithGreen = googColor.blend(green, green, .25); + assertTrue( + 'green + .25 * green = green', + rgbColorsAreEqual(green, greenWithGreen)); + + // Blend different colors using varying factors. + const blackWithWhite = googColor.blend(black, white, .5); + assertTrue( + 'black + .5 * white = gray', rgbColorsAreEqual(gray, blackWithWhite)); + const redAndBlue = googColor.blend(red, blue, .5); + assertTrue( + 'red + .5 * blue = purple', rgbColorsAreEqual(purple, redAndBlue)); + const lightGreen = googColor.blend(green, white, .75); + assertTrue( + 'green + .25 * white = a lighter shade of white', + lightGreen[0] > 0 && lightGreen[1] == 255 && lightGreen[2] > 0); + + // Blend arbitrary colors using factors outside the expected range. + const noGreenAllPurple = googColor.blend(green, purple, -0.5); + assertTrue( + 'green * -0.5 + purple = purple.', + rgbColorsAreEqual(purple, noGreenAllPurple)); + const allBlueNoYellow = googColor.blend(blue, yellow, 1.37); + assertTrue( + 'blue * 1.37 + yellow = blue.', + rgbColorsAreEqual(blue, allBlueNoYellow)); + }, + + /** + * This method runs unit tests against googColor.darken(). Test cases + * include darkening black with arbitrary factors, edge cases (using 0 and 1), + * darkening colors using various factors, and darkening colors using factors + * outside the expected range. + */ + testColorDarken() { + // Define some RGB colors + const black = [0, 0, 0]; + const green = [0, 255, 0]; + const darkGray = [68, 68, 68]; + const olive = [128, 128, 0]; + const purple = [128, 0, 128]; + const white = [255, 255, 255]; + + // Darken black by an arbitrary factor, which should still return black. + const darkBlack = googColor.darken(black, .63); + assertTrue( + 'black darkened by .63 is still black.', + rgbColorsAreEqual(black, darkBlack)); + + // Call darken() with edge-case factors (0 and 1). + const greenNotDarkened = googColor.darken(green, 0); + assertTrue( + 'green darkened by 0 is still green.', + rgbColorsAreEqual(green, greenNotDarkened)); + const whiteFullyDarkened = googColor.darken(white, 1); + assertTrue( + 'white darkened by 1 is black.', + rgbColorsAreEqual(black, whiteFullyDarkened)); + + // Call darken() with various colors and factors. The result should be + // a color with less luminance. + const whiteHsl = googColor.rgbToHsl(white[0], white[1], white[2]); + const whiteDarkened = googColor.darken(white, .43); + const whiteDarkenedHsl = googColor.rgbToHsl( + whiteDarkened[0], whiteDarkened[1], whiteDarkened[2]); + assertTrue( + 'White that\'s darkened has less luminance than white.', + whiteDarkenedHsl[2] < whiteHsl[2]); + const purpleHsl = googColor.rgbToHsl(purple[0], purple[1], purple[2]); + const purpleDarkened = googColor.darken(purple, .1); + const purpleDarkenedHsl = googColor.rgbToHsl( + purpleDarkened[0], purpleDarkened[1], purpleDarkened[2]); + assertTrue( + 'Purple that\'s darkened has less luminance than purple.', + purpleDarkenedHsl[2] < purpleHsl[2]); + + // Call darken() with factors outside the expected range. + const darkGrayTurnedBlack = googColor.darken(darkGray, 2.1); + assertTrue( + 'Darkening dark gray by 2.1 returns black.', + rgbColorsAreEqual(black, darkGrayTurnedBlack)); + const whiteNotDarkened = googColor.darken(white, -0.62); + assertTrue( + 'Darkening white by -0.62 returns white.', + rgbColorsAreEqual(white, whiteNotDarkened)); + }, + + /** + * This method runs unit tests against googColor.lighten(). Test cases + * include lightening white with arbitrary factors, edge cases (using 0 and + * 1), lightening colors using various factors, and lightening colors using + * factors outside the expected range. + */ + testColorLighten() { + // Define some RGB colors + const black = [0, 0, 0]; + const brown = [165, 42, 42]; + const navy = [0, 0, 128]; + const orange = [255, 165, 0]; + const white = [255, 255, 255]; + + // Lighten white by an arbitrary factor, which should still return white. + const lightWhite = googColor.lighten(white, .41); + assertTrue( + 'white lightened by .41 is still white.', + rgbColorsAreEqual(white, lightWhite)); + + // Call lighten() with edge-case factors(0 and 1). + const navyNotLightened = googColor.lighten(navy, 0); + assertTrue( + 'navy lightened by 0 is still navy.', + rgbColorsAreEqual(navy, navyNotLightened)); + const orangeFullyLightened = googColor.lighten(orange, 1); + assertTrue( + 'orange lightened by 1 is white.', + rgbColorsAreEqual(white, orangeFullyLightened)); + + // Call lighten() with various colors and factors. The result should be + // a color with greater luminance. + const blackHsl = googColor.rgbToHsl(black[0], black[1], black[2]); + const blackLightened = googColor.lighten(black, .33); + const blackLightenedHsl = googColor.rgbToHsl( + blackLightened[0], blackLightened[1], blackLightened[2]); + assertTrue( + 'Black that\'s lightened has more luminance than black.', + blackLightenedHsl[2] >= blackHsl[2]); + const orangeHsl = googColor.rgbToHsl(orange[0], orange[1], orange[2]); + const orangeLightened = googColor.lighten(orange, .91); + const orangeLightenedHsl = googColor.rgbToHsl( + orangeLightened[0], orangeLightened[1], orangeLightened[2]); + assertTrue( + 'Orange that\'s lightened has more luminance than orange.', + orangeLightenedHsl[2] >= orangeHsl[2]); + + // Call lighten() with factors outside the expected range. + const navyTurnedWhite = googColor.lighten(navy, 1.01); + assertTrue( + 'Lightening navy by 1.01 returns white.', + rgbColorsAreEqual(white, navyTurnedWhite)); + const brownNotLightened = googColor.lighten(brown, -0.0000001); + assertTrue( + 'Lightening brown by -0.0000001 returns brown.', + rgbColorsAreEqual(brown, brownNotLightened)); + }, + + /** This method runs unit tests against googColor.hslDistance(). */ + testHslDistance() { + // Define some HSL colors + const aliceBlueHsl = googColor.rgbToHsl(240, 248, 255); + const blackHsl = googColor.rgbToHsl(0, 0, 0); + const ghostWhiteHsl = googColor.rgbToHsl(248, 248, 255); + const navyHsl = googColor.rgbToHsl(0, 0, 128); + const redHsl = googColor.rgbToHsl(255, 0, 0); + const whiteHsl = googColor.rgbToHsl(255, 255, 255); + + // The distance between the same colors should be 0. + assertTrue( + 'There is no HSL distance between white and white.', + googColor.hslDistance(whiteHsl, whiteHsl) == 0); + assertTrue( + 'There is no HSL distance between red and red.', + googColor.hslDistance(redHsl, redHsl) == 0); + + // The distance between various colors should be within certain thresholds. + let hslDistance = googColor.hslDistance(whiteHsl, ghostWhiteHsl); + assertTrue( + 'The HSL distance between white and ghost white is > 0.', + hslDistance > 0); + assertTrue( + 'The HSL distance between white and ghost white is <= 0.02.', + hslDistance <= 0.02); + hslDistance = googColor.hslDistance(whiteHsl, redHsl); + assertTrue( + 'The HSL distance between white and red is > 0.02.', + hslDistance > 0.02); + hslDistance = googColor.hslDistance(navyHsl, aliceBlueHsl); + assertTrue( + 'The HSL distance between navy and alice blue is > 0.02.', + hslDistance > 0.02); + hslDistance = googColor.hslDistance(blackHsl, whiteHsl); + assertTrue( + 'The HSL distance between white and black is 1.', hslDistance == 1); + }, + + /** + * This method runs unit tests against googColor.yiqBrightness_(). + * @suppress {visibility} accessing private properties + */ + testYiqBrightness() { + const white = [255, 255, 255]; + const black = [0, 0, 0]; + const coral = [255, 127, 80]; + const lightgreen = [144, 238, 144]; + + const whiteBrightness = googColor.yiqBrightness_(white); + const blackBrightness = googColor.yiqBrightness_(black); + const coralBrightness = googColor.yiqBrightness_(coral); + const lightgreenBrightness = googColor.yiqBrightness_(lightgreen); + + // brightness should be a number + assertTrue( + 'White brightness is a number.', typeof whiteBrightness == 'number'); + assertTrue( + 'Coral brightness is a number.', typeof coralBrightness == 'number'); + + // brightness for known colors should match known values + assertEquals('White brightness is 255', whiteBrightness, 255); + assertEquals('Black brightness is 0', blackBrightness, 0); + assertEquals('Coral brightness is 160', coralBrightness, 160); + assertEquals('Lightgreen brightness is 199', lightgreenBrightness, 199); + }, + + /** + * This method runs unit tests against googColor.yiqBrightnessDiff_(). + * @suppress {visibility} accessing private properties + */ + testYiqBrightnessDiff() { + const colors = { + 'deeppink': [255, 20, 147], + 'indigo': [75, 0, 130], + 'saddlebrown': [139, 69, 19], + }; + + const diffs = new Object(); + for (let name1 in colors) { + for (let name2 in colors) { + diffs[`${name1}-${name2}`] = + googColor.yiqBrightnessDiff_(colors[name1], colors[name2]); + } + } + + for (let pair in diffs) { + // each brightness diff should be a number + assertTrue(`${pair} diff is a number.`, typeof diffs[pair] == 'number'); + // each brightness diff should be greater than or equal to 0 + assertTrue( + `${pair} diff is greater than or equal to 0.`, diffs[pair] >= 0); + } + + // brightness diff for same-color pairs should be 0 + assertEquals('deeppink-deeppink is 0.', diffs['deeppink-deeppink'], 0); + assertEquals('indigo-indigo is 0.', diffs['indigo-indigo'], 0); + + // brightness diff for known pairs should match known values + assertEquals('deeppink-indigo is 68.', diffs['deeppink-indigo'], 68); + assertEquals( + 'saddlebrown-deeppink is 21.', diffs['saddlebrown-deeppink'], 21); + + // reversed pairs should have equal values + assertEquals('indigo-saddlebrown is 47.', diffs['indigo-saddlebrown'], 47); + assertEquals( + 'saddlebrown-indigo is also 47.', diffs['saddlebrown-indigo'], 47); + }, + + /** + * This method runs unit tests against googColor.colorDiff_(). + * @suppress {visibility} accessing private properties + */ + testColorDiff() { + const colors = { + 'mediumblue': [0, 0, 205], + 'oldlace': [253, 245, 230], + 'orchid': [218, 112, 214], + }; + + const diffs = new Object(); + for (let name1 in colors) { + for (let name2 in colors) { + diffs[`${name1}-${name2}`] = + googColor.colorDiff_(colors[name1], colors[name2]); + } + } + + for (let pair in diffs) { + // each color diff should be a number + assertTrue(`${pair} diff is a number.`, typeof diffs[pair] == 'number'); + // each color diff should be greater than or equal to 0 + assertTrue( + `${pair} diff is greater than or equal to 0.`, diffs[pair] >= 0); + } + + // color diff for same-color pairs should be 0 + assertEquals( + 'mediumblue-mediumblue is 0.', diffs['mediumblue-mediumblue'], 0); + assertEquals('oldlace-oldlace is 0.', diffs['oldlace-oldlace'], 0); + + // color diff for known pairs should match known values + assertEquals( + 'mediumblue-oldlace is 523.', diffs['mediumblue-oldlace'], 523); + assertEquals('oldlace-orchid is 184.', diffs['oldlace-orchid'], 184); + + // reversed pairs should have equal values + assertEquals('orchid-mediumblue is 339.', diffs['orchid-mediumblue'], 339); + assertEquals( + 'mediumblue-orchid is also 339.', diffs['mediumblue-orchid'], 339); + }, + + /** This method runs unit tests against googColor.highContrast(). */ + testHighContrast() { + const white = [255, 255, 255]; + const black = [0, 0, 0]; + const lemonchiffron = [255, 250, 205]; + const sienna = [160, 82, 45]; + + const suggestion = + googColor.highContrast(black, [white, black, sienna, lemonchiffron]); + + // should return an array of three numbers + assertTrue('Return value is an array.', typeof suggestion == 'object'); + assertTrue('Return value is 3 long.', suggestion.length == 3); + + // known color combos should return a known (i.e. human-verified) suggestion + assertArrayEquals( + 'White is best on sienna.', + googColor.highContrast(sienna, [white, black, sienna, lemonchiffron]), + white); + assertArrayEquals( + 'Black is best on lemonchiffron.', + googColor.highContrast(white, [white, black, sienna, lemonchiffron]), + black); + }, +}); diff --git a/closure/goog/color/names.js b/closure/goog/color/names.js new file mode 100644 index 0000000000..a07133aa68 --- /dev/null +++ b/closure/goog/color/names.js @@ -0,0 +1,170 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Names of standard colors with their associated hex values. + */ + +goog.provide('goog.color.names'); + + +/** + * A map that contains a lot of colors that are recognised by various browsers. + * This list is way larger than the minimal one dictated by W3C. + * The keys of this map are the lowercase "readable" names of the colors, while + * the values are the "hex" values. + * + * @type {!Object} + */ +goog.color.names = { + 'aliceblue': '#f0f8ff', + 'antiquewhite': '#faebd7', + 'aqua': '#00ffff', + 'aquamarine': '#7fffd4', + 'azure': '#f0ffff', + 'beige': '#f5f5dc', + 'bisque': '#ffe4c4', + 'black': '#000000', + 'blanchedalmond': '#ffebcd', + 'blue': '#0000ff', + 'blueviolet': '#8a2be2', + 'brown': '#a52a2a', + 'burlywood': '#deb887', + 'cadetblue': '#5f9ea0', + 'chartreuse': '#7fff00', + 'chocolate': '#d2691e', + 'coral': '#ff7f50', + 'cornflowerblue': '#6495ed', + 'cornsilk': '#fff8dc', + 'crimson': '#dc143c', + 'cyan': '#00ffff', + 'darkblue': '#00008b', + 'darkcyan': '#008b8b', + 'darkgoldenrod': '#b8860b', + 'darkgray': '#a9a9a9', + 'darkgreen': '#006400', + 'darkgrey': '#a9a9a9', + 'darkkhaki': '#bdb76b', + 'darkmagenta': '#8b008b', + 'darkolivegreen': '#556b2f', + 'darkorange': '#ff8c00', + 'darkorchid': '#9932cc', + 'darkred': '#8b0000', + 'darksalmon': '#e9967a', + 'darkseagreen': '#8fbc8f', + 'darkslateblue': '#483d8b', + 'darkslategray': '#2f4f4f', + 'darkslategrey': '#2f4f4f', + 'darkturquoise': '#00ced1', + 'darkviolet': '#9400d3', + 'deeppink': '#ff1493', + 'deepskyblue': '#00bfff', + 'dimgray': '#696969', + 'dimgrey': '#696969', + 'dodgerblue': '#1e90ff', + 'firebrick': '#b22222', + 'floralwhite': '#fffaf0', + 'forestgreen': '#228b22', + 'fuchsia': '#ff00ff', + 'gainsboro': '#dcdcdc', + 'ghostwhite': '#f8f8ff', + 'gold': '#ffd700', + 'goldenrod': '#daa520', + 'gray': '#808080', + 'green': '#008000', + 'greenyellow': '#adff2f', + 'grey': '#808080', + 'honeydew': '#f0fff0', + 'hotpink': '#ff69b4', + 'indianred': '#cd5c5c', + 'indigo': '#4b0082', + 'ivory': '#fffff0', + 'khaki': '#f0e68c', + 'lavender': '#e6e6fa', + 'lavenderblush': '#fff0f5', + 'lawngreen': '#7cfc00', + 'lemonchiffon': '#fffacd', + 'lightblue': '#add8e6', + 'lightcoral': '#f08080', + 'lightcyan': '#e0ffff', + 'lightgoldenrodyellow': '#fafad2', + 'lightgray': '#d3d3d3', + 'lightgreen': '#90ee90', + 'lightgrey': '#d3d3d3', + 'lightpink': '#ffb6c1', + 'lightsalmon': '#ffa07a', + 'lightseagreen': '#20b2aa', + 'lightskyblue': '#87cefa', + 'lightslategray': '#778899', + 'lightslategrey': '#778899', + 'lightsteelblue': '#b0c4de', + 'lightyellow': '#ffffe0', + 'lime': '#00ff00', + 'limegreen': '#32cd32', + 'linen': '#faf0e6', + 'magenta': '#ff00ff', + 'maroon': '#800000', + 'mediumaquamarine': '#66cdaa', + 'mediumblue': '#0000cd', + 'mediumorchid': '#ba55d3', + 'mediumpurple': '#9370db', + 'mediumseagreen': '#3cb371', + 'mediumslateblue': '#7b68ee', + 'mediumspringgreen': '#00fa9a', + 'mediumturquoise': '#48d1cc', + 'mediumvioletred': '#c71585', + 'midnightblue': '#191970', + 'mintcream': '#f5fffa', + 'mistyrose': '#ffe4e1', + 'moccasin': '#ffe4b5', + 'navajowhite': '#ffdead', + 'navy': '#000080', + 'oldlace': '#fdf5e6', + 'olive': '#808000', + 'olivedrab': '#6b8e23', + 'orange': '#ffa500', + 'orangered': '#ff4500', + 'orchid': '#da70d6', + 'palegoldenrod': '#eee8aa', + 'palegreen': '#98fb98', + 'paleturquoise': '#afeeee', + 'palevioletred': '#db7093', + 'papayawhip': '#ffefd5', + 'peachpuff': '#ffdab9', + 'peru': '#cd853f', + 'pink': '#ffc0cb', + 'plum': '#dda0dd', + 'powderblue': '#b0e0e6', + 'purple': '#800080', + 'red': '#ff0000', + 'rosybrown': '#bc8f8f', + 'royalblue': '#4169e1', + 'saddlebrown': '#8b4513', + 'salmon': '#fa8072', + 'sandybrown': '#f4a460', + 'seagreen': '#2e8b57', + 'seashell': '#fff5ee', + 'sienna': '#a0522d', + 'silver': '#c0c0c0', + 'skyblue': '#87ceeb', + 'slateblue': '#6a5acd', + 'slategray': '#708090', + 'slategrey': '#708090', + 'snow': '#fffafa', + 'springgreen': '#00ff7f', + 'steelblue': '#4682b4', + 'tan': '#d2b48c', + 'teal': '#008080', + 'thistle': '#d8bfd8', + 'tomato': '#ff6347', + 'turquoise': '#40e0d0', + 'violet': '#ee82ee', + 'wheat': '#f5deb3', + 'white': '#ffffff', + 'whitesmoke': '#f5f5f5', + 'yellow': '#ffff00', + 'yellowgreen': '#9acd32' +}; diff --git a/closure/goog/conformance_proto.txt b/closure/goog/conformance_proto.txt new file mode 100644 index 0000000000..35c9cd5464 --- /dev/null +++ b/closure/goog/conformance_proto.txt @@ -0,0 +1,423 @@ +# proto-file: third_party/java_src/jscomp/java/com/google/javascript/jscomp/conformance.proto +# proto-message: ConformanceConfig + +# Conformance users: +# +# DO NOT COPY PASTE THESE RULES. If you do, changes to Closure can break your +# build and you also won't get new or improved rules. Instead use this file in +# your project and extend the rules to disable them or to add their allowlists. + +### Platform restrictions ### + +requirement: { + rule_id: "closure:callee" + type: BANNED_PROPERTY + error_message: "Arguments.prototype.callee is not allowed. See https://google.github.io/closure-library/develop/conformance_rules.html#callee" + + value: "Arguments.prototype.callee" + + allowlist_regexp: ".+/closure/goog/debug/" # legacy stack trace support, etc + # TODO(mlourenco): Fix this? Not sure if possible or not. + allowlist_regexp: ".+/closure/goog/testing/stacktrace.js" +} + +requirement: { + rule_id: "closure:throwOfNonErrorTypes" + type: CUSTOM + java_class: "com.google.javascript.jscomp.ConformanceRules$BanThrowOfNonErrorTypes" + error_message: "Only Error or Error subclass objects may be thrown. See https://google.github.io/closure-library/develop/conformance_rules.html#throwOfNonErrorTypes" + # TODO(user): Violation occurs in code generated by Emscripten. + allowlist_regexp: ".+_wasm_js_library_generated.js" + allowlist_regexp: ".+/closure/goog/storage/" # throws numbers as part of its api + allowlist_regexp: ".+/closure/goog/testing/mock.js" # throws Object in $recordAndThrow +} + +requirement: { + rule_id: "closure:globalVars" + type: CUSTOM + java_class: "com.google.javascript.jscomp.ConformanceRules$BanGlobalVars" + error_message: "Global declarations are not allowed. See https://google.github.io/closure-library/develop/conformance_rules.html#globalVars" + allowlist_regexp: ".+/closure/goog/base.js" # global 'goog' + allowlist_regexp: ".+/closure/goog/labs/testing/" # global matchers, etc + allowlist_regexp: ".+/closure/goog/locale/locale.js" # dumb api + allowlist_regexp: ".+/closure/goog/testing/" # global assert methods, etc + allowlist_regexp: ".+/closure/goog/tweak/testhelpers.js" # global values + allowlist_regexp: "^Post-" # injected '_ModuleManager_initialize' + allowlist_regexp: "\\$togglesinit" # inject '_F_toggles_initialize' + + # Allowlist for global names + value: "CLOSURE_DEFINES" # Closure Compiler requires this to be a global var + value: "CLOSURE_TOGGLE_ORDINALS" # Closure Compiler requires this to be a global var + value: "CLOSURE_UNCOMPILED_DEFINES" # Closure Compiler requires this to be a global var + value: "CLOSURE_NO_DEPS" # Closure Compiler requires this to be a global var +} + +requirement: { + rule_id: "closure:unknownThis" + type: CUSTOM + java_class: "com.google.javascript.jscomp.ConformanceRules$BanUnknownThis" + error_message: "References to \"this\" that are typed as \"unknown\" are not allowed. See https://google.github.io/closure-library/develop/conformance_rules.html#unknownThis" + + allowlist_regexp: ".+/closure/goog/base.js" + allowlist_regexp: ".+/closure/goog/debug/errorhandler.js" + allowlist_regexp: ".+/closure/goog/editor/plugins/linkbubble.js" + allowlist_regexp: ".+/closure/goog/editor/plugins/linkdialogplugin.js" + allowlist_regexp: ".+/closure/goog/functions/functions.js" + allowlist_regexp: ".+/closure/goog/memoize/memoize.js" + allowlist_regexp: ".+/closure/goog/pubsub/pubsub.js" + allowlist_regexp: ".+/closure/goog/testing/" + allowlist_regexp: ".+/closure/goog/ui/editor/bubble.js" + allowlist_regexp: ".+/closure/goog/ui/editor/toolbarcontroller.js" + # TODO(user): Violation occurs in code generated by Emscripten. + allowlist_regexp: ".+_wasm_js_library_generated.js" + allowlist_regexp: "\\$togglesinit" # inject '_F_toggles_initialize' +} + +### Browser tech requirements ### + +# This requirement is somewhat Google-specific: open-source Closure users that +# don't use GAPI could reasonably ignore it depending on how they do messaging +# in their app. +requirement: { + rule_id: "closure:postMessage" + type: BANNED_PROPERTY_CALL + error_message: "Window.prototype.postMessage is not allowed. See https://google.github.io/closure-library/develop/conformance_rules.html#postMessage" + + value: "Window.prototype.postMessage" + + # Known-safe common infrastructure. + allowlist_regexp: ".+/closure/goog/async/nexttick.js" + allowlist_regexp: ".+/closure/goog/net/xpc/nativemessagingtransport.js" + # TODO(user): make sure this gets security reviewed (b/29333525). + allowlist_regexp: ".+/closure/goog/messaging/portchannel.js" +} + +### Security: forbid DOM properties and functions which can cause XSS ### + +# These are properties and functions which might have safe wrappers under +# goog.dom.safe. Two groups: properties and functions which accept +# HTML/CSS/script-as-string, properties and function which accept URLs. + +#### DOM properties and functions which accept HTML/CSS/script-as-string ##### + +requirement: { + rule_id: 'closure:eval' + # TODO(jakubvrana): Change to BANNED_NAME_CALL after cl/154708486 lands. + type: BANNED_NAME + error_message: 'eval is not allowed. See https://google.github.io/closure-library/develop/conformance_rules.html#eval' + + value: 'eval' + value: 'execScript' + value: 'goog.globalEval' + + allowlist_regexp: '.+/closure/goog/base.js' # goog.module loading in uncompiled code. + allowlist_regexp: '.+/closure/goog/goog.js' # Forwards goog.globalEval + allowlist_regexp: '.+/closure/goog/debug/errorhandler.js' # wraps setTimeout and similar functions + allowlist_regexp: '.+/closure/goog/json/json.js' # used in goog.json.parse + allowlist_regexp: '.+/closure/goog/module/loader.js' + allowlist_regexp: '.+/closure/goog/module/moduleloader.js' +} + +requirement: { + rule_id: 'closure:windowEval' + type: BANNED_PROPERTY_CALL + error_message: 'window.eval is not allowed. See https://google.github.io/closure-library/develop/conformance_rules.html#eval' + + value: 'Window.prototype.eval' + value: 'Window.prototype.execScript' + + allowlist_regexp: '.+/closure/goog/base.js' + # TODO(jakubvrana): To be investigated. + allowlist_regexp: '.+/closure/goog/net/xpc/nixtransport.js' +} + +requirement: { + rule_id: 'closure:stringFunctionDefinition' + type: RESTRICTED_NAME_CALL + error_message: 'Function, setTimeout, setInterval and requestAnimationFrame are not allowed with string argument. See https://google.github.io/closure-library/develop/conformance_rules.html#eval' + + value: 'Function:function()' + value: 'setTimeout:function(Function, ...?)' + value: 'setInterval:function(Function, ...?)' + value: 'requestAnimationFrame:function(Function, ...?)' +} + +requirement: { + rule_id: 'closure:windowStringFunctionDefinition' + type: RESTRICTED_METHOD_CALL + error_message: 'window.setTimeout, setInterval and requestAnimationFrame are not allowed with string argument. See https://google.github.io/closure-library/develop/conformance_rules.html#eval' + + value: 'Window.prototype.setTimeout:function(Function, ...?)' + value: 'Window.prototype.setInterval:function(Function, ...?)' + value: 'Window.prototype.requestAnimationFrame:function(Function, ...?)' +} + +requirement: { + rule_id: 'closure:innerHtml' + type: BANNED_PROPERTY_NON_CONSTANT_WRITE + error_message: 'Assignment to Element.prototype.innerHTML is not allowed. See https://google.github.io/closure-library/develop/conformance_rules.html#innerHtml' + + value: 'Element.prototype.innerHTML' + + # Safe wrapper for this property. + allowlist_regexp: '.+/closure/goog/dom/safe.js' + + # Safe DOM Tree Processor and HTML sanitizer, which use it safely in order to + # have the browser parse an HTML string using an inert DOM. + allowlist_regexp: '.+/closure/goog/html/sanitizer/htmlsanitizer.js' + allowlist_regexp: '.+/closure/goog/html/sanitizer/safedomtreeprocessor.js' + # Safely used in goog.string.unescapeEntitiesUsingDom_; the string assigned to + # innerHTML is a single HTML entity. + allowlist_regexp: '.+/closure/goog/string/string.js' + # goog.soy.renderElement and renderAsElement. Safe if used with Strict Soy + # templates. + allowlist_regexp: '.+/closure/goog/soy/soy.js' + allowlist_regexp: '.+/closure/goog/dom/browserrange/ierange.js' + allowlist_regexp: '.+/closure/goog/editor/' + allowlist_regexp: '.+/closure/goog/style/style.js' + allowlist_regexp: '.+/closure/goog/testing/' +} + +requirement: { + rule_id: 'closure:outerHtml' + type: BANNED_PROPERTY_NON_CONSTANT_WRITE + error_message: 'Assignment to Element.prototype.outerHTML is not allowed. See https://google.github.io/closure-library/develop/conformance_rules.html#innerHtml' + + value: 'Element.prototype.outerHTML' + + # Safe wrapper for this property. + allowlist_regexp: '.+/closure/goog/dom/safe.js' + allowlist_regexp: '.+/closure/goog/editor/' +} + +requirement: { + rule_id: 'closure:documentWrite' + type: BANNED_PROPERTY + error_message: 'Using Document.prototype.write is not allowed. Use goog.dom.safe.documentWrite instead. See https://google.github.io/closure-library/develop/conformance_rules.html#documentWrite.' + + value: 'Document.prototype.write' + value: 'Document.prototype.writeln' + + # These are safe. + allowlist_regexp: '.+/closure/goog/async/nexttick.js' + allowlist_regexp: '.+/closure/goog/base.js' + allowlist_regexp: '.+/closure/goog/dom/safe.js' + # TODO(jakubvrana): These need to be refactored. + allowlist_regexp: '.+/closure/goog/editor/icontent.js' + allowlist_regexp: '.+/closure/goog/testing/' +} + +requirement: { + rule_id: "closure:untypedScript" + type: CUSTOM + java_class: "com.google.javascript.jscomp.ConformanceRules$BanCreateElement" + error_message: "Use goog.dom functions with goog.dom.TagName.SCRIPT to create + + +

Closure Performance Tests - byteArrayToString

+
+
+ + + + diff --git a/closure/goog/crypt/bytestring_perf.js b/closure/goog/crypt/bytestring_perf.js new file mode 100644 index 0000000000..11482c44ce --- /dev/null +++ b/closure/goog/crypt/bytestring_perf.js @@ -0,0 +1,117 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Performance test for different implementations of + * byteArrayToString. + */ + + +goog.provide('goog.crypt.byteArrayToStringPerf'); + +goog.require('goog.array'); +goog.require('goog.dom'); +goog.require('goog.testing.PerformanceTable'); + +goog.setTestOnly('goog.crypt.byteArrayToStringPerf'); + + +var table = new goog.testing.PerformanceTable(goog.dom.getElement('perfTable')); + + +var BYTES_LENGTH = Math.pow(2, 20); +var CHUNK_SIZE = 8192; + +function getBytes() { + var bytes = []; + for (var i = 0; i < BYTES_LENGTH; i++) { + bytes.push('A'.charCodeAt(0)); + } + return bytes; +} + +function copyAndSpliceByteArray(bytes) { + // Copy the passed byte array since we're going to destroy it. + var remainingBytes = goog.array.clone(bytes); + var strings = []; + + // Convert each chunk to a string. + while (remainingBytes.length) { + var chunk = remainingBytes.splice(0, CHUNK_SIZE); + strings.push(String.fromCharCode.apply(null, chunk)); + } + return strings.join(''); +} + +function sliceByteArrayConcat(bytes) { + var str = ''; + for (var i = 0; i < bytes.length; i += CHUNK_SIZE) { + var chunk = goog.array.slice(bytes, i, i + CHUNK_SIZE); + str += String.fromCharCode.apply(null, chunk); + } + return str; +} + + +function sliceByteArrayJoin(bytes) { + var strings = []; + for (var i = 0; i < bytes.length; i += CHUNK_SIZE) { + var chunk = goog.array.slice(bytes, i, i + CHUNK_SIZE); + strings.push(String.fromCharCode.apply(null, chunk)); + } + return strings.join(''); +} + +function mapByteArray(bytes) { + var strings = goog.array.map(bytes, String.fromCharCode); + return strings.join(''); +} + +function forLoopByteArrayConcat(bytes) { + var str = ''; + for (var i = 0; i < bytes.length; i++) { + str += String.fromCharCode(bytes[i]); + } + return str; +} + +function forLoopByteArrayJoin(bytes) { + var strs = []; + for (var i = 0; i < bytes.length; i++) { + strs.push(String.fromCharCode(bytes[i])); + } + return strs.join(''); +} + + +function run() { + var bytes = getBytes(); + table.run( + goog.partial(copyAndSpliceByteArray, getBytes()), + 'Copy array and splice out chunks.'); + + table.run( + goog.partial(sliceByteArrayConcat, getBytes()), + 'Slice out copies of the byte array, concatenating results'); + + table.run( + goog.partial(sliceByteArrayJoin, getBytes()), + 'Slice out copies of the byte array, joining results'); + + table.run( + goog.partial(forLoopByteArrayConcat, getBytes()), + 'Use for loop with concat.'); + + table.run( + goog.partial(forLoopByteArrayJoin, getBytes()), + 'Use for loop with join.'); + + // Purposefully commented out. This ends up being tremendously expensive. + // table.run(goog.partial(mapByteArray, getBytes()), + // 'Use goog.array.map and fromCharCode.'); +} + +run(); diff --git a/closure/goog/crypt/cbc.js b/closure/goog/crypt/cbc.js new file mode 100644 index 0000000000..4395bb63bf --- /dev/null +++ b/closure/goog/crypt/cbc.js @@ -0,0 +1,128 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Implementation of CBC mode for block ciphers. See + * http://en.wikipedia.org/wiki/Block_cipher_modes_of_operation + * #Cipher-block_chaining_.28CBC.29. for description. + */ + +goog.provide('goog.crypt.Cbc'); + +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.crypt'); +goog.require('goog.crypt.BlockCipher'); + + + +/** + * Implements the CBC mode for block ciphers. See + * http://en.wikipedia.org/wiki/Block_cipher_modes_of_operation + * #Cipher-block_chaining_.28CBC.29 + * + * @param {!goog.crypt.BlockCipher} cipher The block cipher to use. + * @constructor + * @final + * @struct + */ +goog.crypt.Cbc = function(cipher) { + 'use strict'; + /** + * Block cipher. + * @type {!goog.crypt.BlockCipher} + * @private + */ + this.cipher_ = cipher; +}; + + +/** + * Encrypt a message. + * + * @param {!Array|!Uint8Array} plainText Message to encrypt. An array of + * bytes. The length should be a multiple of the block size. + * @param {!Array|!Uint8Array} initialVector Initial vector for the CBC + * mode. An array of bytes with the same length as the block size. + * @return {!Array} Encrypted message. + */ +goog.crypt.Cbc.prototype.encrypt = function(plainText, initialVector) { + 'use strict'; + goog.asserts.assert( + plainText.length % this.cipher_.BLOCK_SIZE == 0, + 'Data\'s length must be multiple of block size.'); + + goog.asserts.assert( + initialVector.length == this.cipher_.BLOCK_SIZE, + 'Initial vector must be size of one block.'); + + // Implementation of + // http://en.wikipedia.org/wiki/File:Cbc_encryption.png + + var cipherText = []; + var vector = initialVector; + + // Generate each block of the encrypted cypher text. + for (var blockStartIndex = 0; blockStartIndex < plainText.length; + blockStartIndex += this.cipher_.BLOCK_SIZE) { + // Takes one block from the input message. + var plainTextBlock = Array.prototype.slice.call( + plainText, blockStartIndex, blockStartIndex + this.cipher_.BLOCK_SIZE); + + var input = goog.crypt.xorByteArray(plainTextBlock, vector); + var resultBlock = this.cipher_.encrypt(input); + + goog.array.extend(cipherText, resultBlock); + vector = resultBlock; + } + + return cipherText; +}; + + +/** + * Decrypt a message. + * + * @param {!Array|!Uint8Array} cipherText Message to decrypt. An array + * of bytes. The length should be a multiple of the block size. + * @param {!Array|!Uint8Array} initialVector Initial vector for the CBC + * mode. An array of bytes with the same length as the block size. + * @return {!Array} Decrypted message. + */ +goog.crypt.Cbc.prototype.decrypt = function(cipherText, initialVector) { + 'use strict'; + goog.asserts.assert( + cipherText.length % this.cipher_.BLOCK_SIZE == 0, + 'Data\'s length must be multiple of block size.'); + + goog.asserts.assert( + initialVector.length == this.cipher_.BLOCK_SIZE, + 'Initial vector must be size of one block.'); + + // Implementation of + // http://en.wikipedia.org/wiki/File:Cbc_decryption.png + + var plainText = []; + var blockStartIndex = 0; + var vector = initialVector; + + // Generate each block of the decrypted plain text. + while (blockStartIndex < cipherText.length) { + // Takes one block. + var cipherTextBlock = Array.prototype.slice.call( + cipherText, blockStartIndex, blockStartIndex + this.cipher_.BLOCK_SIZE); + + var resultBlock = this.cipher_.decrypt(cipherTextBlock); + var plainTextBlock = goog.crypt.xorByteArray(vector, resultBlock); + + goog.array.extend(plainText, plainTextBlock); + vector = cipherTextBlock; + + blockStartIndex += this.cipher_.BLOCK_SIZE; + } + + return plainText; +}; diff --git a/closure/goog/crypt/cbc_test.js b/closure/goog/crypt/cbc_test.js new file mode 100644 index 0000000000..aef7c6bd7c --- /dev/null +++ b/closure/goog/crypt/cbc_test.js @@ -0,0 +1,91 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Unit tests for CBC mode for block ciphers. + */ + +/** @suppress {extraProvide} */ +goog.module('goog.crypt.CbcTest'); +goog.setTestOnly(); + +const Aes = goog.require('goog.crypt.Aes'); +const Cbc = goog.require('goog.crypt.Cbc'); +const crypt = goog.require('goog.crypt'); +const testSuite = goog.require('goog.testing.testSuite'); + +function stringToBytes(s) { + const bytes = new Array(s.length); + for (let i = 0; i < s.length; ++i) bytes[i] = s.charCodeAt(i) & 255; + return bytes; +} + +function runCbcAesTest( + keyBytes, initialVectorBytes, plainTextBytes, cipherTextBytes) { + const aes = new Aes(keyBytes); + const cbc = new Cbc(aes); + + const encryptedBytes = cbc.encrypt(plainTextBytes, initialVectorBytes); + assertEquals( + 'Encrypted bytes should match cipher text.', + crypt.byteArrayToHex(cipherTextBytes), + crypt.byteArrayToHex(encryptedBytes)); + + const decryptedBytes = cbc.decrypt(cipherTextBytes, initialVectorBytes); + assertEquals( + 'Decrypted bytes should match plain text.', + crypt.byteArrayToHex(plainTextBytes), + crypt.byteArrayToHex(decryptedBytes)); +} + +testSuite({ + testAesCbcCipherAlgorithm() { + // Test values from http://www.ietf.org/rfc/rfc3602.txt + + // Case #1 + runCbcAesTest( + crypt.hexToByteArray('06a9214036b8a15b512e03d534120006'), + crypt.hexToByteArray('3dafba429d9eb430b422da802c9fac41'), + stringToBytes('Single block msg'), + crypt.hexToByteArray('e353779c1079aeb82708942dbe77181a')); + + // Case #2 + runCbcAesTest( + crypt.hexToByteArray('c286696d887c9aa0611bbb3e2025a45a'), + crypt.hexToByteArray('562e17996d093d28ddb3ba695a2e6f58'), + crypt.hexToByteArray( + '000102030405060708090a0b0c0d0e0f' + + '101112131415161718191a1b1c1d1e1f'), + crypt.hexToByteArray( + 'd296cd94c2cccf8a3a863028b5e1dc0a' + + '7586602d253cfff91b8266bea6d61ab1')); + + // Case #3 + runCbcAesTest( + crypt.hexToByteArray('6c3ea0477630ce21a2ce334aa746c2cd'), + crypt.hexToByteArray('c782dc4c098c66cbd9cd27d825682c81'), + stringToBytes('This is a 48-byte message (exactly 3 AES blocks)'), + crypt.hexToByteArray( + 'd0a02b3836451753d493665d33f0e886' + + '2dea54cdb293abc7506939276772f8d5' + + '021c19216bad525c8579695d83ba2684')); + + // Case #4 + runCbcAesTest( + crypt.hexToByteArray('56e47a38c5598974bc46903dba290349'), + crypt.hexToByteArray('8ce82eefbea0da3c44699ed7db51b7d9'), + crypt.hexToByteArray( + 'a0a1a2a3a4a5a6a7a8a9aaabacadaeaf' + + 'b0b1b2b3b4b5b6b7b8b9babbbcbdbebf' + + 'c0c1c2c3c4c5c6c7c8c9cacbcccdcecf' + + 'd0d1d2d3d4d5d6d7d8d9dadbdcdddedf'), + crypt.hexToByteArray( + 'c30e32ffedc0774e6aff6af0869f71aa' + + '0f3af07a9a31a9c684db207eb0ef8e4e' + + '35907aa632c3ffdf868bb7b29d3d46ad' + + '83ce9f9a102ee99d49a53e87f4c3da55')); + }, +}); diff --git a/closure/goog/crypt/crypt.js b/closure/goog/crypt/crypt.js new file mode 100644 index 0000000000..4a8428c5de --- /dev/null +++ b/closure/goog/crypt/crypt.js @@ -0,0 +1,273 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Namespace with crypto related helper functions. + */ + +goog.provide('goog.crypt'); + +goog.require('goog.asserts'); +goog.require('goog.async.throwException'); + + +/** + * Whether to async-throw on unicode input to the legacy versions of + * `goog.crypt.stringToByteArray` (i.e. when `throwSync` is false). + * NOTE: The default will change to `true` soon, after notifying users. + * @define {boolean} + */ +goog.crypt.ASYNC_THROW_ON_UNICODE_TO_BYTE = + goog.define('goog.crypt.ASYNC_THROW_ON_UNICODE_TO_BYTE', goog.DEBUG); + + +/** + * Test-only stub to make our use of async.throwException more testable. + * @const + */ +goog.crypt.TEST_ONLY = {}; + + +/** Remappable alias. */ +goog.crypt.TEST_ONLY.throwException = goog.async.throwException; + + +/** Configurable so that we can test the async-throw behavior. */ +goog.crypt.TEST_ONLY.alwaysThrowSynchronously = goog.DEBUG; + + +/** + * Turns a string into an array of bytes; a "byte" being a JS number in the + * range 0-255. Multi-byte characters will throw. + * @param {string} str String value to arrify. + * @return {!Array} Array of numbers corresponding to the + * UCS character codes of each character in str. + */ +goog.crypt.binaryStringToByteArray = function(str) { + return goog.crypt.stringToByteArray(str, true); +}; + + +/** + * Turns a string into an array of bytes; a "byte" being a JS number in the + * range 0-255. Multi-byte characters are written as little-endian. + * @param {string} str String value to arrify. + * @param {boolean=} throwSync Whether to throw synchronously. + * @return {!Array} Array of numbers corresponding to the + * UCS character codes of each character in str. + */ +goog.crypt.stringToByteArray = function(str, throwSync) { + 'use strict'; + var output = [], p = 0; + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + // NOTE: c <= 0xffff since JavaScript strings are UTF-16. + if (c > 0xff) { + var err = new Error('go/unicode-to-byte-error'); + // NOTE: fail faster in debug to catch errors reliably in tests. + if (goog.crypt.TEST_ONLY.alwaysThrowSynchronously || throwSync) { + throw err; + } else if (goog.crypt.ASYNC_THROW_ON_UNICODE_TO_BYTE) { + goog.crypt.TEST_ONLY.throwException(err); + } + output[p++] = c & 0xff; + c >>= 8; + } + output[p++] = c; + } + return output; +}; + + +/** + * Turns an array of numbers into the string given by the concatenation of the + * characters to which the numbers correspond. + * @param {!Uint8Array|!Array} bytes Array of numbers representing + * characters. + * @return {string} Stringification of the array. + */ +goog.crypt.byteArrayToString = function(bytes) { + return goog.crypt.byteArrayToBinaryString(bytes); +}; + + +/** + * Turns an array of numbers into the string given by the concatenation of the + * characters to which the numbers correspond. + * @param {!Uint8Array|!Array} bytes Array of numbers representing + * characters. + * @return {string} Stringification of the array. + */ +goog.crypt.byteArrayToBinaryString = function(bytes) { + 'use strict'; + var CHUNK_SIZE = 8192; + + // Special-case the simple case for speed's sake. + if (bytes.length <= CHUNK_SIZE) { + return String.fromCharCode.apply(null, bytes); + } + + // The remaining logic splits conversion by chunks since + // Function#apply() has a maximum parameter count. + // See discussion: http://goo.gl/LrWmZ9 + + var str = ''; + for (var i = 0; i < bytes.length; i += CHUNK_SIZE) { + var chunk = Array.prototype.slice.call(bytes, i, i + CHUNK_SIZE); + str += String.fromCharCode.apply(null, chunk); + } + return str; +}; + + +/** + * Turns an array of numbers into the hex string given by the concatenation of + * the hex values to which the numbers correspond. + * @param {Uint8Array|Array} array Array of numbers representing + * characters. + * @param {string=} opt_separator Optional separator between values + * @return {string} Hex string. + */ +goog.crypt.byteArrayToHex = function(array, opt_separator) { + 'use strict'; + return Array.prototype.map + .call( + array, + function(numByte) { + 'use strict'; + var hexByte = numByte.toString(16); + return hexByte.length > 1 ? hexByte : '0' + hexByte; + }) + .join(opt_separator || ''); +}; + + +/** + * Converts a hex string into an integer array. + * @param {string} hexString Hex string of 16-bit integers (two characters + * per integer). + * @return {!Array} Array of {0,255} integers for the given string. + */ +goog.crypt.hexToByteArray = function(hexString) { + 'use strict'; + goog.asserts.assert( + hexString.length % 2 == 0, 'Key string length must be multiple of 2'); + var arr = []; + for (var i = 0; i < hexString.length; i += 2) { + arr.push(parseInt(hexString.substring(i, i + 2), 16)); + } + return arr; +}; + + +/** + * Converts a JS string to a UTF-8 "byte" array. + * @param {string} str 16-bit unicode string. + * @return {!Array} UTF-8 byte array. + */ +goog.crypt.stringToUtf8ByteArray = function(str) { + return goog.crypt.textToByteArray(str); +}; + + +/** + * Converts a JS string to a UTF-8 "byte" array. + * @param {string} str 16-bit unicode string. + * @return {!Array} UTF-8 byte array. + */ +goog.crypt.textToByteArray = function(str) { + 'use strict'; + // TODO(user): Use native implementations if/when available + var out = [], p = 0; + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + if (c < 128) { + out[p++] = c; + } else if (c < 2048) { + out[p++] = (c >> 6) | 192; + out[p++] = (c & 63) | 128; + } else if ( + ((c & 0xFC00) == 0xD800) && (i + 1) < str.length && + ((str.charCodeAt(i + 1) & 0xFC00) == 0xDC00)) { + // Surrogate Pair + c = 0x10000 + ((c & 0x03FF) << 10) + (str.charCodeAt(++i) & 0x03FF); + out[p++] = (c >> 18) | 240; + out[p++] = ((c >> 12) & 63) | 128; + out[p++] = ((c >> 6) & 63) | 128; + out[p++] = (c & 63) | 128; + } else { + out[p++] = (c >> 12) | 224; + out[p++] = ((c >> 6) & 63) | 128; + out[p++] = (c & 63) | 128; + } + } + return out; +}; + + +/** + * Converts a UTF-8 byte array to JavaScript's 16-bit Unicode. + * @param {Uint8Array|Array} bytes UTF-8 byte array. + * @return {string} 16-bit Unicode string. + */ +goog.crypt.utf8ByteArrayToString = function(bytes) { + return goog.crypt.byteArrayToText(bytes); +}; + + +/** + * Converts a UTF-8 byte array to JavaScript's 16-bit Unicode. + * @param {Uint8Array|Array} bytes UTF-8 byte array. + * @return {string} 16-bit Unicode string. + */ +goog.crypt.byteArrayToText = function(bytes) { + 'use strict'; + // TODO(user): Use native implementations if/when available + var out = [], pos = 0, c = 0; + while (pos < bytes.length) { + var c1 = bytes[pos++]; + if (c1 < 128) { + out[c++] = String.fromCharCode(c1); + } else if (c1 > 191 && c1 < 224) { + var c2 = bytes[pos++]; + out[c++] = String.fromCharCode((c1 & 31) << 6 | c2 & 63); + } else if (c1 > 239 && c1 < 365) { + // Surrogate Pair + var c2 = bytes[pos++]; + var c3 = bytes[pos++]; + var c4 = bytes[pos++]; + var u = ((c1 & 7) << 18 | (c2 & 63) << 12 | (c3 & 63) << 6 | c4 & 63) - + 0x10000; + out[c++] = String.fromCharCode(0xD800 + (u >> 10)); + out[c++] = String.fromCharCode(0xDC00 + (u & 1023)); + } else { + var c2 = bytes[pos++]; + var c3 = bytes[pos++]; + out[c++] = + String.fromCharCode((c1 & 15) << 12 | (c2 & 63) << 6 | c3 & 63); + } + } + return out.join(''); +}; + + +/** + * XOR two byte arrays. + * @param {!Uint8Array|!Int8Array|!Array} bytes1 Byte array 1. + * @param {!Uint8Array|!Int8Array|!Array} bytes2 Byte array 2. + * @return {!Array} Resulting XOR of the two byte arrays. + */ +goog.crypt.xorByteArray = function(bytes1, bytes2) { + 'use strict'; + goog.asserts.assert( + bytes1.length == bytes2.length, 'XOR array lengths must match'); + + var result = []; + for (var i = 0; i < bytes1.length; i++) { + result.push(bytes1[i] ^ bytes2[i]); + } + return result; +}; diff --git a/closure/goog/crypt/crypt_perf.html b/closure/goog/crypt/crypt_perf.html new file mode 100644 index 0000000000..a40ddd0b79 --- /dev/null +++ b/closure/goog/crypt/crypt_perf.html @@ -0,0 +1,83 @@ + + + + + + Closure Performance Tests - UTF8 encoding and decoding + + + + + +

Closure Performance Tests - UTF8 encoding and decoding

+

+ User-agent: + +

+
+
+ + + + diff --git a/closure/goog/crypt/crypt_test.js b/closure/goog/crypt/crypt_test.js new file mode 100644 index 0000000000..3450192dde --- /dev/null +++ b/closure/goog/crypt/crypt_test.js @@ -0,0 +1,311 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.cryptTest'); +goog.setTestOnly(); + +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const crypt = goog.require('goog.crypt'); +const googString = goog.require('goog.string'); +const recordFunction = goog.require('goog.testing.recordFunction'); +const testSuite = goog.require('goog.testing.testSuite'); + +const UTF8_RANGES_BYTE_ARRAY = + [0x00, 0x7F, 0xC2, 0x80, 0xDF, 0xBF, 0xE0, 0xA0, 0x80, 0xEF, 0xBF, 0xBF]; + +const UTF8_SURROGATE_PAIR_RANGES_BYTE_ARRAY = [ + 0xF0, 0x90, 0x80, 0x80, // \uD800\uDC00 + 0xF0, 0x90, 0x8F, 0xBF, // \uD800\uDFFF + 0xF4, 0x8F, 0xB0, 0x80, // \uDBFF\uDC00 + 0xF4, 0x8F, 0xBF, 0xBF // \uDBFF\uDFFF +]; + +const UTF8_RANGES_STRING = '\u0000\u007F\u0080\u07FF\u0800\uFFFF'; + +const UTF8_SURROGATE_PAIR_RANGES_STRING = + '\uD800\uDC00\uD800\uDFFF\uDBFF\uDC00\uDBFF\uDFFF'; + +// Tests a one-megabyte byte array conversion to string. +// This would break on many JS implementations unless byteArrayToString +// split the input up. +// See discussion and bug report: http://goo.gl/LrWmZ9 + +testSuite({ + testStringToUtf8ByteArray() { + // Known encodings taken from Java's String.getBytes("UTF8") + + assertArrayEquals( + 'ASCII', [72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100], + crypt.stringToUtf8ByteArray('Hello, world')); + + assertArrayEquals( + 'Latin', [83, 99, 104, 195, 182, 110], + crypt.stringToUtf8ByteArray('Sch\u00f6n')); + + assertArrayEquals( + 'limits of the first 3 UTF-8 character ranges', UTF8_RANGES_BYTE_ARRAY, + crypt.stringToUtf8ByteArray(UTF8_RANGES_STRING)); + + assertArrayEquals( + 'Surrogate Pair', UTF8_SURROGATE_PAIR_RANGES_BYTE_ARRAY, + crypt.stringToUtf8ByteArray(UTF8_SURROGATE_PAIR_RANGES_STRING)); + }, + + testTextToByteArray() { + // Known encodings taken from Java's String.getBytes("UTF8") + + assertArrayEquals( + 'ASCII', [72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100], + crypt.textToByteArray('Hello, world')); + + assertArrayEquals( + 'Latin', [83, 99, 104, 195, 182, 110], + crypt.textToByteArray('Sch\u00f6n')); + + assertArrayEquals( + 'limits of the first 3 UTF-8 character ranges', UTF8_RANGES_BYTE_ARRAY, + crypt.textToByteArray(UTF8_RANGES_STRING)); + + assertArrayEquals( + 'Surrogate Pair', UTF8_SURROGATE_PAIR_RANGES_BYTE_ARRAY, + crypt.textToByteArray(UTF8_SURROGATE_PAIR_RANGES_STRING)); + }, + + testUtf8ByteArrayToString() { + // Known encodings taken from Java's String.getBytes("UTF8") + + assertEquals('ASCII', 'Hello, world', crypt.utf8ByteArrayToString([ + 72, + 101, + 108, + 108, + 111, + 44, + 32, + 119, + 111, + 114, + 108, + 100, + ])); + + assertEquals( + 'Latin', 'Sch\u00f6n', + crypt.utf8ByteArrayToString([83, 99, 104, 195, 182, 110])); + + assertEquals( + 'limits of the first 3 UTF-8 character ranges', UTF8_RANGES_STRING, + crypt.utf8ByteArrayToString(UTF8_RANGES_BYTE_ARRAY)); + + assertEquals( + 'Surrogate Pair', UTF8_SURROGATE_PAIR_RANGES_STRING, + crypt.utf8ByteArrayToString(UTF8_SURROGATE_PAIR_RANGES_BYTE_ARRAY)); + }, + + testByteArrayToText() { + // Known encodings taken from Java's String.getBytes("UTF8") + + assertEquals('ASCII', 'Hello, world', crypt.byteArrayToText([ + 72, + 101, + 108, + 108, + 111, + 44, + 32, + 119, + 111, + 114, + 108, + 100, + ])); + + assertEquals( + 'Latin', 'Sch\u00f6n', + crypt.byteArrayToText([83, 99, 104, 195, 182, 110])); + + assertEquals( + 'limits of the first 3 UTF-8 character ranges', UTF8_RANGES_STRING, + crypt.byteArrayToText(UTF8_RANGES_BYTE_ARRAY)); + + assertEquals( + 'Surrogate Pair', UTF8_SURROGATE_PAIR_RANGES_STRING, + crypt.byteArrayToText(UTF8_SURROGATE_PAIR_RANGES_BYTE_ARRAY)); + }, + + /** + * Same as testUtf8ByteArrayToString but with Uint8Array instead of + * Array. + */ + testUint8ArrayToString() { + if (!globalThis.Uint8Array) { + // Uint8Array not supported. + return; + } + + let arr = new Uint8Array( + [72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100]); + assertEquals('ASCII', 'Hello, world', crypt.utf8ByteArrayToString(arr)); + + arr = new Uint8Array([83, 99, 104, 195, 182, 110]); + assertEquals('Latin', 'Sch\u00f6n', crypt.utf8ByteArrayToString(arr)); + + arr = new Uint8Array(UTF8_RANGES_BYTE_ARRAY); + assertEquals( + 'limits of the first 3 UTF-8 character ranges', UTF8_RANGES_STRING, + crypt.utf8ByteArrayToString(arr)); + }, + + /** + * Same as testByteArrayToText but with Uint8Array instead of + * Array. + */ + testByteArrayToText_Uint8Array() { + if (!globalThis.Uint8Array) { + // Uint8Array not supported. + return; + } + + let arr = new Uint8Array( + [72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100]); + assertEquals('ASCII', 'Hello, world', crypt.byteArrayToText(arr)); + + arr = new Uint8Array([83, 99, 104, 195, 182, 110]); + assertEquals('Latin', 'Sch\u00f6n', crypt.byteArrayToText(arr)); + + arr = new Uint8Array(UTF8_RANGES_BYTE_ARRAY); + assertEquals( + 'limits of the first 3 UTF-8 character ranges', UTF8_RANGES_STRING, + crypt.byteArrayToText(arr)); + }, + + testByteArrayToString() { + assertEquals('', crypt.byteArrayToString([])); + assertEquals('abc', crypt.byteArrayToString([97, 98, 99])); + assertEquals('\xa0\x12\xa1', crypt.byteArrayToString([0xa0, 0x12, 0xa1])); + }, + + testByteArrayToBinaryString() { + assertEquals('', crypt.byteArrayToBinaryString([])); + assertEquals('abc', crypt.byteArrayToBinaryString([97, 98, 99])); + assertEquals( + '\xa0\x12\xa1', crypt.byteArrayToBinaryString([0xa0, 0x12, 0xa1])); + }, + + testStringToByteArray() { + assertArrayEquals([], crypt.stringToByteArray('')); + assertArrayEquals([97, 98, 99], crypt.stringToByteArray('abc')); + assertArrayEquals( + [0xa0, 0x12, 0xa1], crypt.stringToByteArray('\xa0\x12\xa1')); + assertThrows(() => crypt.stringToByteArray('\u0102')); + }, + + testStringToByteArray_asyncThrow() { + const stubs = new PropertyReplacer(); + const stubThrowException = recordFunction(); + stubs.replace(crypt.TEST_ONLY, 'throwException', stubThrowException); + stubs.replace(crypt.TEST_ONLY, 'alwaysThrowSynchronously', false); + try { + assertArrayEquals([], crypt.stringToByteArray('')); + assertArrayEquals([97, 98, 99], crypt.stringToByteArray('abc')); + assertArrayEquals( + [0xa0, 0x12, 0xa1], crypt.stringToByteArray('\xa0\x12\xa1')); + if (stubThrowException.getCallCount() > 0) { + throw stubThrowException.getLastCall().getArgument(0); + } + + // Test async throwing behavior. + assertArrayEquals([2, 1], crypt.stringToByteArray('\u0102')); + assertEquals(1, stubThrowException.getCallCount()); + } finally { + stubs.reset(); + } + }, + + testBinaryStringToByteArray() { + assertArrayEquals([], crypt.binaryStringToByteArray('')); + assertArrayEquals([97, 98, 99], crypt.binaryStringToByteArray('abc')); + assertArrayEquals( + [0xa0, 0x12, 0xa1], crypt.binaryStringToByteArray('\xa0\x12\xa1')); + assertThrows(() => crypt.binaryStringToByteArray('\u0100')); + }, + + testHexToByteArray() { + assertElementsEquals( + [202, 254, 222, 173], + // Java magic number + crypt.hexToByteArray('cafedead')); + + assertElementsEquals( + [222, 173, 190, 239], + // IBM magic number + crypt.hexToByteArray('DEADBEEF')); + }, + + testByteArrayToHex() { + assertEquals( + // Java magic number + 'cafedead', crypt.byteArrayToHex([202, 254, 222, 173])); + + assertEquals( + // IBM magic number + 'deadbeef', crypt.byteArrayToHex([222, 173, 190, 239])); + + assertEquals('c0:ff:ee', crypt.byteArrayToHex([192, 255, 238], ':')); + }, + + /** + Same as testByteArrayToHex but with Uint8Array instead of Array. + */ + testUint8ArrayToHex() { + if (globalThis.Uint8Array === undefined) { + // Uint8Array not supported. + return; + } + + assertEquals( + // Java magic number + 'cafedead', crypt.byteArrayToHex(new Uint8Array([202, 254, 222, 173]))); + + assertEquals( + // IBM magic number + 'deadbeef', crypt.byteArrayToHex(new Uint8Array([222, 173, 190, 239]))); + + assertEquals( + 'c0:ff:ee', crypt.byteArrayToHex(new Uint8Array([192, 255, 238]), ':')); + }, + + testXorByteArray() { + assertElementsEquals( + [20, 83, 96, 66], + crypt.xorByteArray([202, 254, 222, 173], [222, 173, 190, 239])); + }, + + /** Same as testXorByteArray but with Uint8Array instead of Array. */ + testXorUint8Array() { + if (globalThis.Uint8Array === undefined) { + // Uint8Array not supported. + return; + } + + assertElementsEquals( + [20, 83, 96, 66], + crypt.xorByteArray( + new Uint8Array([202, 254, 222, 173]), + new Uint8Array([222, 173, 190, 239]))); + }, + + testByteArrayToStringCallStack() { + // One megabyte is 2 to the 20th. + const count = Math.pow(2, 20); + const bytes = []; + for (let i = 0; i < count; i++) { + bytes.push('A'.charCodeAt(0)); + } + const str = crypt.byteArrayToString(bytes); + assertEquals(googString.repeat('A', count), str); + }, +}); diff --git a/closure/goog/crypt/ctr.js b/closure/goog/crypt/ctr.js new file mode 100644 index 0000000000..268292d8fd --- /dev/null +++ b/closure/goog/crypt/ctr.js @@ -0,0 +1,108 @@ +/** + * @fileoverview + */ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('goog.crypt.Ctr'); + +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.crypt'); +goog.requireType('goog.crypt.BlockCipher'); + +/** + * Implementation of Ctr mode for block ciphers. See + * http://en.wikipedia.org/wiki/Block_cipher_modes_of_operation + * #Cipher-block_chaining_.28Ctr.29. for an overview, and + * http://csrc.nist.gov/publications/nistpubs/800-38a/sp800-38a.pdf + * for the spec. + * + * @param {!goog.crypt.BlockCipher} cipher The block cipher to use. + * @constructor + * @final + * @struct + */ +goog.crypt.Ctr = function(cipher) { + 'use strict'; + /** + * Block cipher. + * @type {!goog.crypt.BlockCipher} + * @private + */ + this.cipher_ = cipher; +}; + +/** + * Encrypts a message. + * + * @param {!Array|!Uint8Array} plainText Message to encrypt. An array of + * bytes. The length does not have to be a multiple of the blocksize. + * @param {!Array|!Uint8Array} initialVector Initial vector for the Ctr + * mode. An array of bytes with the same length as the block size, that + * should be not reused when using the same key. + * @return {!Array} Encrypted message. + */ +goog.crypt.Ctr.prototype.encrypt = function(plainText, initialVector) { + 'use strict'; + goog.asserts.assert( + initialVector.length == this.cipher_.BLOCK_SIZE, + 'Initial vector must be size of one block.'); + + // Copy the IV, so it's not modified. + var counter = goog.array.clone(initialVector); + + var keyStreamBlock = []; + var encryptedArray = []; + var plainTextBlock = []; + + while (encryptedArray.length < plainText.length) { + keyStreamBlock = this.cipher_.encrypt(counter); + goog.crypt.Ctr.incrementBigEndianCounter_(counter); + + plainTextBlock = Array.prototype.slice.call( + plainText, encryptedArray.length, + encryptedArray.length + this.cipher_.BLOCK_SIZE); + goog.array.extend( + encryptedArray, + goog.crypt.xorByteArray( + plainTextBlock, keyStreamBlock.slice(0, plainTextBlock.length))); + } + + return encryptedArray; +}; + + +/** + * Decrypts a message. In CTR, this is the same as encrypting. + * + * @param {!Array|!Uint8Array} cipherText Message to decrypt. The length + * does not have to be a multiple of the blocksize. + * @param {!Array|!Uint8Array} initialVector Initial vector for the Ctr + * mode. An array of bytes with the same length as the block size. + * @return {!Array} Decrypted message. + */ +goog.crypt.Ctr.prototype.decrypt = goog.crypt.Ctr.prototype.encrypt; + +/** + * Increments the big-endian integer represented in counter in-place. + * + * @param {!Array|!Uint8Array} counter The array of bytes to modify. + * @private + */ +goog.crypt.Ctr.incrementBigEndianCounter_ = function(counter) { + 'use strict'; + for (var i = counter.length - 1; i >= 0; i--) { + var currentByte = counter[i]; + currentByte = (currentByte + 1) & 0xFF; // Allow wrapping around. + counter[i] = currentByte; + if (currentByte != 0) { + // This iteration hasn't wrapped around, which means there is + // no carry to add to the next byte. + return; + } // else, repeat with next byte. + } +}; diff --git a/closure/goog/crypt/ctr_test.js b/closure/goog/crypt/ctr_test.js new file mode 100644 index 0000000000..5016e1f036 --- /dev/null +++ b/closure/goog/crypt/ctr_test.js @@ -0,0 +1,201 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Unit tests for CTR mode for block ciphers. + */ + + +goog.module('goog.crypt.CtrTest'); +goog.setTestOnly('goog.crypt.CtrTest'); + +const Aes = goog.require('goog.crypt.Aes'); +const Ctr = goog.require('goog.crypt.Ctr'); +const googCrypt = goog.require('goog.crypt'); +const testSuite = goog.require('goog.testing.testSuite'); + + +/** + * Asserts the given parameters allow encryption and decryption from and to the + * given plaintext/ciphertext. + * + * @param {!Array|!Uint8Array} keyBytes + * @param {!Array|!Uint8Array} initialVectorBytes + * @param {!Array|!Uint8Array} plainTextBytes + * @param {!Array|!Uint8Array} cipherTextBytes + */ +function runCtrAesTest( + keyBytes, initialVectorBytes, plainTextBytes, cipherTextBytes) { + /** @suppress {checkTypes} suppression added to enable type checking */ + const aes = new Aes(keyBytes); + const ctr = new Ctr(aes); + + const encryptedBytes = ctr.encrypt(plainTextBytes, initialVectorBytes); + assertEquals( + 'Encrypted bytes should match cipher text.', + googCrypt.byteArrayToHex(encryptedBytes), + googCrypt.byteArrayToHex(cipherTextBytes)); + + const decryptedBytes = ctr.decrypt(cipherTextBytes, initialVectorBytes); + assertEquals( + 'Decrypted bytes should match plain text.', + googCrypt.byteArrayToHex(decryptedBytes), + googCrypt.byteArrayToHex(plainTextBytes)); +} + +/** + * Asserts Ctr.incrementBigEndianCounter turns the first parameter + * into the second. + * @param {string} toIncrement + * @param {string} expectedResult + * @suppress {visibility} suppression added to enable type checking + */ +function assertIncEquals(toIncrement, expectedResult) { + const counter = googCrypt.hexToByteArray(toIncrement); + Ctr.incrementBigEndianCounter_(counter); + assertEquals(expectedResult, googCrypt.byteArrayToHex(counter)); +} + +testSuite({ + testIncrementCounter() { + assertIncEquals('', ''); + + assertIncEquals('00', '01'); + assertIncEquals('09', '0a'); + assertIncEquals('0e', '0f'); + assertIncEquals('0f', '10'); + assertIncEquals('1f', '20'); + assertIncEquals('ff', '00'); // no length extension + + assertIncEquals('0000', '0001'); + assertIncEquals('00f0', '00f1'); + assertIncEquals('00ff', '0100'); + assertIncEquals('0f00', '0f01'); + assertIncEquals('0fff', '1000'); + assertIncEquals('1000', '1001'); + assertIncEquals('ff00', 'ff01'); + assertIncEquals('ff0f', 'ff10'); + assertIncEquals('ffff', '0000'); + + assertIncEquals( + 'ffffffffffffffffffffffffffffffffffffffffffffffff', + '000000000000000000000000000000000000000000000000'); + }, + + testAes128CtrCipherAlgorithm() { + // Test vectors from NIST sp800-38a, p 55 + // http://csrc.nist.gov/publications/nistpubs/800-38a/sp800-38a.pdf + + // Case #1, no chaining + runCtrAesTest( + googCrypt.hexToByteArray('2b7e151628aed2a6abf7158809cf4f3c'), + googCrypt.hexToByteArray('f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'), + googCrypt.hexToByteArray('6bc1bee22e409f96e93d7e117393172a'), + googCrypt.hexToByteArray('874d6191b620e3261bef6864990db6ce')); + + // Case #2, three blocks + runCtrAesTest( + googCrypt.hexToByteArray('2b7e151628aed2a6abf7158809cf4f3c'), + googCrypt.hexToByteArray('f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'), + googCrypt.hexToByteArray( + '6bc1bee22e409f96e93d7e117393172a' + + 'ae2d8a571e03ac9c9eb76fac45af8e51' + + '30c81c46a35ce411e5fbc1191a0a52ef'), + googCrypt.hexToByteArray( + '874d6191b620e3261bef6864990db6ce' + + '9806f66b7970fdff8617187bb9fffdff' + + '5ae4df3edbd5d35e5b4f09020db03eab')); + + // Case #3, plaintext length not a multiple of blocksize + runCtrAesTest( + googCrypt.hexToByteArray('2b7e151628aed2a6abf7158809cf4f3c'), + googCrypt.hexToByteArray('f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'), + googCrypt.hexToByteArray( + '6bc1bee22e409f96e93d7e117393172a' + + 'ae2d8a571e03ac9c9eb76fac45af8e51' + + '30c81c'), + googCrypt.hexToByteArray( + '874d6191b620e3261bef6864990db6ce' + + '9806f66b7970fdff8617187bb9fffdff' + + '5ae4df')); + }, + + testAes192CtrCipherAlgorithm() { + // Test vectors from NIST sp800-38a, p 56 + // http://csrc.nist.gov/publications/nistpubs/800-38a/sp800-38a.pdf + // Key block is weird, that's normal: 192 is one block and a half. + + + // Case #1, no chaining + runCtrAesTest( + googCrypt.hexToByteArray( + '8e73b0f7da0e6452c810f32b809079e5' + + '62f8ead2522c6b7b'), + googCrypt.hexToByteArray('f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'), + googCrypt.hexToByteArray('6bc1bee22e409f96e93d7e117393172a'), + googCrypt.hexToByteArray('1abc932417521ca24f2b0459fe7e6e0b')); + + // Case #2, three blocks + runCtrAesTest( + googCrypt.hexToByteArray( + '8e73b0f7da0e6452c810f32b809079e5' + + '62f8ead2522c6b7b'), + googCrypt.hexToByteArray('f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'), + googCrypt.hexToByteArray( + '6bc1bee22e409f96e93d7e117393172a' + + 'ae2d8a571e03ac9c9eb76fac45af8e51' + + '30c81c46a35ce411e5fbc1191a0a52ef'), + googCrypt.hexToByteArray( + '1abc932417521ca24f2b0459fe7e6e0b' + + '090339ec0aa6faefd5ccc2c6f4ce8e94' + + '1e36b26bd1ebc670d1bd1d665620abf7')); + }, + + testAes256CtrCipherAlgorithm() { + // Test vectors from NIST sp800-38a, p 57 + // http://csrc.nist.gov/publications/nistpubs/800-38a/sp800-38a.pdf + + // Case #1, no chaining + runCtrAesTest( + googCrypt.hexToByteArray( + '603deb1015ca71be2b73aef0857d7781' + + '1f352c073b6108d72d9810a30914dff4'), + googCrypt.hexToByteArray('f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'), + googCrypt.hexToByteArray('6bc1bee22e409f96e93d7e117393172a'), + googCrypt.hexToByteArray('601ec313775789a5b7a7f504bbf3d228')); + + + // Case #2, three blocks + runCtrAesTest( + googCrypt.hexToByteArray( + '603deb1015ca71be2b73aef0857d7781' + + '1f352c073b6108d72d9810a30914dff4'), + googCrypt.hexToByteArray('f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'), + googCrypt.hexToByteArray( + '6bc1bee22e409f96e93d7e117393172a' + + 'ae2d8a571e03ac9c9eb76fac45af8e51' + + '30c81c46a35ce411e5fbc1191a0a52ef'), + googCrypt.hexToByteArray( + '601ec313775789a5b7a7f504bbf3d228' + + 'f443e3ca4d62b59aca84e990cacaf5c5' + + '2b0930daa23de94ce87017ba2d84988d')); + + // Case #3, plaintext length not a multiple of blocksize + runCtrAesTest( + googCrypt.hexToByteArray( + '603deb1015ca71be2b73aef0857d7781' + + '1f352c073b6108d72d9810a30914dff4'), + googCrypt.hexToByteArray('f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'), + googCrypt.hexToByteArray( + '6bc1bee22e409f96e93d7e117393172a' + + 'ae2d8a571e03ac9c9eb76fac45af8e51' + + '30c81c46a35ce411e5fbc1191a0a52'), + googCrypt.hexToByteArray( + '601ec313775789a5b7a7f504bbf3d228' + + 'f443e3ca4d62b59aca84e990cacaf5c5' + + '2b0930daa23de94ce87017ba2d8498')); + }, +}); diff --git a/closure/goog/crypt/hash.js b/closure/goog/crypt/hash.js new file mode 100644 index 0000000000..e633a0060c --- /dev/null +++ b/closure/goog/crypt/hash.js @@ -0,0 +1,61 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Abstract cryptographic hash interface. + * + * See goog.crypt.Sha1 and goog.crypt.Md5 for sample implementations. + */ + +goog.provide('goog.crypt.Hash'); + + + +/** + * Create a cryptographic hash instance. + * + * @constructor + * @struct + */ +goog.crypt.Hash = function() { + 'use strict'; + /** + * The block size for the hasher. + * @type {number} + */ + this.blockSize = -1; +}; + + +/** + * Resets the internal accumulator. + */ +goog.crypt.Hash.prototype.reset = goog.abstractMethod; + + +/** + * Adds a byte array (array with values in [0-255] range) or a string (must + * only contain 8-bit, i.e., Latin1 characters) to the internal accumulator. + * + * Many hash functions operate on blocks of data and implement optimizations + * when a full chunk of data is readily available. Hence it is often preferable + * to provide large chunks of data (a kilobyte or more) than to repeatedly + * call the update method with few tens of bytes. If this is not possible, or + * not feasible, it might be good to provide data in multiplies of hash block + * size (often 64 bytes). Please see the implementation and performance tests + * of your favourite hash. + * + * @param {Array|Uint8Array|string} bytes Data used for the update. + * @param {number=} opt_length Number of bytes to use. + */ +goog.crypt.Hash.prototype.update = goog.abstractMethod; + + +/** + * @return {!Array} The finalized hash computed + * from the internal accumulator. + */ +goog.crypt.Hash.prototype.digest = goog.abstractMethod; diff --git a/closure/goog/crypt/hash32.js b/closure/goog/crypt/hash32.js new file mode 100644 index 0000000000..d05a238a18 --- /dev/null +++ b/closure/goog/crypt/hash32.js @@ -0,0 +1,231 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Implementation of 32-bit hashing functions. + * + * This is a direct port from the Google Java Hash class + */ + +goog.provide('goog.crypt.hash32'); + +goog.require('goog.crypt'); + + +/** + * Default seed used during hashing, digits of pie. + * See SEED32 in http://go/base.hash.java + * @type {number} + */ +goog.crypt.hash32.SEED32 = 314159265; + + +/** + * Arbitrary constant used during hashing. + * See CONSTANT32 in http://go/base.hash.java + * @type {number} + */ +goog.crypt.hash32.CONSTANT32 = -1640531527; + + +/** + * Hashes a string to a 32-bit value. + * @param {string} str String to hash. + * @return {number} 32-bit hash. + */ +goog.crypt.hash32.encodeBinaryString = function(str) { + return goog.crypt.hash32.encodeString(str, true); +}; + + +/** + * Hashes a string to a 32-bit value. + * @param {string} str String to hash. + * @param {boolean=} throwSync Whether to throw synchronously on unicode input. + * @return {number} 32-bit hash. + */ +goog.crypt.hash32.encodeString = function(str, throwSync) { + 'use strict'; + return goog.crypt.hash32.encodeByteArray( + goog.crypt.stringToByteArray(str, throwSync)); +}; + + +/** + * Hashes a string to a 32-bit value, converting the string to UTF-8 before + * doing the encoding. + * @param {string} str String to hash. + * @return {number} 32-bit hash. + */ +goog.crypt.hash32.encodeStringUtf8 = function(str) { + return goog.crypt.hash32.encodeText(str); +}; + + +/** + * Hashes a string to a 32-bit value, converting the string to UTF-8 before + * doing the encoding. + * @param {string} str String to hash. + * @return {number} 32-bit hash. + */ +goog.crypt.hash32.encodeText = function(str) { + 'use strict'; + return goog.crypt.hash32.encodeByteArray( + goog.crypt.stringToUtf8ByteArray(str)); +}; + + +/** + * Hashes an integer to a 32-bit value. + * @param {number} value Number to hash. + * @return {number} 32-bit hash. + */ +goog.crypt.hash32.encodeInteger = function(value) { + 'use strict'; + // TODO(user): Does this make sense in JavaScript with doubles? Should we + // force the value to be in the correct range? + return goog.crypt.hash32.mix32_( + {a: value, b: goog.crypt.hash32.CONSTANT32, c: goog.crypt.hash32.SEED32}); +}; + + +/** + * Hashes a "byte" array to a 32-bit value using the supplied seed. + * @param {Array} bytes Array of bytes. + * @param {number=} opt_offset The starting position to use for hash + * computation. + * @param {number=} opt_length Number of bytes that are used for hashing. + * @param {number=} opt_seed The seed. + * @return {number} 32-bit hash. + */ +goog.crypt.hash32.encodeByteArray = function( + bytes, opt_offset, opt_length, opt_seed) { + 'use strict'; + var offset = opt_offset || 0; + var length = opt_length || bytes.length; + var seed = opt_seed || goog.crypt.hash32.SEED32; + + var mix = { + a: goog.crypt.hash32.CONSTANT32, + b: goog.crypt.hash32.CONSTANT32, + c: seed + }; + + var keylen; + for (keylen = length; keylen >= 12; keylen -= 12, offset += 12) { + mix.a += goog.crypt.hash32.wordAt_(bytes, offset); + mix.b += goog.crypt.hash32.wordAt_(bytes, offset + 4); + mix.c += goog.crypt.hash32.wordAt_(bytes, offset + 8); + goog.crypt.hash32.mix32_(mix); + } + // Hash any remaining bytes + mix.c += length; + switch (keylen) { // deal with rest. Some cases fall through + case 11: + mix.c += (bytes[offset + 10]) << 24; + case 10: + mix.c += (bytes[offset + 9] & 0xff) << 16; + case 9: + mix.c += (bytes[offset + 8] & 0xff) << 8; + // the first byte of c is reserved for the length + case 8: + mix.b += goog.crypt.hash32.wordAt_(bytes, offset + 4); + mix.a += goog.crypt.hash32.wordAt_(bytes, offset); + break; + case 7: + mix.b += (bytes[offset + 6] & 0xff) << 16; + case 6: + mix.b += (bytes[offset + 5] & 0xff) << 8; + case 5: + mix.b += (bytes[offset + 4] & 0xff); + case 4: + mix.a += goog.crypt.hash32.wordAt_(bytes, offset); + break; + case 3: + mix.a += (bytes[offset + 2] & 0xff) << 16; + case 2: + mix.a += (bytes[offset + 1] & 0xff) << 8; + case 1: + mix.a += (bytes[offset + 0] & 0xff); + // case 0 : nothing left to add + } + return goog.crypt.hash32.mix32_(mix); +}; + + +/** + * Performs an inplace mix of an object with the integer properties (a, b, c) + * and returns the final value of c. + * @param {{a:number, b:number, c:number}} mix Object with properties, a, b, and c. + * @return {number} The end c-value for the mixing. + * @private + */ +goog.crypt.hash32.mix32_ = function(mix) { + 'use strict'; + var a = mix.a, b = mix.b, c = mix.c; + a -= b; + a -= c; + a ^= c >>> 13; + b -= c; + b -= a; + b ^= a << 8; + c -= a; + c -= b; + c ^= b >>> 13; + a -= b; + a -= c; + a ^= c >>> 12; + b -= c; + b -= a; + b ^= a << 16; + c -= a; + c -= b; + c ^= b >>> 5; + a -= b; + a -= c; + a ^= c >>> 3; + b -= c; + b -= a; + b ^= a << 10; + c -= a; + c -= b; + c ^= b >>> 15; + mix.a = a; + mix.b = b; + mix.c = c; + return c; +}; + + +/** + * Returns the word at a given offset. Treating an array of bytes a word at a + * time is far more efficient than byte-by-byte. + * @param {Array} bytes Array of bytes. + * @param {number} offset Offset in the byte array. + * @return {number} Integer value for the word. + * @private + */ +goog.crypt.hash32.wordAt_ = function(bytes, offset) { + 'use strict'; + var a = goog.crypt.hash32.toSigned_(bytes[offset + 0]); + var b = goog.crypt.hash32.toSigned_(bytes[offset + 1]); + var c = goog.crypt.hash32.toSigned_(bytes[offset + 2]); + var d = goog.crypt.hash32.toSigned_(bytes[offset + 3]); + return a + (b << 8) + (c << 16) + (d << 24); +}; + + +/** + * Converts an unsigned "byte" to signed, that is, convert a value in the range + * (0, 2^8-1) to (-2^7, 2^7-1) in order to be compatible with Java's byte type. + * @param {number} n Unsigned "byte" value. + * @return {number} Signed "byte" value. + * @private + */ +goog.crypt.hash32.toSigned_ = function(n) { + 'use strict'; + return n > 127 ? n - 256 : n; +}; diff --git a/closure/goog/crypt/hash32_test.js b/closure/goog/crypt/hash32_test.js new file mode 100644 index 0000000000..f90298eca0 --- /dev/null +++ b/closure/goog/crypt/hash32_test.js @@ -0,0 +1,312 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview + * @suppress {strictMissingProperties,missingProperties} suppression added to + * enable type checking + */ + +goog.module('goog.crypt.hash32Test'); +goog.setTestOnly(); + +const TestCase = goog.require('goog.testing.TestCase'); +const hash32 = goog.require('goog.crypt.hash32'); +const testSuite = goog.require('goog.testing.testSuite'); + +// NOTE: This test uses a custom test case, see end of script block + +// Test data based on known input/output pairs generated using +// http://go/hash.java + +function createByteArray(n) { + const arr = []; + for (let i = 0; i < n; i++) { + arr.push(i); + } + return arr; +} + +const byteArrays = { + 0: 1539411136, + 1: 1773524747, + 2: -254958930, + 3: 1532114172, + 4: 1923165449, + 5: 1611874589, + 6: 1502126780, + 7: -751745251, + 8: -292491321, + 9: 1106193218, + 10: -722791438, + 11: -2130666060, + 12: -259304553, + 13: 871461192, + 14: 865773084, + 15: 1615738330, + 16: -1836636447, + 17: -485722519, + 18: -120832227, + 19: 1954449704, + 20: 491312921, + 21: -1955462668, + 22: 168565425, + 23: -105893922, + 24: 620486614, + 25: -1789602428, + 26: 1765793554, + 27: 1723370948, + 28: -1275405721, + 29: 140421019, + 30: -1438726307, + 31: 538438903, + 32: -729123980, + 33: 1213490939, + 34: -1814248478, + 35: 1943703398, + 36: 1603073219, + 37: -2139639543, + 38: -694153941, + 39: 137511516, + 40: -249943726, + 41: -1166126060, + 42: 53464833, + 43: -915350862, + 44: 1306585409, + 45: 1064798289, + 46: 335555913, + 47: 224485496, + 48: 275599760, + 49: 409559869, + 50: 673770580, + 51: -2113819879, + 52: -791338727, + 53: -1716479479, + 54: 1795018816, + 55: 2020139343, + 56: -1652827750, + 57: -1509632558, + 58: 751641995, + 59: -217881377, + 60: -476546900, + 61: -1893349644, + 62: -729290332, + 63: 1359899321, + 64: 1811814306, + 65: 2100363086, + 66: -794920327, + 67: -1667555017, + 68: -549980099, + 69: -21170740, + 70: -1324143722, + 71: 1406730195, + 72: 2111381574, + 73: -1667480052, + 74: 1071811178, + 75: -1080194099, + 76: -181186882, + 77: 268677507, + 78: -546766334, + 79: 555953522, + 80: -981311675, + 81: 1988867392, + 82: 773172547, + 83: 1160806722, + 84: -1455460187, + 85: 83493600, + 86: 155365142, + 87: 1714618071, + 88: 1487712615, + 89: -810670278, + 90: 2031655097, + 91: 1286349470, + 92: -1873594211, + 93: 1875867480, + 94: -1096259787, + 95: -1054968610, + 96: -1723043458, + 97: 1278708307, + 98: -601104085, + 99: 1497928579, + 100: 1329732615, + 101: -1281696190, + 102: 1471511953, + 103: -62666299, + 104: 807569747, + 105: -1927974759, + 106: 1462243717, + 107: -862975602, + 108: 824369927, + 109: -1448816781, + 110: 1434162022, + 111: -881501413, + 112: -1554381107, + 113: -1730883204, + 114: 431236217, + 115: 1877278608, + 116: -673864625, + 117: 143000665, + 118: -596902829, + 119: 1038860559, + 120: 805884326, + 121: -1536181710, + 122: -1357373256, + 123: 1405134250, + 124: -860816481, + 125: 1393578269, + 126: -810682545, + 127: -635515639, +}; + +let testCase; +if (globalThis.G_testRunner) { + testCase = new TestCase(document.title); + testCase.autoDiscoverTests(); + globalThis.G_testRunner.initialize(testCase); +} +testSuite({ + testEncodeInteger() { + assertEquals(898813988, hash32.encodeInteger(305419896)); + }, + + testEncodeByteArray() { + assertEquals(-1497024495, hash32.encodeByteArray([10, 20, 30, 40])); + assertEquals(-961586214, hash32.encodeByteArray([3, 1, 4, 1, 5, 9])); + assertEquals(-1482202299, hash32.encodeByteArray([127, 0, 0, 0, 123, 45])); + assertEquals(170907881, hash32.encodeByteArray([9, 1, 1])); + }, + + /** @suppress {missingProperties} suppression added to enable type checking */ + testKnownByteArrays() { + for (let i = 0; i < byteArrays.length; i++) { + assertEquals(byteArrays[i], hash32.encodeByteArray(createByteArray(i))); + } + }, + + testEncodeString() { + assertEquals(-937588052, hash32.encodeString('Hello, world')); + assertEquals(62382810, hash32.encodeString('Sch\xF6n')); + }, + + testEncodeBinaryString() { + assertEquals(-937588052, hash32.encodeBinaryString('Hello, world')); + assertEquals(62382810, hash32.encodeBinaryString('Sch\xF6n')); + }, + + testEncodeStringUtf8() { + assertEquals(-937588052, hash32.encodeStringUtf8('Hello, world')); + assertEquals(-833263351, hash32.encodeStringUtf8('Sch\xF6n')); + + assertEquals(-1771620293, hash32.encodeStringUtf8('\u043A\u0440')); + }, + + testEncodeText() { + assertEquals(-937588052, hash32.encodeText('Hello, world')); + assertEquals(-833263351, hash32.encodeText('Sch\xF6n')); + + assertEquals(-1771620293, hash32.encodeText('\u043A\u0440')); + }, + + testEncodeString_ascii() { + assertEquals( + 'For ascii characters UTF8 should be the same', + hash32.encodeText('abc123'), hash32.encodeBinaryString('abc123')); + + assertEquals( + 'For ascii characters UTF8 should be the same', + hash32.encodeText('The,quick.brown-fox'), + hash32.encodeBinaryString('The,quick.brown-fox')); + + assertNotEquals( + 'For non-ascii characters UTF-8 encoding is different', + hash32.encodeText('Sch\xF6n'), hash32.encodeBinaryString('Sch\xF6n')); + }, + + testEncodeString_poe() { + const poe = + 'Once upon a midnight dreary, while I pondered weak and weary,' + + 'Over many a quaint and curious volume of forgotten lore,' + + 'While I nodded, nearly napping, suddenly there came a tapping,' + + 'As of some one gently rapping, rapping at my chamber door.' + + '`\'Tis some visitor,\' I muttered, `tapping at my chamber door -' + + 'Only this, and nothing more.\'' + + 'Ah, distinctly I remember it was in the bleak December,' + + 'And each separate dying ember wrought its ghost upon the floor.' + + 'Eagerly I wished the morrow; - vainly I had sought to borrow' + + 'From my books surcease of sorrow - sorrow for the lost Lenore -' + + 'For the rare and radiant maiden whom the angels named Lenore -' + + 'Nameless here for evermore.' + + 'And the silken sad uncertain rustling of each purple curtain' + + 'Thrilled me - filled me with fantastic terrors never felt before;' + + 'So that now, to still the beating of my heart, I stood repeating' + + '`\'Tis some visitor entreating entrance at my chamber door -' + + 'Some late visitor entreating entrance at my chamber door; -' + + 'This it is, and nothing more,\'' + + 'Presently my soul grew stronger; hesitating then no longer,' + + '`Sir,\' said I, `or Madam, truly your forgiveness I implore;' + + 'But the fact is I was napping, and so gently you came rapping,' + + 'And so faintly you came tapping, tapping at my chamber door,' + + 'That I scarce was sure I heard you\' - here I opened wide the door; -' + + 'Darkness there, and nothing more.' + + 'Deep into that darkness peering, long I stood there wondering, ' + + 'fearing,' + + 'Doubting, dreaming dreams no mortal ever dared to dream before' + + 'But the silence was unbroken, and the darkness gave no token,' + + 'And the only word there spoken was the whispered word, `Lenore!\'' + + 'This I whispered, and an echo murmured back the word, `Lenore!\'' + + 'Merely this and nothing more.' + + 'Back into the chamber turning, all my soul within me burning,' + + 'Soon again I heard a tapping somewhat louder than before.' + + '`Surely,\' said I, `surely that is something at my window lattice;' + + 'Let me see then, what thereat is, and this mystery explore -' + + 'Let my heart be still a moment and this mystery explore; -' + + '\'Tis the wind and nothing more!\''; + + assertEquals(147608747, hash32.encodeBinaryString(poe)); + assertEquals(147608747, hash32.encodeText(poe)); + }, + + testBenchmarking_binary() { + if (!testCase) return; + // Not a real test, just outputs some timing + for (let i = 0; i < 50000; i += 10000) { + const str = makeString(i, 256); + const start = Date.now(); + const hash = hash32.encodeBinaryString(str); + + const diff = Date.now() - start; + testCase.saveMessage( + `testBenchmarking_binary: hashing ${i} chars in ${diff}ms (${hash})`); + } + }, + + testBenchmarking_text() { + if (!testCase) return; + // Not a real test, just outputs some timing + for (let i = 0; i < 50000; i += 10000) { + const str = makeString(i, 0xd000); // avoid unpaired surrogates + const start = Date.now(); + const hash = hash32.encodeText(str); + const diff = Date.now() - start; + testCase.saveMessage( + `testBenchmarking_text: hashing ${i} chars in ${diff}ms (${hash})`); + } + }, +}); + +/** + * Build a random string of the given length and range of characters. + * @param {number} n Length of string + * @param {number=} max Maximum charcode for each character + * @return {string} + */ +function makeString(n, max = 256) { + const str = []; + for (let i = 0; i < n; i++) { + str.push(String.fromCharCode(Math.floor(Math.random() * max))); + } + return str.join(''); +} diff --git a/closure/goog/crypt/hashtester.js b/closure/goog/crypt/hashtester.js new file mode 100644 index 0000000000..376e420b4b --- /dev/null +++ b/closure/goog/crypt/hashtester.js @@ -0,0 +1,253 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Unit tests for the abstract cryptographic hash interface. + */ + +goog.provide('goog.crypt.hashTester'); + +goog.require('goog.crypt'); +goog.require('goog.dom'); +goog.require('goog.dom.TagName'); +goog.require('goog.reflect'); +goog.require('goog.testing.PerformanceTable'); +goog.require('goog.testing.PseudoRandom'); +goog.require('goog.testing.asserts'); +goog.setTestOnly('hashTester'); +goog.requireType('goog.crypt.Hash'); + + +/** + * Runs basic tests. + * + * @param {!goog.crypt.Hash} hash A hash instance. + */ +goog.crypt.hashTester.runBasicTests = function(hash) { + 'use strict'; + // Compute first hash. + hash.update([97, 158]); + var golden1 = hash.digest(); + + // Compute second hash. + hash.reset(); + hash.update('aB'); + var golden2 = hash.digest(); + assertTrue( + 'Two different inputs resulted in a hash collision', + !!goog.testing.asserts.findDifferences(golden1, golden2)); + + // Empty hash. + hash.reset(); + var empty = hash.digest(); + assertTrue( + 'Empty hash collided with a non-trivial one', + !!goog.testing.asserts.findDifferences(golden1, empty) && + !!goog.testing.asserts.findDifferences(golden2, empty)); + + // Zero-length array update. + hash.reset(); + hash.update([]); + assertArrayEquals( + 'Updating with an empty array did not give an empty hash', empty, + hash.digest()); + + // Zero-length string update. + hash.reset(); + hash.update(''); + assertArrayEquals( + 'Updating with an empty string did not give an empty hash', empty, + hash.digest()); + + // Recompute the first hash. + hash.reset(); + hash.update([97, 158]); + assertArrayEquals( + 'The reset did not produce the initial state', golden1, hash.digest()); + + // Check for a trivial collision. + hash.reset(); + hash.update([158, 97]); + assertTrue( + 'Swapping bytes resulted in a hash collision', + !!goog.testing.asserts.findDifferences(golden1, hash.digest())); + + // Compare array and string input. + hash.reset(); + hash.update([97, 66]); + assertArrayEquals( + 'String and array inputs should give the same result', golden2, + hash.digest()); + + // Compute in parts. + hash.reset(); + hash.update('a'); + hash.update([158]); + assertArrayEquals( + 'Partial updates resulted in a different hash', golden1, hash.digest()); + + // Test update with specified length. + hash.reset(); + hash.update('aB', 0); + hash.update([97, 158, 32], 2); + assertArrayEquals( + 'Updating with an explicit buffer length did not work', golden1, + hash.digest()); +}; + + +/** + * Runs block tests. + * + * @param {!goog.crypt.Hash} hash A hash instance. + * @param {number} blockBytes Size of the hash block. + */ +goog.crypt.hashTester.runBlockTests = function(hash, blockBytes) { + 'use strict'; + // Compute a message which is 1 byte shorter than hash block size. + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + var message = ''; + for (var i = 0; i < blockBytes - 1; i++) { + message += chars.charAt(i % chars.length); + } + + // Compute golden hash for 1 block + 2 bytes. + hash.update(message + '123'); + var golden1 = hash.digest(); + + // Compute golden hash for 2 blocks + 1 byte. + hash.reset(); + hash.update(message + message + '123'); + var golden2 = hash.digest(); + + // Almost fill a block, then overflow. + hash.reset(); + hash.update(message); + hash.update('123'); + assertArrayEquals(golden1, hash.digest()); + + // Fill a block. + hash.reset(); + hash.update(message + '1'); + hash.update('23'); + assertArrayEquals(golden1, hash.digest()); + + // Overflow a block. + hash.reset(); + hash.update(message + '12'); + hash.update('3'); + assertArrayEquals(golden1, hash.digest()); + + // Test single overflow with an array. + hash.reset(); + hash.update(goog.crypt.stringToByteArray(message + '123')); + assertArrayEquals(golden1, hash.digest()); + + // Almost fill a block, then overflow this and the next block. + hash.reset(); + hash.update(message); + hash.update(message + '123'); + assertArrayEquals(golden2, hash.digest()); + + // Fill two blocks. + hash.reset(); + hash.update(message + message + '12'); + hash.update('3'); + assertArrayEquals(golden2, hash.digest()); + + // Test double overflow with an array. + hash.reset(); + hash.update(goog.crypt.stringToByteArray(message)); + hash.update(goog.crypt.stringToByteArray(message + '123')); + assertArrayEquals(golden2, hash.digest()); +}; + + +/** + * Runs performance tests. + * + * @param {function():!goog.crypt.Hash} hashFactory A hash factory. + * @param {string} hashName Name of the hashing function. + */ +goog.crypt.hashTester.runPerfTests = function(hashFactory, hashName) { + 'use strict'; + var body = goog.dom.getDocument().body; + var perfTable = goog.dom.createElement(goog.dom.TagName.DIV); + goog.dom.appendChild(body, perfTable); + + var table = new goog.testing.PerformanceTable(perfTable); + + function runPerfTest(byteLength, updateCount) { + var label = + (hashName + ': ' + updateCount + ' update(s) of ' + byteLength + + ' bytes'); + + function run(data, dataType) { + table.run(function() { + 'use strict'; + var hash = hashFactory(); + for (var i = 0; i < updateCount; i++) { + hash.update(data, byteLength); + } + // Prevent JsCompiler optimizations from invalidating the benchmark. + goog.reflect.sinkValue(hash.digest()); + }, label + ' (' + dataType + ')'); + } + + var byteArray = goog.crypt.hashTester.createRandomByteArray_(byteLength); + var byteString = goog.crypt.hashTester.createByteString_(byteArray); + + run(byteArray, 'byte array'); + run(byteString, 'byte string'); + } + + var MESSAGE_LENGTH_LONG = 10000000; // 10 Mbytes + var MESSAGE_LENGTH_SHORT = 10; // 10 bytes + var MESSAGE_COUNT_SHORT = MESSAGE_LENGTH_LONG / MESSAGE_LENGTH_SHORT; + + runPerfTest(MESSAGE_LENGTH_LONG, 1); + runPerfTest(MESSAGE_LENGTH_SHORT, MESSAGE_COUNT_SHORT); +}; + + +/** + * Creates and returns a random byte array. + * + * @param {number} length Length of the byte array. + * @return {!Array} An array of bytes. + * @private + */ +goog.crypt.hashTester.createRandomByteArray_ = function(length) { + 'use strict'; + var random = new goog.testing.PseudoRandom(0); + var bytes = []; + + for (var i = 0; i < length; ++i) { + // Generates an integer from 0 to 255. + var b = Math.floor(random.random() * 0x100); + bytes.push(b); + } + + return bytes; +}; + + +/** + * Creates a string from an array of bytes. + * + * @param {!Array} bytes An array of bytes. + * @return {string} The string encoded by the bytes. + * @private + */ +goog.crypt.hashTester.createByteString_ = function(bytes) { + 'use strict'; + var str = ''; + bytes.forEach(function(b) { + 'use strict'; + str += String.fromCharCode(b); + }); + return str; +}; diff --git a/closure/goog/crypt/hmac.js b/closure/goog/crypt/hmac.js new file mode 100644 index 0000000000..571a0cfbe0 --- /dev/null +++ b/closure/goog/crypt/hmac.js @@ -0,0 +1,157 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Implementation of HMAC in JavaScript. + * + * Usage: + * var hmac = new goog.crypt.Hmac(new goog.crypt.sha1(), key, 64); + * var digest = hmac.getHmac(bytes); + */ + + +goog.provide('goog.crypt.Hmac'); + +goog.require('goog.crypt.Hash'); + + + +/** + * @constructor + * @param {!goog.crypt.Hash} hasher An object to serve as a hash function. + * @param {Array} key The secret key to use to calculate the hmac. + * Should be an array of not more than `blockSize` integers in + {0, 255}. + * @param {number=} opt_blockSize Optional. The block size `hasher` uses. + * If not specified, uses the block size from the hasher, or 16 if it is + * not specified. + * @extends {goog.crypt.Hash} + * @final + * @struct + */ +goog.crypt.Hmac = function(hasher, key, opt_blockSize) { + 'use strict'; + goog.crypt.Hmac.base(this, 'constructor'); + + /** + * The underlying hasher to calculate hash. + * + * @type {!goog.crypt.Hash} + * @private + */ + this.hasher_ = hasher; + + /** @const {number} */ + this.blockSize = opt_blockSize || hasher.blockSize || 16; + + /** + * The outer padding array of hmac + * + * @type {!Array} + * @private + */ + this.keyO_ = new Array(this.blockSize); + + /** + * The inner padding array of hmac + * + * @type {!Array} + * @private + */ + this.keyI_ = new Array(this.blockSize); + + this.initialize_(key); +}; +goog.inherits(goog.crypt.Hmac, goog.crypt.Hash); + + +/** + * Outer padding byte of HMAC algorith, per http://en.wikipedia.org/wiki/HMAC + * + * @type {number} + * @private + */ +goog.crypt.Hmac.OPAD_ = 0x5c; + + +/** + * Inner padding byte of HMAC algorith, per http://en.wikipedia.org/wiki/HMAC + * + * @type {number} + * @private + */ +goog.crypt.Hmac.IPAD_ = 0x36; + + +/** + * Initializes Hmac by precalculating the inner and outer paddings. + * + * @param {Array} key The secret key to use to calculate the hmac. + * Should be an array of not more than `blockSize` integers in + {0, 255}. + * @private + */ +goog.crypt.Hmac.prototype.initialize_ = function(key) { + 'use strict'; + if (key.length > this.blockSize) { + this.hasher_.update(key); + key = this.hasher_.digest(); + this.hasher_.reset(); + } + // Precalculate padded and xor'd keys. + var keyByte; + for (var i = 0; i < this.blockSize; i++) { + if (i < key.length) { + keyByte = key[i]; + } else { + keyByte = 0; + } + this.keyO_[i] = keyByte ^ goog.crypt.Hmac.OPAD_; + this.keyI_[i] = keyByte ^ goog.crypt.Hmac.IPAD_; + } + // Be ready for an immediate update. + this.hasher_.update(this.keyI_); +}; + + +/** @override */ +goog.crypt.Hmac.prototype.reset = function() { + 'use strict'; + this.hasher_.reset(); + this.hasher_.update(this.keyI_); +}; + + +/** @override */ +goog.crypt.Hmac.prototype.update = function(bytes, opt_length) { + 'use strict'; + this.hasher_.update(bytes, opt_length); +}; + + +/** @override */ +goog.crypt.Hmac.prototype.digest = function() { + 'use strict'; + var temp = this.hasher_.digest(); + this.hasher_.reset(); + this.hasher_.update(this.keyO_); + this.hasher_.update(temp); + return this.hasher_.digest(); +}; + + +/** + * Calculates an HMAC for a given message. + * + * @param {Array|Uint8Array|string} message Data to Hmac. + * @return {!Array} the digest of the given message. + */ +goog.crypt.Hmac.prototype.getHmac = function(message) { + 'use strict'; + this.reset(); + this.update(message); + return this.digest(); +}; diff --git a/closure/goog/crypt/hmac_test.js b/closure/goog/crypt/hmac_test.js new file mode 100644 index 0000000000..320b9556c8 --- /dev/null +++ b/closure/goog/crypt/hmac_test.js @@ -0,0 +1,146 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.crypt.HmacTest'); +goog.setTestOnly(); + +const Hmac = goog.require('goog.crypt.Hmac'); +const Sha1 = goog.require('goog.crypt.Sha1'); +const hashTester = goog.require('goog.crypt.hashTester'); +const testSuite = goog.require('goog.testing.testSuite'); + +function stringToBytes(s) { + const bytes = new Array(s.length); + + for (let i = 0; i < s.length; ++i) { + bytes[i] = s.charCodeAt(i) & 255; + } + return bytes; +} + +function hexToBytes(str) { + const arr = []; + + for (let i = 0; i < str.length; i += 2) { + arr.push(parseInt(str.substring(i, i + 2), 16)); + } + + return arr; +} + +function bytesToHex(b) { + const hexchars = '0123456789abcdef'; + const hexrep = new Array(b.length * 2); + + for (let i = 0; i < b.length; ++i) { + hexrep[i * 2] = hexchars.charAt((b[i] >> 4) & 15); + hexrep[i * 2 + 1] = hexchars.charAt(b[i] & 15); + } + return hexrep.join(''); +} + +/** helper to get an hmac of the given message with the given key. */ +function getHmac(key, message, blockSize = undefined) { + const hasher = new Sha1(); + const hmacer = new Hmac(hasher, key, blockSize); + return bytesToHex(hmacer.getHmac(message)); +} + +testSuite({ + /** @suppress {checkTypes} hmac doesn't access strings */ + testBasicOperations() { + const hmac = new Hmac(new Sha1(), 'key', 64); + hashTester.runBasicTests(hmac); + }, + + /** @suppress {checkTypes} hmac doesn't access strings */ + testBasicOperationsWithNoBlockSize() { + const hmac = new Hmac(new Sha1(), 'key'); + hashTester.runBasicTests(hmac); + }, + + testHmac() { + // HMAC test vectors from: + // http://tools.ietf.org/html/2202 + + assertEquals( + 'test 1 failed', 'b617318655057264e28bc0b6fb378c8ef146be00', + getHmac( + hexToBytes('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b'), + stringToBytes('Hi There'))); + + assertEquals( + 'test 2 failed', 'effcdf6ae5eb2fa2d27416d5f184df9c259a7c79', + getHmac( + stringToBytes('Jefe'), + stringToBytes('what do ya want for nothing?'))); + + assertEquals( + 'test 3 failed', '125d7342b9ac11cd91a39af48aa17b4f63f175d3', + getHmac( + hexToBytes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), + hexToBytes( + 'dddddddddddddddddddddddddddddddddddddddd' + + 'dddddddddddddddddddddddddddddddddddddddd' + + 'dddddddddddddddddddd'))); + + assertEquals( + 'test 4 failed', '4c9007f4026250c6bc8414f9bf50c86c2d7235da', + getHmac( + hexToBytes('0102030405060708090a0b0c0d0e0f10111213141516171819'), + hexToBytes( + 'cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd' + + 'cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd' + + 'cdcdcdcdcdcdcdcdcdcd'))); + + assertEquals( + 'test 5 failed', '4c1a03424b55e07fe7f27be1d58bb9324a9a5a04', + getHmac( + hexToBytes('0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c'), + stringToBytes('Test With Truncation'))); + + assertEquals( + 'test 6 failed', 'aa4ae5e15272d00e95705637ce8a3b55ed402112', + getHmac( + hexToBytes( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), + stringToBytes( + 'Test Using Larger Than Block-Size Key - Hash Key First'))); + + assertEquals( + 'test 7 failed', 'b617318655057264e28bc0b6fb378c8ef146be00', + getHmac( + hexToBytes('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b'), + stringToBytes('Hi There'), 64)); + + assertEquals( + 'test 8 failed', '941f806707826395dc510add6a45ce9933db976e', + getHmac( + hexToBytes('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b'), + stringToBytes('Hi There'), 32)); + }, + + /** Regression test for Bug 12863104 */ + testUpdateWithLongKey() { + // Calling update() then digest() should give the same result as just + // calling getHmac() + const key = hexToBytes( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + const message = 'Secret Message'; + const hmac = new Hmac(new Sha1(), key); + hmac.update(message); + const result1 = bytesToHex(hmac.digest()); + hmac.reset(); + const result2 = bytesToHex(hmac.getHmac(message)); + assertEquals('Results must be the same', result1, result2); + }, +}); diff --git a/closure/goog/crypt/md5.js b/closure/goog/crypt/md5.js new file mode 100644 index 0000000000..a318df79da --- /dev/null +++ b/closure/goog/crypt/md5.js @@ -0,0 +1,430 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview MD5 cryptographic hash. + * Implementation of http://tools.ietf.org/html/rfc1321 with common + * optimizations and tweaks (see http://en.wikipedia.org/wiki/MD5). + * + * Usage: + * var md5 = new goog.crypt.Md5(); + * md5.update(bytes); + * var hash = md5.digest(); + * + * Performance: + * Chrome 23 ~680 Mbit/s + * Chrome 13 (in a VM) ~250 Mbit/s + * Firefox 6.0 (in a VM) ~100 Mbit/s + * IE9 (in a VM) ~27 Mbit/s + * Firefox 3.6 ~15 Mbit/s + * IE8 (in a VM) ~13 Mbit/s + */ + +goog.provide('goog.crypt.Md5'); + +goog.require('goog.crypt.Hash'); + + + +/** + * MD5 cryptographic hash constructor. + * @constructor + * @extends {goog.crypt.Hash} + * @final + * @struct + */ +goog.crypt.Md5 = function() { + 'use strict'; + goog.crypt.Md5.base(this, 'constructor'); + + /** @const {number} */ + this.blockSize = 512 / 8; + + /** + * Holds the current values of accumulated A-D variables (MD buffer). + * @type {!Array} + * @private + */ + this.chain_ = new Array(4); + + /** + * A buffer holding the data until the whole block can be processed. + * @type {!Array} + * @private + */ + this.block_ = new Array(this.blockSize); + + /** + * The length of yet-unprocessed data as collected in the block. + * @type {number} + * @private + */ + this.blockLength_ = 0; + + /** + * The total length of the message so far. + * @type {number} + * @private + */ + this.totalLength_ = 0; + + this.reset(); +}; +goog.inherits(goog.crypt.Md5, goog.crypt.Hash); + + +/** + * Integer rotation constants used by the abbreviated implementation. + * They are hardcoded in the unrolled implementation, so it is left + * here commented out. + * @type {Array} + * @private + * +goog.crypt.Md5.S_ = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, + 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, + 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21 +]; + */ + +/** + * Sine function constants used by the abbreviated implementation. + * They are hardcoded in the unrolled implementation, so it is left + * here commented out. + * @type {Array} + * @private + * +goog.crypt.Md5.T_ = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, + 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, + 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, + 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, + 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, + 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, + 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, + 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, + 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, + 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, + 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, + 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, + 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, + 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, + 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391 +]; + */ + + +/** @override */ +goog.crypt.Md5.prototype.reset = function() { + 'use strict'; + this.chain_[0] = 0x67452301; + this.chain_[1] = 0xefcdab89; + this.chain_[2] = 0x98badcfe; + this.chain_[3] = 0x10325476; + + this.blockLength_ = 0; + this.totalLength_ = 0; +}; + + +/** + * Internal compress helper function. It takes a block of data (64 bytes) + * and updates the accumulator. + * @param {Array|Uint8Array|string} buf The block to compress. + * @param {number=} opt_offset Offset of the block in the buffer. + * @private + */ +goog.crypt.Md5.prototype.compress_ = function(buf, opt_offset) { + 'use strict'; + if (!opt_offset) { + opt_offset = 0; + } + + // We allocate the array every time, but it's cheap in practice. + var X = new Array(16); + + // Get 16 little endian words. It is not worth unrolling this for Chrome 11. + if (typeof buf === 'string') { + for (var i = 0; i < 16; ++i) { + X[i] = (buf.charCodeAt(opt_offset++)) | + (buf.charCodeAt(opt_offset++) << 8) | + (buf.charCodeAt(opt_offset++) << 16) | + (buf.charCodeAt(opt_offset++) << 24); + } + } else { + for (var i = 0; i < 16; ++i) { + X[i] = (buf[opt_offset++]) | (buf[opt_offset++] << 8) | + (buf[opt_offset++] << 16) | (buf[opt_offset++] << 24); + } + } + + var A = this.chain_[0]; + var B = this.chain_[1]; + var C = this.chain_[2]; + var D = this.chain_[3]; + var sum = 0; + + /* + * This is an abbreviated implementation, it is left here commented out for + * reference purposes. See below for an unrolled version in use. + * + var f, n, tmp; + for (var i = 0; i < 64; ++i) { + + if (i < 16) { + f = (D ^ (B & (C ^ D))); + n = i; + } else if (i < 32) { + f = (C ^ (D & (B ^ C))); + n = (5 * i + 1) % 16; + } else if (i < 48) { + f = (B ^ C ^ D); + n = (3 * i + 5) % 16; + } else { + f = (C ^ (B | (~D))); + n = (7 * i) % 16; + } + + tmp = D; + D = C; + C = B; + sum = (A + f + goog.crypt.Md5.T_[i] + X[n]) & 0xffffffff; + B += ((sum << goog.crypt.Md5.S_[i]) & 0xffffffff) | + (sum >>> (32 - goog.crypt.Md5.S_[i])); + A = tmp; + } + */ + + /* + * This is an unrolled MD5 implementation, which gives ~30% speedup compared + * to the abbreviated implementation above, as measured on Chrome 11. It is + * important to keep 32-bit croppings to minimum and inline the integer + * rotation. + */ + sum = (A + (D ^ (B & (C ^ D))) + X[0] + 0xd76aa478) & 0xffffffff; + A = B + (((sum << 7) & 0xffffffff) | (sum >>> 25)); + sum = (D + (C ^ (A & (B ^ C))) + X[1] + 0xe8c7b756) & 0xffffffff; + D = A + (((sum << 12) & 0xffffffff) | (sum >>> 20)); + sum = (C + (B ^ (D & (A ^ B))) + X[2] + 0x242070db) & 0xffffffff; + C = D + (((sum << 17) & 0xffffffff) | (sum >>> 15)); + sum = (B + (A ^ (C & (D ^ A))) + X[3] + 0xc1bdceee) & 0xffffffff; + B = C + (((sum << 22) & 0xffffffff) | (sum >>> 10)); + sum = (A + (D ^ (B & (C ^ D))) + X[4] + 0xf57c0faf) & 0xffffffff; + A = B + (((sum << 7) & 0xffffffff) | (sum >>> 25)); + sum = (D + (C ^ (A & (B ^ C))) + X[5] + 0x4787c62a) & 0xffffffff; + D = A + (((sum << 12) & 0xffffffff) | (sum >>> 20)); + sum = (C + (B ^ (D & (A ^ B))) + X[6] + 0xa8304613) & 0xffffffff; + C = D + (((sum << 17) & 0xffffffff) | (sum >>> 15)); + sum = (B + (A ^ (C & (D ^ A))) + X[7] + 0xfd469501) & 0xffffffff; + B = C + (((sum << 22) & 0xffffffff) | (sum >>> 10)); + sum = (A + (D ^ (B & (C ^ D))) + X[8] + 0x698098d8) & 0xffffffff; + A = B + (((sum << 7) & 0xffffffff) | (sum >>> 25)); + sum = (D + (C ^ (A & (B ^ C))) + X[9] + 0x8b44f7af) & 0xffffffff; + D = A + (((sum << 12) & 0xffffffff) | (sum >>> 20)); + sum = (C + (B ^ (D & (A ^ B))) + X[10] + 0xffff5bb1) & 0xffffffff; + C = D + (((sum << 17) & 0xffffffff) | (sum >>> 15)); + sum = (B + (A ^ (C & (D ^ A))) + X[11] + 0x895cd7be) & 0xffffffff; + B = C + (((sum << 22) & 0xffffffff) | (sum >>> 10)); + sum = (A + (D ^ (B & (C ^ D))) + X[12] + 0x6b901122) & 0xffffffff; + A = B + (((sum << 7) & 0xffffffff) | (sum >>> 25)); + sum = (D + (C ^ (A & (B ^ C))) + X[13] + 0xfd987193) & 0xffffffff; + D = A + (((sum << 12) & 0xffffffff) | (sum >>> 20)); + sum = (C + (B ^ (D & (A ^ B))) + X[14] + 0xa679438e) & 0xffffffff; + C = D + (((sum << 17) & 0xffffffff) | (sum >>> 15)); + sum = (B + (A ^ (C & (D ^ A))) + X[15] + 0x49b40821) & 0xffffffff; + B = C + (((sum << 22) & 0xffffffff) | (sum >>> 10)); + sum = (A + (C ^ (D & (B ^ C))) + X[1] + 0xf61e2562) & 0xffffffff; + A = B + (((sum << 5) & 0xffffffff) | (sum >>> 27)); + sum = (D + (B ^ (C & (A ^ B))) + X[6] + 0xc040b340) & 0xffffffff; + D = A + (((sum << 9) & 0xffffffff) | (sum >>> 23)); + sum = (C + (A ^ (B & (D ^ A))) + X[11] + 0x265e5a51) & 0xffffffff; + C = D + (((sum << 14) & 0xffffffff) | (sum >>> 18)); + sum = (B + (D ^ (A & (C ^ D))) + X[0] + 0xe9b6c7aa) & 0xffffffff; + B = C + (((sum << 20) & 0xffffffff) | (sum >>> 12)); + sum = (A + (C ^ (D & (B ^ C))) + X[5] + 0xd62f105d) & 0xffffffff; + A = B + (((sum << 5) & 0xffffffff) | (sum >>> 27)); + sum = (D + (B ^ (C & (A ^ B))) + X[10] + 0x02441453) & 0xffffffff; + D = A + (((sum << 9) & 0xffffffff) | (sum >>> 23)); + sum = (C + (A ^ (B & (D ^ A))) + X[15] + 0xd8a1e681) & 0xffffffff; + C = D + (((sum << 14) & 0xffffffff) | (sum >>> 18)); + sum = (B + (D ^ (A & (C ^ D))) + X[4] + 0xe7d3fbc8) & 0xffffffff; + B = C + (((sum << 20) & 0xffffffff) | (sum >>> 12)); + sum = (A + (C ^ (D & (B ^ C))) + X[9] + 0x21e1cde6) & 0xffffffff; + A = B + (((sum << 5) & 0xffffffff) | (sum >>> 27)); + sum = (D + (B ^ (C & (A ^ B))) + X[14] + 0xc33707d6) & 0xffffffff; + D = A + (((sum << 9) & 0xffffffff) | (sum >>> 23)); + sum = (C + (A ^ (B & (D ^ A))) + X[3] + 0xf4d50d87) & 0xffffffff; + C = D + (((sum << 14) & 0xffffffff) | (sum >>> 18)); + sum = (B + (D ^ (A & (C ^ D))) + X[8] + 0x455a14ed) & 0xffffffff; + B = C + (((sum << 20) & 0xffffffff) | (sum >>> 12)); + sum = (A + (C ^ (D & (B ^ C))) + X[13] + 0xa9e3e905) & 0xffffffff; + A = B + (((sum << 5) & 0xffffffff) | (sum >>> 27)); + sum = (D + (B ^ (C & (A ^ B))) + X[2] + 0xfcefa3f8) & 0xffffffff; + D = A + (((sum << 9) & 0xffffffff) | (sum >>> 23)); + sum = (C + (A ^ (B & (D ^ A))) + X[7] + 0x676f02d9) & 0xffffffff; + C = D + (((sum << 14) & 0xffffffff) | (sum >>> 18)); + sum = (B + (D ^ (A & (C ^ D))) + X[12] + 0x8d2a4c8a) & 0xffffffff; + B = C + (((sum << 20) & 0xffffffff) | (sum >>> 12)); + sum = (A + (B ^ C ^ D) + X[5] + 0xfffa3942) & 0xffffffff; + A = B + (((sum << 4) & 0xffffffff) | (sum >>> 28)); + sum = (D + (A ^ B ^ C) + X[8] + 0x8771f681) & 0xffffffff; + D = A + (((sum << 11) & 0xffffffff) | (sum >>> 21)); + sum = (C + (D ^ A ^ B) + X[11] + 0x6d9d6122) & 0xffffffff; + C = D + (((sum << 16) & 0xffffffff) | (sum >>> 16)); + sum = (B + (C ^ D ^ A) + X[14] + 0xfde5380c) & 0xffffffff; + B = C + (((sum << 23) & 0xffffffff) | (sum >>> 9)); + sum = (A + (B ^ C ^ D) + X[1] + 0xa4beea44) & 0xffffffff; + A = B + (((sum << 4) & 0xffffffff) | (sum >>> 28)); + sum = (D + (A ^ B ^ C) + X[4] + 0x4bdecfa9) & 0xffffffff; + D = A + (((sum << 11) & 0xffffffff) | (sum >>> 21)); + sum = (C + (D ^ A ^ B) + X[7] + 0xf6bb4b60) & 0xffffffff; + C = D + (((sum << 16) & 0xffffffff) | (sum >>> 16)); + sum = (B + (C ^ D ^ A) + X[10] + 0xbebfbc70) & 0xffffffff; + B = C + (((sum << 23) & 0xffffffff) | (sum >>> 9)); + sum = (A + (B ^ C ^ D) + X[13] + 0x289b7ec6) & 0xffffffff; + A = B + (((sum << 4) & 0xffffffff) | (sum >>> 28)); + sum = (D + (A ^ B ^ C) + X[0] + 0xeaa127fa) & 0xffffffff; + D = A + (((sum << 11) & 0xffffffff) | (sum >>> 21)); + sum = (C + (D ^ A ^ B) + X[3] + 0xd4ef3085) & 0xffffffff; + C = D + (((sum << 16) & 0xffffffff) | (sum >>> 16)); + sum = (B + (C ^ D ^ A) + X[6] + 0x04881d05) & 0xffffffff; + B = C + (((sum << 23) & 0xffffffff) | (sum >>> 9)); + sum = (A + (B ^ C ^ D) + X[9] + 0xd9d4d039) & 0xffffffff; + A = B + (((sum << 4) & 0xffffffff) | (sum >>> 28)); + sum = (D + (A ^ B ^ C) + X[12] + 0xe6db99e5) & 0xffffffff; + D = A + (((sum << 11) & 0xffffffff) | (sum >>> 21)); + sum = (C + (D ^ A ^ B) + X[15] + 0x1fa27cf8) & 0xffffffff; + C = D + (((sum << 16) & 0xffffffff) | (sum >>> 16)); + sum = (B + (C ^ D ^ A) + X[2] + 0xc4ac5665) & 0xffffffff; + B = C + (((sum << 23) & 0xffffffff) | (sum >>> 9)); + sum = (A + (C ^ (B | (~D))) + X[0] + 0xf4292244) & 0xffffffff; + A = B + (((sum << 6) & 0xffffffff) | (sum >>> 26)); + sum = (D + (B ^ (A | (~C))) + X[7] + 0x432aff97) & 0xffffffff; + D = A + (((sum << 10) & 0xffffffff) | (sum >>> 22)); + sum = (C + (A ^ (D | (~B))) + X[14] + 0xab9423a7) & 0xffffffff; + C = D + (((sum << 15) & 0xffffffff) | (sum >>> 17)); + sum = (B + (D ^ (C | (~A))) + X[5] + 0xfc93a039) & 0xffffffff; + B = C + (((sum << 21) & 0xffffffff) | (sum >>> 11)); + sum = (A + (C ^ (B | (~D))) + X[12] + 0x655b59c3) & 0xffffffff; + A = B + (((sum << 6) & 0xffffffff) | (sum >>> 26)); + sum = (D + (B ^ (A | (~C))) + X[3] + 0x8f0ccc92) & 0xffffffff; + D = A + (((sum << 10) & 0xffffffff) | (sum >>> 22)); + sum = (C + (A ^ (D | (~B))) + X[10] + 0xffeff47d) & 0xffffffff; + C = D + (((sum << 15) & 0xffffffff) | (sum >>> 17)); + sum = (B + (D ^ (C | (~A))) + X[1] + 0x85845dd1) & 0xffffffff; + B = C + (((sum << 21) & 0xffffffff) | (sum >>> 11)); + sum = (A + (C ^ (B | (~D))) + X[8] + 0x6fa87e4f) & 0xffffffff; + A = B + (((sum << 6) & 0xffffffff) | (sum >>> 26)); + sum = (D + (B ^ (A | (~C))) + X[15] + 0xfe2ce6e0) & 0xffffffff; + D = A + (((sum << 10) & 0xffffffff) | (sum >>> 22)); + sum = (C + (A ^ (D | (~B))) + X[6] + 0xa3014314) & 0xffffffff; + C = D + (((sum << 15) & 0xffffffff) | (sum >>> 17)); + sum = (B + (D ^ (C | (~A))) + X[13] + 0x4e0811a1) & 0xffffffff; + B = C + (((sum << 21) & 0xffffffff) | (sum >>> 11)); + sum = (A + (C ^ (B | (~D))) + X[4] + 0xf7537e82) & 0xffffffff; + A = B + (((sum << 6) & 0xffffffff) | (sum >>> 26)); + sum = (D + (B ^ (A | (~C))) + X[11] + 0xbd3af235) & 0xffffffff; + D = A + (((sum << 10) & 0xffffffff) | (sum >>> 22)); + sum = (C + (A ^ (D | (~B))) + X[2] + 0x2ad7d2bb) & 0xffffffff; + C = D + (((sum << 15) & 0xffffffff) | (sum >>> 17)); + sum = (B + (D ^ (C | (~A))) + X[9] + 0xeb86d391) & 0xffffffff; + B = C + (((sum << 21) & 0xffffffff) | (sum >>> 11)); + + this.chain_[0] = (this.chain_[0] + A) & 0xffffffff; + this.chain_[1] = (this.chain_[1] + B) & 0xffffffff; + this.chain_[2] = (this.chain_[2] + C) & 0xffffffff; + this.chain_[3] = (this.chain_[3] + D) & 0xffffffff; +}; + + +/** @override */ +goog.crypt.Md5.prototype.update = function(bytes, opt_length) { + 'use strict'; + if (opt_length === undefined) { + opt_length = bytes.length; + } + var lengthMinusBlock = opt_length - this.blockSize; + + // Copy some object properties to local variables in order to save on access + // time from inside the loop (~10% speedup was observed on Chrome 11). + var block = this.block_; + var blockLength = this.blockLength_; + var i = 0; + + // The outer while loop should execute at most twice. + while (i < opt_length) { + // When we have no data in the block to top up, we can directly process the + // input buffer (assuming it contains sufficient data). This gives ~30% + // speedup on Chrome 14 and ~70% speedup on Firefox 6.0, but requires that + // the data is provided in large chunks (or in multiples of 64 bytes). + if (blockLength == 0) { + while (i <= lengthMinusBlock) { + this.compress_(bytes, i); + i += this.blockSize; + } + } + + if (typeof bytes === 'string') { + while (i < opt_length) { + block[blockLength++] = bytes.charCodeAt(i++); + if (blockLength == this.blockSize) { + this.compress_(block); + blockLength = 0; + // Jump to the outer loop so we use the full-block optimization. + break; + } + } + } else { + while (i < opt_length) { + block[blockLength++] = bytes[i++]; + if (blockLength == this.blockSize) { + this.compress_(block); + blockLength = 0; + // Jump to the outer loop so we use the full-block optimization. + break; + } + } + } + } + + this.blockLength_ = blockLength; + this.totalLength_ += opt_length; +}; + + +/** @override */ +goog.crypt.Md5.prototype.digest = function() { + 'use strict'; + // This must accommodate at least 1 padding byte (0x80), 8 bytes of + // total bitlength, and must end at a 64-byte boundary. + var pad = new Array( + (this.blockLength_ < 56 ? this.blockSize : this.blockSize * 2) - + this.blockLength_); + + // Add padding: 0x80 0x00* + pad[0] = 0x80; + for (var i = 1; i < pad.length - 8; ++i) { + pad[i] = 0; + } + // Add the total number of bits, little endian 64-bit integer. + var totalBits = this.totalLength_ * 8; + for (var i = pad.length - 8; i < pad.length; ++i) { + pad[i] = totalBits & 0xff; + totalBits /= 0x100; // Don't use bit-shifting here! + } + this.update(pad); + + var digest = new Array(16); + var n = 0; + for (var i = 0; i < 4; ++i) { + for (var j = 0; j < 32; j += 8) { + digest[n++] = (this.chain_[i] >>> j) & 0xff; + } + } + return digest; +}; diff --git a/closure/goog/crypt/md5_perf.html b/closure/goog/crypt/md5_perf.html new file mode 100644 index 0000000000..e765fa7641 --- /dev/null +++ b/closure/goog/crypt/md5_perf.html @@ -0,0 +1,38 @@ + + + + + +Closure Performance Tests - goog.crypt.Md5 + + + + + +

Closure Performance Tests - goog.crypt.Md5

+

+User-agent: + +

+ + + diff --git a/closure/goog/crypt/md5_test.js b/closure/goog/crypt/md5_test.js new file mode 100644 index 0000000000..163316b9e5 --- /dev/null +++ b/closure/goog/crypt/md5_test.js @@ -0,0 +1,137 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.crypt.Md5Test'); +goog.setTestOnly(); + +const Md5 = goog.require('goog.crypt.Md5'); +const crypt = goog.require('goog.crypt'); +const hashTester = goog.require('goog.crypt.hashTester'); +const testSuite = goog.require('goog.testing.testSuite'); + +const sixty = '123456789012345678901234567890123456789012345678901234567890'; + +testSuite({ + testBasicOperations() { + const md5 = new Md5(); + hashTester.runBasicTests(md5); + }, + + testBlockOperations() { + const md5 = new Md5(); + hashTester.runBlockTests(md5, 64); + }, + + testHashing() { + // Empty stream. + const md5 = new Md5(); + assertEquals( + 'd41d8cd98f00b204e9800998ecf8427e', crypt.byteArrayToHex(md5.digest())); + + // Simple stream. + md5.reset(); + md5.update([97]); + assertEquals( + '0cc175b9c0f1b6a831c399e269772661', crypt.byteArrayToHex(md5.digest())); + + // Simple stream with two updates. + md5.reset(); + md5.update([97]); + md5.update('bc'); + assertEquals( + '900150983cd24fb0d6963f7d28e17f72', crypt.byteArrayToHex(md5.digest())); + + // RFC 1321 standard test. + md5.reset(); + md5.update('abcdefghijklmnopqrstuvwxyz'); + assertEquals( + 'c3fcd3d76192e4007dfb496cca67e13b', crypt.byteArrayToHex(md5.digest())); + + // RFC 1321 standard test with two updates. + md5.reset(); + md5.update('message '); + md5.update('digest'); + assertEquals( + 'f96b697d7cb7938d525a2f31aaf161d0', crypt.byteArrayToHex(md5.digest())); + + // RFC 1321 standard test with three updates. + md5.reset(); + md5.update('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + md5.update('abcdefghijklmnopqrstuvwxyz'); + md5.update('0123456789'); + assertEquals( + 'd174ab98d277d9f5a5611c2c9f419d9f', crypt.byteArrayToHex(md5.digest())); + }, + + testPadding() { + // Message + padding fits in two 64-byte blocks. + const md5 = new Md5(); + md5.update(sixty); + md5.update(sixty.slice(0, 59)); + assertEquals( + '6261005311809757906e04c0d670492d', crypt.byteArrayToHex(md5.digest())); + + // Message + padding does not fit in two 64-byte blocks. + md5.reset(); + md5.update(sixty); + md5.update(sixty); + assertEquals( + '1d453b96d48d5e0cec4a20a71fecaa81', crypt.byteArrayToHex(md5.digest())); + }, + + testTwoAccumulators() { + // Two accumulators in parallel. + const md5_A = new Md5(); + const md5_B = new Md5(); + md5_A.update(sixty); + md5_B.update(sixty); + md5_A.update(`${sixty}1`); + md5_B.update(`${sixty}2`); + assertEquals( + '0801d688cc107d4789ec8b9a4519f01f', + crypt.byteArrayToHex(md5_A.digest())); + assertEquals( + '6e1a35ffc185d1e684d6ed281c0d4bd2', + crypt.byteArrayToHex(md5_B.digest())); + }, + + testCollision() { + // Check a known collision. + const A = [ + 0xd1, 0x31, 0xdd, 0x02, 0xc5, 0xe6, 0xee, 0xc4, 0x69, 0x3d, 0x9a, 0x06, + 0x98, 0xaf, 0xf9, 0x5c, 0x2f, 0xca, 0xb5, 0x87, 0x12, 0x46, 0x7e, 0xab, + 0x40, 0x04, 0x58, 0x3e, 0xb8, 0xfb, 0x7f, 0x89, 0x55, 0xad, 0x34, 0x06, + 0x09, 0xf4, 0xb3, 0x02, 0x83, 0xe4, 0x88, 0x83, 0x25, 0x71, 0x41, 0x5a, + 0x08, 0x51, 0x25, 0xe8, 0xf7, 0xcd, 0xc9, 0x9f, 0xd9, 0x1d, 0xbd, 0xf2, + 0x80, 0x37, 0x3c, 0x5b, 0xd8, 0x82, 0x3e, 0x31, 0x56, 0x34, 0x8f, 0x5b, + 0xae, 0x6d, 0xac, 0xd4, 0x36, 0xc9, 0x19, 0xc6, 0xdd, 0x53, 0xe2, 0xb4, + 0x87, 0xda, 0x03, 0xfd, 0x02, 0x39, 0x63, 0x06, 0xd2, 0x48, 0xcd, 0xa0, + 0xe9, 0x9f, 0x33, 0x42, 0x0f, 0x57, 0x7e, 0xe8, 0xce, 0x54, 0xb6, 0x70, + 0x80, 0xa8, 0x0d, 0x1e, 0xc6, 0x98, 0x21, 0xbc, 0xb6, 0xa8, 0x83, 0x93, + 0x96, 0xf9, 0x65, 0x2b, 0x6f, 0xf7, 0x2a, 0x70, + ]; + const B = [ + 0xd1, 0x31, 0xdd, 0x02, 0xc5, 0xe6, 0xee, 0xc4, 0x69, 0x3d, 0x9a, 0x06, + 0x98, 0xaf, 0xf9, 0x5c, 0x2f, 0xca, 0xb5, 0x07, 0x12, 0x46, 0x7e, 0xab, + 0x40, 0x04, 0x58, 0x3e, 0xb8, 0xfb, 0x7f, 0x89, 0x55, 0xad, 0x34, 0x06, + 0x09, 0xf4, 0xb3, 0x02, 0x83, 0xe4, 0x88, 0x83, 0x25, 0xf1, 0x41, 0x5a, + 0x08, 0x51, 0x25, 0xe8, 0xf7, 0xcd, 0xc9, 0x9f, 0xd9, 0x1d, 0xbd, 0x72, + 0x80, 0x37, 0x3c, 0x5b, 0xd8, 0x82, 0x3e, 0x31, 0x56, 0x34, 0x8f, 0x5b, + 0xae, 0x6d, 0xac, 0xd4, 0x36, 0xc9, 0x19, 0xc6, 0xdd, 0x53, 0xe2, 0x34, + 0x87, 0xda, 0x03, 0xfd, 0x02, 0x39, 0x63, 0x06, 0xd2, 0x48, 0xcd, 0xa0, + 0xe9, 0x9f, 0x33, 0x42, 0x0f, 0x57, 0x7e, 0xe8, 0xce, 0x54, 0xb6, 0x70, + 0x80, 0x28, 0x0d, 0x1e, 0xc6, 0x98, 0x21, 0xbc, 0xb6, 0xa8, 0x83, 0x93, + 0x96, 0xf9, 0x65, 0xab, 0x6f, 0xf7, 0x2a, 0x70, + ]; + const digest = '79054025255fb1a26e4bc422aef54eb4'; + const md5_A = new Md5(); + const md5_B = new Md5(); + md5_A.update(A); + md5_B.update(B); + assertEquals(digest, crypt.byteArrayToHex(md5_A.digest())); + assertEquals(digest, crypt.byteArrayToHex(md5_B.digest())); + }, +}); diff --git a/closure/goog/crypt/pbkdf2.js b/closure/goog/crypt/pbkdf2.js new file mode 100644 index 0000000000..ee1f604dfd --- /dev/null +++ b/closure/goog/crypt/pbkdf2.js @@ -0,0 +1,122 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Implementation of PBKDF2 in JavaScript. + * @see http://en.wikipedia.org/wiki/PBKDF2 + * + * Currently we only support HMAC-SHA1 as the underlying hash function. To add a + * new hash function, add a static method similar to deriveKeyFromPasswordSha1() + * and implement the specific computeBlockCallback() using the hash function. + * + * Usage: + * var key = pbkdf2.deriveKeySha1( + * stringToByteArray('password'), stringToByteArray('salt'), 1000, 128); + */ + +goog.provide('goog.crypt.pbkdf2'); + +goog.require('goog.asserts'); +goog.require('goog.crypt'); +goog.require('goog.crypt.Hmac'); +goog.require('goog.crypt.Sha1'); + + +/** + * Derives key from password using PBKDF2-SHA1 + * @param {!Array} password Byte array representation of the password + * from which the key is derived. + * @param {!Array} initialSalt Byte array representation of the salt. + * @param {number} iterations Number of interations when computing the key. + * @param {number} keyLength Length of the output key in bits. + * Must be multiple of 8. + * @return {!Array} Byte array representation of the output key. + */ +goog.crypt.pbkdf2.deriveKeySha1 = function( + password, initialSalt, iterations, keyLength) { + 'use strict'; + // Length of the HMAC-SHA1 output in bits. + var HASH_LENGTH = 160; + + /** + * Compute each block of the key using HMAC-SHA1. + * @param {!Array} index Byte array representation of the index of + * the block to be computed. + * @return {!Array} Byte array representation of the output block. + */ + var computeBlock = function(index) { + 'use strict'; + // Initialize the result to be array of 0 such that its xor with the first + // block would be the first block. + var result = (new Array(HASH_LENGTH / 8)).fill(0); + // Initialize the salt of the first iteration to initialSalt || i. + var salt = initialSalt.concat(index); + var hmac = new goog.crypt.Hmac(new goog.crypt.Sha1(), password, 64); + // Compute and XOR each iteration. + for (var i = 0; i < iterations; i++) { + // The salt of the next iteration is the result of the current iteration. + salt = hmac.getHmac(salt); + result = goog.crypt.xorByteArray(result, salt); + } + return result; + }; + + return goog.crypt.pbkdf2.deriveKeyFromPassword_( + computeBlock, HASH_LENGTH, keyLength); +}; + + +/** + * Compute each block of the key using PBKDF2. + * @param {Function} computeBlock Function to compute each block of the output + * key. + * @param {number} hashLength Length of each block in bits. This is determined + * by the specific hash function used. Must be multiple of 8. + * @param {number} keyLength Length of the output key in bits. + * Must be multiple of 8. + * @return {!Array} Byte array representation of the output key. + * @private + */ +goog.crypt.pbkdf2.deriveKeyFromPassword_ = function( + computeBlock, hashLength, keyLength) { + 'use strict'; + goog.asserts.assert(keyLength % 8 == 0, 'invalid output key length'); + + // Compute and concactate each block of the output key. + var numBlocks = Math.ceil(keyLength / hashLength); + goog.asserts.assert(numBlocks >= 1, 'invalid number of blocks'); + var result = []; + for (var i = 1; i <= numBlocks; i++) { + var indexBytes = goog.crypt.pbkdf2.integerToByteArray_(i); + result = result.concat(computeBlock(indexBytes)); + } + + // Trim the last block if needed. + var lastBlockSize = keyLength % hashLength; + if (lastBlockSize != 0) { + var desiredBytes = ((numBlocks - 1) * hashLength + lastBlockSize) / 8; + result.splice(desiredBytes, (hashLength - lastBlockSize) / 8); + } + return result; +}; + + +/** + * Converts an integer number to a 32-bit big endian byte array. + * @param {number} n Integer number to be converted. + * @return {!Array} Byte Array representation of the 32-bit big endian + * encoding of n. + * @private + */ +goog.crypt.pbkdf2.integerToByteArray_ = function(n) { + 'use strict'; + var result = new Array(4); + result[0] = n >> 24 & 0xFF; + result[1] = n >> 16 & 0xFF; + result[2] = n >> 8 & 0xFF; + result[3] = n & 0xFF; + return result; +}; diff --git a/closure/goog/crypt/pbkdf2_test.js b/closure/goog/crypt/pbkdf2_test.js new file mode 100644 index 0000000000..751cad28f5 --- /dev/null +++ b/closure/goog/crypt/pbkdf2_test.js @@ -0,0 +1,54 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.crypt.pbkdf2Test'); +goog.setTestOnly(); + +const crypt = goog.require('goog.crypt'); +const pbkdf2 = goog.require('goog.crypt.pbkdf2'); +const testSuite = goog.require('goog.testing.testSuite'); +const userAgent = goog.require('goog.userAgent'); + +testSuite({ + testPBKDF2() { + // PBKDF2 test vectors from: + // http://tools.ietf.org/html/rfc6070 + + if (userAgent.IE) { + return; + } + + let testPassword = crypt.stringToByteArray('password'); + let testSalt = crypt.stringToByteArray('salt'); + + assertElementsEquals( + crypt.hexToByteArray('0c60c80f961f0e71f3a9b524af6012062fe037a6'), + pbkdf2.deriveKeySha1(testPassword, testSalt, 1, 160)); + + assertElementsEquals( + crypt.hexToByteArray('ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957'), + pbkdf2.deriveKeySha1(testPassword, testSalt, 2, 160)); + + assertElementsEquals( + crypt.hexToByteArray('4b007901b765489abead49d926f721d065a429c1'), + pbkdf2.deriveKeySha1(testPassword, testSalt, 4096, 160)); + + testPassword = crypt.stringToByteArray('passwordPASSWORDpassword'); + testSalt = crypt.stringToByteArray('saltSALTsaltSALTsaltSALTsaltSALTsalt'); + + assertElementsEquals( + crypt.hexToByteArray( + '3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038'), + pbkdf2.deriveKeySha1(testPassword, testSalt, 4096, 200)); + + testPassword = crypt.stringToByteArray('pass\0word'); + testSalt = crypt.stringToByteArray('sa\0lt'); + + assertElementsEquals( + crypt.hexToByteArray('56fa6aa75548099dcc37d7f03425e0c3'), + pbkdf2.deriveKeySha1(testPassword, testSalt, 4096, 128)); + }, +}); diff --git a/closure/goog/crypt/sha1.js b/closure/goog/crypt/sha1.js new file mode 100644 index 0000000000..2c13a15723 --- /dev/null +++ b/closure/goog/crypt/sha1.js @@ -0,0 +1,289 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview SHA-1 cryptographic hash. + * Variable names follow the notation in FIPS PUB 180-3: + * http://csrc.nist.gov/publications/fips/fips180-3/fips180-3_final.pdf. + * + * Usage: + * var sha1 = new goog.crypt.sha1(); + * sha1.update(bytes); + * var hash = sha1.digest(); + * + * Performance: + * Chrome 23: ~400 Mbit/s + * Firefox 16: ~250 Mbit/s + */ + +goog.provide('goog.crypt.Sha1'); + +goog.require('goog.crypt.Hash'); + + + +/** + * SHA-1 cryptographic hash constructor. + * + * The properties declared here are discussed in the above algorithm document. + * @constructor + * @extends {goog.crypt.Hash} + * @final + * @struct + */ +goog.crypt.Sha1 = function() { + 'use strict'; + goog.crypt.Sha1.base(this, 'constructor'); + + /** @const {number} */ + this.blockSize = 512 / 8; + + /** + * Holds the previous values of accumulated variables a-e in the compress_ + * function. + * @type {!Array} + * @private + */ + this.chain_ = []; + + /** + * A buffer holding the partially computed hash result. + * @type {!Array} + * @private + */ + this.buf_ = []; + + /** + * An array of 80 bytes, each a part of the message to be hashed. Referred to + * as the message schedule in the docs. + * @type {!Array} + * @private + */ + this.W_ = []; + + /** + * Contains data needed to pad messages less than 64 bytes. + * @type {!Array} + * @private + */ + this.pad_ = []; + + this.pad_[0] = 128; + for (var i = 1; i < this.blockSize; ++i) { + this.pad_[i] = 0; + } + + /** + * @private {number} + */ + this.inbuf_ = 0; + + /** + * @private {number} + */ + this.total_ = 0; + + this.reset(); +}; +goog.inherits(goog.crypt.Sha1, goog.crypt.Hash); + + +/** @override */ +goog.crypt.Sha1.prototype.reset = function() { + 'use strict'; + this.chain_[0] = 0x67452301; + this.chain_[1] = 0xefcdab89; + this.chain_[2] = 0x98badcfe; + this.chain_[3] = 0x10325476; + this.chain_[4] = 0xc3d2e1f0; + + this.inbuf_ = 0; + this.total_ = 0; +}; + + +/** + * Internal compress helper function. + * @param {!Array|!Uint8Array|string} buf Block to compress. + * @param {number=} opt_offset Offset of the block in the buffer. + * @private + */ +goog.crypt.Sha1.prototype.compress_ = function(buf, opt_offset) { + 'use strict'; + if (!opt_offset) { + opt_offset = 0; + } + + var W = this.W_; + + // get 16 big endian words + if (typeof buf === 'string') { + for (var i = 0; i < 16; i++) { + // TODO(user): [bug 8140122] Recent versions of Safari for Mac OS and iOS + // have a bug that turns the post-increment ++ operator into pre-increment + // during JIT compilation. We have code that depends heavily on SHA-1 for + // correctness and which is affected by this bug, so I've removed all uses + // of post-increment ++ in which the result value is used. We can revert + // this change once the Safari bug + // (https://bugs.webkit.org/show_bug.cgi?id=109036) has been fixed and + // most clients have been updated. + W[i] = (buf.charCodeAt(opt_offset) << 24) | + (buf.charCodeAt(opt_offset + 1) << 16) | + (buf.charCodeAt(opt_offset + 2) << 8) | + (buf.charCodeAt(opt_offset + 3)); + opt_offset += 4; + } + } else { + for (var i = 0; i < 16; i++) { + W[i] = (buf[opt_offset] << 24) | (buf[opt_offset + 1] << 16) | + (buf[opt_offset + 2] << 8) | (buf[opt_offset + 3]); + opt_offset += 4; + } + } + + // expand to 80 words + for (var i = 16; i < 80; i++) { + var t = W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16]; + W[i] = ((t << 1) | (t >>> 31)) & 0xffffffff; + } + + var a = this.chain_[0]; + var b = this.chain_[1]; + var c = this.chain_[2]; + var d = this.chain_[3]; + var e = this.chain_[4]; + var f, k; + + // TODO(user): Try to unroll this loop to speed up the computation. + for (var i = 0; i < 80; i++) { + if (i < 40) { + if (i < 20) { + f = d ^ (b & (c ^ d)); + k = 0x5a827999; + } else { + f = b ^ c ^ d; + k = 0x6ed9eba1; + } + } else { + if (i < 60) { + f = (b & c) | (d & (b | c)); + k = 0x8f1bbcdc; + } else { + f = b ^ c ^ d; + k = 0xca62c1d6; + } + } + + var t = (((a << 5) | (a >>> 27)) + f + e + k + W[i]) & 0xffffffff; + e = d; + d = c; + c = ((b << 30) | (b >>> 2)) & 0xffffffff; + b = a; + a = t; + } + + this.chain_[0] = (this.chain_[0] + a) & 0xffffffff; + this.chain_[1] = (this.chain_[1] + b) & 0xffffffff; + this.chain_[2] = (this.chain_[2] + c) & 0xffffffff; + this.chain_[3] = (this.chain_[3] + d) & 0xffffffff; + this.chain_[4] = (this.chain_[4] + e) & 0xffffffff; +}; + + +/** @override */ +goog.crypt.Sha1.prototype.update = function(bytes, opt_length) { + 'use strict'; + // TODO(johnlenz): tighten the function signature and remove this check + if (bytes == null) { + return; + } + + if (opt_length === undefined) { + opt_length = bytes.length; + } + + var lengthMinusBlock = opt_length - this.blockSize; + var n = 0; + // Using local instead of member variables gives ~5% speedup on Firefox 16. + var buf = this.buf_; + var inbuf = this.inbuf_; + + // The outer while loop should execute at most twice. + while (n < opt_length) { + // When we have no data in the block to top up, we can directly process the + // input buffer (assuming it contains sufficient data). This gives ~25% + // speedup on Chrome 23 and ~15% speedup on Firefox 16, but requires that + // the data is provided in large chunks (or in multiples of 64 bytes). + if (inbuf == 0) { + while (n <= lengthMinusBlock) { + this.compress_(bytes, n); + n += this.blockSize; + } + } + + if (typeof bytes === 'string') { + while (n < opt_length) { + buf[inbuf] = bytes.charCodeAt(n); + ++inbuf; + ++n; + if (inbuf == this.blockSize) { + this.compress_(buf); + inbuf = 0; + // Jump to the outer loop so we use the full-block optimization. + break; + } + } + } else { + while (n < opt_length) { + buf[inbuf] = bytes[n]; + ++inbuf; + ++n; + if (inbuf == this.blockSize) { + this.compress_(buf); + inbuf = 0; + // Jump to the outer loop so we use the full-block optimization. + break; + } + } + } + } + + this.inbuf_ = inbuf; + this.total_ += opt_length; +}; + + +/** @override */ +goog.crypt.Sha1.prototype.digest = function() { + 'use strict'; + var digest = []; + var totalBits = this.total_ * 8; + + // Add pad 0x80 0x00*. + if (this.inbuf_ < 56) { + this.update(this.pad_, 56 - this.inbuf_); + } else { + this.update(this.pad_, this.blockSize - (this.inbuf_ - 56)); + } + + // Add # bits. + for (var i = this.blockSize - 1; i >= 56; i--) { + this.buf_[i] = totalBits & 255; + totalBits /= 256; // Don't use bit-shifting here! + } + + this.compress_(this.buf_); + + var n = 0; + for (var i = 0; i < 5; i++) { + for (var j = 24; j >= 0; j -= 8) { + digest[n] = (this.chain_[i] >> j) & 255; + ++n; + } + } + + return digest; +}; diff --git a/closure/goog/crypt/sha1_perf.html b/closure/goog/crypt/sha1_perf.html new file mode 100644 index 0000000000..1b1ee33014 --- /dev/null +++ b/closure/goog/crypt/sha1_perf.html @@ -0,0 +1,39 @@ + + + + + +Closure Performance Tests - goog.crypt.Sha1 + + + + + +

Closure Performance Tests - goog.crypt.Sha1

+

+User-agent: + +

+ + + + diff --git a/closure/goog/crypt/sha1_test.js b/closure/goog/crypt/sha1_test.js new file mode 100644 index 0000000000..af7ae909ce --- /dev/null +++ b/closure/goog/crypt/sha1_test.js @@ -0,0 +1,71 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.crypt.Sha1Test'); +goog.setTestOnly(); + +const Sha1 = goog.require('goog.crypt.Sha1'); +const crypt = goog.require('goog.crypt'); +const hashTester = goog.require('goog.crypt.hashTester'); +const testSuite = goog.require('goog.testing.testSuite'); + +testSuite({ + testBasicOperations() { + const sha1 = new Sha1(); + hashTester.runBasicTests(sha1); + }, + + testBlockOperations() { + const sha1 = new Sha1(); + hashTester.runBlockTests(sha1, 64); + }, + + testHashing() { + // Test vectors from: + // csrc.nist.gov/publications/fips/fips180-2/fips180-2withchangenotice.pdf + + // Empty stream. + const sha1 = new Sha1(); + assertEquals( + 'da39a3ee5e6b4b0d3255bfef95601890afd80709', + crypt.byteArrayToHex(sha1.digest())); + + // Test one-block message. + sha1.reset(); + sha1.update([0x61, 0x62, 0x63]); + assertEquals( + 'a9993e364706816aba3e25717850c26c9cd0d89d', + crypt.byteArrayToHex(sha1.digest())); + + // Test multi-block message. + sha1.reset(); + sha1.update('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq'); + assertEquals( + '84983e441c3bd26ebaae4aa1f95129e5e54670f1', + crypt.byteArrayToHex(sha1.digest())); + + // Test long message. + const thousandAs = []; + for (let i = 0; i < 1000; ++i) { + thousandAs[i] = 0x61; + } + sha1.reset(); + for (let i = 0; i < 1000; ++i) { + sha1.update(thousandAs); + } + assertEquals( + '34aa973cd4c4daa4f61eeb2bdbad27316534016f', + crypt.byteArrayToHex(sha1.digest())); + + + // Test standard message. + sha1.reset(); + sha1.update('The quick brown fox jumps over the lazy dog'); + assertEquals( + '2fd4e1c67a2d28fced849ee1bb76e7391b93eb12', + crypt.byteArrayToHex(sha1.digest())); + }, +}); diff --git a/closure/goog/crypt/sha2.js b/closure/goog/crypt/sha2.js new file mode 100644 index 0000000000..0bb72aa5ba --- /dev/null +++ b/closure/goog/crypt/sha2.js @@ -0,0 +1,324 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Base class for SHA-2 cryptographic hash. + * + * Variable names follow the notation in FIPS PUB 180-3: + * http://csrc.nist.gov/publications/fips/fips180-3/fips180-3_final.pdf. + * + * Some code similar to SHA1 are borrowed from sha1.js written by mschilder@. + */ + +goog.provide('goog.crypt.Sha2'); + +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.crypt.Hash'); + + + +/** + * SHA-2 cryptographic hash constructor. + * This constructor should not be used directly to create the object. Rather, + * one should use the constructor of the sub-classes. + * @param {number} numHashBlocks The size of output in 16-byte blocks. + * @param {!Array} initHashBlocks The hash-specific initialization + * @constructor + * @extends {goog.crypt.Hash} + * @struct + */ +goog.crypt.Sha2 = function(numHashBlocks, initHashBlocks) { + 'use strict'; + goog.crypt.Sha2.base(this, 'constructor'); + + /** @const {number} */ + this.blockSize = goog.crypt.Sha2.BLOCKSIZE_; + + /** + * A chunk holding the currently processed message bytes. Once the chunk has + * 64 bytes, we feed it into computeChunk_ function and reset this.chunk_. + * @private {!Array|!Uint8Array} + */ + this.chunk_ = goog.global['Uint8Array'] ? new Uint8Array(this.blockSize) : + new Array(this.blockSize); + + /** + * Current number of bytes in this.chunk_. + * @private {number} + */ + this.inChunk_ = 0; + + /** + * Total number of bytes in currently processed message. + * @private {number} + */ + this.total_ = 0; + + + /** + * Holds the previous values of accumulated hash a-h in the computeChunk_ + * function. + * @private {!Array|!Int32Array} + */ + this.hash_ = []; + + /** + * The number of output hash blocks (each block is 4 bytes long). + * @private {number} + */ + this.numHashBlocks_ = numHashBlocks; + + /** + * @private {!Array} initHashBlocks + */ + this.initHashBlocks_ = initHashBlocks; + + /** + * Temporary array used in chunk computation. Allocate here as a + * member rather than as a local within computeChunk_() as a + * performance optimization to reduce the number of allocations and + * reduce garbage collection. + * @private {!Int32Array|!Array} + */ + this.w_ = goog.global['Int32Array'] ? new Int32Array(64) : new Array(64); + + if (goog.crypt.Sha2.Kx_ === undefined) { + // This is the first time this constructor has been called. + if (goog.global['Int32Array']) { + // Typed arrays exist + goog.crypt.Sha2.Kx_ = new Int32Array(goog.crypt.Sha2.K_); + } else { + // Typed arrays do not exist + goog.crypt.Sha2.Kx_ = goog.crypt.Sha2.K_; + } + } + + this.reset(); +}; +goog.inherits(goog.crypt.Sha2, goog.crypt.Hash); + + +/** + * The block size + * @private {number} + */ +goog.crypt.Sha2.BLOCKSIZE_ = 512 / 8; + + +/** + * Contains data needed to pad messages less than BLOCK_SIZE_ bytes. + * @private {!Array} + */ +goog.crypt.Sha2.PADDING_ = + [].concat(128, goog.array.repeat(0, goog.crypt.Sha2.BLOCKSIZE_ - 1)); + + +/** @override */ +goog.crypt.Sha2.prototype.reset = function() { + 'use strict'; + this.inChunk_ = 0; + this.total_ = 0; + this.hash_ = goog.global['Int32Array'] ? + new Int32Array(this.initHashBlocks_) : + goog.array.clone(this.initHashBlocks_); +}; + + +/** + * Helper function to compute the hashes for a given 512-bit message chunk. + * @private + */ +goog.crypt.Sha2.prototype.computeChunk_ = function() { + 'use strict'; + var chunk = this.chunk_; + goog.asserts.assert(chunk.length == this.blockSize); + var rounds = 64; + + // Divide the chunk into 16 32-bit-words. + var w = this.w_; + var index = 0; + var offset = 0; + while (offset < chunk.length) { + w[index++] = (chunk[offset] << 24) | (chunk[offset + 1] << 16) | + (chunk[offset + 2] << 8) | (chunk[offset + 3]); + offset = index * 4; + } + + // Extend the w[] array to be the number of rounds. + for (var i = 16; i < rounds; i++) { + var w_15 = w[i - 15] | 0; + var s0 = ((w_15 >>> 7) | (w_15 << 25)) ^ ((w_15 >>> 18) | (w_15 << 14)) ^ + (w_15 >>> 3); + var w_2 = w[i - 2] | 0; + var s1 = ((w_2 >>> 17) | (w_2 << 15)) ^ ((w_2 >>> 19) | (w_2 << 13)) ^ + (w_2 >>> 10); + + // As a performance optimization, construct the sum a pair at a time + // with casting to integer (bitwise OR) to eliminate unnecessary + // double<->integer conversions. + var partialSum1 = ((w[i - 16] | 0) + s0) | 0; + var partialSum2 = ((w[i - 7] | 0) + s1) | 0; + w[i] = (partialSum1 + partialSum2) | 0; + } + + var a = this.hash_[0] | 0; + var b = this.hash_[1] | 0; + var c = this.hash_[2] | 0; + var d = this.hash_[3] | 0; + var e = this.hash_[4] | 0; + var f = this.hash_[5] | 0; + var g = this.hash_[6] | 0; + var h = this.hash_[7] | 0; + for (var i = 0; i < rounds; i++) { + var S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ + ((a >>> 22) | (a << 10)); + var maj = ((a & b) ^ (a & c) ^ (b & c)); + var t2 = (S0 + maj) | 0; + var S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ + ((e >>> 25) | (e << 7)); + var ch = ((e & f) ^ ((~e) & g)); + + // As a performance optimization, construct the sum a pair at a time + // with casting to integer (bitwise OR) to eliminate unnecessary + // double<->integer conversions. + var partialSum1 = (h + S1) | 0; + var partialSum2 = (ch + (goog.crypt.Sha2.Kx_[i] | 0)) | 0; + var partialSum3 = (partialSum2 + (w[i] | 0)) | 0; + var t1 = (partialSum1 + partialSum3) | 0; + + h = g; + g = f; + f = e; + e = (d + t1) | 0; + d = c; + c = b; + b = a; + a = (t1 + t2) | 0; + } + + this.hash_[0] = (this.hash_[0] + a) | 0; + this.hash_[1] = (this.hash_[1] + b) | 0; + this.hash_[2] = (this.hash_[2] + c) | 0; + this.hash_[3] = (this.hash_[3] + d) | 0; + this.hash_[4] = (this.hash_[4] + e) | 0; + this.hash_[5] = (this.hash_[5] + f) | 0; + this.hash_[6] = (this.hash_[6] + g) | 0; + this.hash_[7] = (this.hash_[7] + h) | 0; +}; + + +/** @override */ +goog.crypt.Sha2.prototype.update = function(message, opt_length) { + 'use strict'; + if (opt_length === undefined) { + opt_length = message.length; + } + // Process the message from left to right up to |opt_length| bytes. + // When we get a 512-bit chunk, compute the hash of it and reset + // this.chunk_. The message might not be multiple of 512 bits so we + // might end up with a chunk that is less than 512 bits. We store + // such partial chunk in this.chunk_ and it will be filled up later + // in digest(). + var n = 0; + var inChunk = this.inChunk_; + + // The input message could be either byte array of string. + if (typeof message === 'string') { + while (n < opt_length) { + this.chunk_[inChunk++] = message.charCodeAt(n++); + if (inChunk == this.blockSize) { + this.computeChunk_(); + inChunk = 0; + } + } + } else if (goog.isArrayLike(message)) { + while (n < opt_length) { + var b = message[n++]; + if (!('number' == typeof b && 0 <= b && 255 >= b && b == (b | 0))) { + throw new Error('message must be a byte array'); + } + this.chunk_[inChunk++] = b; + if (inChunk == this.blockSize) { + this.computeChunk_(); + inChunk = 0; + } + } + } else { + throw new Error('message must be string or array'); + } + + // Record the current bytes in chunk to support partial update. + this.inChunk_ = inChunk; + + // Record total message bytes we have processed so far. + this.total_ += opt_length; +}; + + +/** @override */ +goog.crypt.Sha2.prototype.digest = function() { + 'use strict'; + var digest = []; + var totalBits = this.total_ * 8; + + // Append pad 0x80 0x00*. + if (this.inChunk_ < 56) { + this.update(goog.crypt.Sha2.PADDING_, 56 - this.inChunk_); + } else { + this.update( + goog.crypt.Sha2.PADDING_, this.blockSize - (this.inChunk_ - 56)); + } + + // Append # bits in the 64-bit big-endian format. + for (var i = 63; i >= 56; i--) { + this.chunk_[i] = totalBits & 255; + totalBits /= 256; // Don't use bit-shifting here! + } + this.computeChunk_(); + + // Finally, output the result digest. + var n = 0; + for (var i = 0; i < this.numHashBlocks_; i++) { + for (var j = 24; j >= 0; j -= 8) { + digest[n++] = ((this.hash_[i] >> j) & 255); + } + } + return digest; +}; + + +/** + * Constants used in SHA-2. + * @const + * @private {!Array} + */ +goog.crypt.Sha2.K_ = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 +]; + + +/** + * Sha2.K as an Int32Array if this JS supports typed arrays; otherwise, + * the same array as Sha2.K. + * + * The compiler cannot remove an Int32Array, even if it is not needed + * (There are certain cases where creating an Int32Array is not + * side-effect free). Instead, the first time we construct a Sha2 + * instance, we convert or assign Sha2.K as appropriate. + * @private {undefined|!Array|!Int32Array} + */ +goog.crypt.Sha2.Kx_; diff --git a/closure/goog/crypt/sha224.js b/closure/goog/crypt/sha224.js new file mode 100644 index 0000000000..7c962fa58d --- /dev/null +++ b/closure/goog/crypt/sha224.js @@ -0,0 +1,42 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview SHA-224 cryptographic hash. + * + * Usage: + * var sha224 = new goog.crypt.Sha224(); + * sha224.update(bytes); + * var hash = sha224.digest(); + */ + +goog.provide('goog.crypt.Sha224'); + +goog.require('goog.crypt.Sha2'); + + + +/** + * SHA-224 cryptographic hash constructor. + * + * @constructor + * @extends {goog.crypt.Sha2} + * @final + * @struct + */ +goog.crypt.Sha224 = function() { + 'use strict'; + goog.crypt.Sha224.base( + this, 'constructor', 7, goog.crypt.Sha224.INIT_HASH_BLOCK_); +}; +goog.inherits(goog.crypt.Sha224, goog.crypt.Sha2); + + +/** @private {!Array} */ +goog.crypt.Sha224.INIT_HASH_BLOCK_ = [ + 0xc1059ed8, 0x367cd507, 0x3070dd17, 0xf70e5939, 0xffc00b31, 0x68581511, + 0x64f98fa7, 0xbefa4fa4 +]; diff --git a/closure/goog/crypt/sha224_perf.html b/closure/goog/crypt/sha224_perf.html new file mode 100644 index 0000000000..faada7b036 --- /dev/null +++ b/closure/goog/crypt/sha224_perf.html @@ -0,0 +1,39 @@ + + + + + +Closure Performance Tests - goog.crypt.Sha224 + + + + + +

Closure Performance Tests - goog.crypt.Sha224

+

+User-agent: + +

+ + + + diff --git a/closure/goog/crypt/sha224_test.js b/closure/goog/crypt/sha224_test.js new file mode 100644 index 0000000000..3137f87a26 --- /dev/null +++ b/closure/goog/crypt/sha224_test.js @@ -0,0 +1,70 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.crypt.Sha224Test'); +goog.setTestOnly(); + +const Sha224 = goog.require('goog.crypt.Sha224'); +const crypt = goog.require('goog.crypt'); +const hashTester = goog.require('goog.crypt.hashTester'); +const testSuite = goog.require('goog.testing.testSuite'); + +testSuite({ + testBasicOperations() { + const sha224 = new Sha224(); + hashTester.runBasicTests(sha224); + }, + + /** @suppress {visibility} accessing private properties */ + testHashing() { + // Some test vectors from: + // csrc.nist.gov/publications/fips/fips180-2/fips180-2withchangenotice.pdf + + const sha224 = new Sha224(); + + // NIST one block test vector. + sha224.update(crypt.stringToByteArray('abc')); + assertEquals( + '23097d223405d8228642a477bda255b32aadbce4bda0b3f7e36c9da7', + crypt.byteArrayToHex(sha224.digest())); + + // NIST multi-block test vector. + sha224.reset(); + sha224.update(crypt.stringToByteArray( + 'abcdbcdecdefdefgefghfghighij' + + 'hijkijkljklmklmnlmnomnopnopq')); + assertEquals( + '75388b16512776cc5dba5da1fd890150b0c6455cb4f58b1952522525', + crypt.byteArrayToHex(sha224.digest())); + + // Message larger than one block (but less than two). + sha224.reset(); + const biggerThanOneBlock = 'abcdbcdecdefdefgefghfghighij' + + 'hijkijkljklmklmnlmnomnopnopq' + + 'asdfljhr78yasdfljh45opa78sdf' + + '120839414104897aavnasdfafasd'; + assertTrue( + biggerThanOneBlock.length > crypt.Sha2.BLOCKSIZE_ && + biggerThanOneBlock.length < 2 * crypt.Sha2.BLOCKSIZE_); + sha224.update(crypt.stringToByteArray(biggerThanOneBlock)); + assertEquals( + '27c9b678012becd6891bac653f355b2d26f63132e840644d565f5dac', + crypt.byteArrayToHex(sha224.digest())); + + // Message larger than two blocks. + sha224.reset(); + const biggerThanTwoBlocks = 'abcdbcdecdefdefgefghfghighij' + + 'hijkijkljklmklmnlmnomnopnopq' + + 'asdfljhr78yasdfljh45opa78sdf' + + '120839414104897aavnasdfafasd' + + 'laasdouvhalacbnalalseryalcla'; + assertTrue(biggerThanTwoBlocks.length > 2 * crypt.Sha2.BLOCKSIZE_); + sha224.update(crypt.stringToByteArray(biggerThanTwoBlocks)); + assertEquals( + '1c2c1455cc984eef6f25ec9d79b1c661b3794887c3d0b24111ed9803', + crypt.byteArrayToHex(sha224.digest())); + }, +}); diff --git a/closure/goog/crypt/sha256.js b/closure/goog/crypt/sha256.js new file mode 100644 index 0000000000..71c082ea2f --- /dev/null +++ b/closure/goog/crypt/sha256.js @@ -0,0 +1,42 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview SHA-256 cryptographic hash. + * + * Usage: + * var sha256 = new goog.crypt.Sha256(); + * sha256.update(bytes); + * var hash = sha256.digest(); + */ + +goog.provide('goog.crypt.Sha256'); + +goog.require('goog.crypt.Sha2'); + + + +/** + * SHA-256 cryptographic hash constructor. + * + * @constructor + * @extends {goog.crypt.Sha2} + * @final + * @struct + */ +goog.crypt.Sha256 = function() { + 'use strict'; + goog.crypt.Sha256.base( + this, 'constructor', 8, goog.crypt.Sha256.INIT_HASH_BLOCK_); +}; +goog.inherits(goog.crypt.Sha256, goog.crypt.Sha2); + + +/** @private {!Array} */ +goog.crypt.Sha256.INIT_HASH_BLOCK_ = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19 +]; diff --git a/closure/goog/crypt/sha256_perf.html b/closure/goog/crypt/sha256_perf.html new file mode 100644 index 0000000000..9199528100 --- /dev/null +++ b/closure/goog/crypt/sha256_perf.html @@ -0,0 +1,39 @@ + + + + + +Closure Performance Tests - goog.crypt.Sha256 + + + + + +

Closure Performance Tests - goog.crypt.Sha256

+

+User-agent: + +

+ + + + diff --git a/closure/goog/crypt/sha256_test.js b/closure/goog/crypt/sha256_test.js new file mode 100644 index 0000000000..0772ede9c3 --- /dev/null +++ b/closure/goog/crypt/sha256_test.js @@ -0,0 +1,96 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.crypt.Sha256Test'); +goog.setTestOnly(); + +const Sha256 = goog.require('goog.crypt.Sha256'); +const crypt = goog.require('goog.crypt'); +const hashTester = goog.require('goog.crypt.hashTester'); +const testSuite = goog.require('goog.testing.testSuite'); + +testSuite({ + testBasicOperations() { + const sha256 = new Sha256(); + hashTester.runBasicTests(sha256); + }, + + /** @suppress {visibility} accessing private properties */ + testHashing() { + // Some test vectors from: + // csrc.nist.gov/publications/fips/fips180-2/fips180-2withchangenotice.pdf + + const sha256 = new Sha256(); + + // Empty message. + sha256.update([]); + assertEquals( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + crypt.byteArrayToHex(sha256.digest())); + + // NIST one block test vector. + sha256.reset(); + sha256.update(crypt.stringToByteArray('abc')); + assertEquals( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + crypt.byteArrayToHex(sha256.digest())); + + // NIST multi-block test vector. + sha256.reset(); + sha256.update(crypt.stringToByteArray( + 'abcdbcdecdefdefgefghfghighij' + + 'hijkijkljklmklmnlmnomnopnopq')); + assertEquals( + '248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1', + crypt.byteArrayToHex(sha256.digest())); + + // Message larger than one block (but less than two). + sha256.reset(); + const biggerThanOneBlock = 'abcdbcdecdefdefgefghfghighij' + + 'hijkijkljklmklmnlmnomnopnopq' + + 'asdfljhr78yasdfljh45opa78sdf' + + '120839414104897aavnasdfafasd'; + assertTrue( + biggerThanOneBlock.length > crypt.Sha2.BLOCKSIZE_ && + biggerThanOneBlock.length < 2 * crypt.Sha2.BLOCKSIZE_); + sha256.update(crypt.stringToByteArray(biggerThanOneBlock)); + assertEquals( + '390a5035433e46b740600f3117d11ece3c64706dc889106666ac04fe4f458abc', + crypt.byteArrayToHex(sha256.digest())); + + // Message larger than two blocks. + sha256.reset(); + const biggerThanTwoBlocks = 'abcdbcdecdefdefgefghfghighij' + + 'hijkijkljklmklmnlmnomnopnopq' + + 'asdfljhr78yasdfljh45opa78sdf' + + '120839414104897aavnasdfafasd' + + 'laasdouvhalacbnalalseryalcla'; + assertTrue(biggerThanTwoBlocks.length > 2 * crypt.Sha2.BLOCKSIZE_); + sha256.update(crypt.stringToByteArray(biggerThanTwoBlocks)); + assertEquals( + 'd655c513fd347e9be372d891f8bb42895ca310fabf6ead6681ebc66a04e84db5', + crypt.byteArrayToHex(sha256.digest())); + }, + + /** Check that the code checks for bad input */ + testBadInput() { + assertThrows( + 'Bad input', + /** @suppress {checkTypes} array like isn't a supported type */ + () => { + new Sha256().update({}); + }); + assertThrows('Floating point not allows', () => { + new Sha256().update([1, 2, 3, 4, 4.5]); + }); + assertThrows('Negative not allowed', () => { + new Sha256().update([1, 2, 3, 4, -10]); + }); + assertThrows('Must be byte array', () => { + new Sha256().update([1, 2, 3, 4, {}]); + }); + }, +}); diff --git a/closure/goog/crypt/sha2_64bit.js b/closure/goog/crypt/sha2_64bit.js new file mode 100644 index 0000000000..73df4d7af8 --- /dev/null +++ b/closure/goog/crypt/sha2_64bit.js @@ -0,0 +1,531 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Base class for the 64-bit SHA-2 cryptographic hashes. + * + * Variable names follow the notation in FIPS PUB 180-3: + * http://csrc.nist.gov/publications/fips/fips180-3/fips180-3_final.pdf. + * + * This code borrows heavily from the 32-bit SHA2 implementation written by + * Yue Zhang (zysxqn@). + */ + +goog.provide('goog.crypt.Sha2_64bit'); + +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.crypt.Hash'); +goog.require('goog.math.Long'); + + + +/** + * Constructs a SHA-2 64-bit cryptographic hash. + * This class should not be used. Rather, one should use one of its + * subclasses. + * @constructor + * @param {number} numHashBlocks The size of the output in 16-byte blocks + * @param {!Array} initHashBlocks The hash-specific initialization + * vector, as a sequence of sixteen 32-bit numbers. + * @extends {goog.crypt.Hash} + * @struct + */ +goog.crypt.Sha2_64bit = function(numHashBlocks, initHashBlocks) { + 'use strict'; + goog.crypt.Sha2_64bit.base(this, 'constructor'); + + /** + * The number of bytes that are digested in each pass of this hasher. + * @const {number} + */ + this.blockSize = goog.crypt.Sha2_64bit.BLOCK_SIZE_; + + /** + * A chunk holding the currently processed message bytes. Once the chunk has + * `this.blocksize` bytes, we feed it into [@code computeChunk_}. + * @private {!Uint8Array|!Array} + */ + this.chunk_ = goog.global['Uint8Array'] ? new Uint8Array(this.blockSize) : + new Array(this.blockSize); + + /** + * Current number of bytes in `this.chunk_`. + * @private {number} + */ + this.chunkBytes_ = 0; + + /** + * Total number of bytes in currently processed message. + * @private {number} + */ + this.total_ = 0; + + /** + * Holds the previous values of accumulated hash a-h in the + * `computeChunk_` function. + * @private {!Array} + */ + this.hash_ = []; + + /** + * The number of blocks of output produced by this hash function, where each + * block is eight bytes long. + * @private {number} + */ + this.numHashBlocks_ = numHashBlocks; + + /** + * Temporary array used in chunk computation. Allocate here as a + * member rather than as a local within computeChunk_() as a + * performance optimization to reduce the number of allocations and + * reduce garbage collection. + * @type {!Array} + * @private + */ + this.w_ = []; + + /** + * The value to which `this.hash_` should be reset when this + * Hasher is reset. + * @private @const {!Array} + */ + this.initHashBlocks_ = goog.crypt.Sha2_64bit.toLongArray_(initHashBlocks); + + /** + * If true, we have taken the digest from this hasher, but we have not + * yet reset it. + * + * @private {boolean} + */ + this.needsReset_ = false; + + this.reset(); +}; +goog.inherits(goog.crypt.Sha2_64bit, goog.crypt.Hash); + + +/** + * The number of bytes that are digested in each pass of this hasher. + * @private @const {number} + */ +goog.crypt.Sha2_64bit.BLOCK_SIZE_ = 1024 / 8; + + +/** + * Contains data needed to pad messages less than `blocksize` bytes. + * @private {!Array} + */ +goog.crypt.Sha2_64bit.PADDING_ = [].concat( + [0x80], goog.array.repeat(0, goog.crypt.Sha2_64bit.BLOCK_SIZE_ - 1)); + + +/** + * Resets this hash function. + * @override + */ +goog.crypt.Sha2_64bit.prototype.reset = function() { + 'use strict'; + this.chunkBytes_ = 0; + this.total_ = 0; + this.hash_ = goog.array.clone(this.initHashBlocks_); + this.needsReset_ = false; +}; + + +/** @override */ +goog.crypt.Sha2_64bit.prototype.update = function(message, opt_length) { + 'use strict'; + var length = (opt_length !== undefined) ? opt_length : message.length; + + // Make sure this hasher is usable. + if (this.needsReset_) { + throw new Error('this hasher needs to be reset'); + } + // Process the message from left to right up to |length| bytes. + // When we get a 512-bit chunk, compute the hash of it and reset + // this.chunk_. The message might not be multiple of 512 bits so we + // might end up with a chunk that is less than 512 bits. We store + // such partial chunk in chunk_ and it will be filled up later + // in digest(). + var chunkBytes = this.chunkBytes_; + + // The input message could be either byte array or string. + if (typeof message === 'string') { + for (var i = 0; i < length; i++) { + var b = message.charCodeAt(i); + if (b > 255) { + throw new Error('Characters must be in range [0,255]'); + } + this.chunk_[chunkBytes++] = b; + if (chunkBytes == this.blockSize) { + this.computeChunk_(); + chunkBytes = 0; + } + } + } else if (goog.isArrayLike(message)) { + for (var i = 0; i < length; i++) { + var b = message[i]; + // Hack: b|0 coerces b to an integer, so the last part confirms that + // b has no fractional part. + if (typeof b !== 'number' || b < 0 || b > 255 || b != (b | 0)) { + throw new Error('message must be a byte array'); + } + this.chunk_[chunkBytes++] = b; + if (chunkBytes == this.blockSize) { + this.computeChunk_(); + chunkBytes = 0; + } + } + } else { + throw new Error('message must be string or array'); + } + + // Record the current bytes in chunk to support partial update. + this.chunkBytes_ = chunkBytes; + + // Record total message bytes we have processed so far. + this.total_ += length; +}; + + +/** @override */ +goog.crypt.Sha2_64bit.prototype.digest = function() { + 'use strict'; + if (this.needsReset_) { + throw new Error('this hasher needs to be reset'); + } + var totalBits = this.total_ * 8; + + // Append pad 0x80 0x00* until this.chunkBytes_ == 112 + if (this.chunkBytes_ < 112) { + this.update(goog.crypt.Sha2_64bit.PADDING_, 112 - this.chunkBytes_); + } else { + // the rest of this block, plus 112 bytes of next block + this.update( + goog.crypt.Sha2_64bit.PADDING_, + this.blockSize - this.chunkBytes_ + 112); + } + + // Append # bits in the 64-bit big-endian format. + for (var i = 127; i >= 112; i--) { + this.chunk_[i] = totalBits & 255; + totalBits /= 256; // Don't use bit-shifting here! + } + this.computeChunk_(); + + // Finally, output the result digest. + var n = 0; + var digest = new Array(8 * this.numHashBlocks_); + for (var i = 0; i < this.numHashBlocks_; i++) { + var block = this.hash_[i]; + var high = block.getHighBits(); + var low = block.getLowBits(); + for (var j = 24; j >= 0; j -= 8) { + digest[n++] = ((high >> j) & 255); + } + for (var j = 24; j >= 0; j -= 8) { + digest[n++] = ((low >> j) & 255); + } + } + + // The next call to this hasher must be a reset + this.needsReset_ = true; + return digest; +}; + + +/** + * Updates this hash by processing the 1024-bit message chunk in this.chunk_. + * @private + */ +goog.crypt.Sha2_64bit.prototype.computeChunk_ = function() { + 'use strict'; + var chunk = this.chunk_; + var K_ = goog.crypt.Sha2_64bit.K_; + + // Divide the chunk into 16 64-bit-words. + var w = this.w_; + for (var i = 0; i < 16; i++) { + var offset = i * 8; + w[i] = new goog.math.Long( + (chunk[offset + 4] << 24) | (chunk[offset + 5] << 16) | + (chunk[offset + 6] << 8) | (chunk[offset + 7]), + (chunk[offset] << 24) | (chunk[offset + 1] << 16) | + (chunk[offset + 2] << 8) | (chunk[offset + 3])); + } + + // Extend the w[] array to be the number of rounds. + for (var i = 16; i < 80; i++) { + var s0 = this.sigma0_(w[i - 15]); + var s1 = this.sigma1_(w[i - 2]); + w[i] = this.sum_(w[i - 16], w[i - 7], s0, s1); + } + + var a = this.hash_[0]; + var b = this.hash_[1]; + var c = this.hash_[2]; + var d = this.hash_[3]; + var e = this.hash_[4]; + var f = this.hash_[5]; + var g = this.hash_[6]; + var h = this.hash_[7]; + for (var i = 0; i < 80; i++) { + var S0 = this.Sigma0_(a); + var maj = this.majority_(a, b, c); + var t2 = S0.add(maj); + var S1 = this.Sigma1_(e); + var ch = this.choose_(e, f, g); + var t1 = this.sum_(h, S1, ch, K_[i], w[i]); + h = g; + g = f; + f = e; + e = d.add(t1); + d = c; + c = b; + b = a; + a = t1.add(t2); + } + + this.hash_[0] = this.hash_[0].add(a); + this.hash_[1] = this.hash_[1].add(b); + this.hash_[2] = this.hash_[2].add(c); + this.hash_[3] = this.hash_[3].add(d); + this.hash_[4] = this.hash_[4].add(e); + this.hash_[5] = this.hash_[5].add(f); + this.hash_[6] = this.hash_[6].add(g); + this.hash_[7] = this.hash_[7].add(h); +}; + + +/** + * Calculates the SHA2 64-bit sigma0 function. + * rotateRight(value, 1) ^ rotateRight(value, 8) ^ (value >>> 7) + * + * @private + * @param {!goog.math.Long} value + * @return {!goog.math.Long} + */ +goog.crypt.Sha2_64bit.prototype.sigma0_ = function(value) { + 'use strict'; + var valueLow = value.getLowBits(); + var valueHigh = value.getHighBits(); + // Implementation note: We purposely do not use the shift operations defined + // in goog.math.Long. Inlining the code for specific values of shifting and + // not generating the intermediate results doubles the speed of this code. + var low = (valueLow >>> 1) ^ (valueHigh << 31) ^ (valueLow >>> 8) ^ + (valueHigh << 24) ^ (valueLow >>> 7) ^ (valueHigh << 25); + var high = (valueHigh >>> 1) ^ (valueLow << 31) ^ (valueHigh >>> 8) ^ + (valueLow << 24) ^ (valueHigh >>> 7); + return new goog.math.Long(low, high); +}; + + +/** + * Calculates the SHA2 64-bit sigma1 function. + * rotateRight(value, 19) ^ rotateRight(value, 61) ^ (value >>> 6) + * + * @private + * @param {!goog.math.Long} value + * @return {!goog.math.Long} + */ +goog.crypt.Sha2_64bit.prototype.sigma1_ = function(value) { + 'use strict'; + var valueLow = value.getLowBits(); + var valueHigh = value.getHighBits(); + // Implementation note: See _sigma0() above + var low = (valueLow >>> 19) ^ (valueHigh << 13) ^ (valueHigh >>> 29) ^ + (valueLow << 3) ^ (valueLow >>> 6) ^ (valueHigh << 26); + var high = (valueHigh >>> 19) ^ (valueLow << 13) ^ (valueLow >>> 29) ^ + (valueHigh << 3) ^ (valueHigh >>> 6); + return new goog.math.Long(low, high); +}; + + +/** + * Calculates the SHA2 64-bit Sigma0 function. + * rotateRight(value, 28) ^ rotateRight(value, 34) ^ rotateRight(value, 39) + * + * @private + * @param {!goog.math.Long} value + * @return {!goog.math.Long} + */ +goog.crypt.Sha2_64bit.prototype.Sigma0_ = function(value) { + 'use strict'; + var valueLow = value.getLowBits(); + var valueHigh = value.getHighBits(); + // Implementation note: See _sigma0() above + var low = (valueLow >>> 28) ^ (valueHigh << 4) ^ (valueHigh >>> 2) ^ + (valueLow << 30) ^ (valueHigh >>> 7) ^ (valueLow << 25); + var high = (valueHigh >>> 28) ^ (valueLow << 4) ^ (valueLow >>> 2) ^ + (valueHigh << 30) ^ (valueLow >>> 7) ^ (valueHigh << 25); + return new goog.math.Long(low, high); +}; + + +/** + * Calculates the SHA2 64-bit Sigma1 function. + * rotateRight(value, 14) ^ rotateRight(value, 18) ^ rotateRight(value, 41) + * + * @private + * @param {!goog.math.Long} value + * @return {!goog.math.Long} + */ +goog.crypt.Sha2_64bit.prototype.Sigma1_ = function(value) { + 'use strict'; + var valueLow = value.getLowBits(); + var valueHigh = value.getHighBits(); + // Implementation note: See _sigma0() above + var low = (valueLow >>> 14) ^ (valueHigh << 18) ^ (valueLow >>> 18) ^ + (valueHigh << 14) ^ (valueHigh >>> 9) ^ (valueLow << 23); + var high = (valueHigh >>> 14) ^ (valueLow << 18) ^ (valueHigh >>> 18) ^ + (valueLow << 14) ^ (valueLow >>> 9) ^ (valueHigh << 23); + return new goog.math.Long(low, high); +}; + + +/** + * Calculates the SHA-2 64-bit choose function. + * + * This function uses `value` as a mask to choose bits from either + * `one` if the bit is set or `two` if the bit is not set. + * + * @private + * @param {!goog.math.Long} value + * @param {!goog.math.Long} one + * @param {!goog.math.Long} two + * @return {!goog.math.Long} + */ +goog.crypt.Sha2_64bit.prototype.choose_ = function(value, one, two) { + 'use strict'; + var valueLow = value.getLowBits(); + var valueHigh = value.getHighBits(); + return new goog.math.Long( + (valueLow & one.getLowBits()) | (~valueLow & two.getLowBits()), + (valueHigh & one.getHighBits()) | (~valueHigh & two.getHighBits())); +}; + + +/** + * Calculates the SHA-2 64-bit majority function. + * This function returns, for each bit position, the bit held by the majority + * of its three arguments. + * + * @private + * @param {!goog.math.Long} one + * @param {!goog.math.Long} two + * @param {!goog.math.Long} three + * @return {!goog.math.Long} + */ +goog.crypt.Sha2_64bit.prototype.majority_ = function(one, two, three) { + 'use strict'; + return new goog.math.Long( + (one.getLowBits() & two.getLowBits()) | + (two.getLowBits() & three.getLowBits()) | + (one.getLowBits() & three.getLowBits()), + (one.getHighBits() & two.getHighBits()) | + (two.getHighBits() & three.getHighBits()) | + (one.getHighBits() & three.getHighBits())); +}; + + +/** + * Adds two or more goog.math.Long values. + * + * @private + * @param {!goog.math.Long} one first summand + * @param {!goog.math.Long} two second summand + * @param {...goog.math.Long} var_args more arguments to sum + * @return {!goog.math.Long} The resulting sum. + */ +goog.crypt.Sha2_64bit.prototype.sum_ = function(one, two, var_args) { + 'use strict'; + // The low bits may be signed, but they represent a 32-bit unsigned quantity. + // We must be careful to normalize them. + // This doesn't matter for the high bits. + // Implementation note: Performance testing shows that this method runs + // fastest when the first two arguments are pulled out of the loop. + var low = (one.getLowBits() ^ 0x80000000) + (two.getLowBits() ^ 0x80000000); + var high = one.getHighBits() + two.getHighBits(); + for (var i = arguments.length - 1; i >= 2; --i) { + low += arguments[i].getLowBits() ^ 0x80000000; + high += arguments[i].getHighBits(); + } + // Because of the ^0x80000000, each value we added is 0x80000000 too small. + // Add arguments.length * 0x80000000 to the current sum. We can do this + // quickly by adding 0x80000000 to low when the number of arguments is + // odd, and adding (number of arguments) >> 1 to high. + if (arguments.length & 1) { + low += 0x80000000; + } + high += arguments.length >> 1; + + // If low is outside the range [0, 0xFFFFFFFF], its overflow or underflow + // should be added to high. We don't actually need to modify low or + // normalize high because the goog.math.Long constructor already does that. + high += Math.floor(low / 0x100000000); + return new goog.math.Long(low, high); +}; + + +/** + * Converts an array of 32-bit integers into an array of goog.math.Long + * elements. + * + * @private + * @param {!Array} values An array of 32-bit numbers. Its length + * must be even. Each pair of numbers represents a 64-bit integer + * in big-endian order + * @return {!Array} + */ +goog.crypt.Sha2_64bit.toLongArray_ = function(values) { + 'use strict'; + goog.asserts.assert(values.length % 2 == 0); + var result = []; + for (var i = 0; i < values.length; i += 2) { + result.push(new goog.math.Long(values[i + 1], values[i])); + } + return result; +}; + + +/** + * Fixed constants used in SHA-512 variants. + * + * These values are from Section 4.2.3 of + * http://csrc.nist.gov/publications/fips/fips180-4/fips-180-4.pdf + * @const + * @private {!Array} + */ +goog.crypt.Sha2_64bit.K_ = goog.crypt.Sha2_64bit.toLongArray_([ + 0x428a2f98, 0xd728ae22, 0x71374491, 0x23ef65cd, 0xb5c0fbcf, 0xec4d3b2f, + 0xe9b5dba5, 0x8189dbbc, 0x3956c25b, 0xf348b538, 0x59f111f1, 0xb605d019, + 0x923f82a4, 0xaf194f9b, 0xab1c5ed5, 0xda6d8118, 0xd807aa98, 0xa3030242, + 0x12835b01, 0x45706fbe, 0x243185be, 0x4ee4b28c, 0x550c7dc3, 0xd5ffb4e2, + 0x72be5d74, 0xf27b896f, 0x80deb1fe, 0x3b1696b1, 0x9bdc06a7, 0x25c71235, + 0xc19bf174, 0xcf692694, 0xe49b69c1, 0x9ef14ad2, 0xefbe4786, 0x384f25e3, + 0x0fc19dc6, 0x8b8cd5b5, 0x240ca1cc, 0x77ac9c65, 0x2de92c6f, 0x592b0275, + 0x4a7484aa, 0x6ea6e483, 0x5cb0a9dc, 0xbd41fbd4, 0x76f988da, 0x831153b5, + 0x983e5152, 0xee66dfab, 0xa831c66d, 0x2db43210, 0xb00327c8, 0x98fb213f, + 0xbf597fc7, 0xbeef0ee4, 0xc6e00bf3, 0x3da88fc2, 0xd5a79147, 0x930aa725, + 0x06ca6351, 0xe003826f, 0x14292967, 0x0a0e6e70, 0x27b70a85, 0x46d22ffc, + 0x2e1b2138, 0x5c26c926, 0x4d2c6dfc, 0x5ac42aed, 0x53380d13, 0x9d95b3df, + 0x650a7354, 0x8baf63de, 0x766a0abb, 0x3c77b2a8, 0x81c2c92e, 0x47edaee6, + 0x92722c85, 0x1482353b, 0xa2bfe8a1, 0x4cf10364, 0xa81a664b, 0xbc423001, + 0xc24b8b70, 0xd0f89791, 0xc76c51a3, 0x0654be30, 0xd192e819, 0xd6ef5218, + 0xd6990624, 0x5565a910, 0xf40e3585, 0x5771202a, 0x106aa070, 0x32bbd1b8, + 0x19a4c116, 0xb8d2d0c8, 0x1e376c08, 0x5141ab53, 0x2748774c, 0xdf8eeb99, + 0x34b0bcb5, 0xe19b48a8, 0x391c0cb3, 0xc5c95a63, 0x4ed8aa4a, 0xe3418acb, + 0x5b9cca4f, 0x7763e373, 0x682e6ff3, 0xd6b2b8a3, 0x748f82ee, 0x5defb2fc, + 0x78a5636f, 0x43172f60, 0x84c87814, 0xa1f0ab72, 0x8cc70208, 0x1a6439ec, + 0x90befffa, 0x23631e28, 0xa4506ceb, 0xde82bde9, 0xbef9a3f7, 0xb2c67915, + 0xc67178f2, 0xe372532b, 0xca273ece, 0xea26619c, 0xd186b8c7, 0x21c0c207, + 0xeada7dd6, 0xcde0eb1e, 0xf57d4f7f, 0xee6ed178, 0x06f067aa, 0x72176fba, + 0x0a637dc5, 0xa2c898a6, 0x113f9804, 0xbef90dae, 0x1b710b35, 0x131c471b, + 0x28db77f5, 0x23047d84, 0x32caab7b, 0x40c72493, 0x3c9ebe0a, 0x15c9bebc, + 0x431d67c4, 0x9c100d4c, 0x4cc5d4be, 0xcb3e42b6, 0x597f299c, 0xfc657e2a, + 0x5fcb6fab, 0x3ad6faec, 0x6c44198c, 0x4a475817 +]); diff --git a/closure/goog/crypt/sha2_64bit_test.js b/closure/goog/crypt/sha2_64bit_test.js new file mode 100644 index 0000000000..d0369c294e --- /dev/null +++ b/closure/goog/crypt/sha2_64bit_test.js @@ -0,0 +1,259 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.crypt.Sha2_64bit_test'); +goog.setTestOnly(); + +const Sha384 = goog.require('goog.crypt.Sha384'); +const Sha512 = goog.require('goog.crypt.Sha512'); +const Sha512_256 = goog.require('goog.crypt.Sha512_256'); +const crypt = goog.require('goog.crypt'); +const hashTester = goog.require('goog.crypt.hashTester'); +const testSuite = goog.require('goog.testing.testSuite'); + +/** + * Each object in the test vector array is a source text and one or more + * hashes of that source text. The source text is either a string or a + * byte array. + *

+ * All hash values, except for the empty string, are from public sources: + * csrc.nist.gov/publications/fips/fips180-2/fips180-2withchangenotice.pdf + * csrc.nist.gov/groups/ST/toolkit/documents/Examples/SHA384.pdf + * csrc.nist.gov/groups/ST/toolkit/documents/Examples/SHA512_256.pdf + * csrc.nist.gov/groups/ST/toolkit/documents/Examples/SHA2_Additional.pdf + * en.wikipedia.org/wiki/SHA-2#Examples_of_SHA-2_variants + */ +const TEST_VECTOR = [ + { + // Make sure the algorithm correctly handles the empty string + source: '', + 512: 'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce' + + '47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e', + }, + { + source: 'abc', + 512: 'ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a' + + '2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f', + 384: 'cb00753f45a35e8bb5a03d699ac65007272c32ab0eded163' + + '1a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7', + 256: '53048e2681941ef99b2e29b76b4c7dabe4c2d0c634fc6d46e0e2f13107e7af23', + }, + { + source: 'abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmn' + + 'hijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu', + 512: '8e959b75dae313da8cf4f72814fc143f8f7779c6eb9f7fa17299aeadb6889018' + + '501d289e4900f7e4331b99dec4b5433ac7d329eeb6dd26545e96e55b874be909', + 384: '09330c33f71147e83d192fc782cd1b4753111b173b3b05d2' + + '2fa08086e3b0f712fcc7c71a557e2db966c3e9fa91746039', + 256: '3928e184fb8690f840da3988121d31be65cb9d3ef83ee6146feac861e19b563a', + }, + { + source: 'The quick brown fox jumps over the lazy dog', + 512: '07e547d9586f6a73f73fbac0435ed76951218fb7d0c8d788a309d785436bbb64' + + '2e93a252a954f23912547d1e8a3b5ed6e1bfd7097821233fa0538f3db854fee6', + 384: 'ca737f1014a48f4c0b6dd43cb177b0afd9e5169367544c49' + + '4011e3317dbf9a509cb1e5dc1e85a941bbee3d7f2afbc9b1', + 256: 'dd9d67b371519c339ed8dbd25af90e976a1eeefd4ad3d889005e532fc5bef04d', + }, +]; + +/** + * For each integer key N, the value is the SHA-512 value of a string + * consisting of N repetitions of the character 'a'. + */ +const TEST_FENCEPOST_VECTOR = { + 110: 'c825949632e509824543f7eaf159fb6041722fce3c1cdcbb613b3d37ff107c51' + + '9417baac32f8e74fe29d7f4823bf6886956603dca5354a6ed6e4a542e06b7d28', + 111: 'fa9121c7b32b9e01733d034cfc78cbf67f926c7ed83e82200ef8681819692176' + + '0b4beff48404df811b953828274461673c68d04e297b0eb7b2b4d60fc6b566a2', + 112: 'c01d080efd492776a1c43bd23dd99d0a2e626d481e16782e75d54c2503b5dc32' + + 'bd05f0f1ba33e568b88fd2d970929b719ecbb152f58f130a407c8830604b70ca', + 113: '55ddd8ac210a6e18ba1ee055af84c966e0dbff091c43580ae1be703bdb85da31' + + 'acf6948cf5bd90c55a20e5450f22fb89bd8d0085e39f85a86cc46abbca75e24d', +}; + +/** + * Function called by the actual testers to ensure that specific strings + * hash to specific published values. + * Each item in the vector has a "source" and one or more additional keys. + * If the item has a key matching the key argument passed to this + * function, it is the expected value of the hash function. + * @param {!crypt.Sha2_64bit} hasher The hasher to test + * @param {number} length The length of the resulting hash, in bits. Also the + * key to use in TEST_VECTOR for the expected hash value + */ +function hashGoldenTester(hasher, length) { + TEST_VECTOR.forEach(data => { + hasher.update(data.source); + const digest = hasher.digest(); + assertEquals('Hash digest has the wrong length', length, digest.length * 8); + if (data[length]) { + // We're given an expected value + const expected = crypt.hexToByteArray(data[length]); + assertElementsEquals( + `Wrong result for hash${length}` + + '(\'' + data.source + '\')', + expected, digest); + } + hasher.reset(); + }); +} + +testSuite({ + /** Simple sanity tests for hash functions. */ + testBasicOperations() { + const sha512 = new Sha512(); + hashTester.runBasicTests(sha512); + + const sha384 = new Sha384(); + hashTester.runBasicTests(sha384); + + const sha256 = new Sha512_256(); + hashTester.runBasicTests(sha256); + }, + + /** Test that Sha512() returns the published values */ + testHashing512() { + hashGoldenTester(new Sha512(), 512); + }, + + /** Test that Sha384 returns the published values */ + testHashing384() { + hashGoldenTester(new Sha384(), 384); + }, + + /** Test that Sha512_256 returns the published values */ + testHashing256() { + hashGoldenTester(new Sha512_256(), 256); + }, + + /** Test that the opt_length works */ + testHashing_optLength() { + const hasher = new Sha512(); + hasher.update('1234567890'); + const digest1 = hasher.digest(); + hasher.reset(); + hasher.update('12345678901234567890', 10); + const digest2 = hasher.digest(); + assertElementsEquals(digest1, digest2); + }, + + /** + * Make sure that we correctly handle strings whose length is 110-113. + * This is the area where we are likely to hit fencepost errors in the padding + * code. + */ + testFencepostErrors() { + const hasher = new Sha512(); + const A64 = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const A128 = A64 + A64; + for (let i = 110; i <= 113; i++) { + hasher.update(A128, i); + const digest = hasher.digest(); + const expected = crypt.hexToByteArray(TEST_FENCEPOST_VECTOR[i]); + assertElementsEquals(`Fencepost ${i}`, expected, digest); + hasher.reset(); + } + }, + + /** Test one really large string using SHA512 */ + testHashing512Large() { + const hasher = new Sha512(); + hasher.update((new Array(1000000)).fill(0)); + const digest = hasher.digest(); + const expected = crypt.hexToByteArray( + 'ce044bc9fd43269d5bbc946cbebc3bb711341115cc4abdf2edbc3ff2c57ad4b1' + + '5deb699bda257fea5aef9c6e55fcf4cf9dc25a8c3ce25f2efe90908379bff7ed'); + assertElementsEquals(expected, digest); + }, + + /** Check that the code throws an error for bad input */ + testBadInput_nullNotAllowed() { + const hasher = new Sha512(); + assertThrows( + 'Null input not allowed', + /** @suppress {checkTypes} array like isn't a supported type */ + () => { + hasher.update({}); + }); + }, + + testBadInput_noFloatingPoint() { + const hasher = new Sha512(); + assertThrows('Floating point not allows', () => { + hasher.update([1, 2, 3, 4, 4.5]); + }); + }, + + testBadInput_negativeNotAllowed() { + const hasher = new Sha512(); + assertThrows('Negative not allowed', () => { + hasher.update([1, 2, 3, 4, -10]); + }); + }, + + testBadInput_mustBeByteArray() { + const hasher = new Sha512(); + assertThrows('Must be byte array', () => { + hasher.update([1, 2, 3, 4, {}]); + }); + }, + + testBadInput_byteTooLarge() { + const hasher = new Sha512(); + assertThrows('>255 not allowed', () => { + hasher.update([1, 2, 3, 4, 256]); + }); + }, + + testBadInput_characterTooLarge() { + const hasher = new Sha512(); + assertThrows('>255 not allowed', () => { + hasher.update('abc' + String.fromCharCode(256)); + }); + }, + + testHasherNeedsReset_beforeDigest() { + const hasher = new Sha512(); + hasher.update('abc'); + hasher.digest(); + assertThrows('Need reset after digest', () => { + hasher.digest(); + }); + }, + + testHasherNeedsReset_beforeUpdate() { + const hasher = new Sha512(); + hasher.update('abc'); + hasher.digest(); + assertThrows('Need reset after digest', () => { + hasher.update('abc'); + }); + }, + + /** @suppress {checkTypes} array like isn't a supported type */ + testHashingArrayLike() { + // Create array-like object + const obj = {}; + obj.length = 26; + for (let i = 0; i < 26; i++) { + obj[i] = 97 + i; + } + + // Check hashing + const hasher = new Sha512(); + hasher.update(obj); + const digest1 = hasher.digest(); + + hasher.reset(); + + hasher.update('abcdefghijklmnopqrstuvwxyz', 26); + const digest2 = hasher.digest(); + + assertElementsEquals(digest1, digest2); + }, +}); diff --git a/closure/goog/crypt/sha384.js b/closure/goog/crypt/sha384.js new file mode 100644 index 0000000000..cdcf2fd950 --- /dev/null +++ b/closure/goog/crypt/sha384.js @@ -0,0 +1,51 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview SHA-384 cryptographic hash. + * + * Usage: + * var sha384 = new goog.crypt.Sha384(); + * sha384.update(bytes); + * var hash = sha384.digest(); + */ + +goog.provide('goog.crypt.Sha384'); + +goog.require('goog.crypt.Sha2_64bit'); + + + +/** + * Constructs a SHA-384 cryptographic hash. + * + * @constructor + * @extends {goog.crypt.Sha2_64bit} + * @final + * @struct + */ +goog.crypt.Sha384 = function() { + 'use strict'; + goog.crypt.Sha384.base( + this, 'constructor', 6 /* numHashBlocks */, + goog.crypt.Sha384.INIT_HASH_BLOCK_); +}; +goog.inherits(goog.crypt.Sha384, goog.crypt.Sha2_64bit); + + +/** @private {!Array} */ +goog.crypt.Sha384.INIT_HASH_BLOCK_ = [ + // Section 5.3.4 of + // csrc.nist.gov/publications/fips/fips180-4/fips-180-4.pdf + 0xcbbb9d5d, 0xc1059ed8, // H0 + 0x629a292a, 0x367cd507, // H1 + 0x9159015a, 0x3070dd17, // H2 + 0x152fecd8, 0xf70e5939, // H3 + 0x67332667, 0xffc00b31, // H4 + 0x8eb44a87, 0x68581511, // H5 + 0xdb0c2e0d, 0x64f98fa7, // H6 + 0x47b5481d, 0xbefa4fa4 // H7 +]; diff --git a/closure/goog/crypt/sha512.js b/closure/goog/crypt/sha512.js new file mode 100644 index 0000000000..81d0a2637e --- /dev/null +++ b/closure/goog/crypt/sha512.js @@ -0,0 +1,51 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview SHA-512 cryptographic hash. + * + * Usage: + * var sha512 = new goog.crypt.Sha512(); + * sha512.update(bytes); + * var hash = sha512.digest(); + */ + +goog.provide('goog.crypt.Sha512'); + +goog.require('goog.crypt.Sha2_64bit'); + + + +/** + * Constructs a SHA-512 cryptographic hash. + * + * @constructor + * @extends {goog.crypt.Sha2_64bit} + * @final + * @struct + */ +goog.crypt.Sha512 = function() { + 'use strict'; + goog.crypt.Sha512.base( + this, 'constructor', 8 /* numHashBlocks */, + goog.crypt.Sha512.INIT_HASH_BLOCK_); +}; +goog.inherits(goog.crypt.Sha512, goog.crypt.Sha2_64bit); + + +/** @private {!Array} */ +goog.crypt.Sha512.INIT_HASH_BLOCK_ = [ + // Section 5.3.5 of + // csrc.nist.gov/publications/fips/fips180-4/fips-180-4.pdf + 0x6a09e667, 0xf3bcc908, // H0 + 0xbb67ae85, 0x84caa73b, // H1 + 0x3c6ef372, 0xfe94f82b, // H2 + 0xa54ff53a, 0x5f1d36f1, // H3 + 0x510e527f, 0xade682d1, // H4 + 0x9b05688c, 0x2b3e6c1f, // H5 + 0x1f83d9ab, 0xfb41bd6b, // H6 + 0x5be0cd19, 0x137e2179 // H7 +]; diff --git a/closure/goog/crypt/sha512_256.js b/closure/goog/crypt/sha512_256.js new file mode 100644 index 0000000000..601fb4e001 --- /dev/null +++ b/closure/goog/crypt/sha512_256.js @@ -0,0 +1,57 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview SHA-512/256 cryptographic hash. + * + * WARNING: SHA-256 and SHA-512/256 are different members of the SHA-2 + * family of hashes. Although both give 32-byte results, the two results + * should bear no relationship to each other. + * + * Please be careful before using this hash function. + *

+ * Usage: + * var sha512_256 = new goog.crypt.Sha512_256(); + * sha512_256.update(bytes); + * var hash = sha512_256.digest(); + */ + +goog.provide('goog.crypt.Sha512_256'); + +goog.require('goog.crypt.Sha2_64bit'); + + + +/** + * Constructs a SHA-512/256 cryptographic hash. + * + * @constructor + * @extends {goog.crypt.Sha2_64bit} + * @final + * @struct + */ +goog.crypt.Sha512_256 = function() { + 'use strict'; + goog.crypt.Sha512_256.base( + this, 'constructor', 4 /* numHashBlocks */, + goog.crypt.Sha512_256.INIT_HASH_BLOCK_); +}; +goog.inherits(goog.crypt.Sha512_256, goog.crypt.Sha2_64bit); + + +/** @private {!Array} */ +goog.crypt.Sha512_256.INIT_HASH_BLOCK_ = [ + // Section 5.3.6.2 of + // csrc.nist.gov/publications/fips/fips180-4/fips-180-4.pdf + 0x22312194, 0xFC2BF72C, // H0 + 0x9F555FA3, 0xC84C64C2, // H1 + 0x2393B86B, 0x6F53B151, // H2 + 0x96387719, 0x5940EABD, // H3 + 0x96283EE2, 0xA88EFFE3, // H4 + 0xBE5E1E25, 0x53863992, // H5 + 0x2B0199FC, 0x2C85B8AA, // H6 + 0x0EB72DDC, 0x81C52CA2 // H7 +]; diff --git a/closure/goog/crypt/sha512_perf.html b/closure/goog/crypt/sha512_perf.html new file mode 100644 index 0000000000..e4cce36322 --- /dev/null +++ b/closure/goog/crypt/sha512_perf.html @@ -0,0 +1,39 @@ + + + + + +Closure Performance Tests - goog.crypt.Sha512 + + + + + +

Closure Performance Tests - goog.crypt.Sha512

+

+User-agent: + +

+ + + + diff --git a/closure/goog/css/BUILD b/closure/goog/css/BUILD new file mode 100644 index 0000000000..ffd0fb0cdc --- /dev/null +++ b/closure/goog/css/BUILD @@ -0,0 +1 @@ +package(default_visibility = ["//visibility:public"]) diff --git a/closure/goog/css/autocomplete.css b/closure/goog/css/autocomplete.css new file mode 100644 index 0000000000..c8490cd9c4 --- /dev/null +++ b/closure/goog/css/autocomplete.css @@ -0,0 +1,41 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styles for goog.ui.ac.AutoComplete and its derivatives. + * Note: these styles need some work to get them working properly at various + * font sizes other than the default. + */ + +@provide 'goog.css.ac'; + +/* + * TODO(annams): Rename (here and in renderer.js) to specify class name as + * goog-autocomplete-renderer + */ +.ac-renderer { + font: normal 13px Arial, sans-serif; + position: absolute; + background: #fff; + border: 1px solid #666; + -moz-box-shadow: 2px 2px 2px rgba(102, 102, 102, .4); + -webkit-box-shadow: 2px 2px 2px rgba(102, 102, 102, .4); + width: 300px; +} + +.ac-row { + cursor: pointer; + padding: .4em; +} + +.ac-highlighted { + font-weight: bold; +} + +.ac-active { + background-color: #b2b4bf; +} diff --git a/closure/goog/css/bubble.css b/closure/goog/css/bubble.css new file mode 100644 index 0000000000..50b44b8300 --- /dev/null +++ b/closure/goog/css/bubble.css @@ -0,0 +1,86 @@ +/* + * Copyright 2010 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +@provide 'goog.css.bubble'; + +.goog-bubble-font { + font-size: 80%; + color: #888888; +} + +.goog-bubble-close-button { + background-image:url(//ssl.gstatic.com/closure/bubble_close.jpg); + background-color: white; + background-position: top right; + background-repeat: no-repeat; + width: 16px; + height: 16px; +} + +.goog-bubble-left { + background-image:url(//ssl.gstatic.com/closure/bubble_left.gif); + background-position:left; + background-repeat:repeat-y; + width: 4px; +} + +.goog-bubble-right { + background-image:url(//ssl.gstatic.com/closure/bubble_right.gif); + background-position: right; + background-repeat: repeat-y; + width: 4px; +} + +.goog-bubble-top-right-anchor { + background-image:url(//ssl.gstatic.com/closure/right_anchor_bubble_top.gif); + background-position: center; + background-repeat: no-repeat; + width: 147px; + height: 16px; +} + +.goog-bubble-top-left-anchor { + background-image:url(//ssl.gstatic.com/closure/left_anchor_bubble_top.gif); + background-position: center; + background-repeat: no-repeat; + width: 147px; + height: 16px; +} + +.goog-bubble-top-no-anchor { + background-image:url(//ssl.gstatic.com/closure/no_anchor_bubble_top.gif); + background-position: center; + background-repeat: no-repeat; + width: 147px; + height: 6px; +} + +.goog-bubble-bottom-right-anchor { + background-image:url(//ssl.gstatic.com/closure/right_anchor_bubble_bot.gif); + background-position: center; + background-repeat: no-repeat; + width: 147px; + height: 16px; +} + +.goog-bubble-bottom-left-anchor { + background-image:url(//ssl.gstatic.com/closure/left_anchor_bubble_bot.gif); + background-position: center; + background-repeat: no-repeat; + width: 147px; + height: 16px; +} + +.goog-bubble-bottom-no-anchor { + background-image:url(//ssl.gstatic.com/closure/no_anchor_bubble_bot.gif); + background-position: center; + background-repeat: no-repeat; + width: 147px; + height: 8px; +} + + diff --git a/closure/goog/css/button.css b/closure/goog/css/button.css new file mode 100644 index 0000000000..a7612c579f --- /dev/null +++ b/closure/goog/css/button.css @@ -0,0 +1,41 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styling for buttons rendered by goog.ui.ButtonRenderer. + */ + +@provide 'goog.css.button'; + +@require './custombutton'; +@require './flatbutton'; + +.goog-button { + color: #036; + border-color: #036; + background-color: #69c; +} + +/* State: disabled. */ +.goog-button-disabled { + border-color: #333; + color: #333; + background-color: #999; +} + +/* State: hover. */ +.goog-button-hover { + color: #369; + border-color: #369; + background-color: #9cf; +} + +/* State: active. */ +.goog-button-active { + color: #69c; + border-color: #69c; +} diff --git a/closure/goog/css/charpicker.css b/closure/goog/css/charpicker.css new file mode 100644 index 0000000000..6337776303 --- /dev/null +++ b/closure/goog/css/charpicker.css @@ -0,0 +1,205 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +@provide 'goog.css.charpicker'; + +.goog-char-picker { + background-color: #ddd; + padding: 16px; + border: 1px solid #777; +} + +/* goog.ui.HoverCard */ +.goog-char-picker-hovercard { + border: solid 5px #ffcc33; + min-width: 64px; + max-width: 160px; + padding: 16px; + background-color: white; + text-align: center; + position: absolute; + visibility: hidden; +} + +.goog-char-picker-name { + font-size: x-small; +} + +.goog-char-picker-unicode { + font-size: x-small; + color: GrayText; +} + +.goog-char-picker-char-zoom { + font-size: xx-large; +} + +/* + * grid + */ +.goog-char-picker-grid-container { + border: 1px solid #777; + background-color: #fff; + width: 272px; +} + +.goog-char-picker-grid { + overflow: hidden; + height: 250px; + width: 250px; + position: relative; +} + +.goog-stick { + width: 1px; + overflow: hidden; +} +.goog-stickwrap { + width: 17px; + height: 250px; + float: right; + overflow: auto; +} + +.goog-char-picker-recents { + border: 1px solid #777; + background-color: #fff; + height: 25px; + width: 275px; + margin: 0 0 16px 0; + position: relative; +} + +.goog-char-picker-notice { + font-size: x-small; + height: 16px; + color: GrayText; + margin: 0 0 16px 0; +} + +/* + * Hex entry + */ + +.goog-char-picker-uplus { +} + +.goog-char-picker-input-box { + width: 96px; +} + +.label-input-label { + color: GrayText; +} + +.goog-char-picker-okbutton { +} + +/* + * Grid buttons + */ +.goog-char-picker-grid .goog-flat-button { + position: relative; + width: 24px; + height: 24px; + line-height: 24px; + border-bottom: 1px solid #ddd; + border-right: 1px solid #ddd; + text-align: center; + cursor: pointer; + outline: none; +} + +.goog-char-picker-grid .goog-flat-button-hover, +.goog-char-picker-grid .goog-flat-button-focus { + background-color: #ffcc33; +} + +/* + * goog.ui.Menu + */ + +/* State: resting. */ +.goog-char-picker-button { + border-width: 0px; + margin: 0; + padding: 0; + position: absolute; + background-position: center left; +} + +/* State: resting. */ +.goog-char-picker-menu { + background-color: #fff; + border-color: #ccc #666 #666 #ccc; + border-style: solid; + border-width: 1px; + cursor: default; + margin: 0; + outline: none; + padding: 0; + position: absolute; + max-height: 400px; + overflow-y: auto; + overflow-x: hide; +} + +/* + * goog.ui.MenuItem + */ + +/* State: resting. */ +.goog-char-picker-menu .goog-menuitem { + color: #000; + list-style: none; + margin: 0; + /* 28px on the left for icon or checkbox; 10ex on the right for shortcut. */ + padding: 1px 32px 1px 8px; + white-space: nowrap; +} + +.goog-char-picker-menu2 .goog-menuitem { + color: #000; + list-style: none; + margin: 0; + /* 28px on the left for icon or checkbox; 10ex on the right for shortcut. */ + padding: 1px 32px 1px 8px; + white-space: nowrap; +} + +.goog-char-picker-menu .goog-subtitle { + color: #fff !important; + background-color: #666; + font-weight: bold; + list-style: none; + margin: 0; + /* 28px on the left for icon or checkbox; 10ex on the right for shortcut. */ + padding: 3px 32px 3px 8px; + white-space: nowrap; +} + +/* BiDi override for the resting state. */ +.goog-char-picker-menu .goog-menuitem-rtl { + /* Flip left/right padding for BiDi. */ + padding: 2px 16px 2px 32px !important; +} + +/* State: hover. */ +.goog-char-picker-menu .goog-menuitem-highlight { + background-color: #d6e9f8; +} +/* + * goog.ui.MenuSeparator + */ + +/* State: resting. */ +.goog-char-picker-menu .goog-menuseparator { + border-top: 1px solid #ccc; + margin: 2px 0; + padding: 0; +} + diff --git a/closure/goog/css/checkbox.css b/closure/goog/css/checkbox.css new file mode 100644 index 0000000000..e15a53464c --- /dev/null +++ b/closure/goog/css/checkbox.css @@ -0,0 +1,38 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* Sample 3-state checkbox styles. */ + +@provide 'goog.css.checkbox'; + +.goog-checkbox { + border: 1px solid #1C5180; + display: -moz-inline-box; + display: inline-block; + font-size: 1px; /* Fixes the height in IE6 */ + height: 11px; + margin: 0 4px 0 1px; + vertical-align: text-bottom; + width: 11px; +} + +.goog-checkbox-checked { + background: #fff url(//ssl.gstatic.com/closure/check-sprite.gif) no-repeat 2px center; +} + +.goog-checkbox-undetermined { + background: #bbb url(//ssl.gstatic.com/closure/check-sprite.gif) no-repeat 2px center; +} + +.goog-checkbox-unchecked { + background: #fff; +} + +.goog-checkbox-disabled { + border: 1px solid lightgray; + background-position: -7px; +} diff --git a/closure/goog/css/colormenubutton.css b/closure/goog/css/colormenubutton.css new file mode 100644 index 0000000000..3d26d49da9 --- /dev/null +++ b/closure/goog/css/colormenubutton.css @@ -0,0 +1,28 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for buttons created by goog.ui.ColorMenuButtonRenderer. + */ + +@provide 'goog.css.colormenubutton'; + +@require './colorpalette'; +@require './common'; +@require './menubutton'; + +/* Color indicator. */ +.goog-color-menu-button-indicator { + border-bottom: 4px solid #f0f0f0; +} + +/* Thinner padding for color picker buttons, to leave room for the indicator. */ +.goog-color-menu-button .goog-menu-button-inner-box, +.goog-toolbar-color-menu-button .goog-toolbar-menu-button-inner-box { + padding-top: 2px !important; + padding-bottom: 2px !important; +} diff --git a/closure/goog/css/colorpalette.css b/closure/goog/css/colorpalette.css new file mode 100644 index 0000000000..0a44d63a05 --- /dev/null +++ b/closure/goog/css/colorpalette.css @@ -0,0 +1,54 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for color palettes. + */ + +@provide 'goog.css.colorpalette'; + +@require './palette'; + +.goog-palette-cell .goog-palette-colorswatch { + border: none; + font-size: x-small; + height: 18px; + position: relative; + width: 18px; +} + +.goog-palette-cell-hover .goog-palette-colorswatch { + border: 1px solid #fff; + height: 16px; + width: 16px; +} + +.goog-palette-cell-selected .goog-palette-colorswatch { + /* Client apps may override the URL at which they serve the sprite. */ + background: url(//ssl.gstatic.com/editor/editortoolbar.png) no-repeat -368px 0; + border: 1px solid #333; + color: #fff; + font-weight: bold; + height: 16px; + width: 16px; +} + +.goog-palette-customcolor { + background-color: #fafafa; + border: 1px solid #eee; + color: #666; + font-size: x-small; + height: 15px; + position: relative; + width: 15px; +} + +.goog-palette-cell-hover .goog-palette-customcolor { + background-color: #fee; + border: 1px solid #f66; + color: #f66; +} diff --git a/closure/goog/css/colorpicker-simplegrid.css b/closure/goog/css/colorpicker-simplegrid.css new file mode 100644 index 0000000000..b98f5dccf6 --- /dev/null +++ b/closure/goog/css/colorpicker-simplegrid.css @@ -0,0 +1,49 @@ +/* + * Copyright 2007 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* Author: pupius@google.com (Daniel Pupius) */ + +/* + Styles to make the colorpicker look like the old gmail color picker + NOTE: without CSS scoping this will override styles defined in palette.css +*/ +.goog-palette { + outline: none; + cursor: default; +} + +.goog-palette-table { + border: 1px solid #666; + border-collapse: collapse; +} + +.goog-palette-cell { + height: 13px; + width: 15px; + margin: 0; + border: 0; + text-align: center; + vertical-align: middle; + border-right: 1px solid #666; + font-size: 1px; +} + +.goog-palette-colorswatch { + position: relative; + height: 13px; + width: 15px; + border: 1px solid #666; +} + +.goog-palette-cell-hover .goog-palette-colorswatch { + border: 1px solid #FFF; +} + +.goog-palette-cell-selected .goog-palette-colorswatch { + border: 1px solid #000; + color: #fff; +} diff --git a/closure/goog/css/combobox.css b/closure/goog/css/combobox.css new file mode 100644 index 0000000000..91bb6388f4 --- /dev/null +++ b/closure/goog/css/combobox.css @@ -0,0 +1,57 @@ +/* + * Copyright 2007 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styles for goog.ui.ComboBox and its derivatives. + */ + + +@require './menu'; +@require './menuitem'; +@require './menuseparator'; + +.goog-combobox { + background: #ddd url(//ssl.gstatic.com/closure/button-bg.gif) repeat-x scroll left top; + border: 1px solid #b5b6b5; + font: normal small arial, sans-serif; +} + +.goog-combobox input { + background-color: #fff; + border: 0; + border-right: 1px solid #b5b6b5; + color: #000; + font: normal small arial, sans-serif; + margin: 0; + padding: 0 0 0 2px; + vertical-align: bottom; /* override demo.css */ + width: 200px; +} + +.goog-combobox input.label-input-label { + background-color: #fff; + color: #aaa; +} + +.goog-combobox .goog-menu { + margin-top: -1px; + width: 219px; /* input width + button width + 3 * 1px border */ + z-index: 1000; +} + +.goog-combobox-button { + cursor: pointer; + display: inline-block; + font-size: 10px; + text-align: center; + width: 16px; +} + +/* IE6 only hack */ +* html .goog-combobox-button { + padding: 0 3px; +} diff --git a/closure/goog/css/common.css b/closure/goog/css/common.css new file mode 100644 index 0000000000..8932125570 --- /dev/null +++ b/closure/goog/css/common.css @@ -0,0 +1,41 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Cross-browser implementation of the "display: inline-block" CSS property. + * See http://www.w3.org/TR/CSS21/visuren.html#propdef-display for details. + * Tested on IE 6 & 7, FF 1.5 & 2.0, Safari 2 & 3, Webkit, and Opera 9. + */ + +@provide 'goog.css.common'; + +/* + * Default rule; only Safari, Webkit, and Opera handle it without hacks. + */ +.goog-inline-block { + position: relative; + display: -moz-inline-box; /* Ignored by FF3 and later. */ + display: inline-block; +} + +/* + * Pre-IE7 IE hack. On IE, "display: inline-block" only gives the element + * layout, but doesn't give it inline behavior. Subsequently setting display + * to inline does the trick. + */ +* html .goog-inline-block { + display: inline; +} + +/* + * IE7-only hack. On IE, "display: inline-block" only gives the element + * layout, but doesn't give it inline behavior. Subsequently setting display + * to inline does the trick. + */ +*:first-child+html .goog-inline-block { + display: inline; +} diff --git a/closure/goog/css/css3button.css b/closure/goog/css/css3button.css new file mode 100644 index 0000000000..d72a3c6c1a --- /dev/null +++ b/closure/goog/css/css3button.css @@ -0,0 +1,78 @@ +/* + * Copyright 2010 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +@provide 'goog.css.css3button'; + +@require './common'; + +/* Imageless button styles. */ +.goog-css3-button { + margin: 0 2px; + padding: 3px 6px; + text-align: center; + vertical-align: middle; + white-space: nowrap; + cursor: default; + outline: none; + font-family: Arial, sans-serif; + color: #000; + border: 1px solid #bbb; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + /* TODO(eae): Change this to -webkit-linear-gradient once + https://bugs.webkit.org/show_bug.cgi?id=28152 is resolved. */ + background: -webkit-gradient(linear, 0% 40%, 0% 70%, from(#f9f9f9), + to(#e3e3e3)); + /* @alternate */ background: -moz-linear-gradient(top, #f9f9f9, #e3e3e3); +} + + +/* Styles for different states (hover, active, focused, open, checked). */ +.goog-css3-button-hover { + border-color: #939393 !important; +} + +.goog-css3-button-focused { + border-color: #444; +} + +.goog-css3-button-active, .goog-css3-button-open, .goog-css3-button-checked { + border-color: #444 !important; + background: -webkit-gradient(linear, 0% 40%, 0% 70%, from(#e3e3e3), + to(#f9f9f9)); + /* @alternate */ background: -moz-linear-gradient(top, #e3e3e3, #f9f9f9); +} + +.goog-css3-button-disabled { + color: #888; +} + +.goog-css3-button-primary { + font-weight: bold; +} + + +/* + * Pill (collapsed border) styles. + */ +.goog-css3-button-collapse-right { + margin-right: 0 !important; + border-right: 1px solid #bbb; + -webkit-border-top-right-radius: 0px; + -webkit-border-bottom-right-radius: 0px; + -moz-border-radius-topright: 0px; + -moz-border-radius-bottomright: 0px; +} + +.goog-css3-button-collapse-left { + border-left: 1px solid #f9f9f9; + margin-left: 0 !important; + -webkit-border-top-left-radius: 0px; + -webkit-border-bottom-left-radius: 0px; + -moz-border-radius-topleft: 0px; + -moz-border-radius-bottomleft: 0px; +} diff --git a/closure/goog/css/css3menubutton.css b/closure/goog/css/css3menubutton.css new file mode 100644 index 0000000000..49e7e9981c --- /dev/null +++ b/closure/goog/css/css3menubutton.css @@ -0,0 +1,22 @@ +/* + * Copyright 2010 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for buttons created by goog.ui.Css3MenuButtonRenderer. + */ + +@provide 'goog.css.css3menubutton'; + +/* Dropdown arrow style. */ +.goog-css3-button-dropdown { + height: 16px; + width: 7px; + /* Client apps may override the URL at which they serve the sprite. */ + background: url(//ssl.gstatic.com/editor/editortoolbar.png) no-repeat -388px 0; + vertical-align: top; + margin-left: 3px; +} diff --git a/closure/goog/css/custombutton.css b/closure/goog/css/custombutton.css new file mode 100644 index 0000000000..43e0799324 --- /dev/null +++ b/closure/goog/css/custombutton.css @@ -0,0 +1,167 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styling for custom buttons rendered by goog.ui.CustomButtonRenderer. + */ + +@provide 'goog.css.custombutton'; + +@require './common'; + +.goog-custom-button { + margin: 2px; + border: 0; + padding: 0; + font-family: Arial, sans-serif; + color: #000; + /* Client apps may override the URL at which they serve the image. */ + background: #ddd url(//ssl.gstatic.com/editor/button-bg.png) repeat-x top left; + text-decoration: none; + list-style: none; + vertical-align: middle; + cursor: default; + outline: none; +} + +/* Pseudo-rounded corners. */ +.goog-custom-button-outer-box, +.goog-custom-button-inner-box { + border-style: solid; + border-color: #aaa; + vertical-align: top; +} + +.goog-custom-button-outer-box { + margin: 0; + border-width: 1px 0; + padding: 0; +} + +.goog-custom-button-inner-box { + margin: 0 -1px; + border-width: 0 1px; + padding: 3px 4px; + white-space: nowrap; /* Prevents buttons from line breaking on android. */ +} + +/* Pre-IE7 IE hack; ignored by IE7 and all non-IE browsers. */ +* html .goog-custom-button-inner-box { + /* IE6 needs to have the box shifted to make the borders line up. */ + left: -1px; +} +/* Pre-IE7 BiDi fixes. */ +/* rtl:begin:ignore */ +* html .goog-custom-button-rtl .goog-custom-button-outer-box { + /* @noflip */ left: -1px; +} +* html .goog-custom-button-rtl .goog-custom-button-inner-box { + /* @noflip */ right: auto; +} +/* rtl:end:ignore */ + +/* IE7-only hack; ignored by all other browsers. */ +*:first-child+html .goog-custom-button-inner-box { + /* IE7 needs to have the box shifted to make the borders line up. */ + left: -1px; +} +/* IE7 BiDi fix. */ +*:first-child+html .goog-custom-button-rtl .goog-custom-button-inner-box { + /* rtl:begin:ignore */ + /* @noflip */ left: 1px; + /* rtl:end:ignore */ +} + +/* Safari-only hacks. */ +::root .goog-custom-button, +::root .goog-custom-button-outer-box { + /* Required to make pseudo-rounded corners work on Safari. */ + line-height: 0; +} + +::root .goog-custom-button-inner-box { + /* Required to make pseudo-rounded corners work on Safari. */ + line-height: normal; +} + +/* State: disabled. */ +.goog-custom-button-disabled { + background-image: none !important; + opacity: 0.3; + -moz-opacity: 0.3; + filter: alpha(opacity=30); +} + +.goog-custom-button-disabled .goog-custom-button-outer-box, +.goog-custom-button-disabled .goog-custom-button-inner-box { + color: #333 !important; + border-color: #999 !important; +} + +/* Pre-IE7 IE hack; ignored by IE7 and all non-IE browsers. */ +* html .goog-custom-button-disabled { + margin: 2px 1px !important; + padding: 0 1px !important; +} + +/* IE7-only hack; ignored by all other browsers. */ +*:first-child+html .goog-custom-button-disabled { + margin: 2px 1px !important; + padding: 0 1px !important; +} + +/* State: hover. */ +.goog-custom-button-hover .goog-custom-button-outer-box, +.goog-custom-button-hover .goog-custom-button-inner-box { + border-color: #9cf #69e #69e #7af !important; /* Hover border wins. */ +} + +/* State: active, checked. */ +.goog-custom-button-active, +.goog-custom-button-checked { + background-color: #bbb; + background-position: bottom left; +} + +/* State: focused. */ +.goog-custom-button-focused .goog-custom-button-outer-box, +.goog-custom-button-focused .goog-custom-button-inner-box { + border-color: orange; +} + +/* Pill (collapsed border) styles. */ +.goog-custom-button-collapse-right, +.goog-custom-button-collapse-right .goog-custom-button-outer-box, +.goog-custom-button-collapse-right .goog-custom-button-inner-box { + margin-right: 0; +} + +.goog-custom-button-collapse-left, +.goog-custom-button-collapse-left .goog-custom-button-outer-box, +.goog-custom-button-collapse-left .goog-custom-button-inner-box { + margin-left: 0; +} + +.goog-custom-button-collapse-left .goog-custom-button-inner-box { + border-left: 1px solid #fff; +} + +.goog-custom-button-collapse-left.goog-custom-button-checked +.goog-custom-button-inner-box { + border-left: 1px solid #ddd; +} + +/* Pre-IE7 IE hack; ignored by IE7 and all non-IE browsers. */ +* html .goog-custom-button-collapse-left .goog-custom-button-inner-box { + left: 0; +} + +/* IE7-only hack; ignored by all other browsers. */ +*:first-child+html .goog-custom-button-collapse-left +.goog-custom-button-inner-box { + left: 0; +} diff --git a/closure/goog/css/datepicker.css b/closure/goog/css/datepicker.css new file mode 100644 index 0000000000..35c9f50948 --- /dev/null +++ b/closure/goog/css/datepicker.css @@ -0,0 +1,154 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for a goog.ui.DatePicker. + */ + +@provide 'goog.css.datepicker'; + +.goog-date-picker, +.goog-date-picker th, +.goog-date-picker td { + font: 13px Arial, sans-serif; +} + +.goog-date-picker { + -moz-user-focus: normal; + -moz-user-select: none; + position: relative; + border: 1px solid #000; + float: left; + padding: 2px; + color: #000; + background: #c3d9ff; + cursor: default; +} + +.goog-date-picker th { + text-align: center; +} + +.goog-date-picker td { + text-align: center; + vertical-align: middle; + padding: 1px 3px; +} + + +.goog-date-picker-menu { + position: absolute; + background: threedface; + border: 1px solid gray; + -moz-user-focus: normal; + z-index: 1; + outline: none; +} + +.goog-date-picker-menu ul { + list-style: none; + margin: 0px; + padding: 0px; +} + +.goog-date-picker-menu ul li { + cursor: default; +} + +.goog-date-picker-menu-selected { + background: #ccf; +} + +.goog-date-picker th { + font-size: .9em; +} + +.goog-date-picker td div { + float: left; +} + +.goog-date-picker button { + padding: 0px; + margin: 1px 0; + border: 0; + color: #20c; + font-weight: bold; + background: transparent; +} + +.goog-date-picker-date { + background: #fff; +} + +.goog-date-picker-week, +.goog-date-picker-wday { + padding: 1px 3px; + border: 0; + border-color: #a2bbdd; + border-style: solid; +} + +.goog-date-picker-week { + border-right-width: 1px; +} + +.goog-date-picker-wday { + border-bottom-width: 1px; +} + +.goog-date-picker-head td { + text-align: center; +} + +/** Use td.className instead of !important */ +td.goog-date-picker-today-cont { + text-align: center; +} + +/** Use td.className instead of !important */ +td.goog-date-picker-none-cont { + text-align: center; +} + +.goog-date-picker-month { + min-width: 11ex; + white-space: nowrap; +} + +.goog-date-picker-year { + min-width: 6ex; + white-space: nowrap; +} + +.goog-date-picker-monthyear { + white-space: nowrap; +} + +.goog-date-picker table { + border-collapse: collapse; +} + +.goog-date-picker-other-month { + color: #888; +} + +.goog-date-picker-wkend-start, +.goog-date-picker-wkend-end { + background: #eee; +} + +/** Use td.className instead of !important */ +td.goog-date-picker-selected { + background: #c3d9ff; +} + +.goog-date-picker-today { + background: #9ab; + font-weight: bold !important; + border-color: #246 #9bd #9bd #246; + color: #fff; +} diff --git a/closure/goog/css/dialog.css b/closure/goog/css/dialog.css new file mode 100644 index 0000000000..bc3093c3da --- /dev/null +++ b/closure/goog/css/dialog.css @@ -0,0 +1,70 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for goog.ui.Dialog. + */ + +@provide 'goog.css.dialog'; + +.modal-dialog { + background: #c1d9ff; + border: 1px solid #3a5774; + color: #000; + padding: 4px; + position: absolute; +} + +.modal-dialog a, +.modal-dialog a:link, +.modal-dialog a:visited { + color: #06c; + cursor: pointer; +} + +.modal-dialog-bg { + background: #666; + left: 0; + position: absolute; + top: 0; +} + +.modal-dialog-title { + background: #e0edfe; + color: #000; + cursor: pointer; + font-size: 120%; + font-weight: bold; + + /* Add padding on the right to ensure the close button has room. */ + padding: 8px 31px 8px 8px; + + position: relative; + _zoom: 1; /* Ensures proper width in IE6 RTL. */ +} + +.modal-dialog-title-close { + /* Client apps may override the URL at which they serve the sprite. */ + background: #e0edfe url(//ssl.gstatic.com/editor/editortoolbar.png) no-repeat -528px 0; + cursor: default; + height: 15px; + position: absolute; + right: 10px; + top: 8px; + width: 15px; + vertical-align: middle; +} + +.modal-dialog-buttons, +.modal-dialog-content { + background-color: #fff; + padding: 8px; +} + +.goog-buttonset-default { + font-weight: bold; +} diff --git a/closure/goog/css/dimensionpicker.css b/closure/goog/css/dimensionpicker.css new file mode 100644 index 0000000000..06cede5d97 --- /dev/null +++ b/closure/goog/css/dimensionpicker.css @@ -0,0 +1,46 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styling for dimension pickers rendered by goog.ui.DimensionPickerRenderer. + */ + +@provide 'goog.css.dimensionpicker'; + +.goog-dimension-picker { + font-size: 18px; + padding: 4px; +} + +.goog-dimension-picker div { + position: relative; +} + +.goog-dimension-picker div.goog-dimension-picker-highlighted { +/* Client apps must provide the URL at which they serve the image. */ + /* background: url(dimension-highlighted.png); */ + left: 0; + overflow: hidden; + position: absolute; + top: 0; +} + +.goog-dimension-picker-unhighlighted { + /* Client apps must provide the URL at which they serve the image. */ + /* background: url(dimension-unhighlighted.png); */ +} + +.goog-dimension-picker-status { + font-size: 10pt; + text-align: center; +} + +.goog-dimension-picker div.goog-dimension-picker-mousecatcher { + left: 0; + position: absolute !important; + top: 0; +} diff --git a/closure/goog/css/dragdropdetector.css b/closure/goog/css/dragdropdetector.css new file mode 100644 index 0000000000..5fea10f526 --- /dev/null +++ b/closure/goog/css/dragdropdetector.css @@ -0,0 +1,50 @@ +/* + * Copyright 2007 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styling for the drag drop detector. + * + * Author: robbyw@google.com (Robby Walker) + * Author: wcrosby@google.com (Wayne Crosby) + */ + +@provide 'goog.css.dragdropdetector'; + +.goog-dragdrop-w3c-editable-iframe { + position: absolute; + width: 100%; + height: 10px; + top: -150px; + left: 0; + z-index: 10000; + padding: 0; + overflow: hidden; + opacity: 0; + -moz-opacity: 0; +} + +.goog-dragdrop-ie-editable-iframe { + width: 100%; + height: 5000px; +} + +.goog-dragdrop-ie-input { + width: 100%; + height: 5000px; +} + +.goog-dragdrop-ie-div { + position: absolute; + top: -5000px; + left: 0; + width: 100%; + height: 5000px; + z-index: 10000; + background-color: white; + filter: alpha(opacity=0); + overflow: hidden; +} diff --git a/closure/goog/css/editor/BUILD b/closure/goog/css/editor/BUILD new file mode 100644 index 0000000000..ffd0fb0cdc --- /dev/null +++ b/closure/goog/css/editor/BUILD @@ -0,0 +1 @@ +package(default_visibility = ["//visibility:public"]) diff --git a/closure/goog/css/editor/bubble.css b/closure/goog/css/editor/bubble.css new file mode 100644 index 0000000000..ba518de4e7 --- /dev/null +++ b/closure/goog/css/editor/bubble.css @@ -0,0 +1,71 @@ +/* + * Copyright 2005 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Bubble styles. + */ + +@provide 'goog.css.editor.bubble'; + +div.tr_bubble { + position: absolute; + + background-color: #e0ecff; + border: 1px solid #99c0ff; + border-radius: 2px; + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + font-size: 83%; + font-family: Arial, Helvetica, sans-serif; + padding: 2px 19px 6px 6px; + white-space: nowrap; +} + +.tr_bubble_link { + color: #00c; + text-decoration: underline; + cursor: pointer; + font-size: 100%; +} + +.tr_bubble .tr_option-link, +.tr_bubble #tr_delete-image, +.tr_bubble #tr_module-options-link { + font-size: 83%; +} + +.tr_bubble_closebox { + position: absolute; + cursor: default; + background: url(//ssl.gstatic.com/editor/bubble_closebox.gif) top left no-repeat; + padding: 0; + margin: 0; + width: 10px; + height: 10px; + top: 3px; + right: 5px; +} + +div.tr_bubble_panel { + padding: 2px 0 1px; +} + +div.tr_bubble_panel_title { + display: none; +} + +div.tr_multi_bubble div.tr_bubble_panel_title { + margin-right: 1px; + display: block; + float: left; + width: 50px; +} + +div.tr_multi_bubble div.tr_bubble_panel { + padding: 2px 0 1px; + margin-right: 50px; +} diff --git a/closure/goog/css/editor/dialog.css b/closure/goog/css/editor/dialog.css new file mode 100644 index 0000000000..acdb255fec --- /dev/null +++ b/closure/goog/css/editor/dialog.css @@ -0,0 +1,69 @@ +/* + * Copyright 2007 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styles for Editor dialogs and their sub-components. + */ + +@provide 'goog.css.editor.dialog'; + +@require '../dialog'; +@require '../tab'; +@require '../tabbar'; + +.tr-dialog { + width: 475px; +} + +.tr-dialog .goog-tab-content { + margin: 0; + border: 1px solid #6b90da; + padding: 4px 8px; + background: #fff; + overflow: auto; +} + +.tr-tabpane { + font-size: 10pt; + padding: 1.3ex 0; +} + +.tr-tabpane-caption { + font-size: 10pt; + margin-bottom: 0.7ex; + background-color: #fffaf5; + line-height: 1.3em; +} + +.tr-tabpane .goog-tab-content { + border: none; + padding: 5px 7px 1px; +} + +.tr-tabpane .goog-tab { + background-color: #fff; + border: none; + width: 136px; + line-height: 1.3em; + margin-bottom: 0.7ex; +} + +.tr-tabpane .goog-tab { + text-decoration: underline; + color: blue; + cursor: pointer; +} + +.tr-tabpane .goog-tab-selected { + font-weight: bold; + text-decoration: none; + color: black; +} + +.tr-tabpane .goog-tab input { + margin: -2px 5px 0 0; +} diff --git a/closure/goog/css/editor/equationeditor.css b/closure/goog/css/editor/equationeditor.css new file mode 100644 index 0000000000..691f923396 --- /dev/null +++ b/closure/goog/css/editor/equationeditor.css @@ -0,0 +1,112 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * The CSS definition for everything inside equation editor dialog. + */ + +@provide 'goog.css.editor.equationeditor'; + +.ee-modal-dialog { + width: 475px; +} + +.ee-content { + background: #FFF; + border: 1px solid #369; + overflow: auto; + padding: 4px 8px; +} + +.ee-tex { + border: 1px solid #000; + display: block; + height: 7.5em; + margin: 4px 0 10px 0; + width: 100%; +} + +.ee-section-title { + font-weight: bold; +} + +.ee-section-title-floating { + float: left; +} + +#ee-section-learn-more { + float: right; +} + +.ee-preview-container { + border: 1px dashed #ccc; + height: 80px; + margin: 4px 0 10px 0; + width: 100%; + overflow: auto; +} + +.ee-warning { + color: #F00; +} + +.ee-palette { + border: 1px solid #aaa; + left: 0; + outline: none; + position: absolute; +} + +.ee-palette-table { + border: 0; + border-collapse: separate; +} + +.ee-palette-cell { + background: #F0F0F0; + border: 1px solid #FFF; + margin: 0; + padding: 1px; +} + +.ee-palette-cell-hover { + background: #E2ECF9 !important; + border: 1px solid #000; + padding: 1px; +} + +.ee-palette-cell-selected { + background: #F0F0F0; + border: 1px solid #CCC !important; + padding: 1px; +} + +.ee-menu-palette-table { + margin-right: 10px; +} + +.ee-menu-palette { + outline: none; + padding-top: 2px; +} + +.ee-menu-palette-cell { + background: #F0F0F0 none repeat scroll 0 0; + border-color: #888 #AAA #AAA #888; + border-style: solid; + border-width: 1px; +} +.ee-menu-palette-cell-hover, +.ee-menu-palette-cell-selected { + background: #F0F0F0; +} + +.ee-palette-item, +.ee-menu-palette-item { + background-image: url(//ssl.gstatic.com/editor/ee-palettes.gif); +} + diff --git a/closure/goog/css/editor/linkdialog.css b/closure/goog/css/editor/linkdialog.css new file mode 100644 index 0000000000..b09972f808 --- /dev/null +++ b/closure/goog/css/editor/linkdialog.css @@ -0,0 +1,41 @@ +/* + * Copyright 2007 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/** + * Styles for the Editor's Edit Link dialog. + * + */ + +@provide 'goog.css.editor.linkdialog'; + +@require './dialog'; +@require '../linkbutton'; + + +.tr-link-dialog-explanation-text { + font-size: 83%; + margin-top: 15px; +} + +.tr-link-dialog-target-input { + width: 100%; + /* Input boxes for URLs and email address should always be LTR. */ + direction: ltr; + box-sizing: border-box; +} + +.tr-link-dialog-email-warning { + text-align: center; + color: #c00; + font-weight: bold; +} + +.tr_pseudo-link { + color: #00c; + text-decoration: underline; + cursor: pointer; +} diff --git a/closure/goog/css/editortoolbar.css b/closure/goog/css/editortoolbar.css new file mode 100644 index 0000000000..fb9d43f9c2 --- /dev/null +++ b/closure/goog/css/editortoolbar.css @@ -0,0 +1,224 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Editor toolbar styles. + */ + +@provide 'goog.css.editortoolbar'; + +/* Common base style for all icons. */ +.tr-icon { + width: 16px; + height: 16px; + background: url(//ssl.gstatic.com/editor/editortoolbar.png) no-repeat; + vertical-align: middle; +} + +.goog-color-menu-button-indicator .tr-icon { + height: 14px; +} + +/* Undo (redo when the chrome is right-to-left). */ +.tr-undo, +.goog-toolbar-button-rtl .tr-redo { + background-position: 0; +} + +/* Redo (undo when the chrome is right-to-left). */ +.tr-redo, +.goog-toolbar-button-rtl .tr-undo { + background-position: -16px; +} + +/* Font name. */ +.tr-fontName .goog-toolbar-menu-button-caption { + color: #246; + width: 16ex; + height: 16px; + overflow: hidden; +} + +/* Font size. */ +.tr-fontSize .goog-toolbar-menu-button-caption { + color: #246; + width: 8ex; + height: 16px; + overflow: hidden; +} + +/* Bold. */ +.tr-bold { + background-position: -32px; +} + +/* Italic. */ +.tr-italic { + background-position: -48px; +} + +/* Underline. */ +.tr-underline { + background-position: -64px; +} + +/* Foreground color. */ +.tr-foreColor { + height: 14px; + background-position: -80px; +} + +/* Background color. */ +.tr-backColor { + height: 14px; + background-position: -96px; +} + +/* Link. */ +.tr-link { + font-weight: bold; + color: #009; + text-decoration: underline; +} + +/* Insert image. */ +.tr-image { + background-position: -112px; +} + +/* Insert drawing. */ +.tr-newDrawing { + background-position: -592px; +} + +/* Insert special character. */ +.tr-spChar { + font-weight: bold; + color: #900; +} + +/* Increase indent. */ +.tr-indent { + background-position: -128px; +} + +/* Increase ident in right-to-left text mode, regardless of chrome direction. */ +.tr-rtl-mode .tr-indent { + background-position: -400px; +} + +/* Decrease indent. */ +.tr-outdent { + background-position: -144px; +} + +/* Decrease indent in right-to-left text mode, regardless of chrome direction. */ +.tr-rtl-mode .tr-outdent { + background-position: -416px; +} + +/* Bullet (unordered) list. */ +.tr-insertUnorderedList { + background-position: -160px; +} + +/* Bullet list in right-to-left text mode, regardless of chrome direction. */ +.tr-rtl-mode .tr-insertUnorderedList { + background-position: -432px; +} + +/* Number (ordered) list. */ +.tr-insertOrderedList { + background-position: -176px; +} + +/* Number list in right-to-left text mode, regardless of chrome direction. */ +.tr-rtl-mode .tr-insertOrderedList { + background-position: -448px; +} + +/* Text alignment buttons. */ +.tr-justifyLeft { + background-position: -192px; +} +.tr-justifyCenter { + background-position: -208px; +} +.tr-justifyRight { + background-position: -224px; +} +.tr-justifyFull { + background-position: -480px; +} + +/* Blockquote. */ +.tr-BLOCKQUOTE { + background-position: -240px; +} + +/* Blockquote in right-to-left text mode, regardless of chrome direction. */ +.tr-rtl-mode .tr-BLOCKQUOTE { + background-position: -464px; +} + +/* Remove formatting. */ +.tr-removeFormat { + background-position: -256px; +} + +/* Spellcheck. */ +.tr-spell { + background-position: -272px; +} + +/* Left-to-right text direction. */ +.tr-ltr { + background-position: -288px; +} + +/* Right-to-left text direction. */ +.tr-rtl { + background-position: -304px; +} + +/* Insert iGoogle module. */ +.tr-insertModule { + background-position: -496px; +} + +/* Strike through text */ +.tr-strikeThrough { + background-position: -544px; +} + +/* Subscript */ +.tr-subscript { + background-position: -560px; +} + +/* Superscript */ +.tr-superscript { + background-position: -576px; +} + +/* Insert drawing. */ +.tr-equation { + background-position: -608px; +} + +/* Edit HTML. */ +.tr-editHtml { + color: #009; +} + +/* "Format block" menu. */ +.tr-formatBlock .goog-toolbar-menu-button-caption { + color: #246; + width: 12ex; + height: 16px; + overflow: hidden; +} diff --git a/closure/goog/css/filteredmenu.css b/closure/goog/css/filteredmenu.css new file mode 100644 index 0000000000..27b2550ad7 --- /dev/null +++ b/closure/goog/css/filteredmenu.css @@ -0,0 +1,31 @@ +/* + * Copyright 2007 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* goog.ui.FilteredMenu */ + +@provide 'goog.css.filteredmenu'; + +.goog-menu-filter { + margin: 2px; + border: 1px solid silver; + background: white; + overflow: hidden; +} + +.goog-menu-filter div { + color: gray; + pointer-events: none; + position: absolute; + padding: 1px; +} + +.goog-menu-filter input { + margin: 0; + border: 0; + background: transparent; + width: 100%; +} diff --git a/closure/goog/css/filterobservingmenuitem.css b/closure/goog/css/filterobservingmenuitem.css new file mode 100644 index 0000000000..3725bab64a --- /dev/null +++ b/closure/goog/css/filterobservingmenuitem.css @@ -0,0 +1,25 @@ +/* + * Copyright 2007 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* goog.ui.FilterObservingMenuItem */ + +@provide 'goog.css.filterobservingmenuitem'; + +.goog-filterobsmenuitem { + padding: 2px 5px; + margin: 0; + list-style: none; +} + +.goog-filterobsmenuitem-highlight { + background-color: #4279A5; + color: #FFF; +} + +.goog-filterobsmenuitem-disabled { + color: #999; +} diff --git a/closure/goog/css/flatbutton.css b/closure/goog/css/flatbutton.css new file mode 100644 index 0000000000..b0977013ed --- /dev/null +++ b/closure/goog/css/flatbutton.css @@ -0,0 +1,66 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styling for flat buttons created by goog.ui.FlatButtonRenderer. + */ + +@provide 'goog.css.flatbutton'; + +@require './common'; + +.goog-flat-button { + position: relative; + /*width: 20ex;*/ + margin: 2px; + border: 1px solid #000; + padding: 2px 6px; + font: normal 13px "Trebuchet MS", Tahoma, Arial, sans-serif; + color: #fff; + background-color: #8c2425; + cursor: pointer; + outline: none; +} + +/* State: disabled. */ +.goog-flat-button-disabled { + border-color: #888; + color: #888; + background-color: #ccc; + cursor: default; +} + +/* State: hover. */ +.goog-flat-button-hover { + border-color: #8c2425; + color: #8c2425; + background-color: #eaa4a5; +} + +/* State: active, selected, checked. */ +.goog-flat-button-active, +.goog-flat-button-selected, +.goog-flat-button-checked { + border-color: #5b4169; + color: #5b4169; + background-color: #d1a8ea; +} + +/* State: focused. */ +.goog-flat-button-focused { + border-color: #5b4169; +} + +/* Pill (collapsed border) styles. */ +.goog-flat-button-collapse-right { + margin-right: 0; +} + +.goog-flat-button-collapse-left { + margin-left: 0; + border-left: none; +} diff --git a/closure/goog/css/flatmenubutton.css b/closure/goog/css/flatmenubutton.css new file mode 100644 index 0000000000..f80ff81a2b --- /dev/null +++ b/closure/goog/css/flatmenubutton.css @@ -0,0 +1,63 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for buttons created by goog.ui.FlatMenuButtonRenderer. + */ + +@provide 'goog.css.flatmenubutton'; + +@require './common'; + +.goog-flat-menu-button { + background-color: #fff; + border: 1px solid #c9c9c9; + color: #333; + cursor: pointer; + font: normal 95%; + list-style: none; + margin: 0 2px; + outline: none; + padding: 1px 4px; + position: relative; + text-decoration: none; + vertical-align: middle; +} + +.goog-flat-menu-button-disabled * { + border-color: #ccc; + color: #999; + cursor: default; +} + +.goog-flat-menu-button-hover { + border-color: #9cf #69e #69e #7af !important; /* Hover border wins. */ +} + +.goog-flat-menu-button-active { + background-color: #bbb; + background-position: bottom left; +} + +.goog-flat-menu-button-focused { + border-color: #bbb; +} + +.goog-flat-menu-button-caption { + padding-right: 10px; + vertical-align: top; +} + +.goog-flat-menu-button-dropdown { + /* Client apps may override the URL at which they serve the sprite. */ + background: url(//ssl.gstatic.com/editor/editortoolbar.png) no-repeat -388px 0; + position: absolute; + right: 2px; + top: 0; + vertical-align: top; + width: 7px; +} diff --git a/closure/goog/css/hovercard.css b/closure/goog/css/hovercard.css new file mode 100644 index 0000000000..5dffd54cd8 --- /dev/null +++ b/closure/goog/css/hovercard.css @@ -0,0 +1,51 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +@provide 'goog.css.hovercard'; + +.goog-hovercard div { + border: solid 5px #69748C; + width: 300px; + height: 115px; + background-color: white; + font-family: arial, sans-serif; +} + +.goog-hovercard .goog-shadow { + border: transparent; + background-color: black; + filter: alpha(Opacity=1); + opacity: 0.01; + -moz-opacity: 0.01; +} + +.goog-hovercard table { + border-collapse: collapse; + border-spacing: 0px; +} + +.goog-hovercard-icons td { + border-bottom: 1px solid #ccc; + padding: 0px; + margin: 0px; + text-align: center; + height: 19px; + width: 100px; + font-size: 90%; +} + +.goog-hovercard-icons td + td { + border-left: 1px solid #ccc; +} + +.goog-hovercard-content { + border-collapse: collapse; +} + +.goog-hovercard-content td { + padding-left: 15px; +} diff --git a/closure/goog/css/hsvapalette.css b/closure/goog/css/hsvapalette.css new file mode 100644 index 0000000000..dca4bc5ebb --- /dev/null +++ b/closure/goog/css/hsvapalette.css @@ -0,0 +1,227 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styles for the HSV color palette. + */ + +@provide 'goog.css.hsvapalette'; + +.goog-hsva-palette, +.goog-hsva-palette-sm { + position: relative; + border: 1px solid #999; + border-color: #ccc #999 #999 #ccc; + width: 442px; + height: 276px; +} + +.goog-hsva-palette-sm { + width: 205px; + height: 185px; +} + +.goog-hsva-palette label span, +.goog-hsva-palette-sm label span { + display: none; +} + +.goog-hsva-palette-hs-backdrop, +.goog-hsva-palette-sm-hs-backdrop, +.goog-hsva-palette-hs-image, +.goog-hsva-palette-sm-hs-image { + position: absolute; + top: 10px; + left: 10px; + width: 256px; + height: 256px; + border: 1px solid #999; +} + +.goog-hsva-palette-sm-hs-backdrop, +.goog-hsva-palette-sm-hs-image { + top: 45px; + width: 128px; + height: 128px; +} + +.goog-hsva-palette-hs-backdrop, +.goog-hsva-palette-sm-hs-backdrop { + background-color: #000; +} + +.goog-hsva-palette-hs-image, +.goog-hsva-palette-v-image, +.goog-hsva-palette-a-image, +.goog-hsva-palette-hs-handle, +.goog-hsva-palette-v-handle, +.goog-hsva-palette-a-handle, +.goog-hsva-palette-swatch-backdrop { + background-image: url(//ssl.gstatic.com/closure/hsva-sprite.png); +} + +.goog-hsva-palette-noalpha .goog-hsva-palette-hs-image, +.goog-hsva-palette-noalpha .goog-hsva-palette-v-image, +.goog-hsva-palette-noalpha .goog-hsva-palette-a-image, +.goog-hsva-palette-noalpha .goog-hsva-palette-hs-handle, +.goog-hsva-palette-noalpha .goog-hsva-palette-v-handle, +.goog-hsva-palette-noalpha .goog-hsva-palette-a-handle, +.goog-hsva-palette-noalpha .goog-hsva-palette-swatch-backdrop { + background-image: url(//ssl.gstatic.com/closure/hsva-sprite.gif); +} + +.goog-hsva-palette-sm-hs-image, +.goog-hsva-palette-sm-v-image, +.goog-hsva-palette-sm-a-image, +.goog-hsva-palette-sm-hs-handle, +.goog-hsva-palette-sm-v-handle, +.goog-hsva-palette-sm-a-handle, +.goog-hsva-palette-sm-swatch-backdrop { + background-image: url(//ssl.gstatic.com/closure/hsva-sprite-sm.png); +} + +.goog-hsva-palette-noalpha .goog-hsva-palette-sm-hs-image, +.goog-hsva-palette-noalpha .goog-hsva-palette-sm-v-image, +.goog-hsva-palette-noalpha .goog-hsva-palette-sm-a-image, +.goog-hsva-palette-noalpha .goog-hsva-palette-sm-hs-handle, +.goog-hsva-palette-noalpha .goog-hsva-palette-sm-v-handle, +.goog-hsva-palette-noalpha .goog-hsva-palette-sm-a-handle, +.goog-hsva-palette-noalpha .goog-hsva-palette-swatch-backdrop { + background-image: url(//ssl.gstatic.com/closure/hsva-sprite-sm.gif); +} + +.goog-hsva-palette-hs-image, +.goog-hsva-palette-sm-hs-image { + background-position: 0 0; +} + +.goog-hsva-palette-hs-handle, +.goog-hsva-palette-sm-hs-handle { + position: absolute; + left: 5px; + top: 5px; + width: 11px; + height: 11px; + overflow: hidden; + background-position: 0 -256px; +} + +.goog-hsva-palette-sm-hs-handle { + top: 40px; + background-position: 0 -128px; +} + +.goog-hsva-palette-v-image, +.goog-hsva-palette-a-image, +.goog-hsva-palette-sm-v-image, +.goog-hsva-palette-sm-a-image { + position: absolute; + top: 10px; + left: 286px; + width: 19px; + height: 256px; + border: 1px solid #999; + background-color: #fff; + background-position: -256px 0; +} + +.goog-hsva-palette-a-image { + left: 325px; + background-position: -275px 0; +} + +.goog-hsva-palette-sm-v-image, +.goog-hsva-palette-sm-a-image { + top: 45px; + left: 155px; + width: 9px; + height: 128px; + background-position: -128px 0; +} + +.goog-hsva-palette-sm-a-image { + left: 182px; + background-position: -137px 0; +} + +.goog-hsva-palette-v-handle, +.goog-hsva-palette-a-handle, +.goog-hsva-palette-sm-v-handle, +.goog-hsva-palette-sm-a-handle { + position: absolute; + top: 5px; + left: 279px; + width: 35px; + height: 11px; + background-position: -11px -256px; + overflow: hidden; +} + +.goog-hsva-palette-a-handle { + left: 318px; +} + +.goog-hsva-palette-sm-v-handle, +.goog-hsva-palette-sm-a-handle { + top: 40px; + left: 148px; + width: 25px; + background-position: -11px -128px; +} + +.goog-hsva-palette-sm-a-handle { + left: 175px; +} + +.goog-hsva-palette-swatch, +.goog-hsva-palette-swatch-backdrop, +.goog-hsva-palette-sm-swatch, +.goog-hsva-palette-sm-swatch-backdrop { + position: absolute; + top: 10px; + right: 10px; + width: 65px; + height: 65px; + border: 1px solid #999; + background-color: #fff; + background-position: -294px 0; +} + +.goog-hsva-palette-sm-swatch, +.goog-hsva-palette-sm-swatch-backdrop { + top: 10px; + right: auto; + left: 10px; + width: 30px; + height: 22px; + background-position: -36px -128px; +} + +.goog-hsva-palette-swatch, +.goog-hsva-palette-sm-swatch { + z-index: 5; +} + +.goog-hsva-palette-swatch-backdrop, +.goog-hsva-palette-sm-swatch-backdrop { + z-index: 1; +} + +.goog-hsva-palette-input, +.goog-hsva-palette-sm-input { + position: absolute; + top: 85px; + right: 10px; + width: 65px; + font-size: 80%; +} + +.goog-hsva-palette-sm-input { + top: 10px; + right: auto; + left: 50px; +} diff --git a/closure/goog/css/hsvpalette.css b/closure/goog/css/hsvpalette.css new file mode 100644 index 0000000000..296a445112 --- /dev/null +++ b/closure/goog/css/hsvpalette.css @@ -0,0 +1,175 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styles for the HSV color palette. + */ + +@provide 'goog.css.hsvpalette'; + +.goog-hsv-palette, +.goog-hsv-palette-sm { + position: relative; + border: 1px solid #999; + border-color: #ccc #999 #999 #ccc; + width: 400px; + height: 276px; +} + +.goog-hsv-palette-sm { + width: 182px; + height: 185px; +} + +.goog-hsv-palette label span, +.goog-hsv-palette-sm label span { + display: none; +} + +.goog-hsv-palette-hs-backdrop, +.goog-hsv-palette-sm-hs-backdrop, +.goog-hsv-palette-hs-image, +.goog-hsv-palette-sm-hs-image { + position: absolute; + top: 10px; + left: 10px; + width: 256px; + height: 256px; + border: 1px solid #999; +} + +.goog-hsv-palette-sm-hs-backdrop, +.goog-hsv-palette-sm-hs-image { + top: 45px; + width: 128px; + height: 128px; +} + +.goog-hsv-palette-hs-backdrop, +.goog-hsv-palette-sm-hs-backdrop { + background-color: #000; +} + +.goog-hsv-palette-hs-image, +.goog-hsv-palette-v-image, +.goog-hsv-palette-hs-handle, +.goog-hsv-palette-v-handle { + background-image: url(//ssl.gstatic.com/closure/hsv-sprite.png); +} + +.goog-hsv-palette-noalpha .goog-hsv-palette-hs-image, +.goog-hsv-palette-noalpha .goog-hsv-palette-v-image, +.goog-hsv-palette-noalpha .goog-hsv-palette-hs-handle, +.goog-hsv-palette-noalpha .goog-hsv-palette-v-handle { + background-image: url(//ssl.gstatic.com/closure/hsv-sprite.gif); +} + +.goog-hsv-palette-sm-hs-image, +.goog-hsv-palette-sm-v-image, +.goog-hsv-palette-sm-hs-handle, +.goog-hsv-palette-sm-v-handle { + background-image: url(//ssl.gstatic.com/closure/hsv-sprite-sm.png); +} + +.goog-hsv-palette-noalpha .goog-hsv-palette-sm-hs-image, +.goog-hsv-palette-noalpha .goog-hsv-palette-sm-v-image, +.goog-hsv-palette-noalpha .goog-hsv-palette-sm-hs-handle, +.goog-hsv-palette-noalpha .goog-hsv-palette-sm-v-handle { + background-image: url(//ssl.gstatic.com/closure/hsv-sprite-sm.gif); +} + +.goog-hsv-palette-hs-image, +.goog-hsv-palette-sm-hs-image { + background-position: 0 0; +} + +.goog-hsv-palette-hs-handle, +.goog-hsv-palette-sm-hs-handle { + position: absolute; + left: 5px; + top: 5px; + width: 11px; + height: 11px; + overflow: hidden; + background-position: 0 -256px; +} + +.goog-hsv-palette-sm-hs-handle { + top: 40px; + background-position: 0 -128px; +} + +.goog-hsv-palette-v-image, +.goog-hsv-palette-sm-v-image { + position: absolute; + top: 10px; + left: 286px; + width: 19px; + height: 256px; + border: 1px solid #999; + background-color: #fff; + background-position: -256px 0; +} + +.goog-hsv-palette-sm-v-image { + top: 45px; + left: 155px; + width: 9px; + height: 128px; + background-position: -128px 0; +} + +.goog-hsv-palette-v-handle, +.goog-hsv-palette-sm-v-handle { + position: absolute; + top: 5px; + left: 279px; + width: 35px; + height: 11px; + background-position: -11px -256px; + overflow: hidden; +} + +.goog-hsv-palette-sm-v-handle { + top: 40px; + left: 148px; + width: 25px; + background-position: -11px -128px; +} + +.goog-hsv-palette-swatch, +.goog-hsv-palette-sm-swatch { + position: absolute; + top: 10px; + right: 10px; + width: 65px; + height: 65px; + border: 1px solid #999; + background-color: #fff; +} + +.goog-hsv-palette-sm-swatch { + top: 10px; + right: auto; + left: 10px; + width: 30px; + height: 22px; +} + +.goog-hsv-palette-input, +.goog-hsv-palette-sm-input { + position: absolute; + top: 85px; + right: 10px; + width: 65px; +} + +.goog-hsv-palette-sm-input { + top: 10px; + right: auto; + left: 50px; +} diff --git a/closure/goog/css/imagelessbutton.css b/closure/goog/css/imagelessbutton.css new file mode 100644 index 0000000000..a61825a702 --- /dev/null +++ b/closure/goog/css/imagelessbutton.css @@ -0,0 +1,163 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styling for buttons created by goog.ui.ImagelessButtonRenderer. + * + * WARNING: This file uses some ineffecient selectors and it may be + * best to avoid using this file in extremely large, or performance + * critical applications. + * + */ + +@provide 'goog.css.imagelessbutton'; + +@require './common'; + +/* Imageless button styles. */ + +/* The base element of the button. */ +.goog-imageless-button { + /* Set the background color at the outermost level. */ + background: #e3e3e3; + /* Place a top and bottom border. Do it on this outermost div so that + * it is easier to make pill buttons work properly. */ + border-color: #bbb; + border-style: solid; + border-width: 1px 0; + color: #222; /* Text content color. */ + cursor: default; + font-family: Arial, sans-serif; + line-height: 0; /* For Opera and old WebKit. */ + list-style: none; + /* Built-in margin for the component. Because of the negative margins + * used to simulate corner rounding, the effective left and right margin is + * actually only 1px. */ + margin: 2px; + outline: none; + padding: 0; + text-decoration: none; + vertical-align: middle; +} + +/* + * Pseudo-rounded corners. Works by pulling the left and right sides slightly + * outside of the parent bounding box before drawing the left and right + * borders. + */ +.goog-imageless-button-outer-box { + /* Left and right border that protrude outside the parent. */ + border-color: #bbb; + border-style: solid; + border-width: 0 1px; + /* Same as margin: 0 -1px, except works better cross browser. These are + * intended to be RTL flipped to work better in IE7. */ + left: -1px; + margin-right: -2px; +} + +/* + * A div to give the light and medium shades of the button that takes up no + * vertical space. + */ +.goog-imageless-button-top-shadow { + /* Light top color in the content. */ + background: #f9f9f9; + /* Thin medium shade. */ + border-bottom: 3px solid #eee; + /* Control height with line-height, since height: will trigger hasLayout. + * Specified in pixels, as a compromise to avoid rounding errors. */ + line-height: 9px; + /* Undo all space this takes up. */ + margin-bottom: -12px; +} + +/* Actual content area for the button. */ +.goog-imageless-button-content { + line-height: 1.5em; + padding: 0px 4px; + text-align: center; +} + + +/* Pill (collapsed border) styles. */ +.goog-imageless-button-collapse-right { + /* Draw a border on the root element to square the button off. The border + * on the outer-box element remains, but gets obscured by the next button. */ + border-right-width: 1px; + margin-right: -2px; /* Undoes the margins between the two buttons. */ +} + +.goog-imageless-button-collapse-left .goog-imageless-button-outer-box { + /* Don't bleed to the left -- keep the border self contained in the box. */ + border-left-color: #eee; + left: 0; + margin-right: -1px; /* Versus the default of -2px. */ +} + + +/* Disabled styles. */ +.goog-imageless-button-disabled, +.goog-imageless-button-disabled .goog-imageless-button-outer-box { + background: #eee; + border-color: #ccc; + color: #666; /* For text */ +} + +.goog-imageless-button-disabled .goog-imageless-button-top-shadow { + /* Just hide the shadow instead of setting individual colors. */ + visibility: hidden; +} + + +/* + * Active and checked styles. + * Identical except for text color according to GUIG. + */ +.goog-imageless-button-active, .goog-imageless-button-checked { + background: #f9f9f9; +} + +.goog-imageless-button-active .goog-imageless-button-top-shadow, +.goog-imageless-button-checked .goog-imageless-button-top-shadow { + background: #e3e3e3; +} + +.goog-imageless-button-active { + color: #000; +} + + +/* Hover styles. Higher priority to override other border styles. */ +.goog-imageless-button-hover, +.goog-imageless-button-hover .goog-imageless-button-outer-box, +.goog-imageless-button-focused, +.goog-imageless-button-focused .goog-imageless-button-outer-box { + border-color: #000; +} + + +/* IE6 hacks. This is the only place inner-box is used. */ +* html .goog-imageless-button-inner-box { + /* Give the element inline-block behavior so that the shadow appears. + * The main requirement is to give the element layout without having the side + * effect of taking up a full line. */ + display: inline; + /* Allow the shadow to show through, overriding position:relative from the + * goog-inline-block styles. */ + position: static; + zoom: 1; +} + +/* rtl:begin:ignore */ +* html .goog-imageless-button-outer-box { + /* In RTL mode, IE is off by one pixel. To fix, override the left: -1px + * (which was flipped to right) without having any effect on LTR mode + * (where IE ignores right when left is specified). */ + /* @noflip */ right: 0; +} +/* rtl:end:ignore */ diff --git a/closure/goog/css/imagelessmenubutton.css b/closure/goog/css/imagelessmenubutton.css new file mode 100644 index 0000000000..69ef7315c2 --- /dev/null +++ b/closure/goog/css/imagelessmenubutton.css @@ -0,0 +1,22 @@ +/* + * Copyright 2010 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for buttons created by goog.ui.ImagelessMenuButtonRenderer. + */ + +@provide 'goog.css.imagelessmenubutton'; + +/* Dropdown arrow style. */ +.goog-imageless-button-dropdown { + height: 16px; + width: 7px; + /* Client apps may override the URL at which they serve the sprite. */ + background: url(//ssl.gstatic.com/editor/editortoolbar.png) no-repeat -388px 0; + vertical-align: top; + margin-right: 2px; +} diff --git a/closure/goog/css/inputdatepicker.css b/closure/goog/css/inputdatepicker.css new file mode 100644 index 0000000000..58a4dac01a --- /dev/null +++ b/closure/goog/css/inputdatepicker.css @@ -0,0 +1,14 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* goog.ui.InputDatePicker */ + +@provide 'goog.css.inputdatepicker'; + +@require './popupdatepicker'; + +@import url(popupdatepicker.css); diff --git a/closure/goog/css/linkbutton.css b/closure/goog/css/linkbutton.css new file mode 100644 index 0000000000..d7f0f05a5f --- /dev/null +++ b/closure/goog/css/linkbutton.css @@ -0,0 +1,28 @@ +/* + * Copyright 2010 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styling for link buttons created by goog.ui.LinkButtonRenderer. + */ + +@provide 'goog.css.linkbutton'; + +@require './common'; + +.goog-link-button { + position: relative; + color: #00f; + text-decoration: underline; + cursor: pointer; +} + +/* State: disabled. */ +.goog-link-button-disabled { + color: #888; + text-decoration: none; + cursor: default; +} diff --git a/closure/goog/css/menu.css b/closure/goog/css/menu.css new file mode 100644 index 0000000000..da44f11cef --- /dev/null +++ b/closure/goog/css/menu.css @@ -0,0 +1,26 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for menus created by goog.ui.MenuRenderer. + */ + +@provide 'goog.css.menu'; + +.goog-menu { + background: #fff; + border-color: #ccc #666 #666 #ccc; + border-style: solid; + border-width: 1px; + cursor: default; + font: normal 13px Arial, sans-serif; + margin: 0; + outline: none; + padding: 4px 0; + position: absolute; + z-index: 20000; /* Arbitrary, but some apps depend on it... */ +} diff --git a/closure/goog/css/menubar.css b/closure/goog/css/menubar.css new file mode 100644 index 0000000000..899b2bf576 --- /dev/null +++ b/closure/goog/css/menubar.css @@ -0,0 +1,56 @@ +/* + * Copyright 2012 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * styling for goog.ui.menuBar and child buttons. + */ + +@provide 'goog.css.menubar'; + +.goog-menubar { + cursor: default; + outline: none; + position: relative; + white-space: nowrap; + background: #fff; +} + +.goog-menubar .goog-menu-button { + padding: 1px 1px; + margin: 0px 0px; + outline: none; + border: none; + background: #fff; + /* @alternate */ border: 1px solid #fff; +} + +.goog-menubar .goog-menu-button-dropdown { + display: none; +} + +.goog-menubar .goog-menu-button-outer-box { + border: none; +} + +.goog-menubar .goog-menu-button-inner-box { + border: none; +} + +.goog-menubar .goog-menu-button-hover { + background: #eee; + border: 1px solid #eee; +} + +.goog-menubar .goog-menu-button-open { + background: #fff; + border-left: 1px solid #ccc; + border-right: 1px solid #ccc; +} + +.goog-menubar .goog-menu-button-disabled { + color: #ccc; +} diff --git a/closure/goog/css/menubutton.css b/closure/goog/css/menubutton.css new file mode 100644 index 0000000000..5e0f7b3b7f --- /dev/null +++ b/closure/goog/css/menubutton.css @@ -0,0 +1,174 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for buttons created by goog.ui.MenuButtonRenderer. + */ + +@provide 'goog.css.menubutton'; + +@require './common'; + +/* State: resting. */ +.goog-menu-button { + /* Client apps may override the URL at which they serve the image. */ + background: #ddd url(//ssl.gstatic.com/editor/button-bg.png) repeat-x top left; + border: 0; + color: #000; + cursor: pointer; + list-style: none; + margin: 2px; + outline: none; + padding: 0; + text-decoration: none; + vertical-align: middle; +} + +/* Pseudo-rounded corners. */ +.goog-menu-button-outer-box, +.goog-menu-button-inner-box { + border-style: solid; + border-color: #aaa; + vertical-align: top; +} +.goog-menu-button-outer-box { + margin: 0; + border-width: 1px 0; + padding: 0; +} +.goog-menu-button-inner-box { + margin: 0 -1px; + border-width: 0 1px; + padding: 3px 4px; +} + +/* Pre-IE7 IE hack; ignored by IE7 and all non-IE browsers. */ +* html .goog-menu-button-inner-box { + /* IE6 needs to have the box shifted to make the borders line up. */ + left: -1px; +} + +/* Pre-IE7 BiDi fixes. */ +/* rtl:begin:ignore */ +* html .goog-menu-button-rtl .goog-menu-button-outer-box { + /* @noflip */ left: -1px; + /* @noflip */ right: auto; +} +* html .goog-menu-button-rtl .goog-menu-button-inner-box { + /* @noflip */ right: auto; +} +/* rtl:end:ignore */ + +/* IE7-only hack; ignored by all other browsers. */ +*:first-child+html .goog-menu-button-inner-box { + /* IE7 needs to have the box shifted to make the borders line up. */ + left: -1px; +} +/* IE7 BiDi fix. */ +/* rtl:begin:ignore */ +*:first-child+html .goog-menu-button-rtl .goog-menu-button-inner-box { + /* @noflip */ left: 1px; + /* @noflip */ right: auto; +} +/* rtl:end:ignore */ + +/* Safari-only hacks. */ +::root .goog-menu-button, +::root .goog-menu-button-outer-box, +::root .goog-menu-button-inner-box { + /* Required to make pseudo-rounded corners work on Safari. */ + line-height: 0; +} +::root .goog-menu-button-caption, +::root .goog-menu-button-dropdown { + /* Required to make pseudo-rounded corners work on Safari. */ + line-height: normal; +} + +/* State: disabled. */ +.goog-menu-button-disabled { + background-image: none !important; + opacity: 0.3; + -moz-opacity: 0.3; + filter: alpha(opacity=30); +} +.goog-menu-button-disabled .goog-menu-button-outer-box, +.goog-menu-button-disabled .goog-menu-button-inner-box, +.goog-menu-button-disabled .goog-menu-button-caption, +.goog-menu-button-disabled .goog-menu-button-dropdown { + color: #333 !important; + border-color: #999 !important; +} + +/* Pre-IE7 IE hack; ignored by IE7 and all non-IE browsers. */ +* html .goog-menu-button-disabled { + margin: 2px 1px !important; + padding: 0 1px !important; +} + +/* IE7-only hack; ignored by all other browsers. */ +*:first-child+html .goog-menu-button-disabled { + margin: 2px 1px !important; + padding: 0 1px !important; +} + +/* State: hover. */ +.goog-menu-button-hover .goog-menu-button-outer-box, +.goog-menu-button-hover .goog-menu-button-inner-box { + border-color: #9cf #69e #69e #7af !important; /* Hover border wins. */ +} + +/* State: active, open. */ +.goog-menu-button-active, +.goog-menu-button-open { + background-color: #bbb; + background-position: bottom left; +} + +/* State: focused. */ +.goog-menu-button-focused .goog-menu-button-outer-box, +.goog-menu-button-focused .goog-menu-button-inner-box { + border-color: orange; +} + +/* Caption style. */ +.goog-menu-button-caption { + padding: 0 4px 0 0; + vertical-align: top; +} + +/* Dropdown arrow style. */ +.goog-menu-button-dropdown { + height: 15px; + width: 7px; + /* Client apps may override the URL at which they serve the sprite. */ + background: url(//ssl.gstatic.com/editor/editortoolbar.png) no-repeat -388px 0; + vertical-align: top; +} + +/* Pill (collapsed border) styles. */ +/* TODO(gboyer): Remove specific menu button styles and have any button support being a menu button. */ +.goog-menu-button-collapse-right, +.goog-menu-button-collapse-right .goog-menu-button-outer-box, +.goog-menu-button-collapse-right .goog-menu-button-inner-box { + margin-right: 0; +} + +.goog-menu-button-collapse-left, +.goog-menu-button-collapse-left .goog-menu-button-outer-box, +.goog-menu-button-collapse-left .goog-menu-button-inner-box { + margin-left: 0; +} + +.goog-menu-button-collapse-left .goog-menu-button-inner-box { + border-left: 1px solid #fff; +} + +.goog-menu-button-collapse-left.goog-menu-button-checked +.goog-menu-button-inner-box { + border-left: 1px solid #ddd; +} diff --git a/closure/goog/css/menuitem.css b/closure/goog/css/menuitem.css new file mode 100644 index 0000000000..1f5c187e32 --- /dev/null +++ b/closure/goog/css/menuitem.css @@ -0,0 +1,155 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for menus created by goog.ui.MenuItemRenderer. + */ + +@provide 'goog.css.menuitem'; + +/** + * State: resting. + * + * NOTE(mleibman,chrishenry): + * The RTL support in Closure is provided via two mechanisms -- "rtl" CSS + * classes and BiDi flipping done by the CSS compiler. Closure supports RTL + * with or without the use of the CSS compiler. In order for them not + * to conflict with each other, the "rtl" CSS classes need to have the @noflip + * annotation. The non-rtl counterparts should ideally have them as well, but, + * since .goog-menuitem existed without .goog-menuitem-rtl for so long before + * being added, there is a risk of people having templates where they are not + * rendering the .goog-menuitem-rtl class when in RTL and instead rely solely + * on the BiDi flipping by the CSS compiler. That's why we're not adding the + * @noflip to .goog-menuitem. + */ +.goog-menuitem { + color: #000; + font: normal 13px Arial, sans-serif; + list-style: none; + margin: 0; + /* 28px on the left for icon or checkbox; 7em on the right for shortcut. */ + padding: 4px 7em 4px 28px; + white-space: nowrap; +} + +/* BiDi override for the resting state. */ +/* rtl:begin:ignore */ +/* @noflip */ +.goog-menuitem.goog-menuitem-rtl { + /* Flip left/right padding for BiDi. */ + padding-left: 7em; + padding-right: 28px; +} +/* rtl:end:ignore */ + +/* If a menu doesn't have checkable items or items with icons, remove padding. */ +.goog-menu-nocheckbox .goog-menuitem, +.goog-menu-noicon .goog-menuitem { + padding-left: 12px; +} + +/* + * If a menu doesn't have items with shortcuts, leave just enough room for + * submenu arrows, if they are rendered. + */ +.goog-menu-noaccel .goog-menuitem { + padding-right: 20px; +} + +.goog-menuitem-content { + color: #000; + font: normal 13px Arial, sans-serif; +} + +/* State: disabled. */ +.goog-menuitem-disabled .goog-menuitem-accel, +.goog-menuitem-disabled .goog-menuitem-content { + color: #ccc !important; +} +.goog-menuitem-disabled .goog-menuitem-icon { + opacity: 0.3; + -moz-opacity: 0.3; + filter: alpha(opacity=30); +} + +/* State: hover. */ +.goog-menuitem-highlight, +.goog-menuitem-hover { + background-color: #d6e9f8; + /* Use an explicit top and bottom border so that the selection is visible + * in high contrast mode. */ + border-color: #d6e9f8; + border-style: dotted; + border-width: 1px 0; + padding-bottom: 3px; + padding-top: 3px; +} + +/* State: selected/checked. */ +.goog-menuitem-checkbox, +.goog-menuitem-icon { + background-repeat: no-repeat; + height: 16px; + left: 6px; + position: absolute; + right: auto; + vertical-align: middle; + width: 16px; +} + +/* BiDi override for the selected/checked state. */ +/* rtl:begin:ignore */ +/* @noflip */ +.goog-menuitem-rtl .goog-menuitem-checkbox, +.goog-menuitem-rtl .goog-menuitem-icon { + /* Flip left/right positioning. */ + left: auto; + right: 6px; +} +/* rtl:end:ignore */ + +.goog-option-selected .goog-menuitem-checkbox, +.goog-option-selected .goog-menuitem-icon { + /* Client apps may override the URL at which they serve the sprite. */ + background: url(//ssl.gstatic.com/editor/editortoolbar.png) no-repeat -512px 0; +} + +/* Keyboard shortcut ("accelerator") style. */ +.goog-menuitem-accel { + color: #999; + /* Keyboard shortcuts are untranslated; always left-to-right. */ +/* rtl:begin:ignore */ + /* @noflip */ direction: ltr; +/* rtl:end:ignore */ + left: auto; + padding: 0 6px; + position: absolute; + right: 0; + text-align: right; +} + +/* BiDi override for shortcut style. */ +/* @noflip */ +/* rtl:begin:ignore */ +.goog-menuitem-rtl .goog-menuitem-accel { + /* Flip left/right positioning and text alignment. */ + left: 0; + right: auto; + text-align: left; +} +/* rtl:end:ignore */ + +/* Mnemonic styles. */ +.goog-menuitem-mnemonic-hint { + text-decoration: underline; +} + +.goog-menuitem-mnemonic-separator { + color: #999; + font-size: 12px; + padding-left: 4px; +} diff --git a/closure/goog/css/menuseparator.css b/closure/goog/css/menuseparator.css new file mode 100644 index 0000000000..47ac7421e3 --- /dev/null +++ b/closure/goog/css/menuseparator.css @@ -0,0 +1,18 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for menus created by goog.ui.MenuSeparatorRenderer. + */ + +@provide 'goog.css.menuseparator'; + +.goog-menuseparator { + border-top: 1px solid #ccc; + margin: 4px 0; + padding: 0; +} diff --git a/closure/goog/css/multitestrunner.css b/closure/goog/css/multitestrunner.css new file mode 100644 index 0000000000..65907bffc8 --- /dev/null +++ b/closure/goog/css/multitestrunner.css @@ -0,0 +1,121 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +@provide 'goog.css.multitestrunner'; + +.goog-testrunner { + background-color: #EEE; + border: 1px solid #999; + padding: 10px; + padding-bottom: 25px; +} + +.goog-testrunner-progress { + width: auto; + height: 20px; + background-color: #FFF; + border: 1px solid #999; +} + +.goog-testrunner-progress table { + width: 100%; + height: 20px; + border-collapse: collapse; +} + +.goog-testrunner-buttons { + margin-top: 7px; +} + +.goog-testrunner-buttons button { + width: 75px; +} + +.goog-testrunner-log, +.goog-testrunner-report, +.goog-testrunner-stats { + margin-top: 7px; + width: auto; + height: 400px; + background-color: #FFF; + border: 1px solid #999; + font: normal medium monospace; + padding: 5px; + overflow: auto; /* Opera doesn't support overflow-y. */ + overflow-y: scroll; + overflow-x: auto; +} + +.goog-testrunner-report div { + margin-bottom: 6px; + border-bottom: 1px solid #999; +} + +.goog-testrunner-stats table { + margin-top: 20px; + border-collapse: collapse; + border: 1px solid #EEE; +} + +.goog-testrunner-stats td, +.goog-testrunner-stats th { + padding: 2px 6px; + border: 1px solid #F0F0F0; +} + +.goog-testrunner-stats th { + font-weight: bold; +} + +.goog-testrunner-stats .center { + text-align: center; +} + +.goog-testrunner-progress-summary { + font: bold small sans-serif; +} + +.goog-testrunner iframe { + position: absolute; + left: -640px; + top: -480px; + width: 640px; + height: 480px; + margin: 0; + border: 0; + padding: 0; +} + +.goog-testrunner-report-failure { + color: #900; +} + +.goog-testrunner-reporttab, +.goog-testrunner-logtab, +.goog-testrunner-statstab { + float: left; + width: 50px; + height: 16px; + text-align: center; + font: normal small arial, helvetica, sans-serif; + color: #666; + background-color: #DDD; + border: 1px solid #999; + border-top: 0; + cursor: pointer; +} + +.goog-testrunner-reporttab, +.goog-testrunner-logtab { + border-right: 0; +} + +.goog-testrunner-activetab { + font-weight: bold; + color: #000; + background-color: #CCC; +} diff --git a/closure/goog/css/palette.css b/closure/goog/css/palette.css new file mode 100644 index 0000000000..922337920d --- /dev/null +++ b/closure/goog/css/palette.css @@ -0,0 +1,34 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for palettes created by goog.ui.PaletteRenderer. + */ + +@provide 'goog.css.palette'; + +.goog-palette { + cursor: default; + outline: none; +} + +.goog-palette-table { + border: 1px solid #666; + border-collapse: collapse; + margin: 5px; +} + +.goog-palette-cell { + border: 0; + border-right: 1px solid #666; + cursor: pointer; + height: 18px; + margin: 0; + text-align: center; + vertical-align: middle; + width: 18px; +} diff --git a/closure/goog/css/popupdatepicker.css b/closure/goog/css/popupdatepicker.css new file mode 100644 index 0000000000..2e74502aa0 --- /dev/null +++ b/closure/goog/css/popupdatepicker.css @@ -0,0 +1,19 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for a goog.ui.PopupDatePicker. + */ + +@provide 'goog.css.popupdatepicker'; + +@require './datepicker'; + +.goog-date-picker { + position: absolute; +} + diff --git a/closure/goog/css/roundedpanel.css b/closure/goog/css/roundedpanel.css new file mode 100644 index 0000000000..8730fc9119 --- /dev/null +++ b/closure/goog/css/roundedpanel.css @@ -0,0 +1,29 @@ +/* + * Copyright 2010 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styles for RoundedPanel. + */ + +@provide 'goog.css.roundedpanel'; + +.goog-roundedpanel { + position: relative; + z-index: 0; +} + +.goog-roundedpanel-background { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: -1; +} + +.goog-roundedpanel-content { +} diff --git a/closure/goog/css/roundedtab.css b/closure/goog/css/roundedtab.css new file mode 100644 index 0000000000..0f9dddefc0 --- /dev/null +++ b/closure/goog/css/roundedtab.css @@ -0,0 +1,157 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +@provide 'goog.css.roundedtab'; + +/* + * Styles used by goog.ui.RoundedTabRenderer. + */ +.goog-rounded-tab { + border: 0; + cursor: default; + padding: 0; +} + +.goog-tab-bar-top .goog-rounded-tab, +.goog-tab-bar-bottom .goog-rounded-tab { + float: left; + margin: 0 4px 0 0; +} + +.goog-tab-bar-start .goog-rounded-tab, +.goog-tab-bar-end .goog-rounded-tab { + margin: 0 0 4px 0; +} + +.goog-rounded-tab-caption { + border: 0; + color: #fff; + margin: 0; + padding: 4px 8px; +} + +.goog-rounded-tab-caption, +.goog-rounded-tab-inner-edge, +.goog-rounded-tab-outer-edge { + background: #036; + border-right: 1px solid #003; +} + +.goog-rounded-tab-inner-edge, +.goog-rounded-tab-outer-edge { + font-size: 1px; + height: 1px; + overflow: hidden; +} + +/* State: Hover */ +.goog-rounded-tab-hover .goog-rounded-tab-caption, +.goog-rounded-tab-hover .goog-rounded-tab-inner-edge, +.goog-rounded-tab-hover .goog-rounded-tab-outer-edge { + background-color: #69c; + border-right: 1px solid #369; +} + +/* State: Disabled */ +.goog-rounded-tab-disabled .goog-rounded-tab-caption, +.goog-rounded-tab-disabled .goog-rounded-tab-inner-edge, +.goog-rounded-tab-disabled .goog-rounded-tab-outer-edge { + background: #ccc; + border-right: 1px solid #ccc; +} + +/* State: Selected */ +.goog-rounded-tab-selected .goog-rounded-tab-caption, +.goog-rounded-tab-selected .goog-rounded-tab-inner-edge, +.goog-rounded-tab-selected .goog-rounded-tab-outer-edge { + background: #369 !important; /* Selected trumps hover. */ + border-right: 1px solid #036 !important; +} + + +/* + * Styles for horizontal (top or bottom) tabs. + */ +.goog-tab-bar-top .goog-rounded-tab { + vertical-align: bottom; +} + +.goog-tab-bar-bottom .goog-rounded-tab { + vertical-align: top; +} + +.goog-tab-bar-top .goog-rounded-tab-outer-edge, +.goog-tab-bar-bottom .goog-rounded-tab-outer-edge { + margin: 0 3px; +} + +.goog-tab-bar-top .goog-rounded-tab-inner-edge, +.goog-tab-bar-bottom .goog-rounded-tab-inner-edge { + margin: 0 1px; +} + + +/* + * Styles for vertical (start or end) tabs. + */ +.goog-tab-bar-start .goog-rounded-tab-table, +.goog-tab-bar-end .goog-rounded-tab-table { + width: 100%; +} + +.goog-tab-bar-start .goog-rounded-tab-inner-edge { + margin-left: 1px; +} + +.goog-tab-bar-start .goog-rounded-tab-outer-edge { + margin-left: 3px; +} + +.goog-tab-bar-end .goog-rounded-tab-inner-edge { + margin-right: 1px; +} + +.goog-tab-bar-end .goog-rounded-tab-outer-edge { + margin-right: 3px; +} + + +/* + * Overrides for start tabs. + */ +.goog-tab-bar-start .goog-rounded-tab-table, +.goog-tab-bar-end .goog-rounded-tab-table { + width: 12ex; /* TODO(attila): Make this work for variable width. */ +} + +.goog-tab-bar-start .goog-rounded-tab-caption, +.goog-tab-bar-start .goog-rounded-tab-inner-edge, +.goog-tab-bar-start .goog-rounded-tab-outer-edge { + border-left: 1px solid #003; + border-right: 0; +} + +.goog-tab-bar-start .goog-rounded-tab-hover .goog-rounded-tab-caption, +.goog-tab-bar-start .goog-rounded-tab-hover .goog-rounded-tab-inner-edge, +.goog-tab-bar-start .goog-rounded-tab-hover .goog-rounded-tab-outer-edge { + border-left: 1px solid #369 !important; + border-right: 0 !important; +} + +.goog-tab-bar-start .goog-rounded-tab-selected .goog-rounded-tab-outer-edge, +.goog-tab-bar-start .goog-rounded-tab-selected .goog-rounded-tab-inner-edge, +.goog-tab-bar-start .goog-rounded-tab-selected .goog-rounded-tab-caption { + border-left: 1px solid #036 !important; + border-right: 0 !important; +} + +.goog-tab-bar-start .goog-rounded-tab-disabled .goog-rounded-tab-outer-edge, +.goog-tab-bar-start .goog-rounded-tab-disabled .goog-rounded-tab-inner-edge, +.goog-tab-bar-start .goog-rounded-tab-disabled .goog-rounded-tab-caption { + border-left: 1px solid #ccc !important; + border-right: 0 !important; +} diff --git a/closure/goog/css/submenu.css b/closure/goog/css/submenu.css new file mode 100644 index 0000000000..a0c1725cbb --- /dev/null +++ b/closure/goog/css/submenu.css @@ -0,0 +1,41 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for menus created by goog.ui.SubMenuRenderer. + */ + +@provide 'goog.css.submenu'; + +/* State: resting. */ +/* @noflip */ +/* rtl:begin:ignore */ +.goog-submenu-arrow { + color: #000; + left: auto; + padding-right: 6px; + position: absolute; + right: 0; + text-align: right; +} +/* rtl:end:ignore */ + +/* BiDi override. */ +/* @noflip */ +/* rtl:begin:ignore */ +.goog-menuitem-rtl .goog-submenu-arrow { + text-align: left; + left: 0; + right: auto; + padding-left: 6px; +} +/* rtl:end:ignore */ + +/* State: disabled. */ +.goog-menuitem-disabled .goog-submenu-arrow { + color: #ccc; +} diff --git a/closure/goog/css/tab.css b/closure/goog/css/tab.css new file mode 100644 index 0000000000..2c3219e2df --- /dev/null +++ b/closure/goog/css/tab.css @@ -0,0 +1,104 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/** + * Styles used by goog.ui.TabRenderer. + */ + +@provide 'goog.css.tab'; + +.goog-tab { + position: relative; + padding: 4px 8px; + color: #00c; + text-decoration: underline; + cursor: default; +} + +.goog-tab-bar-top .goog-tab { + margin: 1px 4px 0 0; + border-bottom: 0; + float: left; +} + +.goog-tab-bar-top:after, +.goog-tab-bar-bottom:after { + content: " "; + display: block; + height: 0; + clear: both; + visibility: hidden; +} + +.goog-tab-bar-bottom .goog-tab { + margin: 0 4px 1px 0; + border-top: 0; + float: left; +} + +.goog-tab-bar-start .goog-tab { + margin: 0 0 4px 1px; + border-right: 0; +} + +.goog-tab-bar-end .goog-tab { + margin: 0 1px 4px 0; + border-left: 0; +} + +/* State: Hover */ +.goog-tab-hover { + background: #eee; +} + +/* State: Disabled */ +.goog-tab-disabled { + color: #666; +} + +/* State: Selected */ +.goog-tab-selected { + color: #000; + background: #fff; + text-decoration: none; + font-weight: bold; + border: 1px solid #6b90da; +} + +.goog-tab-bar-top { + padding-top: 5px !important; + padding-left: 5px !important; + border-bottom: 1px solid #6b90da !important; +} +/* + * Shift selected tabs 1px towards the contents (and compensate via margin and + * padding) to visually merge the borders of the tab with the borders of the + * content area. + */ +.goog-tab-bar-top .goog-tab-selected { + top: 1px; + margin-top: 0; + padding-bottom: 5px; +} + +.goog-tab-bar-bottom .goog-tab-selected { + top: -1px; + margin-bottom: 0; + padding-top: 5px; +} + +.goog-tab-bar-start .goog-tab-selected { + left: 1px; + margin-left: 0; + padding-right: 9px; +} + +.goog-tab-bar-end .goog-tab-selected { + left: -1px; + margin-right: 0; + padding-left: 9px; +} diff --git a/closure/goog/css/tabbar.css b/closure/goog/css/tabbar.css new file mode 100644 index 0000000000..5188973c0e --- /dev/null +++ b/closure/goog/css/tabbar.css @@ -0,0 +1,51 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Styles used by goog.ui.TabBarRenderer. + */ + +@provide 'goog.css.tabbar'; + +.goog-tab-bar { + margin: 0; + border: 0; + padding: 0; + list-style: none; + cursor: default; + outline: none; + background: #ebeff9; +} + +.goog-tab-bar-clear { + clear: both; + height: 0; + overflow: hidden; +} + +.goog-tab-bar-start { + float: left; +} + +.goog-tab-bar-end { + float: right; +} + + +/* + * IE6-only hacks to fix the gap between the floated tabs and the content. + * IE7 and later will ignore these. + */ +/* @if user.agent ie6 */ +* html .goog-tab-bar-start { + margin-right: -3px; +} + +* html .goog-tab-bar-end { + margin-left: -3px; +} +/* @endif */ diff --git a/closure/goog/css/tablesorter.css b/closure/goog/css/tablesorter.css new file mode 100644 index 0000000000..dd4dd96b50 --- /dev/null +++ b/closure/goog/css/tablesorter.css @@ -0,0 +1,14 @@ +/* + * Copyright 2008 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* Styles for goog.ui.TableSorter. */ + +@provide 'goog.css.tablesorter'; + +.goog-tablesorter-header { + cursor: pointer +} diff --git a/closure/goog/css/toolbar.css b/closure/goog/css/toolbar.css new file mode 100644 index 0000000000..517303c008 --- /dev/null +++ b/closure/goog/css/toolbar.css @@ -0,0 +1,409 @@ +/* + * Copyright 2009 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* + * Standard styling for toolbars and toolbar items. + */ + +@provide 'goog.css.toolbar'; + +@require './common'; + +/* + * Styles used by goog.ui.ToolbarRenderer. + */ + +.goog-toolbar { + /* Client apps may override the URL at which they serve the image. */ + background: #fafafa url(//ssl.gstatic.com/editor/toolbar-bg.png) repeat-x bottom left; + border-bottom: 1px solid #d5d5d5; + cursor: default; + font: normal 12px Arial, sans-serif; + margin: 0; + outline: none; + padding: 2px; + position: relative; + zoom: 1; /* The toolbar element must have layout on IE. */ +} + +/* + * Styles used by goog.ui.ToolbarButtonRenderer. + */ + +.goog-toolbar-button { + margin: 0 2px; + border: 0; + padding: 0; + font-family: Arial, sans-serif; + color: #333; + text-decoration: none; + list-style: none; + vertical-align: middle; + cursor: default; + outline: none; +} + +/* Pseudo-rounded corners. */ +.goog-toolbar-button-outer-box, +.goog-toolbar-button-inner-box { + border: 0; + vertical-align: top; +} + +.goog-toolbar-button-outer-box { + margin: 0; + padding: 1px 0; +} + +.goog-toolbar-button-inner-box { + margin: 0 -1px; + padding: 3px 4px; +} + +/* Pre-IE7 IE hack; ignored by IE7 and all non-IE browsers. */ +* html .goog-toolbar-button-inner-box { + /* IE6 needs to have the box shifted to make the borders line up. */ + left: -1px; +} + +/* Pre-IE7 BiDi fixes. */ +/* rtl:begin:ignore */ +* html .goog-toolbar-button-rtl .goog-toolbar-button-outer-box { + /* @noflip */ left: -1px; +} +* html .goog-toolbar-button-rtl .goog-toolbar-button-inner-box { + /* @noflip */ right: auto; +} +/* rtl:end:ignore */ + + +/* IE7-only hack; ignored by all other browsers. */ +*:first-child+html .goog-toolbar-button-inner-box { + /* IE7 needs to have the box shifted to make the borders line up. */ + left: -1px; +} + +/* IE7 BiDi fix. */ +/* rtl:begin:ignore */ +*:first-child+html .goog-toolbar-button-rtl .goog-toolbar-button-inner-box { + /* @noflip */ left: 1px; + /* @noflip */ right: auto; +} +/* rtl:end:ignore */ + +/* Safari-only hacks. */ +::root .goog-toolbar-button, +::root .goog-toolbar-button-outer-box { + /* Required to make pseudo-rounded corners work on Safari. */ + line-height: 0; +} + +::root .goog-toolbar-button-inner-box { + /* Required to make pseudo-rounded corners work on Safari. */ + line-height: normal; +} + +/* Disabled styles. */ +.goog-toolbar-button-disabled { + opacity: 0.3; + -moz-opacity: 0.3; + filter: alpha(opacity=30); +} + +.goog-toolbar-button-disabled .goog-toolbar-button-outer-box, +.goog-toolbar-button-disabled .goog-toolbar-button-inner-box { + /* Disabled text/border color trumps everything else. */ + color: #333 !important; + border-color: #999 !important; +} + +/* Pre-IE7 IE hack; ignored by IE7 and all non-IE browsers. */ +* html .goog-toolbar-button-disabled { + /* IE can't apply alpha to an element with a transparent background... */ + background-color: #f0f0f0; + margin: 0 1px; + padding: 0 1px; +} + +/* IE7-only hack; ignored by all other browsers. */ +*:first-child+html .goog-toolbar-button-disabled { + /* IE can't apply alpha to an element with a transparent background... */ + background-color: #f0f0f0; + margin: 0 1px; + padding: 0 1px; +} + +/* Only draw borders when in a non-default state. */ +.goog-toolbar-button-hover .goog-toolbar-button-outer-box, +.goog-toolbar-button-active .goog-toolbar-button-outer-box, +.goog-toolbar-button-checked .goog-toolbar-button-outer-box, +.goog-toolbar-button-selected .goog-toolbar-button-outer-box { + border-width: 1px 0; + border-style: solid; + padding: 0; +} + +.goog-toolbar-button-hover .goog-toolbar-button-inner-box, +.goog-toolbar-button-active .goog-toolbar-button-inner-box, +.goog-toolbar-button-checked .goog-toolbar-button-inner-box, +.goog-toolbar-button-selected .goog-toolbar-button-inner-box { + border-width: 0 1px; + border-style: solid; + padding: 3px; +} + +/* Hover styles. */ +.goog-toolbar-button-hover .goog-toolbar-button-outer-box, +.goog-toolbar-button-hover .goog-toolbar-button-inner-box { + /* Hover border style wins over active/checked/selected. */ + border-color: #a1badf !important; +} + +/* Active/checked/selected styles. */ +.goog-toolbar-button-active, +.goog-toolbar-button-checked, +.goog-toolbar-button-selected { + /* Active/checked/selected background color always wins. */ + background-color: #dde1eb !important; +} + +.goog-toolbar-button-active .goog-toolbar-button-outer-box, +.goog-toolbar-button-active .goog-toolbar-button-inner-box, +.goog-toolbar-button-checked .goog-toolbar-button-outer-box, +.goog-toolbar-button-checked .goog-toolbar-button-inner-box, +.goog-toolbar-button-selected .goog-toolbar-button-outer-box, +.goog-toolbar-button-selected .goog-toolbar-button-inner-box { + border-color: #729bd1; +} + +/* Pill (collapsed border) styles. */ +.goog-toolbar-button-collapse-right, +.goog-toolbar-button-collapse-right .goog-toolbar-button-outer-box, +.goog-toolbar-button-collapse-right .goog-toolbar-button-inner-box { + margin-right: 0; +} + +.goog-toolbar-button-collapse-left, +.goog-toolbar-button-collapse-left .goog-toolbar-button-outer-box, +.goog-toolbar-button-collapse-left .goog-toolbar-button-inner-box { + margin-left: 0; +} + +/* Pre-IE7 IE hack; ignored by IE7 and all non-IE browsers. */ +* html .goog-toolbar-button-collapse-left .goog-toolbar-button-inner-box { + left: 0; +} + +/* IE7-only hack; ignored by all other browsers. */ +*:first-child+html .goog-toolbar-button-collapse-left +.goog-toolbar-button-inner-box { + left: 0; +} + + +/* + * Styles used by goog.ui.ToolbarMenuButtonRenderer. + */ + +.goog-toolbar-menu-button { + margin: 0 2px; + border: 0; + padding: 0; + font-family: Arial, sans-serif; + color: #333; + text-decoration: none; + list-style: none; + vertical-align: middle; + cursor: default; + outline: none; +} + +/* Pseudo-rounded corners. */ +.goog-toolbar-menu-button-outer-box, +.goog-toolbar-menu-button-inner-box { + border: 0; + vertical-align: top; +} + +.goog-toolbar-menu-button-outer-box { + margin: 0; + padding: 1px 0; +} + +.goog-toolbar-menu-button-inner-box { + margin: 0 -1px; + padding: 3px 4px; +} + +/* Pre-IE7 IE hack; ignored by IE7 and all non-IE browsers. */ +* html .goog-toolbar-menu-button-inner-box { + /* IE6 needs to have the box shifted to make the borders line up. */ + left: -1px; +} + +/* Pre-IE7 BiDi fixes. */ +/* rtl:begin:ignore */ +* html .goog-toolbar-menu-button-rtl .goog-toolbar-menu-button-outer-box { + /* @noflip */ left: -1px; +} +* html .goog-toolbar-menu-button-rtl .goog-toolbar-menu-button-inner-box { + /* @noflip */ right: auto; +} +/* rtl:end:ignore */ + +/* IE7-only hack; ignored by all other browsers. */ +*:first-child+html .goog-toolbar-menu-button-inner-box { + /* IE7 needs to have the box shifted to make the borders line up. */ + left: -1px; +} + +/* IE7 BiDi fix. */ +/* rtl:begin:ignore */ +*:first-child+html .goog-toolbar-menu-button-rtl + .goog-toolbar-menu-button-inner-box { + /* @noflip */ left: 1px; + /* @noflip */ right: auto; +} +/* rtl:end:ignore */ + +/* Safari-only hacks. */ +::root .goog-toolbar-menu-button, +::root .goog-toolbar-menu-button-outer-box, +::root .goog-toolbar-menu-button-inner-box { + /* Required to make pseudo-rounded corners work on Safari. */ + line-height: 0; +} + +::root .goog-toolbar-menu-button-caption, +::root .goog-toolbar-menu-button-dropdown { + /* Required to make pseudo-rounded corners work on Safari. */ + line-height: normal; +} + +/* Disabled styles. */ +.goog-toolbar-menu-button-disabled { + opacity: 0.3; + -moz-opacity: 0.3; + filter: alpha(opacity=30); +} + +.goog-toolbar-menu-button-disabled .goog-toolbar-menu-button-outer-box, +.goog-toolbar-menu-button-disabled .goog-toolbar-menu-button-inner-box { + /* Disabled text/border color trumps everything else. */ + color: #333 !important; + border-color: #999 !important; +} + +/* Pre-IE7 IE hack; ignored by IE7 and all non-IE browsers. */ +* html .goog-toolbar-menu-button-disabled { + /* IE can't apply alpha to an element with a transparent background... */ + background-color: #f0f0f0; + margin: 0 1px; + padding: 0 1px; +} + +/* IE7-only hack; ignored by all other browsers. */ +*:first-child+html .goog-toolbar-menu-button-disabled { + /* IE can't apply alpha to an element with a transparent background... */ + background-color: #f0f0f0; + margin: 0 1px; + padding: 0 1px; +} + +/* Only draw borders when in a non-default state. */ +.goog-toolbar-menu-button-hover .goog-toolbar-menu-button-outer-box, +.goog-toolbar-menu-button-active .goog-toolbar-menu-button-outer-box, +.goog-toolbar-menu-button-open .goog-toolbar-menu-button-outer-box { + border-width: 1px 0; + border-style: solid; + padding: 0; +} + +.goog-toolbar-menu-button-hover .goog-toolbar-menu-button-inner-box, +.goog-toolbar-menu-button-active .goog-toolbar-menu-button-inner-box, +.goog-toolbar-menu-button-open .goog-toolbar-menu-button-inner-box { + border-width: 0 1px; + border-style: solid; + padding: 3px; +} + +/* Hover styles. */ +.goog-toolbar-menu-button-hover .goog-toolbar-menu-button-outer-box, +.goog-toolbar-menu-button-hover .goog-toolbar-menu-button-inner-box { + /* Hover border color trumps active/open style. */ + border-color: #a1badf !important; +} + +/* Active/open styles. */ +.goog-toolbar-menu-button-active, +.goog-toolbar-menu-button-open { + /* Active/open background color wins. */ + background-color: #dde1eb !important; +} + +.goog-toolbar-menu-button-active .goog-toolbar-menu-button-outer-box, +.goog-toolbar-menu-button-active .goog-toolbar-menu-button-inner-box, +.goog-toolbar-menu-button-open .goog-toolbar-menu-button-outer-box, +.goog-toolbar-menu-button-open .goog-toolbar-menu-button-inner-box { + border-color: #729bd1; +} + +/* Menu button caption style. */ +.goog-toolbar-menu-button-caption { + padding: 0 4px 0 0; + vertical-align: middle; +} + +/* Dropdown style. */ +.goog-toolbar-menu-button-dropdown { + width: 7px; + /* Client apps may override the URL at which they serve the sprite. */ + background: url(//ssl.gstatic.com/editor/editortoolbar.png) no-repeat -388px 0; + vertical-align: middle; +} + + +/* + * Styles used by goog.ui.ToolbarSeparatorRenderer. + */ + +.goog-toolbar-separator { + margin: 0 2px; + border-left: 1px solid #d6d6d6; + border-right: 1px solid #f7f7f7; + padding: 0; + width: 0; + text-decoration: none; + list-style: none; + outline: none; + vertical-align: middle; + line-height: normal; + font-size: 120%; + overflow: hidden; +} + + +/* + * Additional styling for toolbar select controls, which always have borders. + */ + +.goog-toolbar-select .goog-toolbar-menu-button-outer-box { + border-width: 1px 0; + border-style: solid; + padding: 0; +} + +.goog-toolbar-select .goog-toolbar-menu-button-inner-box { + border-width: 0 1px; + border-style: solid; + padding: 3px; +} + +.goog-toolbar-select .goog-toolbar-menu-button-outer-box, +.goog-toolbar-select .goog-toolbar-menu-button-inner-box { + border-color: #bfcbdf; +} diff --git a/closure/goog/css/tooltip.css b/closure/goog/css/tooltip.css new file mode 100644 index 0000000000..611288b980 --- /dev/null +++ b/closure/goog/css/tooltip.css @@ -0,0 +1,16 @@ +/* + * Copyright 2010 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +@provide 'goog.css.tooltip'; + +.goog-tooltip { + background: #ffe; + border: 1px solid #999; + border-width: 1px 2px 2px 1px; + padding: 6px; + z-index: 30000; +} diff --git a/closure/goog/css/tree.css b/closure/goog/css/tree.css new file mode 100644 index 0000000000..91bd92809d --- /dev/null +++ b/closure/goog/css/tree.css @@ -0,0 +1,144 @@ +/* + * Copyright 2007 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +@provide 'goog.css.tree'; + +/* + TODO(arv): Currently the sprite image has the height 16px. We should make the + image taller which would allow better flexibility when it comes to the height + of a tree row. +*/ + +.goog-tree-root:focus { + outline: none; +} + +.goog-tree-row { + white-space: nowrap; + font: icon; + line-height: 16px; + height: 16px; +} + +.goog-tree-row span { + overflow: hidden; + text-overflow: ellipsis; +} + +.goog-tree-children { + background-repeat: repeat-y; + background-image: url(//ssl.gstatic.com/closure/tree/I.png) !important; + background-position-y: 1px !important; /* IE only */ + font: icon; +} + +.goog-tree-children-nolines { + font: icon; +} + +.goog-tree-icon { + background-image: url(//ssl.gstatic.com/closure/tree/tree.png); +} + +.goog-tree-expand-icon { + vertical-align: middle; + height: 16px; + width: 16px; + cursor: default; +} + +.goog-tree-expand-icon-plus { + width: 19px; + background-position: 0 0; +} + +.goog-tree-expand-icon-minus { + width: 19px; + background-position: -24px 0; +} + +.goog-tree-expand-icon-tplus { + width: 19px; + background-position: -48px 0; +} + +.goog-tree-expand-icon-tminus { + width: 19px; + background-position: -72px 0; +} + +.goog-tree-expand-icon-lplus { + width: 19px; + background-position: -96px 0; +} + +.goog-tree-expand-icon-lminus { + width: 19px; + background-position: -120px 0; +} + +.goog-tree-expand-icon-t { + width: 19px; + background-position: -144px 0; +} + +.goog-tree-expand-icon-l { + width: 19px; + background-position: -168px 0; +} + +.goog-tree-expand-icon-blank { + width: 19px; + background-position: -168px -24px; +} + +.goog-tree-collapsed-folder-icon { + vertical-align: middle; + height: 16px; + width: 16px; + background-position: -0px -24px; +} + +.goog-tree-expanded-folder-icon { + vertical-align: middle; + height: 16px; + width: 16px; + background-position: -24px -24px; +} + +.goog-tree-file-icon { + vertical-align: middle; + height: 16px; + width: 16px; + background-position: -48px -24px; +} + +.goog-tree-item-label { + margin-left: 3px; + padding: 1px 2px 1px 2px; + text-decoration: none; + color: WindowText; + cursor: default; +} + +.goog-tree-item-label:hover { + text-decoration: underline; +} + +.selected .goog-tree-item-label { + background-color: ButtonFace; + color: ButtonText; +} + +.focused .selected .goog-tree-item-label { + background-color: Highlight; + color: HighlightText; +} + +.goog-tree-hide-root { + display: none; +} diff --git a/closure/goog/css/tristatemenuitem.css b/closure/goog/css/tristatemenuitem.css new file mode 100644 index 0000000000..0b4aa7b48c --- /dev/null +++ b/closure/goog/css/tristatemenuitem.css @@ -0,0 +1,43 @@ +/* + * Copyright 2007 The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* goog.ui.TriStateMenuItem */ + +@provide 'goog.css.tristatemenuitem'; + +.goog-tristatemenuitem { + padding: 2px 5px; + margin: 0; + list-style: none; +} + +.goog-tristatemenuitem-highlight { + background-color: #4279A5; + color: #FFF; +} + +.goog-tristatemenuitem-disabled { + color: #999; +} + +.goog-tristatemenuitem-checkbox { + float: left; + width: 10px; + height: 1.1em; +} + +.goog-tristatemenuitem-partially-checked { + background-image: url(//ssl.gstatic.com/closure/check-outline.gif); + background-position: 4px 50%; + background-repeat: no-repeat; +} + +.goog-tristatemenuitem-fully-checked { + background-image: url(//ssl.gstatic.com/closure/check.gif); + background-position: 4px 50%; + background-repeat: no-repeat; +} diff --git a/closure/goog/cssom/BUILD b/closure/goog/cssom/BUILD new file mode 100644 index 0000000000..437eccde0d --- /dev/null +++ b/closure/goog/cssom/BUILD @@ -0,0 +1,18 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "cssom", + srcs = ["cssom.js"], + lenient = True, + deps = [ + "//closure/goog/array", + "//closure/goog/dom", + "//closure/goog/dom:safe", + "//closure/goog/dom:tagname", + "//closure/goog/labs/useragent:browser", + ], +) diff --git a/closure/goog/cssom/cssom.js b/closure/goog/cssom/cssom.js new file mode 100644 index 0000000000..66da45c8a9 --- /dev/null +++ b/closure/goog/cssom/cssom.js @@ -0,0 +1,493 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview CSS Object Model helper functions. + * References: + * - W3C: http://dev.w3.org/csswg/cssom/ + * - MSDN: http://msdn.microsoft.com/en-us/library/ms531209(VS.85).aspx. + * TODO(user): Consider hacking page, media, etc.. to work. + * This would be pretty challenging. IE returns the text for any rule + * regardless of whether or not the media is correct or not. Firefox at + * least supports CSSRule.type to figure out if it's a media type and then + * we could do something interesting, but IE offers no way for us to tell. + */ + +goog.provide('goog.cssom'); +goog.provide('goog.cssom.CssRuleType'); + +goog.require('goog.array'); +goog.require('goog.dom'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.safe'); +goog.require('goog.labs.userAgent.browser'); + + +/** + * Enumeration of `CSSRule` types. + * @enum {number} + */ +goog.cssom.CssRuleType = { + STYLE: 1, + IMPORT: 3, + MEDIA: 4, + FONT_FACE: 5, + PAGE: 6, + NAMESPACE: 7 +}; + + +/** + * Recursively gets all CSS as text, optionally starting from a given + * StyleSheet. + * @param {(StyleSheet|StyleSheetList)=} opt_styleSheet + * @return {string} css text. + */ +goog.cssom.getAllCssText = function(opt_styleSheet) { + 'use strict'; + var styleSheet = opt_styleSheet || document.styleSheets; + return /** @type {string} */ (goog.cssom.getAllCss_(styleSheet, true)); +}; + + +/** + * Recursively gets all CSSStyleRules, optionally starting from a given + * StyleSheet. + * Note that this excludes any CSSImportRules, CSSMediaRules, etc.. + * @param {(StyleSheet|StyleSheetList)=} opt_styleSheet + * @return {!Array} A list of CSSStyleRules. + */ +goog.cssom.getAllCssStyleRules = function(opt_styleSheet) { + 'use strict'; + var styleSheet = opt_styleSheet || document.styleSheets; + return /** @type {!Array} */ ( + goog.cssom.getAllCss_(styleSheet, false)); +}; + + +/** + * Returns the CSSRules from a styleSheet. + * Worth noting here is that IE and FF differ in terms of what they will return. + * Firefox will return styleSheet.cssRules, which includes ImportRules and + * anything which implements the CSSRules interface. IE returns simply a list of + * CSSRules. + * @param {StyleSheet} styleSheet + * @throws {Error} If we cannot access the rules on a stylesheet object - this + * can happen if a stylesheet object's rules are accessed before the rules + * have been downloaded and parsed and are "ready". + * @return {CSSRuleList} An array of CSSRules or null. + * @suppress {strictMissingProperties} StyleSheet does not define cssRules + */ +goog.cssom.getCssRulesFromStyleSheet = function(styleSheet) { + 'use strict'; + var cssRuleList = null; + try { + // Select cssRules unless it isn't present. For pre-IE9 IE, use the rules + // collection instead. + // It's important to be consistent in using only the W3C or IE apis on + // IE9+ where both are present to ensure that there is no indexing + // mismatches - the collections are subtly different in what the include or + // exclude which can lead to one collection being longer than the other + // depending on the page's construction. + cssRuleList = styleSheet.cssRules /* W3C */ || styleSheet.rules /* IE */; + } catch (e) { + // This can happen if we try to access the CSSOM before it's "ready". + if (e.code == 15) { + // Firefox throws an NS_ERROR_DOM_INVALID_ACCESS_ERR error if a stylesheet + // is read before it has been fully parsed. Let the caller know which + // stylesheet failed. + e.styleSheet = styleSheet; + throw e; + } + } + return cssRuleList; +}; + + +/** + * Gets all StyleSheet objects starting from some StyleSheet. Note that we + * want to return the sheets in the order of the cascade, therefore if we + * encounter an import, we will splice that StyleSheet object in front of + * the StyleSheet that contains it in the returned array of StyleSheets. + * @param {(StyleSheet|StyleSheetList)=} opt_styleSheet A StyleSheet. + * @param {boolean=} opt_includeDisabled If true, includes disabled stylesheets, + * defaults to false. + * @return {!Array} A list of StyleSheet objects. + * @suppress {strictMissingProperties} StyleSheet does not define cssRules + */ +goog.cssom.getAllCssStyleSheets = function( + opt_styleSheet, opt_includeDisabled) { + 'use strict'; + var styleSheetsOutput = []; + var styleSheet = opt_styleSheet || document.styleSheets; + var includeDisabled = + (opt_includeDisabled !== undefined) ? opt_includeDisabled : false; + + // Imports need to go first. + if (styleSheet.imports && styleSheet.imports.length) { + for (var i = 0, n = styleSheet.imports.length; i < n; i++) { + goog.array.extend( + styleSheetsOutput, + goog.cssom.getAllCssStyleSheets(styleSheet.imports[i])); + } + + } else if (styleSheet.length) { + // In case we get a StyleSheetList object. + // http://dev.w3.org/csswg/cssom/#the-stylesheetlist + for (var i = 0, n = styleSheet.length; i < n; i++) { + goog.array.extend( + styleSheetsOutput, + goog.cssom.getAllCssStyleSheets( + /** @type {!StyleSheet} */ (styleSheet[i]))); + } + } else { + // We need to walk through rules in browsers which implement .cssRules + // to see if there are styleSheets buried in there. + // If we have a StyleSheet within CssRules. + var cssRuleList = goog.cssom.getCssRulesFromStyleSheet( + /** @type {!StyleSheet} */ (styleSheet)); + if (cssRuleList && cssRuleList.length) { + // Chrome does not evaluate cssRuleList[i] to undefined when i >=n; + // so we use a (i < n) check instead of cssRuleList[i] in the loop below + // and in other places where we iterate over a rules list. + // See issue # 5917 in Chromium. + for (var i = 0, n = cssRuleList.length, cssRule; i < n; i++) { + cssRule = cssRuleList[i]; + // There are more stylesheets to get on this object.. + if (cssRule.styleSheet) { + goog.array.extend( + styleSheetsOutput, + goog.cssom.getAllCssStyleSheets(cssRule.styleSheet)); + } + } + } + } + + // This is a StyleSheet. (IE uses .rules, W3c and Opera cssRules.) + if ((styleSheet.type || styleSheet.rules || styleSheet.cssRules) && + (!styleSheet.disabled || includeDisabled)) { + styleSheetsOutput.push(styleSheet); + } + + return styleSheetsOutput; +}; + + +/** + * Gets the cssText from a CSSRule object cross-browserly. + * @param {CSSRule} cssRule A CSSRule. + * @return {string} cssText The text for the rule, including the selector. + */ +goog.cssom.getCssTextFromCssRule = function(cssRule) { + 'use strict'; + var cssText = ''; + + // Per github.com/microsoft/ChakraCore/issues/6165, IE/Edge errors when + // referencing the cssText property in some cases. + try { + cssText = cssRule.cssText; + } catch (e) { + return ''; + } + + if (!cssText && cssRule.style && cssRule.style.cssText && + /** @type {!CSSStyleRule} */ (cssRule).selectorText) { + // IE: The spacing here is intended to make the result consistent with + // FF and Webkit. + // We also remove the special properties that we may have added in + // getAllCssStyleRules since IE includes those in the cssText. + var styleCssText = + cssRule.style.cssText + .replace(/\s*-closure-parent-stylesheet:\s*\[object\];?\s*/gi, '') + .replace(/\s*-closure-rule-index:\s*[\d]+;?\s*/gi, ''); + var thisCssText = /** @type {!CSSStyleRule} */ (cssRule).selectorText + + ' { ' + styleCssText + ' }'; + cssText = thisCssText; + } + + return cssText; +}; + + +/** + * Get the index of the CSSRule in it's StyleSheet. + * @param {CSSRule} cssRule A CSSRule. + * @param {StyleSheet=} opt_parentStyleSheet A reference to the stylesheet + * object this cssRule belongs to. + * @throws {Error} When we cannot get the parentStyleSheet. + * @return {number} The index of the CSSRule, or -1. + */ +goog.cssom.getCssRuleIndexInParentStyleSheet = function( + cssRule, opt_parentStyleSheet) { + 'use strict'; + // Look for our special style.ruleIndex property from getAllCss. + if (cssRule.style && /** @type {!Object} */ (cssRule.style)['-closure-rule-index']) { + return (/** @type {!Object} */ (cssRule.style))['-closure-rule-index']; + } + + var parentStyleSheet = + opt_parentStyleSheet || goog.cssom.getParentStyleSheet(cssRule); + + if (!parentStyleSheet) { + // We could call getAllCssStyleRules() here to get our special indexes on + // the style object, but that seems like it could be wasteful. + throw new Error('Cannot find a parentStyleSheet.'); + } + + var cssRuleList = goog.cssom.getCssRulesFromStyleSheet(parentStyleSheet); + if (cssRuleList && cssRuleList.length) { + for (var i = 0, n = cssRuleList.length, thisCssRule; i < n; i++) { + thisCssRule = cssRuleList[i]; + if (thisCssRule == cssRule) { + return i; + } + } + } + return -1; +}; + + +/** + * We do some trickery in getAllCssStyleRules that hacks this in for IE. + * If the cssRule object isn't coming from a result of that function call, this + * method will return undefined in IE. + * @param {CSSRule} cssRule The CSSRule. + * @return {StyleSheet} A styleSheet object. + */ +goog.cssom.getParentStyleSheet = function(cssRule) { + 'use strict'; + return cssRule.parentStyleSheet || + cssRule.style && + (/** @type {!Object} */ (cssRule.style))['-closure-parent-stylesheet']; +}; + + +/** + * Replace a cssRule with some cssText for a new rule. + * If the cssRule object is not one of objects returned by + * getAllCssStyleRules, then you'll need to provide both the styleSheet and + * possibly the index, since we can't infer them from the standard cssRule + * object in IE. We do some trickery in getAllCssStyleRules to hack this in. + * @param {CSSRule} cssRule A CSSRule. + * @param {string} cssText The text for the new CSSRule. + * @param {StyleSheet=} opt_parentStyleSheet A reference to the stylesheet + * object this cssRule belongs to. + * @param {number=} opt_index The index of the cssRule in its parentStylesheet. + * @throws {Error} If we cannot find a parentStyleSheet. + * @throws {Error} If we cannot find a css rule index. + */ +goog.cssom.replaceCssRule = function( + cssRule, cssText, opt_parentStyleSheet, opt_index) { + 'use strict'; + var parentStyleSheet = + opt_parentStyleSheet || goog.cssom.getParentStyleSheet(cssRule); + if (parentStyleSheet) { + var index = Number(opt_index) >= 0 ? + Number(opt_index) : + goog.cssom.getCssRuleIndexInParentStyleSheet(cssRule, parentStyleSheet); + if (index >= 0) { + goog.cssom.removeCssRule(parentStyleSheet, index); + goog.cssom.addCssRule(parentStyleSheet, cssText, index); + } else { + throw new Error('Cannot proceed without the index of the cssRule.'); + } + } else { + throw new Error('Cannot proceed without the parentStyleSheet.'); + } +}; + + +/** + * Cross browser function to add a CSSRule into a StyleSheet, optionally + * at a given index. + * @param {StyleSheet} cssStyleSheet The CSSRule's parentStyleSheet. + * @param {string} cssText The text for the new CSSRule. + * @param {number=} opt_index The index of the cssRule in its parentStylesheet. + * @throws {Error} If the css rule text appears to be ill-formatted. + * TODO(bowdidge): Inserting at index 0 fails on Firefox 2 and 3 with an + * exception warning "Node cannot be inserted at the specified point in + * the hierarchy." + */ +goog.cssom.addCssRule = function(cssStyleSheet, cssText, opt_index) { + 'use strict'; + var index = opt_index; + if (index == undefined || index < 0) { + // If no index specified, insert at the end of the current list + // of rules. + var rules = goog.cssom.getCssRulesFromStyleSheet(cssStyleSheet); + index = rules.length; + } + cssStyleSheet = /** @type {!CSSStyleSheet} */ (cssStyleSheet); + if (cssStyleSheet.insertRule) { + // W3C (including IE9+). + cssStyleSheet.insertRule(cssText, index); + + } else { + // IE, pre 9: We have to parse the cssRule text to get the selector + // separated from the style text. + // aka Everything that isn't a colon, followed by a colon, then + // the rest is the style part. + var matches = /^([^\{]+)\{([^\{]+)\}/.exec(cssText); + if (matches.length == 3) { + var selector = matches[1]; + var style = matches[2]; + cssStyleSheet.addRule(selector, style, index); + } else { + throw new Error('Your CSSRule appears to be ill-formatted.'); + } + } +}; + + +/** + * Cross browser function to remove a CSSRule in a StyleSheet at an index. + * @param {StyleSheet} cssStyleSheet The CSSRule's parentStyleSheet. + * @param {number} index The CSSRule's index in the parentStyleSheet. + */ +goog.cssom.removeCssRule = function(cssStyleSheet, index) { + 'use strict'; + cssStyleSheet = /** @type {!CSSStyleSheet} */ (cssStyleSheet); + if (cssStyleSheet.deleteRule) { + // W3C. + cssStyleSheet.deleteRule(index); + + } else { + // IE. + cssStyleSheet.removeRule(index); + } +}; + + +/** + * Appends a DOM node to HEAD containing the css text that's passed in. + * @param {string} cssText CSS to add to the end of the document. + * @param {goog.dom.DomHelper=} opt_domHelper Optional DOM helper user for + * document interactions. + * @return {!Element} The newly created STYLE element. + */ +goog.cssom.addCssText = function(cssText, opt_domHelper) { + 'use strict'; + var domHelper = opt_domHelper || goog.dom.getDomHelper(); + var document = domHelper.getDocument(); + var cssNode = domHelper.createElement(goog.dom.TagName.STYLE); + + // If a CSP nonce is present, propagate it to style blocks + const nonce = goog.dom.safe.getStyleNonce(goog.dom.getWindow(document)); + if (nonce) { + cssNode.setAttribute('nonce', nonce); + } + + cssNode.type = 'text/css'; + var head = domHelper.getElementsByTagName(goog.dom.TagName.HEAD)[0]; + + // IE requires the element to be inserted in the document before any + // style contents is added to the element. Other browsers don't + // process style content changes made after the element is attached + // to the DOM, as a performance optimization. + const isIE = goog.labs.userAgent.browser.isIE(); + if (isIE) { + head.appendChild(cssNode); + } + + if (cssNode.styleSheet) { + // IE pre-9 + cssNode.styleSheet.cssText = cssText; + } else { + // W3C including IE9+ + var cssTextNode = document.createTextNode(cssText); + cssNode.appendChild(cssTextNode); + } + + if (!isIE) { + head.appendChild(cssNode); + } + + return cssNode; +}; + + +/** + * Cross browser method to get the filename from the StyleSheet's href. + * Explorer only returns the filename in the href, while other agents return + * the full path. + * @param {!StyleSheet} styleSheet Any valid StyleSheet object with an href. + * @throws {Error} When there's no href property found. + * @return {?string} filename The filename, or null if not an external + * styleSheet. + */ +goog.cssom.getFileNameFromStyleSheet = function(styleSheet) { + 'use strict'; + var href = styleSheet.href; + + // Another IE/FF difference. IE returns an empty string, while FF and others + // return null for StyleSheets not from an external file. + if (!href) { + return null; + } + + // We need the regexp to ensure we get the filename minus any query params. + var matches = /([^\/\?]+)[^\/]*$/.exec(href); + var filename = matches[1]; + return filename; +}; + + +/** + * Recursively gets all CSS text or rules. + * @param {StyleSheet|StyleSheetList} styleSheet + * @param {boolean} isTextOutput If true, output is cssText, otherwise cssRules. + * @return {string|!Array} cssText or cssRules. + * @private + */ +goog.cssom.getAllCss_ = function(styleSheet, isTextOutput) { + 'use strict'; + var cssOut = []; + var styleSheets = goog.cssom.getAllCssStyleSheets(styleSheet); + + for (var i = 0; styleSheet = styleSheets[i]; i++) { + var cssRuleList = goog.cssom.getCssRulesFromStyleSheet(styleSheet); + + if (cssRuleList && cssRuleList.length) { + var ruleIndex = 0; + for (var j = 0, n = cssRuleList.length, cssRule; j < n; j++) { + cssRule = cssRuleList[j]; + // Gets cssText output, ignoring CSSImportRules. + if (isTextOutput && !cssRule.href) { + var res = goog.cssom.getCssTextFromCssRule(cssRule); + cssOut.push(res); + + } else if (!cssRule.href) { + // Gets cssRules output, ignoring CSSImportRules. + if (cssRule.style) { + // This is a fun little hack to get parentStyleSheet into the rule + // object for IE since it failed to implement rule.parentStyleSheet. + // We can later read this property when doing things like hunting + // for indexes in order to delete a given CSSRule. + // Unfortunately we have to use the style object to store these + // pieces of info since the rule object is read-only. + if (!cssRule.parentStyleSheet) { + (/** @type {!Object} */ (cssRule.style))[ + '-closure-parent-stylesheet'] = styleSheet; + } + + // This is a hack to help with possible removal of the rule later, + // where we just append the rule's index in its parentStyleSheet + // onto the style object as a property. + // Unfortunately we have to use the style object to store these + // pieces of info since the rule object is read-only. + (/** @type {!Object} */ (cssRule.style))['-closure-rule-index'] = + isTextOutput ? undefined : ruleIndex; + } + cssOut.push(cssRule); + } + if (!isTextOutput) { + ruleIndex++; + } + } + } + } + return isTextOutput ? cssOut.join(' ') : cssOut; +}; diff --git a/closure/goog/cssom/cssom_test.js b/closure/goog/cssom/cssom_test.js new file mode 100644 index 0000000000..a73ebd6fec --- /dev/null +++ b/closure/goog/cssom/cssom_test.js @@ -0,0 +1,311 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.cssomTest'); +goog.setTestOnly(); + +const CssRuleType = goog.require('goog.cssom.CssRuleType'); +const DomHelper = goog.require('goog.dom.DomHelper'); +const cssom = goog.require('goog.cssom'); +const testSuite = goog.require('goog.testing.testSuite'); +const userAgent = goog.require('goog.userAgent'); +const {assertIsHtmlIFrameElement} = goog.require('goog.asserts.dom'); +const {getStyleNonce} = goog.require('goog.dom.safe'); + +// Since sheet cssom_test1.css's first line is to import +// cssom_test2.css, we should get 2 before one in the string. +let cssText = '.css-link-1 { display: block; } ' + + '.css-import-2 { display: block; } ' + + '.css-import-1 { display: block; } ' + + '.css-style-1 { display: block; } ' + + '.css-style-2 { display: block; } ' + + '.css-style-3 { display: block; }'; + +const replacementCssText = '.css-repl-1 { display: block; }'; + +// We're going to toLowerCase cssText before testing, because IE returns +// CSS property names in UPPERCASE, and the function shouldn't +// "fix" the text as it would be expensive and rarely of use. +// Same goes for the trailing whitespace in IE. +// Same goes for fixing the optimized removal of trailing ; in rules. +// Also needed for Opera. +function fixCssTextForIe(cssText) { + cssText = cssText.toLowerCase().replace(/\s*$/, ''); + if (cssText.match(/[^;] \}/)) { + cssText = cssText.replace(/([^;]) \}/g, '$1; }'); + } + return cssText; +} + +// Tests the scenario where we have a known stylesheet and index. + +testSuite({ + testGetFileNameFromStyleSheet() { + // cast to create mock object. + let styleSheet = + /** @type {?} */ ({'href': 'http://foo.com/something/filename.css'}); + assertEquals('filename.css', cssom.getFileNameFromStyleSheet(styleSheet)); + + styleSheet = /** @type {?} */ ( + {'href': 'https://foo.com:123/something/filename.css'}); + assertEquals('filename.css', cssom.getFileNameFromStyleSheet(styleSheet)); + + styleSheet = /** @type {?} */ ( + {'href': 'http://foo.com/something/filename.css?bar=bas'}); + assertEquals('filename.css', cssom.getFileNameFromStyleSheet(styleSheet)); + + styleSheet = /** @type {?} */ ({'href': 'filename.css?bar=bas'}); + assertEquals('filename.css', cssom.getFileNameFromStyleSheet(styleSheet)); + + styleSheet = /** @type {?} */ ({'href': 'filename.css'}); + assertEquals('filename.css', cssom.getFileNameFromStyleSheet(styleSheet)); + }, + + testGetAllCssStyleSheets() { + // NOTE: getAllCssStyleSheets return type is wrong, it should be + // !Array rather than nullable array entries + const styleSheets = /** @type {?} */ (cssom.getAllCssStyleSheets()); + assertEquals(4, styleSheets.length); + // Makes sure they're in the right cascade order. + assertEquals( + 'cssom_test_link_1.css', + cssom.getFileNameFromStyleSheet(styleSheets[0])); + assertEquals( + 'cssom_test_import_2.css', + cssom.getFileNameFromStyleSheet(styleSheets[1])); + assertEquals( + 'cssom_test_import_1.css', + cssom.getFileNameFromStyleSheet(styleSheets[2])); + // Not an external styleSheet + assertNull(cssom.getFileNameFromStyleSheet(styleSheets[3])); + }, + + testGetAllCssText() { + const allCssText = cssom.getAllCssText(); + assertEquals(cssText, fixCssTextForIe(allCssText)); + }, + + testGetAllCssStyleRules() { + const allCssRules = cssom.getAllCssStyleRules(); + assertEquals(6, allCssRules.length); + }, + + testAddCssText() { + const newCssText = '.css-add-1 { display: block; }'; + const newCssNode = cssom.addCssText(newCssText); + + assertEquals(document.styleSheets.length, 3); + + const allCssText = cssom.getAllCssText(); + + assertEquals(`${cssText} ${newCssText}`, fixCssTextForIe(allCssText)); + + let cssRules = cssom.getAllCssStyleRules(); + assertEquals(7, cssRules.length); + + // Remove the new stylesheet now so it doesn't interfere with other + // tests. + newCssNode.parentNode.removeChild(newCssNode); + // Sanity check. + cssRules = cssom.getAllCssStyleRules(); + assertEquals(6, cssRules.length); + }, + + testAddCssTextUsesIframeNonce() { + const iframeWindow = + assertIsHtmlIFrameElement(document.getElementById('frame')) + .contentWindow; + assert(iframeWindow.document !== document); + + const newCssNode = cssom.addCssText( + '.css-add-1 { display: block; }', new DomHelper(iframeWindow.document)); + // Cannot assert on a string literal because IE11 doesn't support nonces + // whatsoever. getStyleNonce returns an empty string if nonce isn't present. + assertEquals( + getStyleNonce(iframeWindow), + newCssNode['nonce'] || newCssNode.getAttribute('nonce') || ''); + }, + + /** @suppress {missingProperties} cssRules not defined on StyleSheet */ + testAddCssRule() { + // test that addCssRule correctly adds the rule to the style + // sheet. + const styleSheets = cssom.getAllCssStyleSheets(); + const styleSheet = styleSheets[3]; + const newCssRule = '.css-addCssRule { display: block; }'; + let rules = styleSheet.rules || styleSheet.cssRules; + const origNumberOfRules = rules.length; + + cssom.addCssRule(styleSheet, newCssRule, 1); + + rules = styleSheet.rules || styleSheet.cssRules; + const newNumberOfRules = rules.length; + assertEquals(newNumberOfRules, origNumberOfRules + 1); + + // Remove the added rule so we don't mess up other tests. + cssom.removeCssRule(styleSheet, 1); + }, + + /** @suppress {missingProperties} cssRules not defined on StyleSheet */ + testAddCssRuleAtPos() { + // test that addCssRule correctly adds the rule to the style + // sheet at the specified position. + const styleSheets = cssom.getAllCssStyleSheets(); + const styleSheet = styleSheets[3]; + const newCssRule = '.css-addCssRulePos { display: block; }'; + let rules = cssom.getCssRulesFromStyleSheet(styleSheet); + const origNumberOfRules = rules.length; + + // Firefox croaks if we try to insert a CSSRule at an index that + // contains a CSSImport Rule. Since we deal only with CSSStyleRule + // objects, we find the first CSSStyleRule and return its index. + // + // NOTE(user): We could have unified the code block below for all + // browsers but IE6 horribly mangled up the stylesheet by creating + // duplicate instances of a rule when removeCssRule was invoked + // just after addCssRule with the looping construct in. This is + // perfectly fine since IE's styleSheet.rules does not contain + // references to anything but CSSStyleRules. + let pos = 0; + if (styleSheet.cssRules) { + pos = Array.prototype.findIndex.call( + rules, rule => rule.type == CssRuleType.STYLE); + } + cssom.addCssRule(styleSheet, newCssRule, pos); + + rules = cssom.getCssRulesFromStyleSheet(styleSheet); + const newNumberOfRules = rules.length; + assertEquals(newNumberOfRules, origNumberOfRules + 1); + + // Remove the added rule so we don't mess up other tests. + cssom.removeCssRule(styleSheet, pos); + + rules = cssom.getCssRulesFromStyleSheet(styleSheet); + assertEquals(origNumberOfRules, rules.length); + }, + + testAddCssRuleNoIndex() { + // How well do we handle cases where the optional index is + // not passed in? + const styleSheets = cssom.getAllCssStyleSheets(); + const styleSheet = styleSheets[3]; + let rules = cssom.getCssRulesFromStyleSheet(styleSheet); + const origNumberOfRules = rules.length; + const newCssRule = '.css-addCssRuleNoIndex { display: block; }'; + + // Try inserting the rule without specifying an index. + // Make sure we don't throw an exception, and that we added + // the entry. + cssom.addCssRule(styleSheet, newCssRule); + + rules = cssom.getCssRulesFromStyleSheet(styleSheet); + const newNumberOfRules = rules.length; + assertEquals(newNumberOfRules, origNumberOfRules + 1); + + // Remove the added rule so we don't mess up the other tests. + cssom.removeCssRule(styleSheet, newNumberOfRules - 1); + + rules = cssom.getCssRulesFromStyleSheet(styleSheet); + assertEquals(origNumberOfRules, rules.length); + }, + + testGetParentStyleSheetAfterGetAllCssStyleRules() { + const cssRules = cssom.getAllCssStyleRules(); + const cssRule = cssRules[4]; + const parentStyleSheet = cssom.getParentStyleSheet(cssRule); + const styleSheets = cssom.getAllCssStyleSheets(); + const styleSheet = styleSheets[3]; + assertEquals(styleSheet, parentStyleSheet); + }, + + /** @suppress {missingProperties} cssRules not defined on StyleSheet */ + testGetCssRuleIndexInParentStyleSheetAfterGetAllCssStyleRules() { + const cssRules = cssom.getAllCssStyleRules(); + const cssRule = cssRules[4]; + // Note here that this is correct - IE's styleSheet.rules does not + // contain references to anything but CSSStyleRules while FF and others + // include anything that inherits from the CSSRule interface. + // See http://dev.w3.org/csswg/cssom/#cssrule. + const parentStyleSheet = cssom.getParentStyleSheet(cssRule); + const ruleIndex = (parentStyleSheet.cssRules != null) ? 2 : 1; + assertEquals(ruleIndex, cssom.getCssRuleIndexInParentStyleSheet(cssRule)); + }, + + /** @suppress {missingProperties} cssRules not defined on StyleSheet */ + testGetCssRuleIndexInParentStyleSheetNonStyleRule() { + // IE's styleSheet.rules only contain CSSStyleRules. + if (!userAgent.IE) { + const styleSheets = cssom.getAllCssStyleSheets(); + const styleSheet = styleSheets[3]; + const newCssRule = '@media print { .css-nonStyle { display: block; } }'; + cssom.addCssRule(styleSheet, newCssRule); + const rules = styleSheet.rules || styleSheet.cssRules; + const cssRule = rules[rules.length - 1]; + assertEquals(CssRuleType.MEDIA, cssRule.type); + // Make sure we don't throw an exception. + cssom.getCssRuleIndexInParentStyleSheet(cssRule, styleSheet); + // Remove the added rule. + cssom.removeCssRule(styleSheet, rules.length - 1); + } + }, + + testReplaceCssRuleWithStyleSheetAndIndex() { + const styleSheets = cssom.getAllCssStyleSheets(); + const styleSheet = styleSheets[3]; + const rules = cssom.getCssRulesFromStyleSheet(styleSheet); + const index = 2; + const origCssRule = rules[index]; + const origCssText = + fixCssTextForIe(cssom.getCssTextFromCssRule(origCssRule)); + + cssom.replaceCssRule(origCssRule, replacementCssText, styleSheet, index); + + const newRules = cssom.getCssRulesFromStyleSheet(styleSheet); + const newCssRule = newRules[index]; + const newCssText = cssom.getCssTextFromCssRule(newCssRule); + assertEquals(replacementCssText, fixCssTextForIe(newCssText)); + + // Now we need to re-replace our rule, to preserve parity for the other + // tests. + cssom.replaceCssRule(newCssRule, origCssText, styleSheet, index); + const nowRules = cssom.getCssRulesFromStyleSheet(styleSheet); + const nowCssRule = nowRules[index]; + const nowCssText = cssom.getCssTextFromCssRule(nowCssRule); + assertEquals(origCssText, fixCssTextForIe(nowCssText)); + }, + + /** @suppress {missingProperties} cssRules not defined on StyleSheet */ + testReplaceCssRuleUsingGetAllCssStyleRules() { + const cssRules = cssom.getAllCssStyleRules(); + const origCssRule = cssRules[4]; + const origCssText = + fixCssTextForIe(cssom.getCssTextFromCssRule(origCssRule)); + // notice we don't pass in the stylesheet or index. + cssom.replaceCssRule(origCssRule, replacementCssText); + + const styleSheets = cssom.getAllCssStyleSheets(); + const styleSheet = styleSheets[3]; + const rules = cssom.getCssRulesFromStyleSheet(styleSheet); + const index = (styleSheet.cssRules != null) ? 2 : 1; + const cssRule = rules[index]; + const cssText = fixCssTextForIe(cssom.getCssTextFromCssRule(cssRule)); + assertEquals(replacementCssText, cssText); + + // try getting it the other way around too. + const newCssRules = cssom.getAllCssStyleRules(); + const newCssRule = newCssRules[4]; + const newCssText = fixCssTextForIe(cssom.getCssTextFromCssRule(newCssRule)); + assertEquals(replacementCssText, newCssText); + + // Now we need to re-replace our rule, to preserve parity for the other + // tests. + cssom.replaceCssRule(newCssRule, origCssText); + const nowCssRules = cssom.getAllCssStyleRules(); + const nowCssRule = nowCssRules[4]; + const nowCssText = fixCssTextForIe(cssom.getCssTextFromCssRule(nowCssRule)); + assertEquals(origCssText, nowCssText); + }, +}); diff --git a/closure/goog/cssom/cssom_test_dom.html b/closure/goog/cssom/cssom_test_dom.html new file mode 100644 index 0000000000..e3b0adad16 --- /dev/null +++ b/closure/goog/cssom/cssom_test_dom.html @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/closure/goog/demos/autocompleterichremotedata.json b/closure/goog/demos/autocompleterichremotedata.json new file mode 100644 index 0000000000..26a9ce8743 --- /dev/null +++ b/closure/goog/demos/autocompleterichremotedata.json @@ -0,0 +1,43 @@ +[ + [ + "apple", + { + "name": "Fuji", + "url": "http://www.google.com/images?q=fuji+apple" + }, + { + "name": "Gala", + "url": "http://www.google.com/images?q=gala+apple" + }, + { + "name": "Golden Delicious", + "url": "http://www.google.com/images?q=golden delicious+apple" + } + ], + [ + "citrus", + { + "name": "Lemon", + "url": "http://www.google.com/images?q=lemon+fruit" + }, + { + "name": "Orange", + "url": "http://www.google.com/images?q=orange+fruit" + } + ], + [ + "berry", + { + "name": "Strawberry", + "url": "http://www.google.com/images?q=strawberry+fruit" + }, + { + "name": "Blueberry", + "url": "http://www.google.com/images?q=blueberry+fruit" + }, + { + "name": "Blackberry", + "url": "http://www.google.com/images?q=blackberry+fruit" + } + ] +] diff --git a/closure/goog/demos/bidiinput.html b/closure/goog/demos/bidiinput.html new file mode 100644 index 0000000000..ce0eda347c --- /dev/null +++ b/closure/goog/demos/bidiinput.html @@ -0,0 +1,72 @@ + + + + + goog.ui.BidiInput + + + + + +

goog.ui.BidiInput

+ +

+ The direction of the input field changes automatically to RTL (right to left) + if the contents is in an RTL language (e.g. Hebrew or Arabic). +

+ +
+ A decorated input:  + Text: + +
+ +
+ +
+ An input created programmatically:  + Text: ! + + +
+ + +
+ +
+ A decorated textarea:  + + +
+ +
+ +
+
+ Right to left div:  + + +
+
+ + + diff --git a/closure/goog/demos/blobhasher.html b/closure/goog/demos/blobhasher.html new file mode 100644 index 0000000000..2e82f659a5 --- /dev/null +++ b/closure/goog/demos/blobhasher.html @@ -0,0 +1,60 @@ + + + + +goog.crypt.BlobHasher + + + + + +

goog.crypt.BlobHasher

+ + + +
File: + + +
MD5:
+ + + diff --git a/closure/goog/demos/bubble.html b/closure/goog/demos/bubble.html new file mode 100644 index 0000000000..a528a6a5e0 --- /dev/null +++ b/closure/goog/demos/bubble.html @@ -0,0 +1,249 @@ + + + + + + goog.ui.Bubble + + + + + + +

goog.ui.Bubble

+ + + + + + + + + + + + + + + + + + +
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
X:
Y:
Corner orientation(0-3):
Auto-hide (true or false):
Timeout (ms):
Decorated
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+ + + + + + diff --git a/closure/goog/demos/button.html b/closure/goog/demos/button.html new file mode 100644 index 0000000000..43be3a9de3 --- /dev/null +++ b/closure/goog/demos/button.html @@ -0,0 +1,395 @@ + + + + + goog.ui.Button and goog.ui.ToggleButton + + + + + + + + + + +

goog.ui.Button

+
+ + The first Button was created programmatically, + the second by decorating an <input> element:  + +
+ +
+
+
+ +
+
+
+ +

goog.ui.FlatButtonRenderer

+
+ + Buttons made with <div>'s instead of + <input>'s or <button>'s + The first rendered, the second decorated:  + +
+ +
+
+
My Flat Button

+ +
+
+
+ Combined +
+ Buttons +
+
+
+
+ +

goog.ui.LinkButtonRenderer

+
+ + Like FlatButtonRenderer, except the style makes the button appear to be a + link. + +
+ +
+ +

goog.ui.CustomButton & goog.ui.ToggleButton

+
+ + These buttons were rendered using goog.ui.CustomButton: +   + +
+ These buttons were created programmatically:
+
+
+ These buttons were created by decorating some DIVs, and they dispatch + state transition events (watch the event log):
+
+ +
+ Decorated Button, yay! +
+
Decorated Disabled
+
Another Button
+
+ Archive +
+ Delete +
+ Report Spam +
+
+
+ Use these ToggleButtons to hide/show and enable/disable + the middle button:
+
Enable
+   +
Show
+

+ Combined toggle buttons:
+
+ Bold +
+ Italics +
+ Underlined +
+
+
+ These buttons have icons, and the second one has an extra CSS class:
+
+
+ + The button with the orange + outline has keyboard focus. Hit Enter to activate focused buttons. + +
+
+
+ +
+ Event Log +
+
+ + + diff --git a/closure/goog/demos/charcounter.html b/closure/goog/demos/charcounter.html new file mode 100644 index 0000000000..cb517424e3 --- /dev/null +++ b/closure/goog/demos/charcounter.html @@ -0,0 +1,57 @@ + + + + + goog.ui.CharCounter + + + + + +

goog.ui.CharCounter

+ +

+ + character(s) remaining +

+

+ + You have entered character(s) of a maximum 160. +

+

+ + character(s) remaining + + +

+

+ + character(s) remaining +

+ + + + diff --git a/closure/goog/demos/charpicker.html b/closure/goog/demos/charpicker.html new file mode 100644 index 0000000000..326f3015e5 --- /dev/null +++ b/closure/goog/demos/charpicker.html @@ -0,0 +1,66 @@ + + + + + goog.ui.CharPicker + + + + + + + + + + + + + + + + + +

goog.ui.CharPicker

+

You have selected: none +

+ + +

+ +

+ + diff --git a/closure/goog/demos/checkbox.html b/closure/goog/demos/checkbox.html new file mode 100644 index 0000000000..87cb065340 --- /dev/null +++ b/closure/goog/demos/checkbox.html @@ -0,0 +1,121 @@ + + + + + goog.ui.Checkbox + + + + + + +

goog.ui.Checkbox

+

This is a tri-state checkbox.

+
+ Enable/disable
+
+
root
+
+
leaf 1
+
leaf 2
+
+
+
+ Created with render +
+
+
+ Created with decorate + + + + +
+

+ +
+ Event Log for 'root', 'leaf1', 'leaf2' +
+
+ + + diff --git a/closure/goog/demos/color-contrast.html b/closure/goog/demos/color-contrast.html new file mode 100644 index 0000000000..cded48ee19 --- /dev/null +++ b/closure/goog/demos/color-contrast.html @@ -0,0 +1,60 @@ + + + + + + + Color Contrast Test + + + + + +

+ # + + This text should be readable +

+

(Only choosing from black and white.)

+ + + + + diff --git a/closure/goog/demos/colormenubutton.html b/closure/goog/demos/colormenubutton.html new file mode 100644 index 0000000000..4318947731 --- /dev/null +++ b/closure/goog/demos/colormenubutton.html @@ -0,0 +1,217 @@ + + + + + goog.ui.ColorMenuButton + + + + + + + + + + + + + + + +

goog.ui.ColorMenuButton Demo

+ + + + + + + +
+
+ goog.ui.ColorMenuButton demo: +
This button was created programmatically: 
+
+
+ This button decorates a DIV:  +
Color
+
+
+
This button has a custom color menu: 
+
+
+
+
+ + goog.ui.ToolbarColorMenuButtonRenderer demo: + +
+ This toolbar button was created programmatically:  +
+
+
+ This toolbar button decorates a DIV:  +
Color
+
+
+
+ This is what these would look like in an actual toolbar, with + icons instead of text captions: +
+
+
+
+
+
+
+
+
+
+
+
+
+ BiDi is all the rage these days +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ Event Log +
+
+
+
+
+ + + diff --git a/closure/goog/demos/colorpicker.html b/closure/goog/demos/colorpicker.html new file mode 100644 index 0000000000..5081ce870b --- /dev/null +++ b/closure/goog/demos/colorpicker.html @@ -0,0 +1,39 @@ + + + + + goog.ui.ColorPicker + + + + + + +

goog.ui.ColorPicker

+ +

Simple Color Grid

+
+

+ + + + diff --git a/closure/goog/demos/combobox.html b/closure/goog/demos/combobox.html new file mode 100644 index 0000000000..60b8f59f6d --- /dev/null +++ b/closure/goog/demos/combobox.html @@ -0,0 +1,160 @@ + + + + + goog.ui.ComboBox + + + + + + + + + + +

goog.ui.ComboBox

+
cb.value = ''
+ +
+ LTR +
+
+ +
+ LTR +
+
+ +
+
+ LTR +
+
+
+ +
+ +
+ RTL +
+
+ +
+ RTL +
+
+ +
+
+ RTL +
+
+
+ + +
+ + Clear Log +
+ + + + diff --git a/closure/goog/demos/container.html b/closure/goog/demos/container.html new file mode 100644 index 0000000000..e6621c7dde --- /dev/null +++ b/closure/goog/demos/container.html @@ -0,0 +1,670 @@ + + + + + goog.ui.Container + + + + + + + + +

goog.ui.Container

+

goog.ui.Container is a base class for menus and toolbars.

+
+ These containers were created programmatically: + + + + + + + + + + + +
+ Vertical container example: + + Horizontal container example: +
+ +   + +
+ +   + +
+ +
+ +   + +
+ +   + +
+ +
+ Try enabling and disabling containers with & without + state transition events, and compare performance! +
+
+
+
+ Non-focusable container with focusable controls: + In this case, the container itself isn't focusable, but each control is:
+
+
+
+
+
+ Another horizontal container: + +
+
+
+
+
+ A decorated container: + +
+
+ Select month +
+
January
+
February
+
March
+
April
+
May
+
June
+
July
+
August
+
September
+
October
+
November
+
December
+
+
+
+ Year +
+
2001
+
2002
+
2003
+
2004
+
2005
+
2006
+
2007
+
2008
+
2009
+
2010
+
+
+
+ Toggle Button +
+
+
Fancy Toggle Button
+
+
+ Another Button +
+
+
+
+
+
+ The same container, right-to-left: + + +
+
+ Select month +
+
January
+
February
+
March
+
April
+
May
+
June
+
July
+
August
+
September
+
October
+
November
+
December
+
+
+
+ Year +
+
2001
+
2002
+
2003
+
2004
+
2005
+
2006
+
2007
+
2008
+
2009
+
2010
+
+
+
+ Toggle Button +
+
+
Fancy Toggle Button
+
+
+ Another Button +
+
+
+
+
+
+ A scrolling container: + +

+ Put focus in the text box and use the arrow keys: + +

+

+ Or quick jump to item: + + 0 1 2 3 + 4 5 6 7 + 8 9 10 11 + 12 13 14 15 + +

+
+
menuitem 0
+
menuitem 1
+
menuitem 2
+
menuitem 3
+
tog 4
+
tog 5
+
tog 6
+
toggley 7
+
toggley 8
+
toggley 9
+
toggley 10
+
toggley 11
+
toggley 12
+
toggley 13
+
menuitem 14
+
menuitem 15
+
+
+
+
+ +
+ Event Log +
+
+
+ + + diff --git a/closure/goog/demos/control.html b/closure/goog/demos/control.html new file mode 100644 index 0000000000..4273e85891 --- /dev/null +++ b/closure/goog/demos/control.html @@ -0,0 +1,477 @@ + + + + + goog.ui.Control Demo + + + + + + +

goog.ui.Control

+ + + + + + + +
+ +
+ This control was created programmatically:  +
+
+ This control dispatches ENTER, LEAVE, and ACTION events on + mouseover, mouseout, and mouseup, respectively. It supports + keyboard focus. +
+
+
+ This was created by decorating a SPAN:  + + Decorated Example + +
+ + You need to enable this component first. + +
+
+ This control is configured to dispatch state transition events in + addition to ENTER, LEAVE, and ACTION. It also supports keyboard + focus. Watch the event log as you interact with this component. +
+
+ +
+ +
+
+

Custom Renderers

+
+ + This control was created using a custom renderer:  + +
+
+
+
+ + This was created by decorating a DIV via a custom renderer:  + +
+ +   + + + Insert Picture + +
+
+
+

Extra CSS Styling

+
+ + These controls have extra CSS classes applied:  + +
+
+
+ Use the addClassName API to add additional CSS class names + to controls, before or after they're rendered or decorated. +
+
+

Right-to-Left Rendering

+
+ + These controls are rendered right-to-left:  + +

These right-to-left controls were progammatically created:

+
+

These right-to-left controls were decorated:

+
+
+ Hello, world +
+ Sample control +
+
+

+ On pre-FF3 Gecko, margin-left and margin-right are + ignored, so controls render right next to each other. + A workaround is to include some &nbsp;s in between + controls. +

+
+
+ +
+ Event Log +
+
+
+ + The control with the + orange outline + has keyboard focus. + +
+
+ + + diff --git a/closure/goog/demos/css/demo.css b/closure/goog/demos/css/demo.css new file mode 100644 index 0000000000..27f4082080 --- /dev/null +++ b/closure/goog/demos/css/demo.css @@ -0,0 +1,75 @@ +/* + * Copyright The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* Author: attila@google.com (Attila Bodis) */ + + +@import url(../../css/common.css); + + +body { + background-color: #ffe; + font: normal 10pt Arial, sans-serif; +} + + +/* Misc. styles used for logging and debugging. */ +fieldset { + padding: 4px 8px; + margin-bottom: 1em; +} + +fieldset legend { + font-weight: bold; + color: #036; +} + +label, input { + vertical-align: middle; +} + +.hint { + font-size: 90%; + color: #369; +} + +.goog-debug-panel { + border: 1px solid #369; +} + +.goog-debug-panel .logdiv { + position: relative; + width: 100%; + height: 8em; + overflow: scroll; + overflow-x: hidden; + overflow-y: scroll; +} + +.goog-debug-panel .logdiv .logmsg { + font: normal 10px "Lucida Sans Typewriter", "Courier New", Courier, fixed; +} + +.perf { + margin: 0; + border: 0; + padding: 4px; + font: italic 95% Arial, sans-serif; + color: #999; +} + +#perf { + position: absolute; + right: 0; + bottom: 0; + text-align: right; + margin: 0; + border: 0; + padding: 4px; + font: italic 95% Arial, sans-serif; + color: #999; +} diff --git a/closure/goog/demos/css/emojipicker.css b/closure/goog/demos/css/emojipicker.css new file mode 100644 index 0000000000..c7ec32a408 --- /dev/null +++ b/closure/goog/demos/css/emojipicker.css @@ -0,0 +1,36 @@ +/* + * Copyright The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* Author: dalewis@google.com (Darren Lewis) */ + +/* Styles used in the emojipicker demo */ +.goog-ui-popupemojipicker { + position: absolute; + -moz-outline: 0; + outline: 0; + visibility: hidden; +} + +.goog-palette-cell { + padding: 2px; + background: white; +} + +.goog-palette-cell div { + vertical-align: middle; + text-align: center; + margin: auto; +} + +.goog-palette-cell-wrapper { + width: 25px; + height: 25px; +} + +.goog-palette-cell-hover { + background: lightblue; +} diff --git a/closure/goog/demos/css/emojisprite.css b/closure/goog/demos/css/emojisprite.css new file mode 100644 index 0000000000..72dda288db --- /dev/null +++ b/closure/goog/demos/css/emojisprite.css @@ -0,0 +1,92 @@ +/* + * Copyright The Closure Library Authors. All Rights Reserved. + * + * Use of this source code is governed by the Apache License, Version 2.0. + * See the COPYING file for details. + */ + +/* This file is autogenerated. To regenerate, do: +cd google3/javascript/closure/demos/emoji +/home/build/static/projects/sitespeed optisprite *.gif + +This will generate an optimal sprite in /usr/local/google/tmp/bestsprite.tar.gz. + +Rename the optimal sprite to "sprite.png" (from sprite_XX.png), rename the css +to "sprite.css" (from sprite_XX.css), then change all the URLs in sprite.css to +point to sprite.png in google3/javascript/closure/demos/emoji, and cp the +sprite.png into that directory. +*/ + +.SPRITE_200{background:no-repeat url(../emoji/sprite.png) -18px 0;width:18px;height:18px} +.SPRITE_201{background:no-repeat url(../emoji/sprite.png) 0 -234px;width:18px;height:18px} +.SPRITE_202{background:no-repeat url(../emoji/sprite.png) -18px -338px;width:18px;height:18px} +.SPRITE_203{background:no-repeat url(../emoji/sprite.png) -36px 0;width:18px;height:18px} +.SPRITE_204{background:no-repeat url(../emoji/sprite.png) 0 -305px;width:18px;height:19px} +.SPRITE_205{background:no-repeat url(../emoji/sprite.png) -36px -126px;width:18px;height:18px} +.SPRITE_206{background:no-repeat url(../emoji/sprite.png) -36px -36px;width:18px;height:18px} +.SPRITE_2BC{background:no-repeat url(../emoji/sprite.png) -18px -144px;width:18px;height:18px} +.SPRITE_2BD{background:no-repeat url(../emoji/sprite.png) 0 -18px;width:18px;height:18px} +.SPRITE_2BE{background:no-repeat url(../emoji/sprite.png) -36px -54px;width:18px;height:18px} +.SPRITE_2BF{background:no-repeat url(../emoji/sprite.png) 0 -126px;width:18px;height:18px} +.SPRITE_2C0{background:no-repeat url(../emoji/sprite.png) -18px -305px;width:18px;height:18px} +.SPRITE_2C1{background:no-repeat url(../emoji/sprite.png) 0 -287px;width:18px;height:18px} +.SPRITE_2C2{background:no-repeat url(../emoji/sprite.png) -18px -126px;width:18px;height:18px} +.SPRITE_2C3{background:no-repeat url(../emoji/sprite.png) -36px -234px;width:18px;height:20px} +.SPRITE_2C4{background:no-repeat url(../emoji/sprite.png) -36px -72px;width:18px;height:18px} +.SPRITE_2C5{background:no-repeat url(../emoji/sprite.png) -54px -54px;width:18px;height:18px} +.SPRITE_2C6{background:no-repeat url(../emoji/sprite.png) 0 -72px;width:18px;height:18px} +.SPRITE_2C7{background:no-repeat url(../emoji/sprite.png) -18px -180px;width:18px;height:18px} +.SPRITE_2C8{background:no-repeat url(../emoji/sprite.png) -36px -198px;width:18px;height:18px} +.SPRITE_2C9{background:no-repeat url(../emoji/sprite.png) -36px -287px;width:18px;height:18px} +.SPRITE_2CB{background:no-repeat url(../emoji/sprite.png) -54px -252px;width:18px;height:18px} +.SPRITE_2CC{background:no-repeat url(../emoji/sprite.png) -54px -288px;width:18px;height:16px} +.SPRITE_2CD{background:no-repeat url(../emoji/sprite.png) -36px -162px;width:18px;height:18px} +.SPRITE_2CE{background:no-repeat url(../emoji/sprite.png) 0 -269px;width:18px;height:18px} +.SPRITE_2CF{background:no-repeat url(../emoji/sprite.png) -36px -108px;width:18px;height:18px} +.SPRITE_2D0{background:no-repeat url(../emoji/sprite.png) -36px -338px;width:18px;height:18px} +.SPRITE_2D1{background:no-repeat url(../emoji/sprite.png) 0 -338px;width:18px;height:18px} +.SPRITE_2D2{background:no-repeat url(../emoji/sprite.png) -54px -36px;width:18px;height:16px} +.SPRITE_2D3{background:no-repeat url(../emoji/sprite.png) -36px -305px;width:18px;height:18px} +.SPRITE_2D4{background:no-repeat url(../emoji/sprite.png) -36px -18px;width:18px;height:18px} +.SPRITE_2D5{background:no-repeat url(../emoji/sprite.png) -18px -108px;width:18px;height:18px} +.SPRITE_2D6{background:no-repeat url(../emoji/sprite.png) -36px -144px;width:18px;height:18px} +.SPRITE_2D7{background:no-repeat url(../emoji/sprite.png) 0 -36px;width:18px;height:18px} +.SPRITE_2D8{background:no-repeat url(../emoji/sprite.png) -54px -126px;width:18px;height:18px} +.SPRITE_2D9{background:no-repeat url(../emoji/sprite.png) -18px -287px;width:18px;height:18px} +.SPRITE_2DA{background:no-repeat url(../emoji/sprite.png) -54px -216px;width:18px;height:18px} +.SPRITE_2DB{background:no-repeat url(../emoji/sprite.png) -36px -180px;width:18px;height:18px} +.SPRITE_2DC{background:no-repeat url(../emoji/sprite.png) 0 -54px;width:18px;height:18px} +.SPRITE_2DD{background:no-repeat url(../emoji/sprite.png) -18px -72px;width:18px;height:18px} +.SPRITE_2DE{background:no-repeat url(../emoji/sprite.png) -36px -90px;width:18px;height:18px} +.SPRITE_2DF{background:no-repeat url(../emoji/sprite.png) -54px -108px;width:18px;height:18px} +.SPRITE_2E0{background:no-repeat url(../emoji/sprite.png) -18px -198px;width:18px;height:18px} +.SPRITE_2E1{background:no-repeat url(../emoji/sprite.png) 0 -180px;width:18px;height:18px} +.SPRITE_2E2{background:no-repeat url(../emoji/sprite.png) -54px -338px;width:18px;height:18px} +.SPRITE_2E4{background:no-repeat url(../emoji/sprite.png) -54px -198px;width:18px;height:18px} +.SPRITE_2E5{background:no-repeat url(../emoji/sprite.png) 0 -162px;width:18px;height:18px} +.SPRITE_2E6{background:no-repeat url(../emoji/sprite.png) -54px -270px;width:18px;height:18px} +.SPRITE_2E7{background:no-repeat url(../emoji/sprite.png) 0 -108px;width:18px;height:18px} +.SPRITE_2E8{background:no-repeat url(../emoji/sprite.png) 0 -198px;width:18px;height:18px} +.SPRITE_2E9{background:no-repeat url(../emoji/sprite.png) -54px 0;width:18px;height:18px} +.SPRITE_2EA{background:no-repeat url(../emoji/sprite.png) -54px -144px;width:18px;height:18px} +.SPRITE_2EB{background:no-repeat url(../emoji/sprite.png) -18px -36px;width:18px;height:18px} +.SPRITE_2EC{background:no-repeat url(../emoji/sprite.png) -18px -18px;width:18px;height:18px} +.SPRITE_2ED{background:no-repeat url(../emoji/sprite.png) -36px -269px;width:18px;height:18px} +.SPRITE_2EE{background:no-repeat url(../emoji/sprite.png) -18px -90px;width:18px;height:18px} +.SPRITE_2F0{background:no-repeat url(../emoji/sprite.png) 0 0;width:18px;height:18px} +.SPRITE_2F2{background:no-repeat url(../emoji/sprite.png) -54px -234px;width:18px;height:18px} +.SPRITE_2F3{background:no-repeat url(../emoji/sprite.png) 0 -144px;width:18px;height:18px} +.SPRITE_2F4{background:no-repeat url(../emoji/sprite.png) 0 -252px;width:18px;height:17px} +.SPRITE_2F5{background:no-repeat url(../emoji/sprite.png) -54px -321px;width:18px;height:14px} +.SPRITE_2F6{background:no-repeat url(../emoji/sprite.png) -36px -254px;width:18px;height:15px} +.SPRITE_2F7{background:no-repeat url(../emoji/sprite.png) -18px -54px;width:18px;height:18px} +.SPRITE_2F8{background:no-repeat url(../emoji/sprite.png) 0 -216px;width:18px;height:18px} +.SPRITE_2F9{background:no-repeat url(../emoji/sprite.png) -18px -234px;width:18px;height:18px} +.SPRITE_2FA{background:no-repeat url(../emoji/sprite.png) -18px -216px;width:18px;height:18px} +.SPRITE_2FB{background:no-repeat url(../emoji/sprite.png) -36px -216px;width:18px;height:18px} +.SPRITE_2FC{background:no-repeat url(../emoji/sprite.png) -54px -162px;width:18px;height:18px} +.SPRITE_2FD{background:no-repeat url(../emoji/sprite.png) 0 -90px;width:18px;height:18px} +.SPRITE_2FE{background:no-repeat url(../emoji/sprite.png) -54px -305px;width:18px;height:16px} +.SPRITE_2FF{background:no-repeat url(../emoji/sprite.png) -54px -72px;width:18px;height:16px} +.SPRITE_none{background:no-repeat url(../emoji/sprite.png) -54px -180px;width:18px;height:18px} +.SPRITE_unknown{background:no-repeat url(../emoji/sprite.png) -36px -323px;width:14px;height:15px} diff --git a/closure/goog/demos/css3button.html b/closure/goog/demos/css3button.html new file mode 100644 index 0000000000..18d6442680 --- /dev/null +++ b/closure/goog/demos/css3button.html @@ -0,0 +1,165 @@ + + + + + + goog.ui.Css3ButtonRenderer Demo + + + + + + + + +

Demo of goog.ui.Css3ButtonRenderer

+
+ + These buttons were rendered using + goog.ui.Css3ButtonRenderer: + +
+ These buttons were created programmatically:
+
+
+ These buttons were created by decorating some DIVs, and they dispatch + state transition events (watch the event log):
+
+
+ Decorated Button, yay! +
Decorated Disabled +
Another Button
+ Archive +
+ Delete +
+ Report Spam +
+
+
+ Use these ToggleButtons to hide/show and enable/disable + the middle button:
+
Enable
+
Show
+

+ Combined toggle buttons
+
+ Bold +
+ Italics +
+ Underlined +
+
+
+
+ +
+ Event Log +
+
+ + + + diff --git a/closure/goog/demos/css3menubutton.html b/closure/goog/demos/css3menubutton.html new file mode 100644 index 0000000000..c151ac0036 --- /dev/null +++ b/closure/goog/demos/css3menubutton.html @@ -0,0 +1,284 @@ + + + + + goog.ui.Css3MenuButtonRenderer Demo + + + + + + + + + + + + +

goog.ui.Css3MenuButtonRenderer

+ + + + + + + +
+
+ + These MenuButtons were created programmatically: +   + + + + + + + + +
+ + + Enable first button: + +   + Show second button: + +   +
+ +
+
+
+ + This MenuButton decorates an element:  + + + + + + + + +
+
+ +
+ Format + +
+
Bold
+
Italic
+
Underline
+
+
+ Strikethrough +
+
+
Font...
+
Color...
+
+
+
+ Enable button: + +   + Show button: + +   +
+ +
+
+
+ +
+ Event Log +
+
+
+
+
+ + + diff --git a/closure/goog/demos/cssspriteanimation.html b/closure/goog/demos/cssspriteanimation.html new file mode 100644 index 0000000000..f1b2a9d66a --- /dev/null +++ b/closure/goog/demos/cssspriteanimation.html @@ -0,0 +1,80 @@ + + + + +CssSpriteAnimation demo + + + + + + +

The following just runs and runs...

+
+ +

The animation is just an ordinary animation so you can pause it etc. +

+ +

+ + +

+ +

The animation can be played once by stopping it after it finishes for the +first time. + +

+ + + + + diff --git a/closure/goog/demos/datepicker.html b/closure/goog/demos/datepicker.html new file mode 100644 index 0000000000..a49286d726 --- /dev/null +++ b/closure/goog/demos/datepicker.html @@ -0,0 +1,311 @@ + + + + + + goog.ui.DatePicker + + + + + + +

goog.ui.DatePicker

+ + + +
+

Default: ISO 8601

+
+
 
+ +

+

Custom

+
+
+
+
+
+
+
+
+
+
+
+
+
 
+ +
+ +

English (US)

+
+
 
+ +

+ +

German

+
+
 
+ +

+ +

Malayalam

+
+
 
+ +

+ +
+ +

Arabic (Yemen)

+
+
 
+ +

+ +

Thai

+
+
 
+ +

+ +

Japanese

+
+
 
+ +

+ +
+ + + + diff --git a/closure/goog/demos/debug.html b/closure/goog/demos/debug.html new file mode 100644 index 0000000000..7ac0c1503a --- /dev/null +++ b/closure/goog/demos/debug.html @@ -0,0 +1,118 @@ + + + + +Debug + + + + +Look in the log window for debugging examples. + + + diff --git a/closure/goog/demos/depsgraph.html b/closure/goog/demos/depsgraph.html new file mode 100644 index 0000000000..1263bb5782 --- /dev/null +++ b/closure/goog/demos/depsgraph.html @@ -0,0 +1,220 @@ + + + + +Deps Tree + + + + + +

Closure Dependency Graph

+ + + +
selected item
+
...is in same file as the selected item
+
...is a dependency of the selected item
+
the selected item is a dependency of...
+ + diff --git a/closure/goog/demos/dialog.html b/closure/goog/demos/dialog.html new file mode 100644 index 0000000000..2b9e5ccda9 --- /dev/null +++ b/closure/goog/demos/dialog.html @@ -0,0 +1,154 @@ + + + + + goog.ui.Dialog + + + + + + + + +

goog.ui.Dialog

+
+ + (use "Space" to open dialog with no Iframe, "Enter" to open with Iframe + mask +
+
+ +
+ +
+ + + +
+ A sample web page +

+ A World Beyond AJAX: Accessing Google's APIs from Flash and + Non-JavaScript Environments +

+ Vadim Spivak (Google) + +

+ AJAX isn't the only way to access Google APIs. Learn how to use Google's + services from Flash and other non-JavaScript programming environments. + We'll show you how easy it is to augment your site with dynamic search + and feed data from non-JavaScript environments. +

+ +

+ Participants should be familiar with general web application + development. +

+ +

Select Element: + +

+ +

+ + + + + + +

+
+ + + diff --git a/closure/goog/demos/dimensionpicker.html b/closure/goog/demos/dimensionpicker.html new file mode 100644 index 0000000000..4a20f1cf4f --- /dev/null +++ b/closure/goog/demos/dimensionpicker.html @@ -0,0 +1,103 @@ + + + + + goog.ui.DimensionPicker + + + + + + + +

goog.ui.DimensionPicker

+ + + + + + + +
+
+ Demo of the goog.ui.DimensionPicker + component: + +
+ +
+ +
+
+
+
+
+ +
+ Event Log +
+
+
+
+
+ + + diff --git a/closure/goog/demos/dimensionpicker_rtl.html b/closure/goog/demos/dimensionpicker_rtl.html new file mode 100644 index 0000000000..cc288132b9 --- /dev/null +++ b/closure/goog/demos/dimensionpicker_rtl.html @@ -0,0 +1,118 @@ + + + + + goog.ui.DimensionPicker + + + + + + + + + +

goog.ui.DimensionPicker

+ + + + + + + +
+ +
+ Event Log +
+
+
+
+ Demo of the goog.ui.DimensionPicker + component: + +
+

+ +
+ +
+
+
+
+
+
+
+ + + diff --git a/closure/goog/demos/dom_selection.html b/closure/goog/demos/dom_selection.html new file mode 100644 index 0000000000..b23084508d --- /dev/null +++ b/closure/goog/demos/dom_selection.html @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + diff --git a/closure/goog/demos/drag.html b/closure/goog/demos/drag.html new file mode 100644 index 0000000000..48a2bb2026 --- /dev/null +++ b/closure/goog/demos/drag.html @@ -0,0 +1,191 @@ + + + + goog.fx.Dragger + + + + + + + +

goog.fx.Dragger

+

Demonstrations of the drag utiities.

+ +
+ +
Drag Me...
+
Drag Me...
+
Drag Me...
+ +
+
0
+ + + + + + diff --git a/closure/goog/demos/dragdrop.html b/closure/goog/demos/dragdrop.html new file mode 100644 index 0000000000..6c18b496bd --- /dev/null +++ b/closure/goog/demos/dragdrop.html @@ -0,0 +1,264 @@ + + + + + goog.fx.DragDrop + + + + + + + +

goog.fx.DragDrop

+ +List 1 (combined source/target, can be dropped on list 1, list 2, button 1 or +button 2). +
    +
  • Item 1.1
  • +
  • Item 1.2
  • +
  • Item 1.3
  • +
  • Item 1.4
  • +
  • Item 1.5
  • +
  • Item 1.6
  • +
  • Item 1.7
  • +
  • Item 1.8
  • +
  • Item 1.9
  • +
  • Item 1.10
  • +
  • Item 1.11
  • +
  • Item 1.12
  • +
  • Item 1.13
  • +
  • Item 1.14
  • +
  • Item 1.15
  • +
+ +List 2 (source only, can be dropped on list 1 or button 2) +
    +
  • Item 2.1
  • +
  • Item 2.2
  • +
  • Item 2.3
  • +
  • Item 2.4
  • +
  • Item 2.5
  • +
  • Item 2.6
  • +
  • Item 2.7
  • +
  • Item 2.8
  • +
  • Item 2.9
  • +
  • Item 2.10
  • +
  • Item 2.11
  • +
  • Item 2.12
  • +
  • Item 2.13
  • +
  • Item 2.14
  • +
  • Item 2.15
  • +
+ +
+ Button 1 (combined source/target, can be dropped on list 1) +
+ +
+ Button 2 (target only) +
+ + + + + diff --git a/closure/goog/demos/dragdropdetector.html b/closure/goog/demos/dragdropdetector.html new file mode 100644 index 0000000000..c961e0dc29 --- /dev/null +++ b/closure/goog/demos/dragdropdetector.html @@ -0,0 +1,46 @@ + + + + + goog.ui.DragDropDetector + + + + + + + +

goog.ui.DragDropDetector

+

Try dropping images from other web pages on this page.

+ + + diff --git a/closure/goog/demos/dragdropdetector_target.html b/closure/goog/demos/dragdropdetector_target.html new file mode 100644 index 0000000000..06715f788a --- /dev/null +++ b/closure/goog/demos/dragdropdetector_target.html @@ -0,0 +1,17 @@ + + + + + + + diff --git a/closure/goog/demos/dragger.html b/closure/goog/demos/dragger.html new file mode 100644 index 0000000000..380d62efc1 --- /dev/null +++ b/closure/goog/demos/dragger.html @@ -0,0 +1,83 @@ + + + + +goog.fx.Dragger + + + + + + + +

goog.fx.Dragger

+ +

This demo shows how to use a dragger to capture mouse move events. It tests +that you can drag things outside the window and that alerts ends the dragging. + +

Drag me

+
Status
+ +
Drag over me to generate an +alert
+ + + + diff --git a/closure/goog/demos/draglistgroup.html b/closure/goog/demos/draglistgroup.html new file mode 100644 index 0000000000..7bce8b9520 --- /dev/null +++ b/closure/goog/demos/draglistgroup.html @@ -0,0 +1,273 @@ + + + + + goog.fx.DragListGroup + + + + + + + + +

goog.fx.DragListGroup

+

You can drag any squares into any of the first 4 lists.

+
+ +

Horizontal list 1 (grows right):

+
+
1
+
2
+
3
+
4
+
+
+ + + + + + + + + +
+

Vertical list 1:

+
+
1
+
2
+
3
+
4
+
+
+

Vertical list 2 (style changes on drag hover):

+
+
1
+
2
+
3
+
4
+
+
+
+

Horizontal list 3 (grows left):

+

Bug: drop position is off by one.

+
+
1
+
2
+
3
+
4
+
+
+ +

Horizontal list 5 (grows right, has multiple rows, hysteresis is enabled):

+

Bug: can't drop into the last row.

+
+
11
+
22
+
33
+
44
+
55
+
66
+
77
+
88
+
99
+
+
+ +

The items in this list can be moved around with shift-dragging:

+
+
1
+
2
+
3
+
4
+
+
+ +

The items have different width:

+

+ Bug: the drop positions are off. + For example try moving box 1 a bit to the left. +

+
+
1
+
2
+
3
+
4
+
+
+ + + diff --git a/closure/goog/demos/dragscrollsupport.html b/closure/goog/demos/dragscrollsupport.html new file mode 100644 index 0000000000..a78fbbf6c7 --- /dev/null +++ b/closure/goog/demos/dragscrollsupport.html @@ -0,0 +1,133 @@ + + + + + goog.fx.DragScrollSupport + + + + + + + +

goog.fx.DragScrollSupport

+ +List 1 in a scrollable area. +
+
    +
  • Item 1.1 ----------
  • +
  • Item 1.2 ----------
  • +
  • Item 1.3 ----------
  • +
  • Item 1.4 ----------
  • +
  • Item 1.5 ----------
  • +
  • Item 1.6 ----------
  • +
  • Item 1.7 ----------
  • +
  • Item 1.8 ----------
  • +
  • Item 1.9 ----------
  • +
  • Item 1.10 ----------
  • +
  • Item 1.11 ----------
  • +
  • Item 1.12 ----------
  • +
  • Item 1.13 ----------
  • +
  • Item 1.14 ----------
  • +
  • Item 1.15 ----------
  • +
+
+ + + + diff --git a/closure/goog/demos/drilldownrow.html b/closure/goog/demos/drilldownrow.html new file mode 100644 index 0000000000..c64ca927db --- /dev/null +++ b/closure/goog/demos/drilldownrow.html @@ -0,0 +1,78 @@ + + + + +Demo of DrilldownRow + + + + + + + + + + + + + + + + +
Column HeadSecond Head
First rowSecond column
+ + + + diff --git a/closure/goog/demos/editor/editor.html b/closure/goog/demos/editor/editor.html new file mode 100644 index 0000000000..76cd027bcb --- /dev/null +++ b/closure/goog/demos/editor/editor.html @@ -0,0 +1,137 @@ + + + + + goog.editor Demo + + + + + + + + + + + + + + + + + + + + + + + + + + + +

goog.editor Demo

+

This is a demonstration of a editable field, with installed plugins, +hooked up to a toolbar.

+
+
+
+
+

Current field contents + (updates as contents of the editable field above change):
+
+ + (Use to set contents of the editable field to the contents of this textarea) +

+ + + + diff --git a/closure/goog/demos/editor/field_basic.html b/closure/goog/demos/editor/field_basic.html new file mode 100644 index 0000000000..a2da12ee70 --- /dev/null +++ b/closure/goog/demos/editor/field_basic.html @@ -0,0 +1,74 @@ + + + + + + goog.editor.Field + + + + + + +

goog.editor.Field

+

This is a very basic demonstration of how to make a region editable.

+ + +

+
I am a regular div. Click "Make Editable" above to transform me into an editable region.
+
+

Current field contents + (updates as contents of the editable field above change):
+
+ + (Use to set contents of the editable field to the contents of this textarea) +

+ + + + diff --git a/closure/goog/demos/editor/helloworld.html b/closure/goog/demos/editor/helloworld.html new file mode 100644 index 0000000000..1ee9a07aa6 --- /dev/null +++ b/closure/goog/demos/editor/helloworld.html @@ -0,0 +1,91 @@ + + + + + + + goog.editor Hello World plugins Demo + + + + + + + + +

goog.editor Hello World plugins Demo

+

This is a demonstration of an editable field with the two sample plugins + installed: goog.editor.plugins.HelloWorld and + goog.editor.plugins.HelloWorldDialogPlugin.

+
+ +
+
    +
  • Click Hello World to insert "Hello World!".
  • +
  • Click Hello World Dialog to open a dialog where you can customize + your hello world message to be inserted.
  • +
The hello world message will be inserted at the cursor, or will replace + the selected text.
+
+

Current field contents + (updates as contents of the editable field above change):
+
+ + (Use to set contents of the editable field to the contents of this textarea) +

+ + + + diff --git a/closure/goog/demos/editor/helloworld.js b/closure/goog/demos/editor/helloworld.js new file mode 100644 index 0000000000..afdb7dcc8d --- /dev/null +++ b/closure/goog/demos/editor/helloworld.js @@ -0,0 +1,69 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A simple plugin that inserts 'Hello World!' on command. This + * plugin is intended to be an example of a very simple plugin for plugin + * developers. + * + * @see helloworld.html + */ + +goog.provide('goog.demos.editor.HelloWorld'); + +goog.require('goog.dom'); +goog.require('goog.dom.TagName'); +goog.require('goog.editor.Plugin'); + + + +/** + * Plugin to insert 'Hello World!' into an editable field. + * @final + * @unrestricted + */ +goog.demos.editor.HelloWorld = class extends goog.editor.Plugin { + constructor() { + super(); + } + + /** @override */ + getTrogClassId() { + return 'HelloWorld'; + } + + /** @override */ + isSupportedCommand(command) { + return command == goog.demos.editor.HelloWorld.COMMAND.HELLO_WORLD; + } + + /** + * Executes a command. Does not fire any BEFORECHANGE, CHANGE, or + * SELECTIONCHANGE events (these are handled by the super class implementation + * of `execCommand`. + * @param {string} command Command to execute. + * @override + * @protected + */ + execCommandInternal(command) { + const domHelper = this.getFieldObject().getEditableDomHelper(); + const range = this.getFieldObject().getRange(); + range.removeContents(); + const newNode = + domHelper.createDom(goog.dom.TagName.SPAN, null, 'Hello World!'); + range.insertNode(newNode, false); + } +}; + + + +/** + * Commands implemented by this plugin. + * @enum {string} + */ +goog.demos.editor.HelloWorld.COMMAND = { + HELLO_WORLD: '+helloWorld' +}; diff --git a/closure/goog/demos/editor/helloworld_test.js b/closure/goog/demos/editor/helloworld_test.js new file mode 100644 index 0000000000..86125eb787 --- /dev/null +++ b/closure/goog/demos/editor/helloworld_test.js @@ -0,0 +1,67 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.demos.editor.HelloWorldTest'); +goog.setTestOnly('goog.demos.editor.HelloWorldTest'); + +const FieldMock = goog.require('goog.testing.editor.FieldMock'); +const HelloWorld = goog.require('goog.demos.editor.HelloWorld'); +const TestHelper = goog.require('goog.testing.editor.TestHelper'); +const googDom = goog.require('goog.dom'); +const googUserAgent = goog.require('goog.userAgent'); +const testSuite = goog.require('goog.testing.testSuite'); + +let FIELD; +let plugin; +let fieldMock; +let testHelper; + +testSuite({ + setUpPage() { + FIELD = googDom.getElement('field'); + testHelper = new TestHelper(FIELD); + }, + + setUp() { + testHelper.setUpEditableElement(); + FIELD.focus(); + plugin = new HelloWorld(); + fieldMock = /** @type {?} */ (new FieldMock()); + plugin.registerFieldObject(fieldMock); + }, + + tearDown() { + testHelper.tearDownEditableElement(); + }, + + testIsSupportedCommand() { + fieldMock.$replay(); + assertTrue( + '+helloWorld should be suported', + plugin.isSupportedCommand('+helloWorld')); + assertFalse( + 'other commands should not be supported', + plugin.isSupportedCommand('blah')); + fieldMock.$verify(); + }, + + testExecCommandInternal() { + // fails on Firefox + if (googUserAgent.GECKO) { + return; + } + + fieldMock.$replay(); + /** @suppress {visibility} suppression added to enable type checking */ + const result = plugin.execCommandInternal(HelloWorld.COMMAND.HELLO_WORLD); + assertUndefined(result); + const spans = FIELD.getElementsByTagName('span'); + assertEquals(1, spans.length); + const helloWorldSpan = spans.item(0); + assertEquals('Hello World!', googDom.getTextContent(helloWorldSpan)); + fieldMock.$verify(); + }, +}); diff --git a/closure/goog/demos/editor/helloworld_test_dom.html b/closure/goog/demos/editor/helloworld_test_dom.html new file mode 100644 index 0000000000..a25b1c99fd --- /dev/null +++ b/closure/goog/demos/editor/helloworld_test_dom.html @@ -0,0 +1,8 @@ + +
+
\ No newline at end of file diff --git a/closure/goog/demos/editor/helloworlddialog.js b/closure/goog/demos/editor/helloworlddialog.js new file mode 100644 index 0000000000..4d9e3825c6 --- /dev/null +++ b/closure/goog/demos/editor/helloworlddialog.js @@ -0,0 +1,180 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview An example of how to write a dialog to be opened by a plugin. + */ + + +// TODO(user): We're trying to migrate all ES5 subclasses of Closure +// Library to ES6. In ES6 this cannot be referenced before super is called. This +// file has at least one this before a super call (in ES5) and cannot be +// automatically upgraded to ES6 as a result. Please fix this if you have a +// chance. Note: This can sometimes be caused by not calling the super +// constructor at all. You can run the conversion tool yourself to see what it +// does on this file: blaze run //javascript/refactoring/es6_classes:convert. + +goog.provide('goog.demos.editor.HelloWorldDialog'); +goog.provide('goog.demos.editor.HelloWorldDialog.OkEvent'); + +goog.require('goog.dom.TagName'); +goog.require('goog.events.Event'); +goog.require('goog.string'); +goog.require('goog.ui.editor.AbstractDialog'); +goog.requireType('goog.dom.DomHelper'); + + +// *** Public interface ***************************************************** // + + + +/** + * Creates a dialog to let the user enter a customized hello world message. + * @param {goog.dom.DomHelper} domHelper DomHelper to be used to create the + * dialog's dom structure. + * @constructor + * @extends {goog.ui.editor.AbstractDialog} + * @final + */ +goog.demos.editor.HelloWorldDialog = function(domHelper) { + 'use strict'; + goog.ui.editor.AbstractDialog.call(this, domHelper); +}; +goog.inherits( + goog.demos.editor.HelloWorldDialog, goog.ui.editor.AbstractDialog); + + +// *** Event **************************************************************** // + + + +/** + * OK event object for the hello world dialog. + * @param {string} message Customized hello world message chosen by the user. + * @constructor + * @extends {goog.events.Event} + * @final + */ +goog.demos.editor.HelloWorldDialog.OkEvent = function(message) { + 'use strict'; + this.message = message; +}; +goog.inherits(goog.demos.editor.HelloWorldDialog.OkEvent, goog.events.Event); + + +/** + * Event type. + * @type {goog.ui.editor.AbstractDialog.EventType} + * @override + */ +goog.demos.editor.HelloWorldDialog.OkEvent.prototype.type = + goog.ui.editor.AbstractDialog.EventType.OK; + + +/** + * Customized hello world message chosen by the user. + * @type {string} + */ +goog.demos.editor.HelloWorldDialog.OkEvent.prototype.message; + + +// *** Protected interface ************************************************** // + + +/** @override */ +goog.demos.editor.HelloWorldDialog.prototype.createDialogControl = function() { + 'use strict'; + const builder = new goog.ui.editor.AbstractDialog.Builder(this); + /** @desc Title of the hello world dialog. */ + const MSG_HELLO_WORLD_DIALOG_TITLE = goog.getMsg('Add a Hello World message'); + builder.setTitle(MSG_HELLO_WORLD_DIALOG_TITLE) + .setContent(this.createContent_()); + return builder.build(); +}; + + +/** + * Creates and returns the event object to be used when dispatching the OK + * event to listeners, or returns null to prevent the dialog from closing. + * @param {goog.events.Event} e The event object dispatched by the wrapped + * dialog. + * @return {goog.demos.editor.HelloWorldDialog.OkEvent} The event object to be + * used when dispatching the OK event to listeners. + * @protected + * @override + */ +goog.demos.editor.HelloWorldDialog.prototype.createOkEvent = function(e) { + 'use strict'; + const message = this.getMessage_(); + if (message && + goog.demos.editor.HelloWorldDialog.isValidHelloWorld_(message)) { + return new goog.demos.editor.HelloWorldDialog.OkEvent(message); + } else { + /** @desc Error message telling the user why their message was rejected. */ + const MSG_HELLO_WORLD_DIALOG_ERROR = + goog.getMsg('Your message must contain the words "hello" and "world".'); + this.dom.getWindow().alert(MSG_HELLO_WORLD_DIALOG_ERROR); + return null; // Prevents the dialog from closing. + } +}; + + +// *** Private implementation *********************************************** // + + +/** + * Input element where the user will type their hello world message. + * @type {HTMLInputElement} + * @private + */ +goog.demos.editor.HelloWorldDialog.prototype.input_; + + +/** + * Creates the DOM structure that makes up the dialog's content area. + * @return {!Element} The DOM structure that makes up the dialog's content area. + * @private + */ +goog.demos.editor.HelloWorldDialog.prototype.createContent_ = function() { + 'use strict'; + /** @desc Sample hello world message to prepopulate the dialog with. */ + const MSG_HELLO_WORLD_DIALOG_SAMPLE = goog.getMsg('Hello, world!'); + this.input_ = this.dom.createDom( + goog.dom.TagName.INPUT, {size: 25, value: MSG_HELLO_WORLD_DIALOG_SAMPLE}); + /** @desc Prompt telling the user to enter a hello world message. */ + const MSG_HELLO_WORLD_DIALOG_PROMPT = + goog.getMsg('Enter your Hello World message'); + return this.dom.createDom( + goog.dom.TagName.DIV, null, [MSG_HELLO_WORLD_DIALOG_PROMPT, this.input_]); +}; + + +/** + * Returns the hello world message currently typed into the dialog's input. + * @return {?string} The hello world message currently typed into the dialog's + * input, or null if called before the input is created. + * @private + */ +goog.demos.editor.HelloWorldDialog.prototype.getMessage_ = function() { + 'use strict'; + return this.input_ && this.input_.value; +}; + + +/** + * Returns whether or not the given message contains the strings "hello" and + * "world". Case-insensitive and order doesn't matter. + * @param {string} message The message to be checked. + * @return {boolean} Whether or not the given message contains the strings + * "hello" and "world". + * @private + */ +goog.demos.editor.HelloWorldDialog.isValidHelloWorld_ = function(message) { + 'use strict'; + message = message.toLowerCase(); + return goog.string.contains(message, 'hello') && + goog.string.contains(message, 'world'); +}; diff --git a/closure/goog/demos/editor/helloworlddialog_test.js b/closure/goog/demos/editor/helloworlddialog_test.js new file mode 100644 index 0000000000..37649b9a0b --- /dev/null +++ b/closure/goog/demos/editor/helloworlddialog_test.js @@ -0,0 +1,86 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.demos.editor.HelloWorldDialogTest'); +goog.setTestOnly('goog.demos.editor.HelloWorldDialogTest'); + +const ArgumentMatcher = goog.require('goog.testing.mockmatchers.ArgumentMatcher'); +const DomHelper = goog.require('goog.dom.DomHelper'); +const EventHandler = goog.require('goog.events.EventHandler'); +const EventType = goog.require('goog.ui.editor.AbstractDialog.EventType'); +const HelloWorldDialog = goog.require('goog.demos.editor.HelloWorldDialog'); +const LooseMock = goog.require('goog.testing.LooseMock'); +const googTestingEvents = goog.require('goog.testing.events'); +const testSuite = goog.require('goog.testing.testSuite'); + +let dialog; +let mockOkHandler; + +const CUSTOM_MESSAGE = 'Hello, cruel world...'; + +testSuite({ + setUp() { + mockOkHandler = new LooseMock(EventHandler); + }, + + tearDown() { + dialog.dispose(); + }, + + /** + * Tests that when you show the dialog, the input field has the correct + * sample text in it. + * @suppress {visibility} suppression added to enable type checking + */ + testShow() { + mockOkHandler.$replay(); + createAndShow(); + + assertEquals( + 'Input field has incorrect sample text', 'Hello, world!', + dialog.input_.value); + mockOkHandler.$verify(); + }, + + /** + * Tests that clicking OK dispatches an event carying the entered message. + * @suppress {visibility} suppression added to enable type checking + */ + testOk() { + expectOk(CUSTOM_MESSAGE); + mockOkHandler.$replay(); + createAndShow(); + + /** @suppress {visibility} suppression added to enable type checking */ + dialog.input_.value = CUSTOM_MESSAGE; + googTestingEvents.fireClickSequence(dialog.getOkButtonElement()); + + mockOkHandler.$verify(); // Verifies OK is dispatched with correct message. + }, +}); + +/** + * Creates and shows the dialog to be tested. + * @suppress {checkTypes} suppression added to enable type checking + */ +function createAndShow() { + dialog = new HelloWorldDialog(new DomHelper()); + dialog.addEventListener(EventType.OK, mockOkHandler); + dialog.show(); +} + +/** + * Sets up the mock event handler to expect an OK event with the given + * message. + * @param {string} message Hello world message the OK event is expected to + * carry. + * @suppress {missingProperties} suppression added to enable type checking + */ +function expectOk(message) { + mockOkHandler.handleEvent(new ArgumentMatcher(function(arg) { + return arg.type == EventType.OK && arg.message == message; + })); +} diff --git a/closure/goog/demos/editor/helloworlddialog_test_dom.html b/closure/goog/demos/editor/helloworlddialog_test_dom.html new file mode 100644 index 0000000000..5268952c7b --- /dev/null +++ b/closure/goog/demos/editor/helloworlddialog_test_dom.html @@ -0,0 +1,7 @@ + + diff --git a/closure/goog/demos/editor/helloworlddialogplugin.js b/closure/goog/demos/editor/helloworlddialogplugin.js new file mode 100644 index 0000000000..e75f9f079a --- /dev/null +++ b/closure/goog/demos/editor/helloworlddialogplugin.js @@ -0,0 +1,105 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview An example of how to write a dialog plugin. + */ + +goog.provide('goog.demos.editor.HelloWorldDialogPlugin'); +goog.provide('goog.demos.editor.HelloWorldDialogPlugin.Command'); + +goog.require('goog.demos.editor.HelloWorldDialog'); +goog.require('goog.dom.TagName'); +goog.require('goog.editor.plugins.AbstractDialogPlugin'); +goog.require('goog.editor.range'); +goog.require('goog.functions'); +goog.require('goog.ui.editor.AbstractDialog'); +goog.requireType('goog.dom.DomHelper'); + + +// *** Public interface ***************************************************** // + + + +/** + * A plugin that opens the hello world dialog. + * @final + * @unrestricted + */ +goog.demos.editor.HelloWorldDialogPlugin = + class extends goog.editor.plugins.AbstractDialogPlugin { + constructor() { + super(goog.demos.editor.HelloWorldDialogPlugin.Command.HELLO_WORLD_DIALOG); + } + + /** + * Creates a new instance of the dialog and registers for the relevant events. + * @param {goog.dom.DomHelper} dialogDomHelper The dom helper to be used to + * create the dialog. + * @return {!goog.demos.editor.HelloWorldDialog} The dialog. + * @override + * @protected + */ + createDialog(dialogDomHelper) { + const dialog = new goog.demos.editor.HelloWorldDialog(dialogDomHelper); + dialog.addEventListener( + goog.ui.editor.AbstractDialog.EventType.OK, this.handleOk_, false, + this); + return dialog; + } + + /** + * Handles the OK event from the dialog by inserting the hello world message + * into the field. + * @param {goog.demos.editor.HelloWorldDialog.OkEvent} e OK event object. + * @private + */ + handleOk_(e) { + // First restore the selection so we can manipulate the field's content + // according to what was selected. + this.restoreOriginalSelection(); + + // Notify listeners that the field's contents are about to change. + this.getFieldObject().dispatchBeforeChange(); + + // Now we can clear out what was previously selected (if anything). + const range = this.getFieldObject().getRange(); + range.removeContents(); + // And replace it with a span containing our hello world message. + let createdNode = this.getFieldDomHelper().createDom( + goog.dom.TagName.SPAN, null, e.message); + createdNode = range.insertNode(createdNode, false); + // Place the cursor at the end of the new text node (false == to the right). + goog.editor.range.placeCursorNextTo(createdNode, false); + + // Notify listeners that the field's selection has changed. + this.getFieldObject().dispatchSelectionChangeEvent(); + // Notify listeners that the field's contents have changed. + this.getFieldObject().dispatchChange(); + } +}; + + + +/** + * Commands implemented by this plugin. + * @enum {string} + */ +goog.demos.editor.HelloWorldDialogPlugin.Command = { + HELLO_WORLD_DIALOG: 'helloWorldDialog' +}; + + +/** @override */ +goog.demos.editor.HelloWorldDialogPlugin.prototype.getTrogClassId = + goog.functions.constant('HelloWorldDialog'); + + +// *** Protected interface ************************************************** // + + + +// *** Private implementation *********************************************** // diff --git a/closure/goog/demos/editor/helloworlddialogplugin_test.js b/closure/goog/demos/editor/helloworlddialogplugin_test.js new file mode 100644 index 0000000000..511e71ac2d --- /dev/null +++ b/closure/goog/demos/editor/helloworlddialogplugin_test.js @@ -0,0 +1,189 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.demos.editor.HelloWorldDialogPluginTest'); +goog.setTestOnly('goog.demos.editor.HelloWorldDialogPluginTest'); + +const ArgumentMatcher = goog.require('goog.testing.mockmatchers.ArgumentMatcher'); +const Command = goog.require('goog.demos.editor.HelloWorldDialogPlugin.Command'); +const ExpectedFailures = goog.require('goog.testing.ExpectedFailures'); +const Field = goog.require('goog.editor.Field'); +const FieldMock = goog.require('goog.testing.editor.FieldMock'); +const HelloWorldDialog = goog.require('goog.demos.editor.HelloWorldDialog'); +const HelloWorldDialogPlugin = goog.require('goog.demos.editor.HelloWorldDialogPlugin'); +const MockControl = goog.require('goog.testing.MockControl'); +const MockRange = goog.require('goog.testing.MockRange'); +const NodeType = goog.require('goog.dom.NodeType'); +const OkEvent = goog.require('goog.demos.editor.HelloWorldDialog.OkEvent'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const SafeHtml = goog.require('goog.html.SafeHtml'); +const TagName = goog.require('goog.dom.TagName'); +const TestHelper = goog.require('goog.testing.editor.TestHelper'); +const googDom = goog.require('goog.dom'); +const googEditorRange = goog.require('goog.editor.range'); +const googTestingEditorDom = goog.require('goog.testing.editor.dom'); +const googTestingEvents = goog.require('goog.testing.events'); +const googUserAgent = goog.require('goog.userAgent'); +const testSuite = goog.require('goog.testing.testSuite'); + +let plugin; +let mockCtrl; +let mockField; +let mockRange; +let mockPlaceCursorNextTo; +const stubs = new PropertyReplacer(); + +let fieldObj; + +const CUSTOM_MESSAGE = 'Hello, cruel world...'; + +let expectedFailures; + +testSuite({ + setUpPage() { + expectedFailures = new ExpectedFailures(); + }, + + setUp() { + mockCtrl = new MockControl(); + + mockRange = new MockRange(); + mockCtrl.addMock(mockRange); + + /** @suppress {checkTypes} suppression added to enable type checking */ + mockField = new FieldMock(undefined, undefined, mockRange); + mockCtrl.addMock(mockField); + + mockPlaceCursorNextTo = mockCtrl.createFunctionMock('placeCursorNextTo'); + }, + + tearDown() { + plugin.dispose(); + tearDownRealEditableField(); + expectedFailures.handleTearDown(); + stubs.reset(); + googDom.removeChildren(googDom.getElement('myField')); + }, + + /** + * Tests that the plugin's dialog is properly created. + * @suppress {checkTypes} suppression added to enable type checking + */ + testCreateDialog() { + mockField.$replay(); + + plugin = new HelloWorldDialogPlugin(); + plugin.registerFieldObject(mockField); + + /** @suppress {visibility} suppression added to enable type checking */ + const dialog = plugin.createDialog(googDom.getDomHelper()); + assertTrue( + 'Dialog should be of type goog.demos.editor.HelloWorldDialog', + dialog instanceof HelloWorldDialog); + + mockField.$verify(); + }, + + /** + * Tests that when the OK event fires the editable field is properly updated. + * @suppress {missingProperties,checkTypes} suppression added to enable type + * checking + */ + testOk() { + mockField.focus(); + mockField.dispatchBeforeChange(); + mockRange.removeContents(); + // Tests that an argument is a span with the custom message. + const createdNodeMatcher = new ArgumentMatcher(function(arg) { + return arg.nodeType == NodeType.ELEMENT && arg.tagName == TagName.SPAN && + googDom.getRawTextContent(arg) == CUSTOM_MESSAGE; + }); + mockRange.insertNode(createdNodeMatcher, false); + mockRange.$does(function(node, before) { + return node; + }); + mockPlaceCursorNextTo(createdNodeMatcher, false); + stubs.set(googEditorRange, 'placeCursorNextTo', mockPlaceCursorNextTo); + mockField.dispatchSelectionChangeEvent(); + mockField.dispatchChange(); + mockCtrl.$replayAll(); + + plugin = new HelloWorldDialogPlugin(); + plugin.registerFieldObject(mockField); + /** @suppress {visibility} suppression added to enable type checking */ + const dialog = plugin.createDialog(googDom.getDomHelper()); + + // Mock of execCommand + clicking OK without actually opening the dialog. + dialog.dispatchEvent(new OkEvent(CUSTOM_MESSAGE)); + + mockCtrl.$verifyAll(); + }, + + /** + * Tests that the selection is cleared when the dialog opens and is + * correctly restored after ok is clicked. + * @suppress {visibility} suppression added to enable type checking + */ + testRestoreSelectionOnOk() { + setUpRealEditableField(); + + fieldObj.setSafeHtml(false, SafeHtml.htmlEscape('12345')); + + const elem = fieldObj.getElement(); + const helper = new TestHelper(elem); + helper.select('12345', 1, '12345', 4); // Selects '234'. + + assertEquals( + 'Incorrect text selected before dialog is opened', '234', + fieldObj.getRange().getText()); + plugin.execCommand(Command.HELLO_WORLD_DIALOG); + + // TODO(user): IE returns some bogus range when field doesn't have + // selection. Remove the expectedFailure when robbyw fixes the issue. + // NOTE(user): You can't remove the selection from a field in Opera without + // blurring it. + elem.parentNode.blur(); + expectedFailures.expectFailureFor(googUserAgent.IE); + try { + assertNull( + 'There should be no selection while dialog is open', + fieldObj.getRange()); + } catch (e) { + expectedFailures.handleException(e); + } + + googTestingEvents.fireClickSequence(plugin.dialog_.getOkButtonElement()); + assertEquals( + 'No text should be selected after clicking ok', '', + fieldObj.getRange().getText()); + + // Test that the caret is placed after the custom message. + googTestingEditorDom.assertRangeBetweenText( + 'Hello, world!', '5', fieldObj.getRange()); + }, +}); + +/** + * Setup a real editable field (instead of a mock) and register the plugin to + * it. + */ +function setUpRealEditableField() { + fieldObj = new Field('myField', document); + fieldObj.makeEditable(); + // Register the plugin to that field. + plugin = new HelloWorldDialogPlugin(); + fieldObj.registerPlugin(plugin); +} + +/** + * Tear down the real editable field. + */ +function tearDownRealEditableField() { + if (fieldObj) { + fieldObj.makeUneditable(); + fieldObj.dispose(); + } +} diff --git a/closure/goog/demos/editor/helloworlddialogplugin_test_dom.html b/closure/goog/demos/editor/helloworlddialogplugin_test_dom.html new file mode 100644 index 0000000000..9ab2cd5e42 --- /dev/null +++ b/closure/goog/demos/editor/helloworlddialogplugin_test_dom.html @@ -0,0 +1,9 @@ + + +
+
\ No newline at end of file diff --git a/closure/goog/demos/editor/seamlessfield.html b/closure/goog/demos/editor/seamlessfield.html new file mode 100644 index 0000000000..e280406d04 --- /dev/null +++ b/closure/goog/demos/editor/seamlessfield.html @@ -0,0 +1,106 @@ + + + + + + goog.editor.SeamlessField + + + + + + +

goog.editor.SeamlessField

+

This is a very basic demonstration of how to make a region editable, that + blends in with the surrounding page, even if the editable content is inside + an iframe.

+ + +
+
+

+
I am a regular div. + Click "Make Editable" above to transform me into an editable region. + I'll grow and shrink with my content! + And I'll inherit styles from the parent document. + +

Heading styled by outer document.

+
    +
  1. And lists too! One!
  2. +
  3. Two!
  4. +
+

Paragraph 1

+
+

Inherited CSS works!

+
+
+

+
+
+

Current field contents + (updates as contents of the editable field above change):
+
+ + (Use to set contents of the editable field to the contents of this textarea) +

+ + + + diff --git a/closure/goog/demos/editor/tableeditor.html b/closure/goog/demos/editor/tableeditor.html new file mode 100644 index 0000000000..6ab5cbdc40 --- /dev/null +++ b/closure/goog/demos/editor/tableeditor.html @@ -0,0 +1,91 @@ + + + + + goog.editor.plugins.TableEditor Demo + + + + + + + + + + + + + + + + + + + + +

goog.editor.plugins.TableEditor Demo

+

This is a demonstration of the table editor plugin for goog.editor.

+
+
+
+
+

Current field contents + (updates as contents of the editable field above change):
+
+ + (Use to set contents of the editable field to the contents of this textarea) +

+ + + + diff --git a/closure/goog/demos/effects.html b/closure/goog/demos/effects.html new file mode 100644 index 0000000000..a240b43ae2 --- /dev/null +++ b/closure/goog/demos/effects.html @@ -0,0 +1,162 @@ + + + + + goog.fx.dom + + + + + + +

goog.fx.dom

+

Demonstrations of the goog.fx.dom library

+ +

+
+
+
+ +

+ +

+
+
+
+
+ +

+ +

+
+ +

+ +

+
+
+
+ +

+ +

+ +

+ + +

+ + + + diff --git a/closure/goog/demos/emoji/200.gif b/closure/goog/demos/emoji/200.gif new file mode 100644 index 0000000000..6245f6968c Binary files /dev/null and b/closure/goog/demos/emoji/200.gif differ diff --git a/closure/goog/demos/emoji/201.gif b/closure/goog/demos/emoji/201.gif new file mode 100644 index 0000000000..b740d39de4 Binary files /dev/null and b/closure/goog/demos/emoji/201.gif differ diff --git a/closure/goog/demos/emoji/202.gif b/closure/goog/demos/emoji/202.gif new file mode 100644 index 0000000000..2bc9be6927 Binary files /dev/null and b/closure/goog/demos/emoji/202.gif differ diff --git a/closure/goog/demos/emoji/203.gif b/closure/goog/demos/emoji/203.gif new file mode 100644 index 0000000000..1ce3f56144 Binary files /dev/null and b/closure/goog/demos/emoji/203.gif differ diff --git a/closure/goog/demos/emoji/204.gif b/closure/goog/demos/emoji/204.gif new file mode 100644 index 0000000000..2166ce8b3d Binary files /dev/null and b/closure/goog/demos/emoji/204.gif differ diff --git a/closure/goog/demos/emoji/205.gif b/closure/goog/demos/emoji/205.gif new file mode 100644 index 0000000000..363e045b33 Binary files /dev/null and b/closure/goog/demos/emoji/205.gif differ diff --git a/closure/goog/demos/emoji/206.gif b/closure/goog/demos/emoji/206.gif new file mode 100644 index 0000000000..5b95f4457a Binary files /dev/null and b/closure/goog/demos/emoji/206.gif differ diff --git a/closure/goog/demos/emoji/2BC.gif b/closure/goog/demos/emoji/2BC.gif new file mode 100644 index 0000000000..aecbdc0d39 Binary files /dev/null and b/closure/goog/demos/emoji/2BC.gif differ diff --git a/closure/goog/demos/emoji/2BD.gif b/closure/goog/demos/emoji/2BD.gif new file mode 100644 index 0000000000..0b352dd629 Binary files /dev/null and b/closure/goog/demos/emoji/2BD.gif differ diff --git a/closure/goog/demos/emoji/2BE.gif b/closure/goog/demos/emoji/2BE.gif new file mode 100644 index 0000000000..282c361d38 Binary files /dev/null and b/closure/goog/demos/emoji/2BE.gif differ diff --git a/closure/goog/demos/emoji/2BF.gif b/closure/goog/demos/emoji/2BF.gif new file mode 100644 index 0000000000..5b88ee7ea4 Binary files /dev/null and b/closure/goog/demos/emoji/2BF.gif differ diff --git a/closure/goog/demos/emoji/2C0.gif b/closure/goog/demos/emoji/2C0.gif new file mode 100644 index 0000000000..17fa1a39ac Binary files /dev/null and b/closure/goog/demos/emoji/2C0.gif differ diff --git a/closure/goog/demos/emoji/2C1.gif b/closure/goog/demos/emoji/2C1.gif new file mode 100644 index 0000000000..a1f294a41c Binary files /dev/null and b/closure/goog/demos/emoji/2C1.gif differ diff --git a/closure/goog/demos/emoji/2C2.gif b/closure/goog/demos/emoji/2C2.gif new file mode 100644 index 0000000000..01dadbeaf4 Binary files /dev/null and b/closure/goog/demos/emoji/2C2.gif differ diff --git a/closure/goog/demos/emoji/2C3.gif b/closure/goog/demos/emoji/2C3.gif new file mode 100644 index 0000000000..69a6126957 Binary files /dev/null and b/closure/goog/demos/emoji/2C3.gif differ diff --git a/closure/goog/demos/emoji/2C4.gif b/closure/goog/demos/emoji/2C4.gif new file mode 100644 index 0000000000..224527b6a9 Binary files /dev/null and b/closure/goog/demos/emoji/2C4.gif differ diff --git a/closure/goog/demos/emoji/2C5.gif b/closure/goog/demos/emoji/2C5.gif new file mode 100644 index 0000000000..2fe94b359b Binary files /dev/null and b/closure/goog/demos/emoji/2C5.gif differ diff --git a/closure/goog/demos/emoji/2C6.gif b/closure/goog/demos/emoji/2C6.gif new file mode 100644 index 0000000000..8b1e7318d4 Binary files /dev/null and b/closure/goog/demos/emoji/2C6.gif differ diff --git a/closure/goog/demos/emoji/2C7.gif b/closure/goog/demos/emoji/2C7.gif new file mode 100644 index 0000000000..3d7c63a78b Binary files /dev/null and b/closure/goog/demos/emoji/2C7.gif differ diff --git a/closure/goog/demos/emoji/2C8.gif b/closure/goog/demos/emoji/2C8.gif new file mode 100644 index 0000000000..cb44d16dfb Binary files /dev/null and b/closure/goog/demos/emoji/2C8.gif differ diff --git a/closure/goog/demos/emoji/2C9.gif b/closure/goog/demos/emoji/2C9.gif new file mode 100644 index 0000000000..69fe427b00 Binary files /dev/null and b/closure/goog/demos/emoji/2C9.gif differ diff --git a/closure/goog/demos/emoji/2CA.gif b/closure/goog/demos/emoji/2CA.gif new file mode 100644 index 0000000000..cba4c24ab0 Binary files /dev/null and b/closure/goog/demos/emoji/2CA.gif differ diff --git a/closure/goog/demos/emoji/2CB.gif b/closure/goog/demos/emoji/2CB.gif new file mode 100644 index 0000000000..c1f035e14f Binary files /dev/null and b/closure/goog/demos/emoji/2CB.gif differ diff --git a/closure/goog/demos/emoji/2CC.gif b/closure/goog/demos/emoji/2CC.gif new file mode 100644 index 0000000000..bd757fab90 Binary files /dev/null and b/closure/goog/demos/emoji/2CC.gif differ diff --git a/closure/goog/demos/emoji/2CD.gif b/closure/goog/demos/emoji/2CD.gif new file mode 100644 index 0000000000..f42f5a1516 Binary files /dev/null and b/closure/goog/demos/emoji/2CD.gif differ diff --git a/closure/goog/demos/emoji/2CE.gif b/closure/goog/demos/emoji/2CE.gif new file mode 100644 index 0000000000..3f6eff355e Binary files /dev/null and b/closure/goog/demos/emoji/2CE.gif differ diff --git a/closure/goog/demos/emoji/2CF.gif b/closure/goog/demos/emoji/2CF.gif new file mode 100644 index 0000000000..2f7d40750a Binary files /dev/null and b/closure/goog/demos/emoji/2CF.gif differ diff --git a/closure/goog/demos/emoji/2D0.gif b/closure/goog/demos/emoji/2D0.gif new file mode 100644 index 0000000000..37da48e468 Binary files /dev/null and b/closure/goog/demos/emoji/2D0.gif differ diff --git a/closure/goog/demos/emoji/2D1.gif b/closure/goog/demos/emoji/2D1.gif new file mode 100644 index 0000000000..2bc951d30a Binary files /dev/null and b/closure/goog/demos/emoji/2D1.gif differ diff --git a/closure/goog/demos/emoji/2D2.gif b/closure/goog/demos/emoji/2D2.gif new file mode 100644 index 0000000000..a7c50dbfb6 Binary files /dev/null and b/closure/goog/demos/emoji/2D2.gif differ diff --git a/closure/goog/demos/emoji/2D3.gif b/closure/goog/demos/emoji/2D3.gif new file mode 100644 index 0000000000..22ceddf19d Binary files /dev/null and b/closure/goog/demos/emoji/2D3.gif differ diff --git a/closure/goog/demos/emoji/2D4.gif b/closure/goog/demos/emoji/2D4.gif new file mode 100644 index 0000000000..8e996524f5 Binary files /dev/null and b/closure/goog/demos/emoji/2D4.gif differ diff --git a/closure/goog/demos/emoji/2D5.gif b/closure/goog/demos/emoji/2D5.gif new file mode 100644 index 0000000000..4837a48f67 Binary files /dev/null and b/closure/goog/demos/emoji/2D5.gif differ diff --git a/closure/goog/demos/emoji/2D6.gif b/closure/goog/demos/emoji/2D6.gif new file mode 100644 index 0000000000..bd7230fdf4 Binary files /dev/null and b/closure/goog/demos/emoji/2D6.gif differ diff --git a/closure/goog/demos/emoji/2D7.gif b/closure/goog/demos/emoji/2D7.gif new file mode 100644 index 0000000000..880829fe55 Binary files /dev/null and b/closure/goog/demos/emoji/2D7.gif differ diff --git a/closure/goog/demos/emoji/2D8.gif b/closure/goog/demos/emoji/2D8.gif new file mode 100644 index 0000000000..7d727db91f Binary files /dev/null and b/closure/goog/demos/emoji/2D8.gif differ diff --git a/closure/goog/demos/emoji/2D9.gif b/closure/goog/demos/emoji/2D9.gif new file mode 100644 index 0000000000..98a0fa2057 Binary files /dev/null and b/closure/goog/demos/emoji/2D9.gif differ diff --git a/closure/goog/demos/emoji/2DA.gif b/closure/goog/demos/emoji/2DA.gif new file mode 100644 index 0000000000..c831816309 Binary files /dev/null and b/closure/goog/demos/emoji/2DA.gif differ diff --git a/closure/goog/demos/emoji/2DB.gif b/closure/goog/demos/emoji/2DB.gif new file mode 100644 index 0000000000..301c931a9e Binary files /dev/null and b/closure/goog/demos/emoji/2DB.gif differ diff --git a/closure/goog/demos/emoji/2DC.gif b/closure/goog/demos/emoji/2DC.gif new file mode 100644 index 0000000000..27ab40852b Binary files /dev/null and b/closure/goog/demos/emoji/2DC.gif differ diff --git a/closure/goog/demos/emoji/2DD.gif b/closure/goog/demos/emoji/2DD.gif new file mode 100644 index 0000000000..b5e6edfb16 Binary files /dev/null and b/closure/goog/demos/emoji/2DD.gif differ diff --git a/closure/goog/demos/emoji/2DE.gif b/closure/goog/demos/emoji/2DE.gif new file mode 100644 index 0000000000..b9a7272dd8 Binary files /dev/null and b/closure/goog/demos/emoji/2DE.gif differ diff --git a/closure/goog/demos/emoji/2DF.gif b/closure/goog/demos/emoji/2DF.gif new file mode 100644 index 0000000000..89fa186663 Binary files /dev/null and b/closure/goog/demos/emoji/2DF.gif differ diff --git a/closure/goog/demos/emoji/2E0.gif b/closure/goog/demos/emoji/2E0.gif new file mode 100644 index 0000000000..7fd754ab16 Binary files /dev/null and b/closure/goog/demos/emoji/2E0.gif differ diff --git a/closure/goog/demos/emoji/2E1.gif b/closure/goog/demos/emoji/2E1.gif new file mode 100644 index 0000000000..6926e4e872 Binary files /dev/null and b/closure/goog/demos/emoji/2E1.gif differ diff --git a/closure/goog/demos/emoji/2E2.gif b/closure/goog/demos/emoji/2E2.gif new file mode 100644 index 0000000000..1718dae3ad Binary files /dev/null and b/closure/goog/demos/emoji/2E2.gif differ diff --git a/closure/goog/demos/emoji/2E3.gif b/closure/goog/demos/emoji/2E3.gif new file mode 100644 index 0000000000..4f23b2b8f2 Binary files /dev/null and b/closure/goog/demos/emoji/2E3.gif differ diff --git a/closure/goog/demos/emoji/2E4.gif b/closure/goog/demos/emoji/2E4.gif new file mode 100644 index 0000000000..ab2c9eb105 Binary files /dev/null and b/closure/goog/demos/emoji/2E4.gif differ diff --git a/closure/goog/demos/emoji/2E5.gif b/closure/goog/demos/emoji/2E5.gif new file mode 100644 index 0000000000..ff8f45b5fa Binary files /dev/null and b/closure/goog/demos/emoji/2E5.gif differ diff --git a/closure/goog/demos/emoji/2E6.gif b/closure/goog/demos/emoji/2E6.gif new file mode 100644 index 0000000000..56e75e8c51 Binary files /dev/null and b/closure/goog/demos/emoji/2E6.gif differ diff --git a/closure/goog/demos/emoji/2E7.gif b/closure/goog/demos/emoji/2E7.gif new file mode 100644 index 0000000000..157042d347 Binary files /dev/null and b/closure/goog/demos/emoji/2E7.gif differ diff --git a/closure/goog/demos/emoji/2E8.gif b/closure/goog/demos/emoji/2E8.gif new file mode 100644 index 0000000000..1eb1cc90a5 Binary files /dev/null and b/closure/goog/demos/emoji/2E8.gif differ diff --git a/closure/goog/demos/emoji/2E9.gif b/closure/goog/demos/emoji/2E9.gif new file mode 100644 index 0000000000..5b9814905d Binary files /dev/null and b/closure/goog/demos/emoji/2E9.gif differ diff --git a/closure/goog/demos/emoji/2EA.gif b/closure/goog/demos/emoji/2EA.gif new file mode 100644 index 0000000000..40d60a6dbe Binary files /dev/null and b/closure/goog/demos/emoji/2EA.gif differ diff --git a/closure/goog/demos/emoji/2EB.gif b/closure/goog/demos/emoji/2EB.gif new file mode 100644 index 0000000000..8e2ca7d81b Binary files /dev/null and b/closure/goog/demos/emoji/2EB.gif differ diff --git a/closure/goog/demos/emoji/2EC.gif b/closure/goog/demos/emoji/2EC.gif new file mode 100644 index 0000000000..884e2267a2 Binary files /dev/null and b/closure/goog/demos/emoji/2EC.gif differ diff --git a/closure/goog/demos/emoji/2ED.gif b/closure/goog/demos/emoji/2ED.gif new file mode 100644 index 0000000000..b50ba968ac Binary files /dev/null and b/closure/goog/demos/emoji/2ED.gif differ diff --git a/closure/goog/demos/emoji/2EE.gif b/closure/goog/demos/emoji/2EE.gif new file mode 100644 index 0000000000..a96fbd1bec Binary files /dev/null and b/closure/goog/demos/emoji/2EE.gif differ diff --git a/closure/goog/demos/emoji/2EF.gif b/closure/goog/demos/emoji/2EF.gif new file mode 100644 index 0000000000..13a0d2b80c Binary files /dev/null and b/closure/goog/demos/emoji/2EF.gif differ diff --git a/closure/goog/demos/emoji/2F0.gif b/closure/goog/demos/emoji/2F0.gif new file mode 100644 index 0000000000..1538221f7d Binary files /dev/null and b/closure/goog/demos/emoji/2F0.gif differ diff --git a/closure/goog/demos/emoji/2F1.gif b/closure/goog/demos/emoji/2F1.gif new file mode 100644 index 0000000000..d04c68d0f1 Binary files /dev/null and b/closure/goog/demos/emoji/2F1.gif differ diff --git a/closure/goog/demos/emoji/2F2.gif b/closure/goog/demos/emoji/2F2.gif new file mode 100644 index 0000000000..402dfce732 Binary files /dev/null and b/closure/goog/demos/emoji/2F2.gif differ diff --git a/closure/goog/demos/emoji/2F3.gif b/closure/goog/demos/emoji/2F3.gif new file mode 100644 index 0000000000..250271e311 Binary files /dev/null and b/closure/goog/demos/emoji/2F3.gif differ diff --git a/closure/goog/demos/emoji/2F4.gif b/closure/goog/demos/emoji/2F4.gif new file mode 100644 index 0000000000..dec31af04f Binary files /dev/null and b/closure/goog/demos/emoji/2F4.gif differ diff --git a/closure/goog/demos/emoji/2F5.gif b/closure/goog/demos/emoji/2F5.gif new file mode 100644 index 0000000000..bed6e71916 Binary files /dev/null and b/closure/goog/demos/emoji/2F5.gif differ diff --git a/closure/goog/demos/emoji/2F6.gif b/closure/goog/demos/emoji/2F6.gif new file mode 100644 index 0000000000..e9b885f773 Binary files /dev/null and b/closure/goog/demos/emoji/2F6.gif differ diff --git a/closure/goog/demos/emoji/2F7.gif b/closure/goog/demos/emoji/2F7.gif new file mode 100644 index 0000000000..5bdcb64327 Binary files /dev/null and b/closure/goog/demos/emoji/2F7.gif differ diff --git a/closure/goog/demos/emoji/2F8.gif b/closure/goog/demos/emoji/2F8.gif new file mode 100644 index 0000000000..629016b2bf Binary files /dev/null and b/closure/goog/demos/emoji/2F8.gif differ diff --git a/closure/goog/demos/emoji/2F9.gif b/closure/goog/demos/emoji/2F9.gif new file mode 100644 index 0000000000..f8b41da66e Binary files /dev/null and b/closure/goog/demos/emoji/2F9.gif differ diff --git a/closure/goog/demos/emoji/2FA.gif b/closure/goog/demos/emoji/2FA.gif new file mode 100644 index 0000000000..0a4a5b3bd7 Binary files /dev/null and b/closure/goog/demos/emoji/2FA.gif differ diff --git a/closure/goog/demos/emoji/2FB.gif b/closure/goog/demos/emoji/2FB.gif new file mode 100644 index 0000000000..620d898f84 Binary files /dev/null and b/closure/goog/demos/emoji/2FB.gif differ diff --git a/closure/goog/demos/emoji/2FC.gif b/closure/goog/demos/emoji/2FC.gif new file mode 100644 index 0000000000..2171097d5f Binary files /dev/null and b/closure/goog/demos/emoji/2FC.gif differ diff --git a/closure/goog/demos/emoji/2FD.gif b/closure/goog/demos/emoji/2FD.gif new file mode 100644 index 0000000000..c6bcdb49eb Binary files /dev/null and b/closure/goog/demos/emoji/2FD.gif differ diff --git a/closure/goog/demos/emoji/2FE.gif b/closure/goog/demos/emoji/2FE.gif new file mode 100644 index 0000000000..a8888c57ca Binary files /dev/null and b/closure/goog/demos/emoji/2FE.gif differ diff --git a/closure/goog/demos/emoji/2FF.gif b/closure/goog/demos/emoji/2FF.gif new file mode 100644 index 0000000000..6022c4ca60 Binary files /dev/null and b/closure/goog/demos/emoji/2FF.gif differ diff --git a/closure/goog/demos/emoji/none.gif b/closure/goog/demos/emoji/none.gif new file mode 100644 index 0000000000..8e1f90ede5 Binary files /dev/null and b/closure/goog/demos/emoji/none.gif differ diff --git a/closure/goog/demos/emoji/sprite.png b/closure/goog/demos/emoji/sprite.png new file mode 100644 index 0000000000..f16efa9758 Binary files /dev/null and b/closure/goog/demos/emoji/sprite.png differ diff --git a/closure/goog/demos/emoji/sprite2.png b/closure/goog/demos/emoji/sprite2.png new file mode 100644 index 0000000000..399d5244dc Binary files /dev/null and b/closure/goog/demos/emoji/sprite2.png differ diff --git a/closure/goog/demos/emoji/unknown.gif b/closure/goog/demos/emoji/unknown.gif new file mode 100644 index 0000000000..7f0b80459c Binary files /dev/null and b/closure/goog/demos/emoji/unknown.gif differ diff --git a/closure/goog/demos/event-propagation.html b/closure/goog/demos/event-propagation.html new file mode 100644 index 0000000000..b646dab132 --- /dev/null +++ b/closure/goog/demos/event-propagation.html @@ -0,0 +1,192 @@ + + + + +goog.events + + + + + + + +

goog.events - Stop Propagation

+

Test the cancelling of capture and bubbling events. Click + one of the nodes to see the event trace, then use the check boxes to cancel the + capture or bubble at a given branch. + (Double click the text area to clear it)

+ +
+ +
+ + + + + diff --git a/closure/goog/demos/events.html b/closure/goog/demos/events.html new file mode 100644 index 0000000000..633dfbf125 --- /dev/null +++ b/closure/goog/demos/events.html @@ -0,0 +1,94 @@ + + + + Event Test + + + + +

+ Link 1
+ Link 2
+ Link 3
+ Link 4 +

+

+ Listen | + UnListen | + Remove One | + Remove Two | + Remove Three | +

+

+
+  
+ Test 1 +
+     Test 2 +
+         Test 3 +
+     Test 2 +
+ Test 1 +
+ + + + diff --git a/closure/goog/demos/eventtarget.html b/closure/goog/demos/eventtarget.html new file mode 100644 index 0000000000..e8b9121dfa --- /dev/null +++ b/closure/goog/demos/eventtarget.html @@ -0,0 +1,70 @@ + + + + Event Test + + + + + + + + diff --git a/closure/goog/demos/filedrophandler.html b/closure/goog/demos/filedrophandler.html new file mode 100644 index 0000000000..23004b13c2 --- /dev/null +++ b/closure/goog/demos/filedrophandler.html @@ -0,0 +1,65 @@ + + + + + goog.events.FileDropHandler Demo + + + + + +

Demo of goog.events.FileDropHandler

+ +
+ Demo of the goog.events.FileDropHandler: + + +
+ +
+ Event Log +
+
+ + + + diff --git a/closure/goog/demos/filteredmenu.html b/closure/goog/demos/filteredmenu.html new file mode 100644 index 0000000000..e7a5fce45a --- /dev/null +++ b/closure/goog/demos/filteredmenu.html @@ -0,0 +1,119 @@ + + + + +goog.ui.FilteredMenu + + + + + + + + + + + + + + + + + + + diff --git a/closure/goog/demos/focushandler.html b/closure/goog/demos/focushandler.html new file mode 100644 index 0000000000..0090407333 --- /dev/null +++ b/closure/goog/demos/focushandler.html @@ -0,0 +1,58 @@ + + + + +goog.events.FocusHandler + + + + + +

goog.events.FocusHandler

+

i1: + + +

i2 + + +

i3: + +

+ + + + diff --git a/closure/goog/demos/fpsdisplay.html b/closure/goog/demos/fpsdisplay.html new file mode 100644 index 0000000000..174c768022 --- /dev/null +++ b/closure/goog/demos/fpsdisplay.html @@ -0,0 +1,50 @@ + + + + +FPS Display + + + + +

+ + + +
+ + + diff --git a/closure/goog/demos/fx/css3/transition.html b/closure/goog/demos/fx/css3/transition.html new file mode 100644 index 0000000000..a1133eca86 --- /dev/null +++ b/closure/goog/demos/fx/css3/transition.html @@ -0,0 +1,220 @@ + + + + +Closure: CSS3 Transition Demo + + + + + + + + +
+
+
+ CSS3 transition choices +
+
+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
Hi there!
+
+ + +
+ Event log for the transition object +
+
+ + + + diff --git a/closure/goog/demos/gauge.html b/closure/goog/demos/gauge.html new file mode 100644 index 0000000000..91114fc003 --- /dev/null +++ b/closure/goog/demos/gauge.html @@ -0,0 +1,158 @@ + + + + + goog.ui.Gauge + + + + + + + + + +

goog.ui.Gauge

+

Note: This component requires vector graphics support

+ + + + + + + + + + + + + +
+ Basic + + Background colors, title. custom ticks + + Value change, formatted value, tick labels + + Custom colors +
+ + + + + +
+ + +
+
+ +
+ + + diff --git a/closure/goog/demos/graphics/advancedcoordinates.html b/closure/goog/demos/graphics/advancedcoordinates.html new file mode 100644 index 0000000000..3246fbb202 --- /dev/null +++ b/closure/goog/demos/graphics/advancedcoordinates.html @@ -0,0 +1,141 @@ + + + + + Graphics Advanced Coordinates Demo Page + + + + + + + +
+

+ W: + H: + R: +

+ +

The front ellipse is sized based on absolute units. The back ellipse is + sized based on percentage of the parent.

+ +
+ + + diff --git a/closure/goog/demos/graphics/advancedcoordinates2.html b/closure/goog/demos/graphics/advancedcoordinates2.html new file mode 100644 index 0000000000..8261f2fe95 --- /dev/null +++ b/closure/goog/demos/graphics/advancedcoordinates2.html @@ -0,0 +1,130 @@ + + + + + Graphics Advanced Coordinates Demo Page - + Using Percentage Based Surface Size + + + + + + + + + + + diff --git a/closure/goog/demos/graphics/basicelements.html b/closure/goog/demos/graphics/basicelements.html new file mode 100644 index 0000000000..e426a3b3f9 --- /dev/null +++ b/closure/goog/demos/graphics/basicelements.html @@ -0,0 +1,264 @@ + + + + + + Graphics Basic Elements Demo Page + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Text: fonts, alignment, vertical-alignment, direction + + Basic shapes: Rectangle, Circle, Ellipses, Path, Clip to canvas +
+ + + +
+ Paths: Lines, arcs, curves + + Colors: solid, gradients, transparency +
+ + + +
+ Coordinate scaling + stroke types +
+ +
+ + + diff --git a/closure/goog/demos/graphics/events.html b/closure/goog/demos/graphics/events.html new file mode 100644 index 0000000000..01f93a1eb2 --- /dev/null +++ b/closure/goog/demos/graphics/events.html @@ -0,0 +1,114 @@ + + + + + Graphics Basic events Demo Page + + + + + + + + +
+ +
+ +
+ +
+ +

+ Clear Log +

+ +
+ +

Try to mouse over, mouse out, or click the ellipse and the group of + circles. The ellipse will be disposed in 10 sec. +

+ + + + diff --git a/closure/goog/demos/graphics/modifyelements.html b/closure/goog/demos/graphics/modifyelements.html new file mode 100644 index 0000000000..310e959e00 --- /dev/null +++ b/closure/goog/demos/graphics/modifyelements.html @@ -0,0 +1,196 @@ + + + + + + Modifing Graphic Elements Demo + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Colors (stroke/fill): + + + + + +
Rectangle position: + + + +
Rectangle size: + + + +
Ellipse center: + + + +
Ellipse radius: + + + +
Path: + + +
Text: + +
+ + +
+ + + + diff --git a/closure/goog/demos/graphics/subpixel.html b/closure/goog/demos/graphics/subpixel.html new file mode 100644 index 0000000000..1b5c1d4139 --- /dev/null +++ b/closure/goog/demos/graphics/subpixel.html @@ -0,0 +1,80 @@ + + + + +Sub pixel rendering + + + + + + + + diff --git a/closure/goog/demos/graphics/tiger.html b/closure/goog/demos/graphics/tiger.html new file mode 100644 index 0000000000..3ffa91c217 --- /dev/null +++ b/closure/goog/demos/graphics/tiger.html @@ -0,0 +1,105 @@ + + + + +The SVG tiger drawn with goog.graphics + + + + + + + +
+
+
+ + + diff --git a/closure/goog/demos/graphics/tigerdata.js b/closure/goog/demos/graphics/tigerdata.js new file mode 100644 index 0000000000..d9f2a2779b --- /dev/null +++ b/closure/goog/demos/graphics/tigerdata.js @@ -0,0 +1,4107 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview This data is generated from an SVG image of a tiger. + */ + + +const tigerData = [ + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [77.696, 284.285]}, + {t: 'C', p: [77.696, 284.285, 77.797, 286.179, 76.973, 286.16]}, + {t: 'C', p: [76.149, 286.141, 59.695, 238.066, 39.167, 240.309]}, + {t: 'C', p: [39.167, 240.309, 56.95, 232.956, 77.696, 284.285]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [81.226, 281.262]}, + {t: 'C', p: [81.226, 281.262, 80.677, 283.078, 79.908, 282.779]}, + {t: 'C', p: [79.14, 282.481, 80.023, 231.675, 59.957, 226.801]}, + {t: 'C', p: [59.957, 226.801, 79.18, 225.937, 81.226, 281.262]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [108.716, 323.59]}, + {t: 'C', p: [108.716, 323.59, 110.352, 324.55, 109.882, 325.227]}, + {t: 'C', p: [109.411, 325.904, 60.237, 313.102, 50.782, 331.459]}, + {t: 'C', p: [50.782, 331.459, 54.461, 312.572, 108.716, 323.59]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [105.907, 333.801]}, + {t: 'C', p: [105.907, 333.801, 107.763, 334.197, 107.529, 334.988]}, + {t: 'C', p: [107.296, 335.779, 56.593, 339.121, 53.403, 359.522]}, + {t: 'C', p: [53.403, 359.522, 50.945, 340.437, 105.907, 333.801]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [101.696, 328.276]}, + {t: 'C', p: [101.696, 328.276, 103.474, 328.939, 103.128, 329.687]}, + {t: 'C', p: [102.782, 330.435, 52.134, 326.346, 46.002, 346.064]}, + {t: 'C', p: [46.002, 346.064, 46.354, 326.825, 101.696, 328.276]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [90.991, 310.072]}, + {t: 'C', p: [90.991, 310.072, 92.299, 311.446, 91.66, 311.967]}, + {t: 'C', p: [91.021, 312.488, 47.278, 286.634, 33.131, 301.676]}, + {t: 'C', p: [33.131, 301.676, 41.872, 284.533, 90.991, 310.072]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [83.446, 314.263]}, + {t: 'C', p: [83.446, 314.263, 84.902, 315.48, 84.326, 316.071]}, + {t: 'C', p: [83.75, 316.661, 37.362, 295.922, 25.008, 312.469]}, + {t: 'C', p: [25.008, 312.469, 31.753, 294.447, 83.446, 314.263]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [80.846, 318.335]}, + {t: 'C', p: [80.846, 318.335, 82.454, 319.343, 81.964, 320.006]}, + {t: 'C', p: [81.474, 320.669, 32.692, 306.446, 22.709, 324.522]}, + {t: 'C', p: [22.709, 324.522, 26.934, 305.749, 80.846, 318.335]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [91.58, 318.949]}, + {t: 'C', p: [91.58, 318.949, 92.702, 320.48, 92.001, 320.915]}, + {t: 'C', p: [91.3, 321.35, 51.231, 290.102, 35.273, 303.207]}, + {t: 'C', p: [35.273, 303.207, 46.138, 287.326, 91.58, 318.949]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [71.8, 290]}, + {t: 'C', p: [71.8, 290, 72.4, 291.8, 71.6, 292]}, + {t: 'C', p: [70.8, 292.2, 42.2, 250.2, 22.999, 257.8]}, + {t: 'C', p: [22.999, 257.8, 38.2, 246, 71.8, 290]}, {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [72.495, 296.979]}, + {t: 'C', p: [72.495, 296.979, 73.47, 298.608, 72.731, 298.975]}, + {t: 'C', p: [71.993, 299.343, 35.008, 264.499, 17.899, 276.061]}, + {t: 'C', p: [17.899, 276.061, 30.196, 261.261, 72.495, 296.979]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.172}, + p: [ + {t: 'M', p: [72.38, 301.349]}, + {t: 'C', p: [72.38, 301.349, 73.502, 302.88, 72.801, 303.315]}, + {t: 'C', p: [72.1, 303.749, 32.031, 272.502, 16.073, 285.607]}, + {t: 'C', p: [16.073, 285.607, 26.938, 269.726, 72.38, 301.349]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: '#000', + p: [ + {t: 'M', p: [70.17, 303.065]}, + {t: 'C', p: [70.673, 309.113, 71.661, 315.682, 73.4, 318.801]}, + {t: 'C', p: [73.4, 318.801, 69.8, 331.201, 78.6, 344.401]}, + {t: 'C', p: [78.6, 344.401, 78.2, 351.601, 79.8, 354.801]}, + {t: 'C', p: [79.8, 354.801, 83.8, 363.201, 88.6, 364.001]}, + {t: 'C', p: [92.484, 364.648, 101.207, 367.717, 111.068, 369.121]}, + {t: 'C', p: [111.068, 369.121, 128.2, 383.201, 125, 396.001]}, + {t: 'C', p: [125, 396.001, 124.6, 412.401, 121, 414.001]}, + {t: 'C', p: [121, 414.001, 132.6, 402.801, 123, 419.601]}, + {t: 'L', p: [118.6, 438.401]}, + {t: 'C', p: [118.6, 438.401, 144.2, 416.801, 128.6, 435.201]}, + {t: 'L', p: [118.6, 461.201]}, + {t: 'C', p: [118.6, 461.201, 138.2, 442.801, 131, 451.201]}, + {t: 'L', p: [127.8, 460.001]}, + {t: 'C', p: [127.8, 460.001, 171, 432.801, 140.2, 462.401]}, + {t: 'C', p: [140.2, 462.401, 148.2, 458.801, 152.6, 461.601]}, + {t: 'C', p: [152.6, 461.601, 159.4, 460.401, 158.6, 462.001]}, + {t: 'C', p: [158.6, 462.001, 137.8, 472.401, 134.2, 490.801]}, + {t: 'C', p: [134.2, 490.801, 142.6, 480.801, 139.4, 491.601]}, + {t: 'L', p: [139.8, 503.201]}, + {t: 'C', p: [139.8, 503.201, 143.8, 481.601, 143.4, 519.201]}, + {t: 'C', p: [143.4, 519.201, 162.6, 501.201, 151, 522.001]}, + {t: 'L', p: [151, 538.801]}, + {t: 'C', p: [151, 538.801, 166.2, 522.401, 159.8, 535.201]}, + {t: 'C', p: [159.8, 535.201, 169.8, 526.401, 165.8, 541.601]}, + {t: 'C', p: [165.8, 541.601, 165, 552.001, 169.4, 540.801]}, + {t: 'C', p: [169.4, 540.801, 185.4, 510.201, 179.4, 536.401]}, + {t: 'C', p: [179.4, 536.401, 178.6, 555.601, 183.4, 540.801]}, + {t: 'C', p: [183.4, 540.801, 183.8, 551.201, 193, 558.401]}, + {t: 'C', p: [193, 558.401, 191.8, 507.601, 204.6, 543.601]}, + {t: 'L', p: [208.6, 560.001]}, + {t: 'C', p: [208.6, 560.001, 211.4, 550.801, 211, 545.601]}, + {t: 'C', p: [211, 545.601, 225.8, 529.201, 219, 553.601]}, + {t: 'C', p: [219, 553.601, 234.2, 530.801, 231, 544.001]}, + {t: 'C', p: [231, 544.001, 223.4, 560.001, 225, 564.801]}, + {t: 'C', p: [225, 564.801, 241.8, 530.001, 243, 528.401]}, + {t: 'C', p: [243, 528.401, 241, 570.802, 251.8, 534.801]}, + {t: 'C', p: [251.8, 534.801, 257.4, 546.801, 254.6, 551.201]}, + {t: 'C', p: [254.6, 551.201, 262.6, 543.201, 261.8, 540.001]}, + {t: 'C', p: [261.8, 540.001, 266.4, 531.801, 269.2, 545.401]}, + {t: 'C', p: [269.2, 545.401, 271, 554.801, 272.6, 551.601]}, + {t: 'C', p: [272.6, 551.601, 276.6, 575.602, 277.8, 552.801]}, + {t: 'C', p: [277.8, 552.801, 279.4, 539.201, 272.2, 527.601]}, + {t: 'C', p: [272.2, 527.601, 273, 524.401, 270.2, 520.401]}, + {t: 'C', p: [270.2, 520.401, 283.8, 542.001, 276.6, 513.201]}, + {t: 'C', p: [276.6, 513.201, 287.801, 521.201, 289.001, 521.201]}, + {t: 'C', p: [289.001, 521.201, 275.4, 498.001, 284.2, 502.801]}, + {t: 'C', p: [284.2, 502.801, 279, 492.401, 297.001, 504.401]}, + {t: 'C', p: [297.001, 504.401, 281, 488.401, 298.601, 498.001]}, + {t: 'C', p: [298.601, 498.001, 306.601, 504.401, 299.001, 494.401]}, + {t: 'C', p: [299.001, 494.401, 284.6, 478.401, 306.601, 496.401]}, + {t: 'C', p: [306.601, 496.401, 318.201, 512.801, 319.001, 515.601]}, + {t: 'C', p: [319.001, 515.601, 309.001, 486.401, 304.601, 483.601]}, + {t: 'C', p: [304.601, 483.601, 313.001, 447.201, 354.201, 462.801]}, + {t: 'C', p: [354.201, 462.801, 361.001, 480.001, 365.401, 461.601]}, + {t: 'C', p: [365.401, 461.601, 378.201, 455.201, 389.401, 482.801]}, + {t: 'C', p: [389.401, 482.801, 393.401, 469.201, 392.601, 466.401]}, + {t: 'C', p: [392.601, 466.401, 399.401, 467.601, 398.601, 466.401]}, + {t: 'C', p: [398.601, 466.401, 411.801, 470.801, 413.001, 470.001]}, + {t: 'C', p: [413.001, 470.001, 419.801, 476.801, 420.201, 473.201]}, + {t: 'C', p: [420.201, 473.201, 429.401, 476.001, 427.401, 472.401]}, + {t: 'C', p: [427.401, 472.401, 436.201, 488.001, 436.601, 491.601]}, + {t: 'L', p: [439.001, 477.601]}, + {t: 'L', p: [441.001, 480.401]}, + {t: 'C', p: [441.001, 480.401, 442.601, 472.801, 441.801, 471.601]}, + {t: 'C', p: [441.001, 470.401, 461.801, 478.401, 466.601, 499.201]}, + {t: 'L', p: [468.601, 507.601]}, + {t: 'C', p: [468.601, 507.601, 474.601, 492.801, 473.001, 488.801]}, + {t: 'C', p: [473.001, 488.801, 478.201, 489.601, 478.601, 494.001]}, + {t: 'C', p: [478.601, 494.001, 482.601, 470.801, 477.801, 464.801]}, + {t: 'C', p: [477.801, 464.801, 482.201, 464.001, 483.401, 467.601]}, + {t: 'L', p: [483.401, 460.401]}, + {t: 'C', p: [483.401, 460.401, 490.601, 461.201, 490.601, 458.801]}, + {t: 'C', p: [490.601, 458.801, 495.001, 454.801, 497.001, 459.601]}, + {t: 'C', p: [497.001, 459.601, 484.601, 424.401, 503.001, 443.601]}, + {t: 'C', p: [503.001, 443.601, 510.201, 454.401, 506.601, 435.601]}, + {t: 'C', p: [503.001, 416.801, 499.001, 415.201, 503.801, 414.801]}, + {t: 'C', p: [503.801, 414.801, 504.601, 411.201, 502.601, 409.601]}, + {t: 'C', p: [500.601, 408.001, 503.801, 409.601, 503.801, 409.601]}, + {t: 'C', p: [503.801, 409.601, 508.601, 413.601, 503.401, 391.601]}, + {t: 'C', p: [503.401, 391.601, 509.801, 393.201, 497.801, 364.001]}, + {t: 'C', p: [497.801, 364.001, 500.601, 361.601, 496.601, 353.201]}, + {t: 'C', p: [496.601, 353.201, 504.601, 357.601, 507.401, 356.001]}, + {t: 'C', p: [507.401, 356.001, 507.001, 354.401, 503.801, 350.401]}, + {t: 'C', p: [503.801, 350.401, 482.201, 295.6, 502.601, 317.601]}, + {t: 'C', p: [502.601, 317.601, 514.451, 331.151, 508.051, 308.351]}, + {t: 'C', p: [508.051, 308.351, 498.94, 284.341, 499.717, 280.045]}, + {t: 'L', p: [70.17, 303.065]}, + {t: 'z', p: []} + ] + }, + + { + f: '#cc7226', + s: '#000', + p: [ + {t: 'M', p: [499.717, 280.245]}, + {t: 'C', p: [500.345, 280.426, 502.551, 281.55, 503.801, 283.2]}, + {t: 'C', p: [503.801, 283.2, 510.601, 294, 505.401, 275.6]}, + {t: 'C', p: [505.401, 275.6, 496.201, 246.8, 505.001, 258]}, + {t: 'C', p: [505.001, 258, 511.001, 265.2, 507.801, 251.6]}, + {t: 'C', p: [503.936, 235.173, 501.401, 228.8, 501.401, 228.8]}, + {t: 'C', p: [501.401, 228.8, 513.001, 233.6, 486.201, 194]}, + {t: 'L', p: [495.001, 197.6]}, + {t: 'C', p: [495.001, 197.6, 475.401, 158, 453.801, 152.8]}, + {t: 'L', p: [445.801, 146.8]}, + {t: 'C', p: [445.801, 146.8, 484.201, 108.8, 471.401, 72]}, + {t: 'C', p: [471.401, 72, 464.601, 66.8, 455.001, 76]}, + {t: 'C', p: [455.001, 76, 448.601, 80.8, 442.601, 79.2]}, + {t: 'C', p: [442.601, 79.2, 411.801, 80.4, 409.801, 80.4]}, + {t: 'C', p: [407.801, 80.4, 373.001, 43.2, 307.401, 60.8]}, + {t: 'C', p: [307.401, 60.8, 302.201, 62.8, 297.801, 61.6]}, + {t: 'C', p: [297.801, 61.6, 279.4, 45.6, 230.6, 68.4]}, + {t: 'C', p: [230.6, 68.4, 220.6, 70.4, 219, 70.4]}, + {t: 'C', p: [217.4, 70.4, 214.6, 70.4, 206.6, 76.8]}, + {t: 'C', p: [198.6, 83.2, 198.2, 84, 196.2, 85.6]}, + {t: 'C', p: [196.2, 85.6, 179.8, 96.8, 175, 97.6]}, + {t: 'C', p: [175, 97.6, 163.4, 104, 159, 114]}, + {t: 'L', p: [155.4, 115.2]}, + {t: 'C', p: [155.4, 115.2, 153.8, 122.4, 153.4, 123.6]}, + {t: 'C', p: [153.4, 123.6, 148.6, 127.2, 147.8, 132.8]}, + {t: 'C', p: [147.8, 132.8, 139, 138.8, 139.4, 143.2]}, + {t: 'C', p: [139.4, 143.2, 137.8, 148.4, 137, 153.2]}, + {t: 'C', p: [137, 153.2, 129.8, 158, 130.6, 160.8]}, + {t: 'C', p: [130.6, 160.8, 123, 174.8, 124.2, 181.6]}, + {t: 'C', p: [124.2, 181.6, 117.8, 181.2, 115, 183.6]}, + {t: 'C', p: [115, 183.6, 114.2, 188.4, 112.6, 188.8]}, + {t: 'C', p: [112.6, 188.8, 109.8, 190, 112.2, 194]}, + {t: 'C', p: [112.2, 194, 110.6, 196.8, 110.2, 198.4]}, + {t: 'C', p: [110.2, 198.4, 111, 201.2, 106.6, 206.8]}, + {t: 'C', p: [106.6, 206.8, 100.2, 225.6, 102.2, 230.8]}, + {t: 'C', p: [102.2, 230.8, 102.6, 235.6, 99.8, 237.2]}, + {t: 'C', p: [99.8, 237.2, 96.2, 236.8, 104.6, 248.8]}, + {t: 'C', p: [104.6, 248.8, 105.4, 250, 102.2, 252.4]}, + {t: 'C', p: [102.2, 252.4, 85, 256, 82.6, 272.4]}, + {t: 'C', p: [82.6, 272.4, 69, 287.2, 69, 292.4]}, + {t: 'C', p: [69, 294.705, 69.271, 297.852, 69.97, 302.465]}, + {t: 'C', p: [69.97, 302.465, 69.4, 310.801, 97, 311.601]}, + {t: 'C', p: [124.6, 312.401, 499.717, 280.245, 499.717, 280.245]}, + {t: 'z', p: []} + ] + }, + + { + f: '#cc7226', + s: null, + p: [ + {t: 'M', p: [84.4, 302.6]}, + {t: 'C', p: [59.4, 263.2, 73.8, 319.601, 73.8, 319.601]}, + {t: 'C', p: [82.6, 354.001, 212.2, 316.401, 212.2, 316.401]}, + {t: 'C', p: [212.2, 316.401, 381.001, 286, 392.201, 282]}, + {t: 'C', p: [403.401, 278, 498.601, 284.4, 498.601, 284.4]}, + {t: 'L', p: [493.001, 267.6]}, + {t: 'C', p: [428.201, 221.2, 409.001, 244.4, 395.401, 240.4]}, + {t: 'C', p: [381.801, 236.4, 384.201, 246, 381.001, 246.8]}, + {t: 'C', p: [377.801, 247.6, 338.601, 222.8, 332.201, 223.6]}, + {t: 'C', p: [325.801, 224.4, 300.459, 200.649, 315.401, 232.4]}, + {t: 'C', p: [331.401, 266.4, 257, 271.6, 240.2, 260.4]}, + {t: 'C', p: [223.4, 249.2, 247.4, 278.8, 247.4, 278.8]}, + {t: 'C', p: [265.8, 298.8, 231.4, 282, 231.4, 282]}, + {t: 'C', p: [197, 269.2, 173, 294.8, 169.8, 295.6]}, + {t: 'C', p: [166.6, 296.4, 161.8, 299.6, 161, 293.2]}, + {t: 'C', p: [160.2, 286.8, 152.69, 270.099, 121, 296.4]}, + {t: 'C', p: [101, 313.001, 87.2, 291, 87.2, 291]}, + {t: 'L', p: [84.4, 302.6]}, {t: 'z', p: []} + ] + }, + + { + f: '#e87f3a', + s: null, + p: [ + {t: 'M', p: [333.51, 225.346]}, + {t: 'C', p: [327.11, 226.146, 301.743, 202.407, 316.71, 234.146]}, + {t: 'C', p: [333.31, 269.346, 258.31, 273.346, 241.51, 262.146]}, + {t: 'C', p: [224.709, 250.946, 248.71, 280.546, 248.71, 280.546]}, + {t: 'C', p: [267.11, 300.546, 232.709, 283.746, 232.709, 283.746]}, + {t: 'C', p: [198.309, 270.946, 174.309, 296.546, 171.109, 297.346]}, + {t: 'C', p: [167.909, 298.146, 163.109, 301.346, 162.309, 294.946]}, + {t: 'C', p: [161.509, 288.546, 154.13, 272.012, 122.309, 298.146]}, + {t: 'C', p: [101.073, 315.492, 87.582, 294.037, 87.582, 294.037]}, + {t: 'L', p: [84.382, 304.146]}, + {t: 'C', p: [59.382, 264.346, 74.454, 322.655, 74.454, 322.655]}, + {t: 'C', p: [83.255, 357.056, 213.509, 318.146, 213.509, 318.146]}, + {t: 'C', p: [213.509, 318.146, 382.31, 287.746, 393.51, 283.746]}, + {t: 'C', p: [404.71, 279.746, 499.038, 286.073, 499.038, 286.073]}, + {t: 'L', p: [493.51, 268.764]}, + {t: 'C', p: [428.71, 222.364, 410.31, 246.146, 396.71, 242.146]}, + {t: 'C', p: [383.11, 238.146, 385.51, 247.746, 382.31, 248.546]}, + {t: 'C', p: [379.11, 249.346, 339.91, 224.546, 333.51, 225.346]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ea8c4d', + s: null, + p: [ + {t: 'M', p: [334.819, 227.091]}, + {t: 'C', p: [328.419, 227.891, 303.685, 203.862, 318.019, 235.891]}, + {t: 'C', p: [334.219, 272.092, 259.619, 275.092, 242.819, 263.892]}, + {t: 'C', p: [226.019, 252.692, 250.019, 282.292, 250.019, 282.292]}, + {t: 'C', p: [268.419, 302.292, 234.019, 285.492, 234.019, 285.492]}, + {t: 'C', p: [199.619, 272.692, 175.618, 298.292, 172.418, 299.092]}, + {t: 'C', p: [169.218, 299.892, 164.418, 303.092, 163.618, 296.692]}, + {t: 'C', p: [162.818, 290.292, 155.57, 273.925, 123.618, 299.892]}, + {t: 'C', p: [101.145, 317.983, 87.964, 297.074, 87.964, 297.074]}, + {t: 'L', p: [84.364, 305.692]}, + {t: 'C', p: [60.564, 266.692, 75.109, 325.71, 75.109, 325.71]}, + {t: 'C', p: [83.909, 360.11, 214.819, 319.892, 214.819, 319.892]}, + {t: 'C', p: [214.819, 319.892, 383.619, 289.492, 394.819, 285.492]}, + {t: 'C', p: [406.019, 281.492, 499.474, 287.746, 499.474, 287.746]}, + {t: 'L', p: [494.02, 269.928]}, + {t: 'C', p: [429.219, 223.528, 411.619, 247.891, 398.019, 243.891]}, + {t: 'C', p: [384.419, 239.891, 386.819, 249.491, 383.619, 250.292]}, + {t: 'C', p: [380.419, 251.092, 341.219, 226.291, 334.819, 227.091]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ec9961', + s: null, + p: [ + {t: 'M', p: [336.128, 228.837]}, + {t: 'C', p: [329.728, 229.637, 304.999, 205.605, 319.328, 237.637]}, + {t: 'C', p: [336.128, 275.193, 260.394, 276.482, 244.128, 265.637]}, + {t: 'C', p: [227.328, 254.437, 251.328, 284.037, 251.328, 284.037]}, + {t: 'C', p: [269.728, 304.037, 235.328, 287.237, 235.328, 287.237]}, + {t: 'C', p: [200.928, 274.437, 176.928, 300.037, 173.728, 300.837]}, + {t: 'C', p: [170.528, 301.637, 165.728, 304.837, 164.928, 298.437]}, + {t: 'C', p: [164.128, 292.037, 157.011, 275.839, 124.927, 301.637]}, + {t: 'C', p: [101.218, 320.474, 88.345, 300.11, 88.345, 300.11]}, + {t: 'L', p: [84.345, 307.237]}, + {t: 'C', p: [62.545, 270.437, 75.764, 328.765, 75.764, 328.765]}, + {t: 'C', p: [84.564, 363.165, 216.128, 321.637, 216.128, 321.637]}, + {t: 'C', p: [216.128, 321.637, 384.928, 291.237, 396.129, 287.237]}, + {t: 'C', p: [407.329, 283.237, 499.911, 289.419, 499.911, 289.419]}, + {t: 'L', p: [494.529, 271.092]}, + {t: 'C', p: [429.729, 224.691, 412.929, 249.637, 399.329, 245.637]}, + {t: 'C', p: [385.728, 241.637, 388.128, 251.237, 384.928, 252.037]}, + {t: 'C', p: [381.728, 252.837, 342.528, 228.037, 336.128, 228.837]}, + {t: 'z', p: []} + ] + }, + + { + f: '#eea575', + s: null, + p: [ + {t: 'M', p: [337.438, 230.583]}, + {t: 'C', p: [331.037, 231.383, 306.814, 207.129, 320.637, 239.383]}, + {t: 'C', p: [337.438, 278.583, 262.237, 278.583, 245.437, 267.383]}, + {t: 'C', p: [228.637, 256.183, 252.637, 285.783, 252.637, 285.783]}, + {t: 'C', p: [271.037, 305.783, 236.637, 288.983, 236.637, 288.983]}, + {t: 'C', p: [202.237, 276.183, 178.237, 301.783, 175.037, 302.583]}, + {t: 'C', p: [171.837, 303.383, 167.037, 306.583, 166.237, 300.183]}, + {t: 'C', p: [165.437, 293.783, 158.452, 277.752, 126.237, 303.383]}, + {t: 'C', p: [101.291, 322.965, 88.727, 303.146, 88.727, 303.146]}, + {t: 'L', p: [84.327, 308.783]}, + {t: 'C', p: [64.527, 273.982, 76.418, 331.819, 76.418, 331.819]}, + {t: 'C', p: [85.218, 366.22, 217.437, 323.383, 217.437, 323.383]}, + {t: 'C', p: [217.437, 323.383, 386.238, 292.983, 397.438, 288.983]}, + {t: 'C', p: [408.638, 284.983, 500.347, 291.092, 500.347, 291.092]}, + {t: 'L', p: [495.038, 272.255]}, + {t: 'C', p: [430.238, 225.855, 414.238, 251.383, 400.638, 247.383]}, + {t: 'C', p: [387.038, 243.383, 389.438, 252.983, 386.238, 253.783]}, + {t: 'C', p: [383.038, 254.583, 343.838, 229.783, 337.438, 230.583]}, + {t: 'z', p: []} + ] + }, + + { + f: '#f1b288', + s: null, + p: [ + {t: 'M', p: [338.747, 232.328]}, + {t: 'C', p: [332.347, 233.128, 306.383, 209.677, 321.947, 241.128]}, + {t: 'C', p: [341.147, 279.928, 263.546, 280.328, 246.746, 269.128]}, + {t: 'C', p: [229.946, 257.928, 253.946, 287.528, 253.946, 287.528]}, + {t: 'C', p: [272.346, 307.528, 237.946, 290.728, 237.946, 290.728]}, + {t: 'C', p: [203.546, 277.928, 179.546, 303.528, 176.346, 304.328]}, + {t: 'C', p: [173.146, 305.128, 168.346, 308.328, 167.546, 301.928]}, + {t: 'C', p: [166.746, 295.528, 159.892, 279.665, 127.546, 305.128]}, + {t: 'C', p: [101.364, 325.456, 89.109, 306.183, 89.109, 306.183]}, + {t: 'L', p: [84.309, 310.328]}, + {t: 'C', p: [66.309, 277.128, 77.073, 334.874, 77.073, 334.874]}, + {t: 'C', p: [85.873, 369.274, 218.746, 325.128, 218.746, 325.128]}, + {t: 'C', p: [218.746, 325.128, 387.547, 294.728, 398.747, 290.728]}, + {t: 'C', p: [409.947, 286.728, 500.783, 292.764, 500.783, 292.764]}, + {t: 'L', p: [495.547, 273.419]}, + {t: 'C', p: [430.747, 227.019, 415.547, 253.128, 401.947, 249.128]}, + {t: 'C', p: [388.347, 245.128, 390.747, 254.728, 387.547, 255.528]}, + {t: 'C', p: [384.347, 256.328, 345.147, 231.528, 338.747, 232.328]}, + {t: 'z', p: []} + ] + }, + + { + f: '#f3bf9c', + s: null, + p: [ + {t: 'M', p: [340.056, 234.073]}, + {t: 'C', p: [333.655, 234.873, 307.313, 211.613, 323.255, 242.873]}, + {t: 'C', p: [343.656, 282.874, 264.855, 282.074, 248.055, 270.874]}, + {t: 'C', p: [231.255, 259.674, 255.255, 289.274, 255.255, 289.274]}, + {t: 'C', p: [273.655, 309.274, 239.255, 292.474, 239.255, 292.474]}, + {t: 'C', p: [204.855, 279.674, 180.855, 305.274, 177.655, 306.074]}, + {t: 'C', p: [174.455, 306.874, 169.655, 310.074, 168.855, 303.674]}, + {t: 'C', p: [168.055, 297.274, 161.332, 281.578, 128.855, 306.874]}, + {t: 'C', p: [101.436, 327.947, 89.491, 309.219, 89.491, 309.219]}, + {t: 'L', p: [84.291, 311.874]}, + {t: 'C', p: [68.291, 281.674, 77.727, 337.929, 77.727, 337.929]}, + {t: 'C', p: [86.527, 372.329, 220.055, 326.874, 220.055, 326.874]}, + {t: 'C', p: [220.055, 326.874, 388.856, 296.474, 400.056, 292.474]}, + {t: 'C', p: [411.256, 288.474, 501.22, 294.437, 501.22, 294.437]}, + {t: 'L', p: [496.056, 274.583]}, + {t: 'C', p: [431.256, 228.183, 416.856, 254.874, 403.256, 250.874]}, + {t: 'C', p: [389.656, 246.873, 392.056, 256.474, 388.856, 257.274]}, + {t: 'C', p: [385.656, 258.074, 346.456, 233.273, 340.056, 234.073]}, + {t: 'z', p: []} + ] + }, + + { + f: '#f5ccb0', + s: null, + p: [ + {t: 'M', p: [341.365, 235.819]}, + {t: 'C', p: [334.965, 236.619, 307.523, 213.944, 324.565, 244.619]}, + {t: 'C', p: [346.565, 284.219, 266.164, 283.819, 249.364, 272.619]}, + {t: 'C', p: [232.564, 261.419, 256.564, 291.019, 256.564, 291.019]}, + {t: 'C', p: [274.964, 311.019, 240.564, 294.219, 240.564, 294.219]}, + {t: 'C', p: [206.164, 281.419, 182.164, 307.019, 178.964, 307.819]}, + {t: 'C', p: [175.764, 308.619, 170.964, 311.819, 170.164, 305.419]}, + {t: 'C', p: [169.364, 299.019, 162.773, 283.492, 130.164, 308.619]}, + {t: 'C', p: [101.509, 330.438, 89.873, 312.256, 89.873, 312.256]}, + {t: 'L', p: [84.273, 313.419]}, + {t: 'C', p: [69.872, 285.019, 78.382, 340.983, 78.382, 340.983]}, + {t: 'C', p: [87.182, 375.384, 221.364, 328.619, 221.364, 328.619]}, + {t: 'C', p: [221.364, 328.619, 390.165, 298.219, 401.365, 294.219]}, + {t: 'C', p: [412.565, 290.219, 501.656, 296.11, 501.656, 296.11]}, + {t: 'L', p: [496.565, 275.746]}, + {t: 'C', p: [431.765, 229.346, 418.165, 256.619, 404.565, 252.619]}, + {t: 'C', p: [390.965, 248.619, 393.365, 258.219, 390.165, 259.019]}, + {t: 'C', p: [386.965, 259.819, 347.765, 235.019, 341.365, 235.819]}, + {t: 'z', p: []} + ] + }, + + { + f: '#f8d8c4', + s: null, + p: [ + {t: 'M', p: [342.674, 237.565]}, + {t: 'C', p: [336.274, 238.365, 308.832, 215.689, 325.874, 246.365]}, + {t: 'C', p: [347.874, 285.965, 267.474, 285.565, 250.674, 274.365]}, + {t: 'C', p: [233.874, 263.165, 257.874, 292.765, 257.874, 292.765]}, + {t: 'C', p: [276.274, 312.765, 241.874, 295.965, 241.874, 295.965]}, + {t: 'C', p: [207.473, 283.165, 183.473, 308.765, 180.273, 309.565]}, + {t: 'C', p: [177.073, 310.365, 172.273, 313.565, 171.473, 307.165]}, + {t: 'C', p: [170.673, 300.765, 164.214, 285.405, 131.473, 310.365]}, + {t: 'C', p: [101.582, 332.929, 90.255, 315.293, 90.255, 315.293]}, + {t: 'L', p: [84.255, 314.965]}, + {t: 'C', p: [70.654, 288.564, 79.037, 344.038, 79.037, 344.038]}, + {t: 'C', p: [87.837, 378.438, 222.673, 330.365, 222.673, 330.365]}, + {t: 'C', p: [222.673, 330.365, 391.474, 299.965, 402.674, 295.965]}, + {t: 'C', p: [413.874, 291.965, 502.093, 297.783, 502.093, 297.783]}, + {t: 'L', p: [497.075, 276.91]}, + {t: 'C', p: [432.274, 230.51, 419.474, 258.365, 405.874, 254.365]}, + {t: 'C', p: [392.274, 250.365, 394.674, 259.965, 391.474, 260.765]}, + {t: 'C', p: [388.274, 261.565, 349.074, 236.765, 342.674, 237.565]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fae5d7', + s: null, + p: [ + {t: 'M', p: [343.983, 239.31]}, + {t: 'C', p: [337.583, 240.11, 310.529, 217.223, 327.183, 248.11]}, + {t: 'C', p: [349.183, 288.91, 268.783, 287.31, 251.983, 276.11]}, + {t: 'C', p: [235.183, 264.91, 259.183, 294.51, 259.183, 294.51]}, + {t: 'C', p: [277.583, 314.51, 243.183, 297.71, 243.183, 297.71]}, + {t: 'C', p: [208.783, 284.91, 184.783, 310.51, 181.583, 311.31]}, + {t: 'C', p: [178.382, 312.11, 173.582, 315.31, 172.782, 308.91]}, + {t: 'C', p: [171.982, 302.51, 165.654, 287.318, 132.782, 312.11]}, + {t: 'C', p: [101.655, 335.42, 90.637, 318.329, 90.637, 318.329]}, + {t: 'L', p: [84.236, 316.51]}, + {t: 'C', p: [71.236, 292.51, 79.691, 347.093, 79.691, 347.093]}, + {t: 'C', p: [88.491, 381.493, 223.983, 332.11, 223.983, 332.11]}, + {t: 'C', p: [223.983, 332.11, 392.783, 301.71, 403.983, 297.71]}, + {t: 'C', p: [415.183, 293.71, 502.529, 299.456, 502.529, 299.456]}, + {t: 'L', p: [497.583, 278.074]}, + {t: 'C', p: [432.783, 231.673, 420.783, 260.11, 407.183, 256.11]}, + {t: 'C', p: [393.583, 252.11, 395.983, 261.71, 392.783, 262.51]}, + {t: 'C', p: [389.583, 263.31, 350.383, 238.51, 343.983, 239.31]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fcf2eb', + s: null, + p: [ + {t: 'M', p: [345.292, 241.055]}, + {t: 'C', p: [338.892, 241.855, 312.917, 218.411, 328.492, 249.855]}, + {t: 'C', p: [349.692, 292.656, 270.092, 289.056, 253.292, 277.856]}, + {t: 'C', p: [236.492, 266.656, 260.492, 296.256, 260.492, 296.256]}, + {t: 'C', p: [278.892, 316.256, 244.492, 299.456, 244.492, 299.456]}, + {t: 'C', p: [210.092, 286.656, 186.092, 312.256, 182.892, 313.056]}, + {t: 'C', p: [179.692, 313.856, 174.892, 317.056, 174.092, 310.656]}, + {t: 'C', p: [173.292, 304.256, 167.095, 289.232, 134.092, 313.856]}, + {t: 'C', p: [101.727, 337.911, 91.018, 321.365, 91.018, 321.365]}, + {t: 'L', p: [84.218, 318.056]}, + {t: 'C', p: [71.418, 294.856, 80.346, 350.147, 80.346, 350.147]}, + {t: 'C', p: [89.146, 384.547, 225.292, 333.856, 225.292, 333.856]}, + {t: 'C', p: [225.292, 333.856, 394.093, 303.456, 405.293, 299.456]}, + {t: 'C', p: [416.493, 295.456, 502.965, 301.128, 502.965, 301.128]}, + {t: 'L', p: [498.093, 279.237]}, + {t: 'C', p: [433.292, 232.837, 422.093, 261.856, 408.493, 257.856]}, + {t: 'C', p: [394.893, 253.855, 397.293, 263.456, 394.093, 264.256]}, + {t: 'C', p: [390.892, 265.056, 351.692, 240.255, 345.292, 241.055]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: null, + p: [ + {t: 'M', p: [84.2, 319.601]}, + {t: 'C', p: [71.4, 297.6, 81, 353.201, 81, 353.201]}, + {t: 'C', p: [89.8, 387.601, 226.6, 335.601, 226.6, 335.601]}, + {t: 'C', p: [226.6, 335.601, 395.401, 305.2, 406.601, 301.2]}, + {t: 'C', p: [417.801, 297.2, 503.401, 302.8, 503.401, 302.8]}, + {t: 'L', p: [498.601, 280.4]}, + {t: 'C', p: [433.801, 234, 423.401, 263.6, 409.801, 259.6]}, + {t: 'C', p: [396.201, 255.6, 398.601, 265.2, 395.401, 266]}, + {t: 'C', p: [392.201, 266.8, 353.001, 242, 346.601, 242.8]}, + {t: 'C', p: [340.201, 243.6, 314.981, 219.793, 329.801, 251.6]}, + {t: 'C', p: [352.028, 299.307, 269.041, 289.227, 254.6, 279.6]}, + {t: 'C', p: [237.8, 268.4, 261.8, 298, 261.8, 298]}, + {t: 'C', p: [280.2, 318.001, 245.8, 301.2, 245.8, 301.2]}, + {t: 'C', p: [211.4, 288.4, 187.4, 314.001, 184.2, 314.801]}, + {t: 'C', p: [181, 315.601, 176.2, 318.801, 175.4, 312.401]}, + {t: 'C', p: [174.6, 306, 168.535, 291.144, 135.4, 315.601]}, + {t: 'C', p: [101.8, 340.401, 91.4, 324.401, 91.4, 324.401]}, + {t: 'L', p: [84.2, 319.601]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [125.8, 349.601]}, + {t: 'C', p: [125.8, 349.601, 118.6, 361.201, 139.4, 374.401]}, + {t: 'C', p: [139.4, 374.401, 140.8, 375.801, 122.8, 371.601]}, + {t: 'C', p: [122.8, 371.601, 116.6, 369.601, 115, 359.201]}, + {t: 'C', p: [115, 359.201, 110.2, 354.801, 105.4, 349.201]}, + {t: 'C', p: [100.6, 343.601, 125.8, 349.601, 125.8, 349.601]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [265.8, 302]}, + {t: 'C', p: [265.8, 302, 283.498, 328.821, 282.9, 333.601]}, + {t: 'C', p: [281.6, 344.001, 281.4, 353.601, 284.6, 357.601]}, + {t: 'C', p: [287.801, 361.601, 296.601, 394.801, 296.601, 394.801]}, + {t: 'C', p: [296.601, 394.801, 296.201, 396.001, 308.601, 358.001]}, + {t: 'C', p: [308.601, 358.001, 320.201, 342.001, 300.201, 323.601]}, + {t: 'C', p: [300.201, 323.601, 265, 294.8, 265.8, 302]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [145.8, 376.401]}, + {t: 'C', p: [145.8, 376.401, 157, 383.601, 142.6, 414.801]}, + {t: 'L', p: [149, 412.401]}, + {t: 'C', p: [149, 412.401, 148.2, 423.601, 145, 426.001]}, + {t: 'L', p: [152.2, 422.801]}, + {t: 'C', p: [152.2, 422.801, 157, 430.801, 153, 435.601]}, + {t: 'C', p: [153, 435.601, 169.8, 443.601, 169, 450.001]}, + {t: 'C', p: [169, 450.001, 175.4, 442.001, 171.4, 435.601]}, + {t: 'C', p: [167.4, 429.201, 160.2, 433.201, 161, 414.801]}, + {t: 'L', p: [152.2, 418.001]}, + {t: 'C', p: [152.2, 418.001, 157.8, 409.201, 157.8, 402.801]}, + {t: 'L', p: [149.8, 405.201]}, + {t: 'C', p: [149.8, 405.201, 165.269, 378.623, 154.6, 377.201]}, + {t: 'C', p: [148.6, 376.401, 145.8, 376.401, 145.8, 376.401]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [178.2, 393.201]}, + {t: 'C', p: [178.2, 393.201, 181, 388.801, 178.2, 389.601]}, + {t: 'C', p: [175.4, 390.401, 144.2, 405.201, 138.2, 414.801]}, + {t: 'C', p: [138.2, 414.801, 172.6, 390.401, 178.2, 393.201]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [188.6, 401.201]}, + {t: 'C', p: [188.6, 401.201, 191.4, 396.801, 188.6, 397.601]}, + {t: 'C', p: [185.8, 398.401, 154.6, 413.201, 148.6, 422.801]}, + {t: 'C', p: [148.6, 422.801, 183, 398.401, 188.6, 401.201]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [201.8, 386.001]}, + {t: 'C', p: [201.8, 386.001, 204.6, 381.601, 201.8, 382.401]}, + {t: 'C', p: [199, 383.201, 167.8, 398.001, 161.8, 407.601]}, + {t: 'C', p: [161.8, 407.601, 196.2, 383.201, 201.8, 386.001]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [178.6, 429.601]}, + {t: 'C', p: [178.6, 429.601, 178.6, 423.601, 175.8, 424.401]}, + {t: 'C', p: [173, 425.201, 137, 442.801, 131, 452.401]}, + {t: 'C', p: [131, 452.401, 173, 426.801, 178.6, 429.601]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [179.8, 418.801]}, + {t: 'C', p: [179.8, 418.801, 181, 414.001, 178.2, 414.801]}, + {t: 'C', p: [176.2, 414.801, 149.8, 426.401, 143.8, 436.001]}, + {t: 'C', p: [143.8, 436.001, 173.4, 414.401, 179.8, 418.801]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [165.4, 466.401]}, + {t: 'L', p: [155.4, 474.001]}, + {t: 'C', p: [155.4, 474.001, 165.8, 466.401, 169.4, 467.601]}, + {t: 'C', p: [169.4, 467.601, 162.6, 478.801, 161.8, 484.001]}, + {t: 'C', p: [161.8, 484.001, 172.2, 471.201, 177.8, 471.601]}, + {t: 'C', p: [177.8, 471.601, 185.4, 472.001, 185.4, 482.801]}, + {t: 'C', p: [185.4, 482.801, 191, 472.401, 194.2, 472.801]}, + {t: 'C', p: [194.2, 472.801, 195.4, 479.201, 194.2, 486.001]}, + {t: 'C', p: [194.2, 486.001, 198.2, 478.401, 202.2, 480.001]}, + {t: 'C', p: [202.2, 480.001, 208.6, 478.001, 207.8, 489.601]}, + {t: 'C', p: [207.8, 489.601, 207.8, 500.001, 207, 502.801]}, + {t: 'C', p: [207, 502.801, 212.6, 476.401, 215, 476.001]}, + {t: 'C', p: [215, 476.001, 223, 474.801, 227.8, 483.601]}, + {t: 'C', p: [227.8, 483.601, 223.8, 476.001, 228.6, 478.001]}, + {t: 'C', p: [228.6, 478.001, 239.4, 479.601, 242.6, 486.401]}, + {t: 'C', p: [242.6, 486.401, 235.8, 474.401, 241.4, 477.601]}, + {t: 'C', p: [241.4, 477.601, 248.2, 477.601, 249.4, 484.001]}, + {t: 'C', p: [249.4, 484.001, 257.8, 505.201, 259.8, 506.801]}, + {t: 'C', p: [259.8, 506.801, 252.2, 485.201, 253.8, 485.201]}, + {t: 'C', p: [253.8, 485.201, 251.8, 473.201, 257, 488.001]}, + {t: 'C', p: [257, 488.001, 253.8, 474.001, 259.4, 474.801]}, + {t: 'C', p: [265, 475.601, 269.4, 485.601, 277.8, 483.201]}, + {t: 'C', p: [277.8, 483.201, 287.401, 488.801, 289.401, 419.601]}, + {t: 'L', p: [165.4, 466.401]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [170.2, 373.601]}, + {t: 'C', p: [170.2, 373.601, 185, 367.601, 225, 373.601]}, + {t: 'C', p: [225, 373.601, 232.2, 374.001, 239, 365.201]}, + {t: 'C', p: [245.8, 356.401, 272.6, 349.201, 279, 351.201]}, + {t: 'L', p: [288.601, 357.601]}, {t: 'L', p: [289.401, 358.801]}, + {t: 'C', p: [289.401, 358.801, 301.801, 369.201, 302.201, 376.801]}, + {t: 'C', p: [302.601, 384.401, 287.801, 432.401, 278.2, 448.401]}, + {t: 'C', p: [268.6, 464.401, 259, 476.801, 239.8, 474.401]}, + {t: 'C', p: [239.8, 474.401, 219, 470.401, 193.4, 474.401]}, + {t: 'C', p: [193.4, 474.401, 164.2, 472.801, 161.4, 464.801]}, + {t: 'C', p: [158.6, 456.801, 172.6, 441.601, 172.6, 441.601]}, + {t: 'C', p: [172.6, 441.601, 177, 433.201, 175.8, 418.801]}, + {t: 'C', p: [174.6, 404.401, 175, 376.401, 170.2, 373.601]}, + {t: 'z', p: []} + ] + }, + + { + f: '#e5668c', + s: null, + p: [ + {t: 'M', p: [192.2, 375.601]}, + {t: 'C', p: [200.6, 394.001, 171, 459.201, 171, 459.201]}, + {t: 'C', p: [169, 460.801, 183.66, 466.846, 193.8, 464.401]}, + {t: 'C', p: [204.746, 461.763, 245, 466.001, 245, 466.001]}, + {t: 'C', p: [268.6, 450.401, 281.4, 406.001, 281.4, 406.001]}, + {t: 'C', p: [281.4, 406.001, 291.801, 382.001, 274.2, 378.801]}, + {t: 'C', p: [256.6, 375.601, 192.2, 375.601, 192.2, 375.601]}, + {t: 'z', p: []} + ] + }, + + { + f: '#b23259', + s: null, + p: [ + {t: 'M', p: [190.169, 406.497]}, + {t: 'C', p: [193.495, 393.707, 195.079, 381.906, 192.2, 375.601]}, + {t: 'C', p: [192.2, 375.601, 254.6, 382.001, 265.8, 361.201]}, + {t: 'C', p: [270.041, 353.326, 284.801, 384.001, 284.4, 393.601]}, + {t: 'C', p: [284.4, 393.601, 221.4, 408.001, 206.6, 396.801]}, + {t: 'L', p: [190.169, 406.497]}, {t: 'z', p: []} + ] + }, + + { + f: '#a5264c', + s: null, + p: [ + {t: 'M', p: [194.6, 422.801]}, + {t: 'C', p: [194.6, 422.801, 196.6, 430.001, 194.2, 434.001]}, + {t: 'C', p: [194.2, 434.001, 192.6, 434.801, 191.4, 435.201]}, + {t: 'C', p: [191.4, 435.201, 192.6, 438.801, 198.6, 440.401]}, + {t: 'C', p: [198.6, 440.401, 200.6, 444.801, 203, 445.201]}, + {t: 'C', p: [205.4, 445.601, 210.2, 451.201, 214.2, 450.001]}, + {t: 'C', p: [218.2, 448.801, 229.4, 444.801, 229.4, 444.801]}, + {t: 'C', p: [229.4, 444.801, 235, 441.601, 243.8, 445.201]}, + {t: 'C', p: [243.8, 445.201, 246.175, 444.399, 246.6, 440.401]}, + {t: 'C', p: [247.1, 435.701, 250.2, 432.001, 252.2, 430.001]}, + {t: 'C', p: [254.2, 428.001, 263.8, 415.201, 262.6, 414.801]}, + {t: 'C', p: [261.4, 414.401, 194.6, 422.801, 194.6, 422.801]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ff727f', + s: '#000', + p: [ + {t: 'M', p: [190.2, 374.401]}, + {t: 'C', p: [190.2, 374.401, 187.4, 396.801, 190.6, 405.201]}, + {t: 'C', p: [193.8, 413.601, 193, 415.601, 192.2, 419.601]}, + {t: 'C', p: [191.4, 423.601, 195.8, 433.601, 201.4, 439.601]}, + {t: 'L', p: [213.4, 441.201]}, + {t: 'C', p: [213.4, 441.201, 228.6, 437.601, 237.8, 440.401]}, + {t: 'C', p: [237.8, 440.401, 246.794, 441.744, 250.2, 426.801]}, + {t: 'C', p: [250.2, 426.801, 255, 420.401, 262.2, 417.601]}, + {t: 'C', p: [269.4, 414.801, 276.6, 373.201, 272.6, 365.201]}, + {t: 'C', p: [268.6, 357.201, 254.2, 352.801, 238.2, 368.401]}, + {t: 'C', p: [222.2, 384.001, 220.2, 367.201, 190.2, 374.401]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [191.8, 449.201]}, + {t: 'C', p: [191.8, 449.201, 191, 447.201, 186.6, 446.801]}, + {t: 'C', p: [186.6, 446.801, 164.2, 443.201, 155.8, 430.801]}, + {t: 'C', p: [155.8, 430.801, 149, 425.201, 153.4, 436.801]}, + {t: 'C', p: [153.4, 436.801, 163.8, 457.201, 170.6, 460.001]}, + {t: 'C', p: [170.6, 460.001, 187, 464.001, 191.8, 449.201]}, + {t: 'z', p: []} + ] + }, + + { + f: '#cc3f4c', + s: null, + p: [ + {t: 'M', p: [271.742, 385.229]}, + {t: 'C', p: [272.401, 377.323, 274.354, 368.709, 272.6, 365.201]}, + {t: 'C', p: [266.154, 352.307, 249.181, 357.695, 238.2, 368.401]}, + {t: 'C', p: [222.2, 384.001, 220.2, 367.201, 190.2, 374.401]}, + {t: 'C', p: [190.2, 374.401, 188.455, 388.364, 189.295, 398.376]}, + {t: 'C', p: [189.295, 398.376, 226.6, 386.801, 227.4, 392.401]}, + {t: 'C', p: [227.4, 392.401, 229, 389.201, 238.2, 389.201]}, + {t: 'C', p: [247.4, 389.201, 270.142, 388.029, 271.742, 385.229]}, + {t: 'z', p: []} + ] + }, + + { + f: null, + s: {c: '#a51926', w: 2}, + p: [ + {t: 'M', p: [228.6, 375.201]}, + {t: 'C', p: [228.6, 375.201, 233.4, 380.001, 229.8, 389.601]}, + {t: 'C', p: [229.8, 389.601, 215.4, 405.601, 217.4, 419.601]} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [180.6, 460.001]}, + {t: 'C', p: [180.6, 460.001, 176.2, 447.201, 185, 454.001]}, + {t: 'C', p: [185, 454.001, 189.8, 456.001, 188.6, 457.601]}, + {t: 'C', p: [187.4, 459.201, 181.8, 463.201, 180.6, 460.001]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [185.64, 461.201]}, + {t: 'C', p: [185.64, 461.201, 182.12, 450.961, 189.16, 456.401]}, + {t: 'C', p: [189.16, 456.401, 193.581, 458.849, 192.04, 459.281]}, + {t: 'C', p: [187.48, 460.561, 192.04, 463.121, 185.64, 461.201]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [190.44, 461.201]}, + {t: 'C', p: [190.44, 461.201, 186.92, 450.961, 193.96, 456.401]}, + {t: 'C', p: [193.96, 456.401, 198.335, 458.711, 196.84, 459.281]}, + {t: 'C', p: [193.48, 460.561, 196.84, 463.121, 190.44, 461.201]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [197.04, 461.401]}, + {t: 'C', p: [197.04, 461.401, 193.52, 451.161, 200.56, 456.601]}, + {t: 'C', p: [200.56, 456.601, 204.943, 458.933, 203.441, 459.481]}, + {t: 'C', p: [200.48, 460.561, 203.441, 463.321, 197.04, 461.401]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [203.52, 461.321]}, + {t: 'C', p: [203.52, 461.321, 200, 451.081, 207.041, 456.521]}, + {t: 'C', p: [207.041, 456.521, 210.881, 458.121, 209.921, 459.401]}, + {t: 'C', p: [208.961, 460.681, 209.921, 463.241, 203.52, 461.321]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [210.2, 462.001]}, + {t: 'C', p: [210.2, 462.001, 205.4, 449.601, 214.6, 456.001]}, + {t: 'C', p: [214.6, 456.001, 219.4, 458.001, 218.2, 459.601]}, + {t: 'C', p: [217, 461.201, 218.2, 464.401, 210.2, 462.001]}, + {t: 'z', p: []} + ] + }, + + { + f: null, + s: {c: '#a5264c', w: 2}, + p: [ + {t: 'M', p: [181.8, 444.801]}, + {t: 'C', p: [181.8, 444.801, 195, 442.001, 201, 445.201]}, + {t: 'C', p: [201, 445.201, 207, 446.401, 208.2, 446.001]}, + {t: 'C', p: [209.4, 445.601, 212.6, 445.201, 212.6, 445.201]} + ] + }, + + { + f: null, + s: {c: '#a5264c', w: 2}, + p: [ + {t: 'M', p: [215.8, 453.601]}, + {t: 'C', p: [215.8, 453.601, 227.8, 440.001, 239.8, 444.401]}, + {t: 'C', p: [246.816, 446.974, 245.8, 443.601, 246.6, 440.801]}, + {t: 'C', p: [247.4, 438.001, 247.6, 433.801, 252.6, 430.801]} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [233, 437.601]}, + {t: 'C', p: [233, 437.601, 229, 426.801, 226.2, 439.601]}, + {t: 'C', p: [223.4, 452.401, 220.2, 456.001, 218.6, 458.801]}, + {t: 'C', p: [218.6, 458.801, 218.6, 464.001, 227, 463.601]}, + {t: 'C', p: [227, 463.601, 237.8, 463.201, 238.2, 460.401]}, + {t: 'C', p: [238.6, 457.601, 237, 446.001, 233, 437.601]}, {t: 'z', p: []} + ] + }, + + { + f: null, + s: {c: '#a5264c', w: 2}, + p: [ + {t: 'M', p: [247, 444.801]}, + {t: 'C', p: [247, 444.801, 250.6, 442.401, 253, 443.601]} + ] + }, + + { + f: null, + s: {c: '#a5264c', w: 2}, + p: [ + {t: 'M', p: [253.5, 428.401]}, + {t: 'C', p: [253.5, 428.401, 256.4, 423.501, 261.2, 422.701]} + ] + }, + + { + f: '#b2b2b2', + s: null, + p: [ + {t: 'M', p: [174.2, 465.201]}, + {t: 'C', p: [174.2, 465.201, 192.2, 468.401, 196.6, 466.801]}, + {t: 'C', p: [196.6, 466.801, 205.4, 466.801, 197, 468.801]}, + {t: 'C', p: [197, 468.801, 184.2, 468.801, 176.2, 467.601]}, + {t: 'C', p: [176.2, 467.601, 164.6, 462.001, 174.2, 465.201]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [188.2, 372.001]}, + {t: 'C', p: [188.2, 372.001, 205.8, 372.001, 207.8, 372.801]}, + {t: 'C', p: [207.8, 372.801, 215, 403.601, 211.4, 411.201]}, + {t: 'C', p: [211.4, 411.201, 210.2, 414.001, 207.4, 408.401]}, + {t: 'C', p: [207.4, 408.401, 189, 375.601, 185.8, 373.601]}, + {t: 'C', p: [182.6, 371.601, 187, 372.001, 188.2, 372.001]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [111.1, 369.301]}, + {t: 'C', p: [111.1, 369.301, 120, 371.001, 132.6, 373.601]}, + {t: 'C', p: [132.6, 373.601, 137.4, 396.001, 140.6, 400.801]}, + {t: 'C', p: [143.8, 405.601, 140.2, 405.601, 136.6, 402.801]}, + {t: 'C', p: [133, 400.001, 118.2, 386.001, 116.2, 381.601]}, + {t: 'C', p: [114.2, 377.201, 111.1, 369.301, 111.1, 369.301]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [132.961, 373.818]}, + {t: 'C', p: [132.961, 373.818, 138.761, 375.366, 139.77, 377.581]}, + {t: 'C', p: [140.778, 379.795, 138.568, 383.092, 138.568, 383.092]}, + {t: 'C', p: [138.568, 383.092, 137.568, 386.397, 136.366, 384.235]}, + {t: 'C', p: [135.164, 382.072, 132.292, 374.412, 132.961, 373.818]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [133, 373.601]}, + {t: 'C', p: [133, 373.601, 136.6, 378.801, 140.2, 378.801]}, + {t: 'C', p: [143.8, 378.801, 144.182, 378.388, 147, 379.001]}, + {t: 'C', p: [151.6, 380.001, 151.2, 378.001, 157.8, 379.201]}, + {t: 'C', p: [160.44, 379.681, 163, 378.801, 165.8, 380.001]}, + {t: 'C', p: [168.6, 381.201, 171.8, 380.401, 173, 378.401]}, + {t: 'C', p: [174.2, 376.401, 179, 372.201, 179, 372.201]}, + {t: 'C', p: [179, 372.201, 166.2, 374.001, 163.4, 374.801]}, + {t: 'C', p: [163.4, 374.801, 141, 376.001, 133, 373.601]}, {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [177.6, 373.801]}, + {t: 'C', p: [177.6, 373.801, 171.15, 377.301, 170.75, 379.701]}, + {t: 'C', p: [170.35, 382.101, 176, 385.801, 176, 385.801]}, + {t: 'C', p: [176, 385.801, 178.75, 390.401, 179.35, 388.001]}, + {t: 'C', p: [179.95, 385.601, 178.4, 374.201, 177.6, 373.801]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [140.115, 379.265]}, + {t: 'C', p: [140.115, 379.265, 147.122, 390.453, 147.339, 379.242]}, + {t: 'C', p: [147.339, 379.242, 147.896, 377.984, 146.136, 377.962]}, + {t: 'C', p: [140.061, 377.886, 141.582, 373.784, 140.115, 379.265]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [147.293, 379.514]}, + {t: 'C', p: [147.293, 379.514, 155.214, 390.701, 154.578, 379.421]}, + {t: 'C', p: [154.578, 379.421, 154.585, 379.089, 152.832, 378.936]}, + {t: 'C', p: [148.085, 378.522, 148.43, 374.004, 147.293, 379.514]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [154.506, 379.522]}, + {t: 'C', p: [154.506, 379.522, 162.466, 390.15, 161.797, 380.484]}, + {t: 'C', p: [161.797, 380.484, 161.916, 379.251, 160.262, 378.95]}, + {t: 'C', p: [156.37, 378.244, 156.159, 374.995, 154.506, 379.522]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ffc', + s: {c: '#000', w: 0.5}, + p: [ + {t: 'M', p: [161.382, 379.602]}, + {t: 'C', p: [161.382, 379.602, 169.282, 391.163, 169.63, 381.382]}, + {t: 'C', p: [169.63, 381.382, 171.274, 380.004, 169.528, 379.782]}, + {t: 'C', p: [163.71, 379.042, 164.508, 374.588, 161.382, 379.602]}, + {t: 'z', p: []} + ] + }, + + { + f: '#e5e5b2', + s: null, + p: [ + {t: 'M', p: [125.208, 383.132]}, {t: 'L', p: [117.55, 381.601]}, + {t: 'C', p: [114.95, 376.601, 112.85, 370.451, 112.85, 370.451]}, + {t: 'C', p: [112.85, 370.451, 119.2, 371.451, 131.7, 374.251]}, + {t: 'C', p: [131.7, 374.251, 132.576, 377.569, 134.048, 383.364]}, + {t: 'L', p: [125.208, 383.132]}, {t: 'z', p: []} + ] + }, + + { + f: '#e5e5b2', + s: null, + p: [ + {t: 'M', p: [190.276, 378.47]}, + {t: 'C', p: [188.61, 375.964, 187.293, 374.206, 186.643, 373.8]}, + {t: 'C', p: [183.63, 371.917, 187.773, 372.294, 188.902, 372.294]}, + {t: 'C', p: [188.902, 372.294, 205.473, 372.294, 207.356, 373.047]}, + {t: 'C', p: [207.356, 373.047, 207.88, 375.289, 208.564, 378.68]}, + {t: 'C', p: [208.564, 378.68, 198.476, 376.67, 190.276, 378.47]}, + {t: 'z', p: []} + ] + }, + + { + f: '#cc7226', + s: null, + p: [ + {t: 'M', p: [243.88, 240.321]}, + {t: 'C', p: [271.601, 244.281, 297.121, 208.641, 298.881, 198.96]}, + {t: 'C', p: [300.641, 189.28, 290.521, 177.4, 290.521, 177.4]}, + {t: 'C', p: [291.841, 174.32, 287.001, 160.24, 281.721, 151]}, + {t: 'C', p: [276.441, 141.76, 260.54, 142.734, 243, 141.76]}, + {t: 'C', p: [227.16, 140.88, 208.68, 164.2, 207.36, 165.96]}, + {t: 'C', p: [206.04, 167.72, 212.2, 206.001, 213.52, 211.721]}, + {t: 'C', p: [214.84, 217.441, 212.2, 243.841, 212.2, 243.841]}, + {t: 'C', p: [246.44, 234.741, 216.16, 236.361, 243.88, 240.321]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ea8e51', + s: null, + p: [ + {t: 'M', p: [208.088, 166.608]}, + {t: 'C', p: [206.792, 168.336, 212.84, 205.921, 214.136, 211.537]}, + {t: 'C', p: [215.432, 217.153, 212.84, 243.073, 212.84, 243.073]}, + {t: 'C', p: [245.512, 234.193, 216.728, 235.729, 243.944, 239.617]}, + {t: 'C', p: [271.161, 243.505, 296.217, 208.513, 297.945, 199.008]}, + {t: 'C', p: [299.673, 189.504, 289.737, 177.84, 289.737, 177.84]}, + {t: 'C', p: [291.033, 174.816, 286.281, 160.992, 281.097, 151.92]}, + {t: 'C', p: [275.913, 142.848, 260.302, 143.805, 243.08, 142.848]}, + {t: 'C', p: [227.528, 141.984, 209.384, 164.88, 208.088, 166.608]}, + {t: 'z', p: []} + ] + }, + + { + f: '#efaa7c', + s: null, + p: [ + {t: 'M', p: [208.816, 167.256]}, + {t: 'C', p: [207.544, 168.952, 213.48, 205.841, 214.752, 211.353]}, + {t: 'C', p: [216.024, 216.865, 213.48, 242.305, 213.48, 242.305]}, + {t: 'C', p: [244.884, 233.145, 217.296, 235.097, 244.008, 238.913]}, + {t: 'C', p: [270.721, 242.729, 295.313, 208.385, 297.009, 199.056]}, + {t: 'C', p: [298.705, 189.728, 288.953, 178.28, 288.953, 178.28]}, + {t: 'C', p: [290.225, 175.312, 285.561, 161.744, 280.473, 152.84]}, + {t: 'C', p: [275.385, 143.936, 260.063, 144.875, 243.16, 143.936]}, + {t: 'C', p: [227.896, 143.088, 210.088, 165.56, 208.816, 167.256]}, + {t: 'z', p: []} + ] + }, + + { + f: '#f4c6a8', + s: null, + p: [ + {t: 'M', p: [209.544, 167.904]}, + {t: 'C', p: [208.296, 169.568, 214.12, 205.761, 215.368, 211.169]}, + {t: 'C', p: [216.616, 216.577, 214.12, 241.537, 214.12, 241.537]}, + {t: 'C', p: [243.556, 232.497, 217.864, 234.465, 244.072, 238.209]}, + {t: 'C', p: [270.281, 241.953, 294.409, 208.257, 296.073, 199.105]}, + {t: 'C', p: [297.737, 189.952, 288.169, 178.72, 288.169, 178.72]}, + {t: 'C', p: [289.417, 175.808, 284.841, 162.496, 279.849, 153.76]}, + {t: 'C', p: [274.857, 145.024, 259.824, 145.945, 243.24, 145.024]}, + {t: 'C', p: [228.264, 144.192, 210.792, 166.24, 209.544, 167.904]}, + {t: 'z', p: []} + ] + }, + + { + f: '#f9e2d3', + s: null, + p: [ + {t: 'M', p: [210.272, 168.552]}, + {t: 'C', p: [209.048, 170.184, 214.76, 205.681, 215.984, 210.985]}, + {t: 'C', p: [217.208, 216.289, 214.76, 240.769, 214.76, 240.769]}, + {t: 'C', p: [242.628, 231.849, 218.432, 233.833, 244.136, 237.505]}, + {t: 'C', p: [269.841, 241.177, 293.505, 208.129, 295.137, 199.152]}, + {t: 'C', p: [296.769, 190.176, 287.385, 179.16, 287.385, 179.16]}, + {t: 'C', p: [288.609, 176.304, 284.121, 163.248, 279.225, 154.68]}, + {t: 'C', p: [274.329, 146.112, 259.585, 147.015, 243.32, 146.112]}, + {t: 'C', p: [228.632, 145.296, 211.496, 166.92, 210.272, 168.552]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: null, + p: [ + {t: 'M', p: [244.2, 236.8]}, + {t: 'C', p: [269.4, 240.4, 292.601, 208, 294.201, 199.2]}, + {t: 'C', p: [295.801, 190.4, 286.601, 179.6, 286.601, 179.6]}, + {t: 'C', p: [287.801, 176.8, 283.4, 164, 278.6, 155.6]}, + {t: 'C', p: [273.8, 147.2, 259.346, 148.086, 243.4, 147.2]}, + {t: 'C', p: [229, 146.4, 212.2, 167.6, 211, 169.2]}, + {t: 'C', p: [209.8, 170.8, 215.4, 205.6, 216.6, 210.8]}, + {t: 'C', p: [217.8, 216, 215.4, 240, 215.4, 240]}, + {t: 'C', p: [240.9, 231.4, 219, 233.2, 244.2, 236.8]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [290.601, 202.8]}, + {t: 'C', p: [290.601, 202.8, 262.8, 210.4, 251.2, 208.8]}, + {t: 'C', p: [251.2, 208.8, 235.4, 202.2, 226.6, 224]}, + {t: 'C', p: [226.6, 224, 223, 231.2, 221, 233.2]}, + {t: 'C', p: [219, 235.2, 290.601, 202.8, 290.601, 202.8]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [294.401, 200.6]}, + {t: 'C', p: [294.401, 200.6, 265.4, 212.8, 255.4, 212.4]}, + {t: 'C', p: [255.4, 212.4, 239, 207.8, 230.6, 222.4]}, + {t: 'C', p: [230.6, 222.4, 222.2, 231.6, 219, 233.2]}, + {t: 'C', p: [219, 233.2, 218.6, 234.8, 225, 230.8]}, + {t: 'L', p: [235.4, 236]}, + {t: 'C', p: [235.4, 236, 250.2, 245.6, 259.8, 229.6]}, + {t: 'C', p: [259.8, 229.6, 263.8, 218.4, 263.8, 216.4]}, + {t: 'C', p: [263.8, 214.4, 285, 208.8, 286.601, 208.4]}, + {t: 'C', p: [288.201, 208, 294.801, 203.8, 294.401, 200.6]}, + {t: 'z', p: []} + ] + }, + + { + f: '#99cc32', + s: null, + p: [ + {t: 'M', p: [247, 236.514]}, + {t: 'C', p: [240.128, 236.514, 231.755, 232.649, 231.755, 226.4]}, + {t: 'C', p: [231.755, 220.152, 240.128, 213.887, 247, 213.887]}, + {t: 'C', p: [253.874, 213.887, 259.446, 218.952, 259.446, 225.2]}, + {t: 'C', p: [259.446, 231.449, 253.874, 236.514, 247, 236.514]}, + {t: 'z', p: []} + ] + }, + + { + f: '#659900', + s: null, + p: [ + {t: 'M', p: [243.377, 219.83]}, + {t: 'C', p: [238.531, 220.552, 233.442, 222.055, 233.514, 221.839]}, + {t: 'C', p: [235.054, 217.22, 241.415, 213.887, 247, 213.887]}, + {t: 'C', p: [251.296, 213.887, 255.084, 215.865, 257.32, 218.875]}, + {t: 'C', p: [257.32, 218.875, 252.004, 218.545, 243.377, 219.83]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: null, + p: [ + {t: 'M', p: [255.4, 219.6]}, + {t: 'C', p: [255.4, 219.6, 251, 216.4, 251, 218.6]}, + {t: 'C', p: [251, 218.6, 254.6, 223, 255.4, 219.6]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [245.4, 227.726]}, + {t: 'C', p: [242.901, 227.726, 240.875, 225.7, 240.875, 223.2]}, + {t: 'C', p: [240.875, 220.701, 242.901, 218.675, 245.4, 218.675]}, + {t: 'C', p: [247.9, 218.675, 249.926, 220.701, 249.926, 223.2]}, + {t: 'C', p: [249.926, 225.7, 247.9, 227.726, 245.4, 227.726]}, + {t: 'z', p: []} + ] + }, + + { + f: '#cc7226', + s: null, + p: [ + {t: 'M', p: [141.4, 214.4]}, + {t: 'C', p: [141.4, 214.4, 138.2, 193.2, 140.6, 188.8]}, + {t: 'C', p: [140.6, 188.8, 151.4, 178.8, 151, 175.2]}, + {t: 'C', p: [151, 175.2, 150.6, 157.2, 149.4, 156.4]}, + {t: 'C', p: [148.2, 155.6, 140.6, 149.6, 134.6, 156]}, + {t: 'C', p: [134.6, 156, 124.2, 174, 125, 180.4]}, + {t: 'L', p: [125, 182.4]}, + {t: 'C', p: [125, 182.4, 117.4, 182, 115.8, 184]}, + {t: 'C', p: [115.8, 184, 114.6, 189.2, 113.4, 189.6]}, + {t: 'C', p: [113.4, 189.6, 110.6, 192, 112.6, 194.8]}, + {t: 'C', p: [112.6, 194.8, 110.6, 197.2, 111, 201.2]}, + {t: 'L', p: [118.6, 205.2]}, + {t: 'C', p: [118.6, 205.2, 120.6, 219.6, 131.4, 224.8]}, + {t: 'C', p: [136.236, 227.129, 139.4, 220.4, 141.4, 214.4]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: null, + p: [ + {t: 'M', p: [140.4, 212.56]}, + {t: 'C', p: [140.4, 212.56, 137.52, 193.48, 139.68, 189.52]}, + {t: 'C', p: [139.68, 189.52, 149.4, 180.52, 149.04, 177.28]}, + {t: 'C', p: [149.04, 177.28, 148.68, 161.08, 147.6, 160.36]}, + {t: 'C', p: [146.52, 159.64, 139.68, 154.24, 134.28, 160]}, + {t: 'C', p: [134.28, 160, 124.92, 176.2, 125.64, 181.96]}, + {t: 'L', p: [125.64, 183.76]}, + {t: 'C', p: [125.64, 183.76, 118.8, 183.4, 117.36, 185.2]}, + {t: 'C', p: [117.36, 185.2, 116.28, 189.88, 115.2, 190.24]}, + {t: 'C', p: [115.2, 190.24, 112.68, 192.4, 114.48, 194.92]}, + {t: 'C', p: [114.48, 194.92, 112.68, 197.08, 113.04, 200.68]}, + {t: 'L', p: [119.88, 204.28]}, + {t: 'C', p: [119.88, 204.28, 121.68, 217.24, 131.4, 221.92]}, + {t: 'C', p: [135.752, 224.015, 138.6, 217.96, 140.4, 212.56]}, + {t: 'z', p: []} + ] + }, + + { + f: '#eb955c', + s: null, + p: [ + {t: 'M', p: [148.95, 157.39]}, + {t: 'C', p: [147.86, 156.53, 140.37, 150.76, 134.52, 157]}, + {t: 'C', p: [134.52, 157, 124.38, 174.55, 125.16, 180.79]}, + {t: 'L', p: [125.16, 182.74]}, + {t: 'C', p: [125.16, 182.74, 117.75, 182.35, 116.19, 184.3]}, + {t: 'C', p: [116.19, 184.3, 115.02, 189.37, 113.85, 189.76]}, + {t: 'C', p: [113.85, 189.76, 111.12, 192.1, 113.07, 194.83]}, + {t: 'C', p: [113.07, 194.83, 111.12, 197.17, 111.51, 201.07]}, + {t: 'L', p: [118.92, 204.97]}, + {t: 'C', p: [118.92, 204.97, 120.87, 219.01, 131.4, 224.08]}, + {t: 'C', p: [136.114, 226.35, 139.2, 219.79, 141.15, 213.94]}, + {t: 'C', p: [141.15, 213.94, 138.03, 193.27, 140.37, 188.98]}, + {t: 'C', p: [140.37, 188.98, 150.9, 179.23, 150.51, 175.72]}, + {t: 'C', p: [150.51, 175.72, 150.12, 158.17, 148.95, 157.39]}, + {t: 'z', p: []} + ] + }, + + { + f: '#f2b892', + s: null, + p: [ + {t: 'M', p: [148.5, 158.38]}, + {t: 'C', p: [147.52, 157.46, 140.14, 151.92, 134.44, 158]}, + {t: 'C', p: [134.44, 158, 124.56, 175.1, 125.32, 181.18]}, + {t: 'L', p: [125.32, 183.08]}, + {t: 'C', p: [125.32, 183.08, 118.1, 182.7, 116.58, 184.6]}, + {t: 'C', p: [116.58, 184.6, 115.44, 189.54, 114.3, 189.92]}, + {t: 'C', p: [114.3, 189.92, 111.64, 192.2, 113.54, 194.86]}, + {t: 'C', p: [113.54, 194.86, 111.64, 197.14, 112.02, 200.94]}, + {t: 'L', p: [119.24, 204.74]}, + {t: 'C', p: [119.24, 204.74, 121.14, 218.42, 131.4, 223.36]}, + {t: 'C', p: [135.994, 225.572, 139, 219.18, 140.9, 213.48]}, + {t: 'C', p: [140.9, 213.48, 137.86, 193.34, 140.14, 189.16]}, + {t: 'C', p: [140.14, 189.16, 150.4, 179.66, 150.02, 176.24]}, + {t: 'C', p: [150.02, 176.24, 149.64, 159.14, 148.5, 158.38]}, + {t: 'z', p: []} + ] + }, + + { + f: '#f8dcc8', + s: null, + p: [ + {t: 'M', p: [148.05, 159.37]}, + {t: 'C', p: [147.18, 158.39, 139.91, 153.08, 134.36, 159]}, + {t: 'C', p: [134.36, 159, 124.74, 175.65, 125.48, 181.57]}, + {t: 'L', p: [125.48, 183.42]}, + {t: 'C', p: [125.48, 183.42, 118.45, 183.05, 116.97, 184.9]}, + {t: 'C', p: [116.97, 184.9, 115.86, 189.71, 114.75, 190.08]}, + {t: 'C', p: [114.75, 190.08, 112.16, 192.3, 114.01, 194.89]}, + {t: 'C', p: [114.01, 194.89, 112.16, 197.11, 112.53, 200.81]}, + {t: 'L', p: [119.56, 204.51]}, + {t: 'C', p: [119.56, 204.51, 121.41, 217.83, 131.4, 222.64]}, + {t: 'C', p: [135.873, 224.794, 138.8, 218.57, 140.65, 213.02]}, + {t: 'C', p: [140.65, 213.02, 137.69, 193.41, 139.91, 189.34]}, + {t: 'C', p: [139.91, 189.34, 149.9, 180.09, 149.53, 176.76]}, + {t: 'C', p: [149.53, 176.76, 149.16, 160.11, 148.05, 159.37]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: null, + p: [ + {t: 'M', p: [140.4, 212.46]}, + {t: 'C', p: [140.4, 212.46, 137.52, 193.48, 139.68, 189.52]}, + {t: 'C', p: [139.68, 189.52, 149.4, 180.52, 149.04, 177.28]}, + {t: 'C', p: [149.04, 177.28, 148.68, 161.08, 147.6, 160.36]}, + {t: 'C', p: [146.84, 159.32, 139.68, 154.24, 134.28, 160]}, + {t: 'C', p: [134.28, 160, 124.92, 176.2, 125.64, 181.96]}, + {t: 'L', p: [125.64, 183.76]}, + {t: 'C', p: [125.64, 183.76, 118.8, 183.4, 117.36, 185.2]}, + {t: 'C', p: [117.36, 185.2, 116.28, 189.88, 115.2, 190.24]}, + {t: 'C', p: [115.2, 190.24, 112.68, 192.4, 114.48, 194.92]}, + {t: 'C', p: [114.48, 194.92, 112.68, 197.08, 113.04, 200.68]}, + {t: 'L', p: [119.88, 204.28]}, + {t: 'C', p: [119.88, 204.28, 121.68, 217.24, 131.4, 221.92]}, + {t: 'C', p: [135.752, 224.015, 138.6, 217.86, 140.4, 212.46]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [137.3, 206.2]}, + {t: 'C', p: [137.3, 206.2, 115.7, 196, 114.8, 195.2]}, + {t: 'C', p: [114.8, 195.2, 123.9, 203.4, 124.7, 203.4]}, + {t: 'C', p: [125.5, 203.4, 137.3, 206.2, 137.3, 206.2]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [120.2, 200]}, + {t: 'C', p: [120.2, 200, 138.6, 203.6, 138.6, 208]}, + {t: 'C', p: [138.6, 210.912, 138.357, 224.331, 133, 222.8]}, + {t: 'C', p: [124.6, 220.4, 128.2, 206, 120.2, 200]}, {t: 'z', p: []} + ] + }, + + { + f: '#99cc32', + s: null, + p: [ + {t: 'M', p: [128.6, 203.8]}, + {t: 'C', p: [128.6, 203.8, 137.578, 205.274, 138.6, 208]}, + {t: 'C', p: [139.2, 209.6, 139.863, 217.908, 134.4, 219]}, + {t: 'C', p: [129.848, 219.911, 127.618, 209.69, 128.6, 203.8]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [214.595, 246.349]}, + {t: 'C', p: [214.098, 244.607, 215.409, 244.738, 217.2, 244.2]}, + {t: 'C', p: [219.2, 243.6, 231.4, 239.8, 232.2, 237.2]}, + {t: 'C', p: [233, 234.6, 246.2, 239, 246.2, 239]}, + {t: 'C', p: [248, 239.8, 252.4, 242.4, 252.4, 242.4]}, + {t: 'C', p: [257.2, 243.6, 263.8, 244, 263.8, 244]}, + {t: 'C', p: [266.2, 245, 269.6, 247.8, 269.6, 247.8]}, + {t: 'C', p: [284.2, 258, 296.601, 250.8, 296.601, 250.8]}, + {t: 'C', p: [316.601, 244.2, 310.601, 227, 310.601, 227]}, + {t: 'C', p: [307.601, 218, 310.801, 214.6, 310.801, 214.6]}, + {t: 'C', p: [311.001, 210.8, 318.201, 217.2, 318.201, 217.2]}, + {t: 'C', p: [320.801, 221.4, 321.601, 226.4, 321.601, 226.4]}, + {t: 'C', p: [329.601, 237.6, 326.201, 219.8, 326.201, 219.8]}, + {t: 'C', p: [326.401, 218.8, 323.601, 215.2, 323.601, 214]}, + {t: 'C', p: [323.601, 212.8, 321.801, 209.4, 321.801, 209.4]}, + {t: 'C', p: [318.801, 206, 321.201, 199, 321.201, 199]}, + {t: 'C', p: [323.001, 185.2, 320.801, 187, 320.801, 187]}, + {t: 'C', p: [319.601, 185.2, 310.401, 195.2, 310.401, 195.2]}, + {t: 'C', p: [308.201, 198.6, 302.201, 200.2, 302.201, 200.2]}, + {t: 'C', p: [299.401, 202, 296.001, 200.6, 296.001, 200.6]}, + {t: 'C', p: [293.401, 200.2, 287.801, 207.2, 287.801, 207.2]}, + {t: 'C', p: [290.601, 207, 293.001, 211.4, 295.401, 211.6]}, + {t: 'C', p: [297.801, 211.8, 299.601, 209.2, 301.201, 208.6]}, + {t: 'C', p: [302.801, 208, 305.601, 213.8, 305.601, 213.8]}, + {t: 'C', p: [306.001, 216.4, 300.401, 221.2, 300.401, 221.2]}, + {t: 'C', p: [300.001, 225.8, 298.401, 224.2, 298.401, 224.2]}, + {t: 'C', p: [295.401, 223.6, 294.201, 227.4, 293.201, 232]}, + {t: 'C', p: [292.201, 236.6, 288.001, 237, 288.001, 237]}, + {t: 'C', p: [286.401, 244.4, 285.2, 241.4, 285.2, 241.4]}, + {t: 'C', p: [285, 235.8, 279, 241.6, 279, 241.6]}, + {t: 'C', p: [277.8, 243.6, 273.2, 241.4, 273.2, 241.4]}, + {t: 'C', p: [266.4, 239.4, 268.8, 237.4, 268.8, 237.4]}, + {t: 'C', p: [270.6, 235.2, 281.8, 237.4, 281.8, 237.4]}, + {t: 'C', p: [284, 235.8, 276, 231.8, 276, 231.8]}, + {t: 'C', p: [275.4, 230, 276.4, 225.6, 276.4, 225.6]}, + {t: 'C', p: [277.6, 222.4, 284.4, 216.8, 284.4, 216.8]}, + {t: 'C', p: [293.801, 215.6, 291.001, 214, 291.001, 214]}, + {t: 'C', p: [284.801, 208.8, 279, 216.4, 279, 216.4]}, + {t: 'C', p: [276.8, 222.6, 259.4, 237.6, 259.4, 237.6]}, + {t: 'C', p: [254.6, 241, 257.2, 234.2, 253.2, 237.6]}, + {t: 'C', p: [249.2, 241, 228.6, 232, 228.6, 232]}, + {t: 'C', p: [217.038, 230.807, 214.306, 246.549, 210.777, 243.429]}, + {t: 'C', p: [210.777, 243.429, 216.195, 251.949, 214.595, 246.349]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [409.401, 80]}, + {t: 'C', p: [409.401, 80, 383.801, 88, 381.001, 106.8]}, + {t: 'C', p: [381.001, 106.8, 378.601, 129.6, 399.001, 147.2]}, + {t: 'C', p: [399.001, 147.2, 399.401, 153.6, 401.401, 156.8]}, + {t: 'C', p: [401.401, 156.8, 399.801, 161.6, 418.601, 154]}, + {t: 'L', p: [445.801, 145.6]}, + {t: 'C', p: [445.801, 145.6, 452.201, 143.2, 457.401, 134.4]}, + {t: 'C', p: [462.601, 125.6, 477.801, 106.8, 474.201, 81.6]}, + {t: 'C', p: [474.201, 81.6, 475.401, 70.4, 469.401, 70]}, + {t: 'C', p: [469.401, 70, 461.001, 68.4, 453.801, 76]}, + {t: 'C', p: [453.801, 76, 447.001, 79.2, 444.601, 78.8]}, + {t: 'L', p: [409.401, 80]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [464.022, 79.01]}, + {t: 'C', p: [464.022, 79.01, 466.122, 70.08, 461.282, 74.92]}, + {t: 'C', p: [461.282, 74.92, 454.242, 80.64, 446.761, 80.64]}, + {t: 'C', p: [446.761, 80.64, 432.241, 82.84, 427.841, 96.04]}, + {t: 'C', p: [427.841, 96.04, 423.881, 122.88, 431.801, 128.6]}, + {t: 'C', p: [431.801, 128.6, 436.641, 136.08, 443.681, 129.48]}, + {t: 'C', p: [450.722, 122.88, 466.222, 92.65, 464.022, 79.01]}, + {t: 'z', p: []} + ] + }, + + { + f: '#323232', + s: null, + p: [ + {t: 'M', p: [463.648, 79.368]}, + {t: 'C', p: [463.648, 79.368, 465.738, 70.624, 460.986, 75.376]}, + {t: 'C', p: [460.986, 75.376, 454.074, 80.992, 446.729, 80.992]}, + {t: 'C', p: [446.729, 80.992, 432.473, 83.152, 428.153, 96.112]}, + {t: 'C', p: [428.153, 96.112, 424.265, 122.464, 432.041, 128.08]}, + {t: 'C', p: [432.041, 128.08, 436.793, 135.424, 443.705, 128.944]}, + {t: 'C', p: [450.618, 122.464, 465.808, 92.76, 463.648, 79.368]}, + {t: 'z', p: []} + ] + }, + + { + f: '#666', + s: null, + p: [ + {t: 'M', p: [463.274, 79.726]}, + {t: 'C', p: [463.274, 79.726, 465.354, 71.168, 460.69, 75.832]}, + {t: 'C', p: [460.69, 75.832, 453.906, 81.344, 446.697, 81.344]}, + {t: 'C', p: [446.697, 81.344, 432.705, 83.464, 428.465, 96.184]}, + {t: 'C', p: [428.465, 96.184, 424.649, 122.048, 432.281, 127.56]}, + {t: 'C', p: [432.281, 127.56, 436.945, 134.768, 443.729, 128.408]}, + {t: 'C', p: [450.514, 122.048, 465.394, 92.87, 463.274, 79.726]}, + {t: 'z', p: []} + ] + }, + + { + f: '#999', + s: null, + p: [ + {t: 'M', p: [462.9, 80.084]}, + {t: 'C', p: [462.9, 80.084, 464.97, 71.712, 460.394, 76.288]}, + {t: 'C', p: [460.394, 76.288, 453.738, 81.696, 446.665, 81.696]}, + {t: 'C', p: [446.665, 81.696, 432.937, 83.776, 428.777, 96.256]}, + {t: 'C', p: [428.777, 96.256, 425.033, 121.632, 432.521, 127.04]}, + {t: 'C', p: [432.521, 127.04, 437.097, 134.112, 443.753, 127.872]}, + {t: 'C', p: [450.41, 121.632, 464.98, 92.98, 462.9, 80.084]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [462.526, 80.442]}, + {t: 'C', p: [462.526, 80.442, 464.586, 72.256, 460.098, 76.744]}, + {t: 'C', p: [460.098, 76.744, 453.569, 82.048, 446.633, 82.048]}, + {t: 'C', p: [446.633, 82.048, 433.169, 84.088, 429.089, 96.328]}, + {t: 'C', p: [429.089, 96.328, 425.417, 121.216, 432.761, 126.52]}, + {t: 'C', p: [432.761, 126.52, 437.249, 133.456, 443.777, 127.336]}, + {t: 'C', p: [450.305, 121.216, 464.566, 93.09, 462.526, 80.442]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: null, + p: [ + {t: 'M', p: [462.151, 80.8]}, + {t: 'C', p: [462.151, 80.8, 464.201, 72.8, 459.801, 77.2]}, + {t: 'C', p: [459.801, 77.2, 453.401, 82.4, 446.601, 82.4]}, + {t: 'C', p: [446.601, 82.4, 433.401, 84.4, 429.401, 96.4]}, + {t: 'C', p: [429.401, 96.4, 425.801, 120.8, 433.001, 126]}, + {t: 'C', p: [433.001, 126, 437.401, 132.8, 443.801, 126.8]}, + {t: 'C', p: [450.201, 120.8, 464.151, 93.2, 462.151, 80.8]}, + {t: 'z', p: []} + ] + }, + + { + f: '#992600', + s: null, + p: [ + {t: 'M', p: [250.6, 284]}, + {t: 'C', p: [250.6, 284, 230.2, 264.8, 222.2, 264]}, + {t: 'C', p: [222.2, 264, 187.8, 260, 173, 278]}, + {t: 'C', p: [173, 278, 190.6, 257.6, 218.2, 263.2]}, + {t: 'C', p: [218.2, 263.2, 196.6, 258.8, 184.2, 262]}, + {t: 'C', p: [184.2, 262, 167.4, 262, 157.8, 276]}, + {t: 'L', p: [155, 280.8]}, + {t: 'C', p: [155, 280.8, 159, 266, 177.4, 260]}, + {t: 'C', p: [177.4, 260, 200.2, 255.2, 211, 260]}, + {t: 'C', p: [211, 260, 189.4, 253.2, 179.4, 255.2]}, + {t: 'C', p: [179.4, 255.2, 149, 252.8, 136.2, 279.2]}, + {t: 'C', p: [136.2, 279.2, 140.2, 264.8, 155, 257.6]}, + {t: 'C', p: [155, 257.6, 168.6, 248.8, 189, 251.6]}, + {t: 'C', p: [189, 251.6, 203.4, 254.8, 208.6, 257.2]}, + {t: 'C', p: [213.8, 259.6, 212.6, 256.8, 204.2, 252]}, + {t: 'C', p: [204.2, 252, 198.6, 242, 184.6, 242.4]}, + {t: 'C', p: [184.6, 242.4, 141.8, 246, 131.4, 258]}, + {t: 'C', p: [131.4, 258, 145, 246.8, 155.4, 244]}, + {t: 'C', p: [155.4, 244, 177.8, 236, 186.2, 236.8]}, + {t: 'C', p: [186.2, 236.8, 211, 237.8, 218.6, 233.8]}, + {t: 'C', p: [218.6, 233.8, 207.4, 238.8, 210.6, 242]}, + {t: 'C', p: [213.8, 245.2, 220.6, 252.8, 220.6, 254]}, + {t: 'C', p: [220.6, 255.2, 244.8, 277.3, 248.4, 281.7]}, + {t: 'L', p: [250.6, 284]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [389, 478]}, {t: 'C', p: [389, 478, 373.5, 441.5, 361, 432]}, + {t: 'C', p: [361, 432, 387, 448, 390.5, 466]}, + {t: 'C', p: [390.5, 466, 390.5, 476, 389, 478]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [436, 485.5]}, + {t: 'C', p: [436, 485.5, 409.5, 430.5, 391, 406.5]}, + {t: 'C', p: [391, 406.5, 434.5, 444, 439.5, 470.5]}, + {t: 'L', p: [440, 476]}, {t: 'L', p: [437, 473.5]}, + {t: 'C', p: [437, 473.5, 436.5, 482.5, 436, 485.5]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [492.5, 437]}, + {t: 'C', p: [492.5, 437, 430, 377.5, 428.5, 375]}, + {t: 'C', p: [428.5, 375, 489, 441, 492, 448.5]}, + {t: 'C', p: [492, 448.5, 490, 439.5, 492.5, 437]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [304, 480.5]}, + {t: 'C', p: [304, 480.5, 323.5, 428.5, 342.5, 451]}, + {t: 'C', p: [342.5, 451, 357.5, 461, 357, 464]}, + {t: 'C', p: [357, 464, 353, 457.5, 335, 458]}, + {t: 'C', p: [335, 458, 316, 455, 304, 480.5]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [494.5, 353]}, + {t: 'C', p: [494.5, 353, 449.5, 324.5, 442, 323]}, + {t: 'C', p: [430.193, 320.639, 491.5, 352, 496.5, 362.5]}, + {t: 'C', p: [496.5, 362.5, 498.5, 360, 494.5, 353]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [343.801, 459.601]}, + {t: 'C', p: [343.801, 459.601, 364.201, 457.601, 371.001, 450.801]}, + {t: 'L', p: [375.401, 454.401]}, + {t: 'L', p: [393.001, 416.001]}, + {t: 'L', p: [396.601, 421.201]}, + {t: 'C', p: [396.601, 421.201, 411.001, 406.401, 410.201, 398.401]}, + {t: 'C', p: [409.401, 390.401, 423.001, 404.401, 423.001, 404.401]}, + {t: 'C', p: [423.001, 404.401, 422.201, 392.801, 429.401, 399.601]}, + {t: 'C', p: [429.401, 399.601, 427.001, 384.001, 435.401, 392.001]}, + {t: 'C', p: [435.401, 392.001, 424.864, 361.844, 447.401, 387.601]}, + {t: 'C', p: [453.001, 394.001, 448.601, 387.201, 448.601, 387.201]}, + {t: 'C', p: [448.601, 387.201, 422.601, 339.201, 444.201, 353.601]}, + {t: 'C', p: [444.201, 353.601, 446.201, 330.801, 445.001, 326.401]}, + {t: 'C', p: [443.801, 322.001, 441.801, 299.6, 437.001, 294.4]}, + {t: 'C', p: [432.201, 289.2, 437.401, 287.6, 443.001, 292.8]}, + {t: 'C', p: [443.001, 292.8, 431.801, 268.8, 445.001, 280.8]}, + {t: 'C', p: [445.001, 280.8, 441.401, 265.6, 437.001, 262.8]}, + {t: 'C', p: [437.001, 262.8, 431.401, 245.6, 446.601, 256.4]}, + {t: 'C', p: [446.601, 256.4, 442.201, 244, 439.001, 240.8]}, + {t: 'C', p: [439.001, 240.8, 427.401, 213.2, 434.601, 218]}, + {t: 'L', p: [439.001, 221.6]}, + {t: 'C', p: [439.001, 221.6, 432.201, 207.6, 438.601, 212]}, + {t: 'C', p: [445.001, 216.4, 445.001, 216, 445.001, 216]}, + {t: 'C', p: [445.001, 216, 423.801, 182.8, 444.201, 200.4]}, + {t: 'C', p: [444.201, 200.4, 436.042, 186.482, 432.601, 179.6]}, + {t: 'C', p: [432.601, 179.6, 413.801, 159.2, 428.201, 165.6]}, + {t: 'L', p: [433.001, 167.2]}, + {t: 'C', p: [433.001, 167.2, 424.201, 157.2, 416.201, 155.6]}, + {t: 'C', p: [408.201, 154, 418.601, 147.6, 425.001, 149.6]}, + {t: 'C', p: [431.401, 151.6, 447.001, 159.2, 447.001, 159.2]}, + {t: 'C', p: [447.001, 159.2, 459.801, 178, 463.801, 178.4]}, + {t: 'C', p: [463.801, 178.4, 443.801, 170.8, 449.801, 178.8]}, + {t: 'C', p: [449.801, 178.8, 464.201, 192.8, 457.001, 192.4]}, + {t: 'C', p: [457.001, 192.4, 451.001, 199.6, 455.801, 208.4]}, + {t: 'C', p: [455.801, 208.4, 437.342, 190.009, 452.201, 215.6]}, + {t: 'L', p: [459.001, 232]}, + {t: 'C', p: [459.001, 232, 434.601, 207.2, 445.801, 229.2]}, + {t: 'C', p: [445.801, 229.2, 463.001, 252.8, 465.001, 253.2]}, + {t: 'C', p: [467.001, 253.6, 471.401, 262.4, 471.401, 262.4]}, + {t: 'L', p: [467.001, 260.4]}, + {t: 'L', p: [472.201, 269.2]}, + {t: 'C', p: [472.201, 269.2, 461.001, 257.2, 467.001, 270.4]}, + {t: 'L', p: [472.601, 284.8]}, + {t: 'C', p: [472.601, 284.8, 452.201, 262.8, 465.801, 292.4]}, + {t: 'C', p: [465.801, 292.4, 449.401, 287.2, 458.201, 304.4]}, + {t: 'C', p: [458.201, 304.4, 456.601, 320.401, 457.001, 325.601]}, + {t: 'C', p: [457.401, 330.801, 458.601, 359.201, 454.201, 367.201]}, + {t: 'C', p: [449.801, 375.201, 460.201, 394.401, 462.201, 398.401]}, + {t: 'C', p: [464.201, 402.401, 467.801, 413.201, 459.001, 404.001]}, + {t: 'C', p: [450.201, 394.801, 454.601, 400.401, 456.601, 409.201]}, + {t: 'C', p: [458.601, 418.001, 464.601, 433.601, 463.801, 439.201]}, + {t: 'C', p: [463.801, 439.201, 462.601, 440.401, 459.401, 436.801]}, + {t: 'C', p: [459.401, 436.801, 444.601, 414.001, 446.201, 428.401]}, + {t: 'C', p: [446.201, 428.401, 445.001, 436.401, 441.801, 445.201]}, + {t: 'C', p: [441.801, 445.201, 438.601, 456.001, 438.601, 447.201]}, + {t: 'C', p: [438.601, 447.201, 435.401, 430.401, 432.601, 438.001]}, + {t: 'C', p: [429.801, 445.601, 426.201, 451.601, 423.401, 454.001]}, + {t: 'C', p: [420.601, 456.401, 415.401, 433.601, 414.201, 444.001]}, + {t: 'C', p: [414.201, 444.001, 402.201, 431.601, 397.401, 448.001]}, + {t: 'L', p: [385.801, 464.401]}, + {t: 'C', p: [385.801, 464.401, 385.401, 452.001, 384.201, 458.001]}, + {t: 'C', p: [384.201, 458.001, 354.201, 464.001, 343.801, 459.601]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [309.401, 102.8]}, + {t: 'C', p: [309.401, 102.8, 297.801, 94.8, 293.801, 95.2]}, + {t: 'C', p: [289.801, 95.6, 321.401, 86.4, 362.601, 114]}, + {t: 'C', p: [362.601, 114, 367.401, 116.8, 371.001, 116.4]}, + {t: 'C', p: [371.001, 116.4, 374.201, 118.8, 371.401, 122.4]}, + {t: 'C', p: [371.401, 122.4, 362.601, 132, 373.801, 143.2]}, + {t: 'C', p: [373.801, 143.2, 392.201, 150, 386.601, 141.2]}, + {t: 'C', p: [386.601, 141.2, 397.401, 145.2, 399.801, 149.2]}, + {t: 'C', p: [402.201, 153.2, 401.001, 149.2, 401.001, 149.2]}, + {t: 'C', p: [401.001, 149.2, 394.601, 142, 388.601, 136.8]}, + {t: 'C', p: [388.601, 136.8, 383.401, 134.8, 380.601, 126.4]}, + {t: 'C', p: [377.801, 118, 375.401, 108, 379.801, 104.8]}, + {t: 'C', p: [379.801, 104.8, 375.801, 109.2, 376.601, 105.2]}, + {t: 'C', p: [377.401, 101.2, 381.001, 97.6, 382.601, 97.2]}, + {t: 'C', p: [384.201, 96.8, 400.601, 81, 407.401, 80.6]}, + {t: 'C', p: [407.401, 80.6, 398.201, 82, 395.201, 81]}, + {t: 'C', p: [392.201, 80, 365.601, 68.6, 359.601, 67.4]}, + {t: 'C', p: [359.601, 67.4, 342.801, 60.8, 354.801, 62.8]}, + {t: 'C', p: [354.801, 62.8, 390.601, 66.6, 408.801, 79.8]}, + {t: 'C', p: [408.801, 79.8, 401.601, 71.4, 383.201, 64.4]}, + {t: 'C', p: [383.201, 64.4, 361.001, 51.8, 325.801, 56.8]}, + {t: 'C', p: [325.801, 56.8, 308.001, 60, 300.201, 61.8]}, + {t: 'C', p: [300.201, 61.8, 297.601, 61.2, 297.001, 60.8]}, + {t: 'C', p: [296.401, 60.4, 284.6, 51.4, 257, 58.4]}, + {t: 'C', p: [257, 58.4, 240, 63, 231.4, 67.8]}, + {t: 'C', p: [231.4, 67.8, 216.2, 69, 212.6, 72.2]}, + {t: 'C', p: [212.6, 72.2, 194, 86.8, 192, 87.6]}, + {t: 'C', p: [190, 88.4, 178.6, 96, 177.8, 96.4]}, + {t: 'C', p: [177.8, 96.4, 202.4, 89.8, 204.8, 87.4]}, + {t: 'C', p: [207.2, 85, 224.6, 82.4, 227, 83.8]}, + {t: 'C', p: [229.4, 85.2, 237.8, 84.6, 228.2, 85.2]}, + {t: 'C', p: [228.2, 85.2, 303.801, 100, 304.601, 102]}, + {t: 'C', p: [305.401, 104, 309.401, 102.8, 309.401, 102.8]}, + {t: 'z', p: []} + ] + }, + + { + f: '#cc7226', + s: null, + p: [ + {t: 'M', p: [380.801, 93.6]}, + {t: 'C', p: [380.801, 93.6, 370.601, 86.2, 368.601, 86.2]}, + {t: 'C', p: [366.601, 86.2, 354.201, 76, 350.001, 76.4]}, + {t: 'C', p: [345.801, 76.8, 333.601, 66.8, 306.201, 75]}, + {t: 'C', p: [306.201, 75, 305.601, 73, 309.201, 72.2]}, + {t: 'C', p: [309.201, 72.2, 315.601, 70, 316.001, 69.4]}, + {t: 'C', p: [316.001, 69.4, 336.201, 65.2, 343.401, 68.8]}, + {t: 'C', p: [343.401, 68.8, 352.601, 71.4, 358.801, 77.6]}, + {t: 'C', p: [358.801, 77.6, 370.001, 80.8, 373.201, 79.8]}, + {t: 'C', p: [373.201, 79.8, 382.001, 82, 382.401, 83.8]}, + {t: 'C', p: [382.401, 83.8, 388.201, 86.8, 386.401, 89.4]}, + {t: 'C', p: [386.401, 89.4, 386.801, 91, 380.801, 93.6]}, {t: 'z', p: []} + ] + }, + + { + f: '#cc7226', + s: null, + p: [ + {t: 'M', p: [368.33, 91.491]}, + {t: 'C', p: [369.137, 92.123, 370.156, 92.221, 370.761, 93.03]}, + {t: 'C', p: [370.995, 93.344, 370.706, 93.67, 370.391, 93.767]}, + {t: 'C', p: [369.348, 94.084, 368.292, 93.514, 367.15, 94.102]}, + {t: 'C', p: [366.748, 94.309, 366.106, 94.127, 365.553, 93.978]}, + {t: 'C', p: [363.921, 93.537, 362.092, 93.512, 360.401, 94.2]}, + {t: 'C', p: [358.416, 93.071, 356.056, 93.655, 353.975, 92.654]}, + {t: 'C', p: [353.917, 92.627, 353.695, 92.973, 353.621, 92.946]}, + {t: 'C', p: [350.575, 91.801, 346.832, 92.084, 344.401, 89.8]}, + {t: 'C', p: [341.973, 89.388, 339.616, 88.926, 337.188, 88.246]}, + {t: 'C', p: [335.37, 87.737, 333.961, 86.748, 332.341, 85.916]}, + {t: 'C', p: [330.964, 85.208, 329.507, 84.686, 327.973, 84.314]}, + {t: 'C', p: [326.11, 83.862, 324.279, 83.974, 322.386, 83.454]}, + {t: 'C', p: [322.293, 83.429, 322.101, 83.773, 322.019, 83.746]}, + {t: 'C', p: [321.695, 83.638, 321.405, 83.055, 321.234, 83.108]}, + {t: 'C', p: [319.553, 83.63, 318.065, 82.658, 316.401, 83]}, + {t: 'C', p: [315.223, 81.776, 313.495, 82.021, 311.949, 81.579]}, + {t: 'C', p: [308.985, 80.731, 305.831, 82.001, 302.801, 81]}, + {t: 'C', p: [306.914, 79.158, 311.601, 80.39, 315.663, 78.321]}, + {t: 'C', p: [317.991, 77.135, 320.653, 78.237, 323.223, 77.477]}, + {t: 'C', p: [323.71, 77.333, 324.401, 77.131, 324.801, 77.8]}, + {t: 'C', p: [324.935, 77.665, 325.117, 77.426, 325.175, 77.454]}, + {t: 'C', p: [327.625, 78.611, 329.94, 79.885, 332.422, 80.951]}, + {t: 'C', p: [332.763, 81.097, 333.295, 80.865, 333.547, 81.067]}, + {t: 'C', p: [335.067, 82.283, 337.01, 82.18, 338.401, 83.4]}, + {t: 'C', p: [340.099, 82.898, 341.892, 83.278, 343.621, 82.654]}, + {t: 'C', p: [343.698, 82.627, 343.932, 82.968, 343.965, 82.946]}, + {t: 'C', p: [345.095, 82.198, 346.25, 82.469, 347.142, 82.773]}, + {t: 'C', p: [347.48, 82.888, 348.143, 83.135, 348.448, 83.209]}, + {t: 'C', p: [349.574, 83.485, 350.43, 83.965, 351.609, 84.148]}, + {t: 'C', p: [351.723, 84.166, 351.908, 83.826, 351.98, 83.854]}, + {t: 'C', p: [353.103, 84.292, 354.145, 84.236, 354.801, 85.4]}, + {t: 'C', p: [354.936, 85.265, 355.101, 85.027, 355.183, 85.054]}, + {t: 'C', p: [356.21, 85.392, 356.859, 86.147, 357.96, 86.388]}, + {t: 'C', p: [358.445, 86.494, 359.057, 87.12, 359.633, 87.296]}, + {t: 'C', p: [362.025, 88.027, 363.868, 89.556, 366.062, 90.451]}, + {t: 'C', p: [366.821, 90.761, 367.697, 90.995, 368.33, 91.491]}, + {t: 'z', p: []} + ] + }, + + { + f: '#cc7226', + s: null, + p: [ + {t: 'M', p: [291.696, 77.261]}, + {t: 'C', p: [289.178, 75.536, 286.81, 74.43, 284.368, 72.644]}, + {t: 'C', p: [284.187, 72.511, 283.827, 72.681, 283.625, 72.559]}, + {t: 'C', p: [282.618, 71.95, 281.73, 71.369, 280.748, 70.673]}, + {t: 'C', p: [280.209, 70.291, 279.388, 70.302, 278.88, 70.044]}, + {t: 'C', p: [276.336, 68.752, 273.707, 68.194, 271.2, 67]}, + {t: 'C', p: [271.882, 66.362, 273.004, 66.606, 273.6, 65.8]}, + {t: 'C', p: [273.795, 66.08, 274.033, 66.364, 274.386, 66.173]}, + {t: 'C', p: [276.064, 65.269, 277.914, 65.116, 279.59, 65.206]}, + {t: 'C', p: [281.294, 65.298, 283.014, 65.603, 284.789, 65.875]}, + {t: 'C', p: [285.096, 65.922, 285.295, 66.445, 285.618, 66.542]}, + {t: 'C', p: [287.846, 67.205, 290.235, 66.68, 292.354, 67.518]}, + {t: 'C', p: [293.945, 68.147, 295.515, 68.97, 296.754, 70.245]}, + {t: 'C', p: [297.006, 70.505, 296.681, 70.806, 296.401, 71]}, + {t: 'C', p: [296.789, 70.891, 297.062, 71.097, 297.173, 71.41]}, + {t: 'C', p: [297.257, 71.649, 297.257, 71.951, 297.173, 72.19]}, + {t: 'C', p: [297.061, 72.502, 296.782, 72.603, 296.408, 72.654]}, + {t: 'C', p: [295.001, 72.844, 296.773, 71.464, 296.073, 71.912]}, + {t: 'C', p: [294.8, 72.726, 295.546, 74.132, 294.801, 75.4]}, + {t: 'C', p: [294.521, 75.206, 294.291, 74.988, 294.401, 74.6]}, + {t: 'C', p: [294.635, 75.122, 294.033, 75.412, 293.865, 75.728]}, + {t: 'C', p: [293.48, 76.453, 292.581, 77.868, 291.696, 77.261]}, + {t: 'z', p: []} + ] + }, + + { + f: '#cc7226', + s: null, + p: [ + {t: 'M', p: [259.198, 84.609]}, + {t: 'C', p: [256.044, 83.815, 252.994, 83.93, 249.978, 82.654]}, + {t: 'C', p: [249.911, 82.626, 249.688, 82.973, 249.624, 82.946]}, + {t: 'C', p: [248.258, 82.352, 247.34, 81.386, 246.264, 80.34]}, + {t: 'C', p: [245.351, 79.452, 243.693, 79.839, 242.419, 79.352]}, + {t: 'C', p: [242.095, 79.228, 241.892, 78.716, 241.591, 78.677]}, + {t: 'C', p: [240.372, 78.52, 239.445, 77.571, 238.4, 77]}, + {t: 'C', p: [240.736, 76.205, 243.147, 76.236, 245.609, 75.852]}, + {t: 'C', p: [245.722, 75.834, 245.867, 76.155, 246, 76.155]}, + {t: 'C', p: [246.136, 76.155, 246.266, 75.934, 246.4, 75.8]}, + {t: 'C', p: [246.595, 76.08, 246.897, 76.406, 247.154, 76.152]}, + {t: 'C', p: [247.702, 75.612, 248.258, 75.802, 248.798, 75.842]}, + {t: 'C', p: [248.942, 75.852, 249.067, 76.155, 249.2, 76.155]}, + {t: 'C', p: [249.336, 76.155, 249.467, 75.844, 249.6, 75.844]}, + {t: 'C', p: [249.736, 75.845, 249.867, 76.155, 250, 76.155]}, + {t: 'C', p: [250.136, 76.155, 250.266, 75.934, 250.4, 75.8]}, + {t: 'C', p: [251.092, 76.582, 251.977, 76.028, 252.799, 76.207]}, + {t: 'C', p: [253.837, 76.434, 254.104, 77.582, 255.178, 77.88]}, + {t: 'C', p: [259.893, 79.184, 264.03, 81.329, 268.393, 83.416]}, + {t: 'C', p: [268.7, 83.563, 268.91, 83.811, 268.8, 84.2]}, + {t: 'C', p: [269.067, 84.2, 269.38, 84.112, 269.57, 84.244]}, + {t: 'C', p: [270.628, 84.976, 271.669, 85.524, 272.366, 86.622]}, + {t: 'C', p: [272.582, 86.961, 272.253, 87.368, 272.02, 87.316]}, + {t: 'C', p: [267.591, 86.321, 263.585, 85.713, 259.198, 84.609]}, + {t: 'z', p: []} + ] + }, + + { + f: '#cc7226', + s: null, + p: [ + {t: 'M', p: [245.338, 128.821]}, + {t: 'C', p: [243.746, 127.602, 243.162, 125.571, 242.034, 123.779]}, + {t: 'C', p: [241.82, 123.439, 242.094, 123.125, 242.411, 123.036]}, + {t: 'C', p: [242.971, 122.877, 243.514, 123.355, 243.923, 123.557]}, + {t: 'C', p: [245.668, 124.419, 247.203, 125.661, 249.2, 125.8]}, + {t: 'C', p: [251.19, 128.034, 255.45, 128.419, 255.457, 131.8]}, + {t: 'C', p: [255.458, 132.659, 254.03, 131.741, 253.6, 132.6]}, + {t: 'C', p: [251.149, 131.597, 248.76, 131.7, 246.38, 130.233]}, + {t: 'C', p: [245.763, 129.852, 246.093, 129.399, 245.338, 128.821]}, + {t: 'z', p: []} + ] + }, + + { + f: '#cc7226', + s: null, + p: [ + {t: 'M', p: [217.8, 76.244]}, + {t: 'C', p: [217.935, 76.245, 224.966, 76.478, 224.949, 76.592]}, + {t: 'C', p: [224.904, 76.901, 217.174, 77.95, 216.81, 77.78]}, + {t: 'C', p: [216.646, 77.704, 209.134, 80.134, 209, 80]}, + {t: 'C', p: [209.268, 79.865, 217.534, 76.244, 217.8, 76.244]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [233.2, 86]}, + {t: 'C', p: [233.2, 86, 218.4, 87.8, 214, 89]}, + {t: 'C', p: [209.6, 90.2, 191, 97.8, 188, 99.8]}, + {t: 'C', p: [188, 99.8, 174.6, 105.2, 157.6, 125.2]}, + {t: 'C', p: [157.6, 125.2, 165.2, 121.8, 167.4, 119]}, + {t: 'C', p: [167.4, 119, 181, 106.4, 180.8, 109]}, + {t: 'C', p: [180.8, 109, 193, 100.4, 192.4, 102.6]}, + {t: 'C', p: [192.4, 102.6, 216.8, 91.4, 214.8, 94.6]}, + {t: 'C', p: [214.8, 94.6, 236.4, 90, 235.4, 92]}, + {t: 'C', p: [235.4, 92, 254.2, 96.4, 251.4, 96.6]}, + {t: 'C', p: [251.4, 96.6, 245.6, 97.8, 252, 101.4]}, + {t: 'C', p: [252, 101.4, 248.6, 105.8, 243.2, 101.8]}, + {t: 'C', p: [237.8, 97.8, 240.8, 100, 235.8, 101]}, + {t: 'C', p: [235.8, 101, 233.2, 101.8, 228.6, 97.8]}, + {t: 'C', p: [228.6, 97.8, 223, 93.2, 214.2, 96.8]}, + {t: 'C', p: [214.2, 96.8, 183.6, 109.4, 181.6, 110]}, + {t: 'C', p: [181.6, 110, 178, 112.8, 175.6, 116.4]}, + {t: 'C', p: [175.6, 116.4, 169.8, 120.8, 166.8, 122.2]}, + {t: 'C', p: [166.8, 122.2, 154, 133.8, 152.8, 135.2]}, + {t: 'C', p: [152.8, 135.2, 149.4, 140.4, 148.6, 140.8]}, + {t: 'C', p: [148.6, 140.8, 155, 137, 157, 135]}, + {t: 'C', p: [157, 135, 171, 125, 176.4, 124.2]}, + {t: 'C', p: [176.4, 124.2, 180.8, 121.2, 181.6, 119.8]}, + {t: 'C', p: [181.6, 119.8, 196, 110.6, 200.2, 110.6]}, + {t: 'C', p: [200.2, 110.6, 209.4, 115.8, 211.8, 108.8]}, + {t: 'C', p: [211.8, 108.8, 217.6, 107, 223.2, 108.2]}, + {t: 'C', p: [223.2, 108.2, 226.4, 105.6, 225.6, 103.4]}, + {t: 'C', p: [225.6, 103.4, 227.2, 101.6, 228.2, 105.4]}, + {t: 'C', p: [228.2, 105.4, 231.6, 109, 236.4, 107]}, + {t: 'C', p: [236.4, 107, 240.4, 106.8, 238.4, 109.2]}, + {t: 'C', p: [238.4, 109.2, 234, 113, 222.2, 113.2]}, + {t: 'C', p: [222.2, 113.2, 209.8, 113.8, 193.4, 121.4]}, + {t: 'C', p: [193.4, 121.4, 163.6, 131.8, 154.4, 142.2]}, + {t: 'C', p: [154.4, 142.2, 148, 151, 142.6, 152.2]}, + {t: 'C', p: [142.6, 152.2, 136.8, 153, 130.8, 160.4]}, + {t: 'C', p: [130.8, 160.4, 140.6, 154.6, 149.6, 154.6]}, + {t: 'C', p: [149.6, 154.6, 153.6, 152.2, 149.8, 155.8]}, + {t: 'C', p: [149.8, 155.8, 146.2, 163.4, 147.8, 168.8]}, + {t: 'C', p: [147.8, 168.8, 147.2, 174, 146.4, 175.6]}, + {t: 'C', p: [146.4, 175.6, 138.6, 188.4, 138.6, 190.8]}, + {t: 'C', p: [138.6, 193.2, 139.8, 203, 140.2, 203.6]}, + {t: 'C', p: [140.6, 204.2, 139.2, 202, 143, 204.4]}, + {t: 'C', p: [146.8, 206.8, 149.6, 208.4, 150.4, 211.2]}, + {t: 'C', p: [151.2, 214, 148.4, 205.8, 148.2, 204]}, + {t: 'C', p: [148, 202.2, 143.8, 195, 144.6, 192.6]}, + {t: 'C', p: [144.6, 192.6, 145.6, 193.6, 146.4, 195]}, + {t: 'C', p: [146.4, 195, 145.8, 194.4, 146.4, 190.8]}, + {t: 'C', p: [146.4, 190.8, 147.2, 185.6, 148.6, 182.4]}, + {t: 'C', p: [150, 179.2, 152, 175.4, 152.4, 174.6]}, + {t: 'C', p: [152.8, 173.8, 152.8, 168, 154.2, 170.6]}, + {t: 'L', p: [157.6, 173.2]}, + {t: 'C', p: [157.6, 173.2, 154.8, 170.6, 157, 168.4]}, + {t: 'C', p: [157, 168.4, 156, 162.8, 157.8, 160.2]}, + {t: 'C', p: [157.8, 160.2, 164.8, 151.8, 166.4, 150.8]}, + {t: 'C', p: [168, 149.8, 166.6, 150.2, 166.6, 150.2]}, + {t: 'C', p: [166.6, 150.2, 172.6, 146, 166.8, 147.6]}, + {t: 'C', p: [166.8, 147.6, 162.8, 149.2, 159.8, 149.2]}, + {t: 'C', p: [159.8, 149.2, 152.2, 151.2, 156.2, 147]}, + {t: 'C', p: [160.2, 142.8, 170.2, 137.4, 174, 137.6]}, + {t: 'L', p: [174.8, 139.2]}, + {t: 'L', p: [186, 136.8]}, + {t: 'L', p: [184.8, 137.6]}, + {t: 'C', p: [184.8, 137.6, 184.6, 137.4, 188.8, 137]}, + {t: 'C', p: [193, 136.6, 198.8, 138, 200.2, 136.2]}, + {t: 'C', p: [201.6, 134.4, 205, 133.4, 204.6, 134.8]}, + {t: 'C', p: [204.2, 136.2, 204, 138.2, 204, 138.2]}, + {t: 'C', p: [204, 138.2, 209, 132.4, 208.4, 134.6]}, + {t: 'C', p: [207.8, 136.8, 199.6, 142, 198.2, 148.2]}, + {t: 'L', p: [208.6, 140]}, + {t: 'L', p: [212.2, 137]}, + {t: 'C', p: [212.2, 137, 215.8, 139.2, 216, 137.6]}, + {t: 'C', p: [216.2, 136, 220.8, 130.2, 222, 130.4]}, + {t: 'C', p: [223.2, 130.6, 225.2, 127.8, 225, 130.4]}, + {t: 'C', p: [224.8, 133, 232.4, 138.4, 232.4, 138.4]}, + {t: 'C', p: [232.4, 138.4, 235.6, 136.6, 237, 138]}, + {t: 'C', p: [238.4, 139.4, 242.6, 118.2, 242.6, 118.2]}, + {t: 'L', p: [267.6, 107.6]}, + {t: 'L', p: [311.201, 104.2]}, + {t: 'L', p: [294.201, 97.4]}, + {t: 'L', p: [233.2, 86]}, + {t: 'z', p: []} + ] + }, + + { + f: null, + s: {c: '#4c0000', w: 2}, + p: [ + {t: 'M', p: [251.4, 285]}, + {t: 'C', p: [251.4, 285, 236.4, 268.2, 228, 265.6]}, + {t: 'C', p: [228, 265.6, 214.6, 258.8, 190, 266.6]} + ] + }, + + { + f: null, + s: {c: '#4c0000', w: 2}, + p: [ + {t: 'M', p: [224.8, 264.2]}, + {t: 'C', p: [224.8, 264.2, 199.6, 256.2, 184.2, 260.4]}, + {t: 'C', p: [184.2, 260.4, 165.8, 262.4, 157.4, 276.2]} + ] + }, + + { + f: null, + s: {c: '#4c0000', w: 2}, + p: [ + {t: 'M', p: [221.2, 263]}, + {t: 'C', p: [221.2, 263, 204.2, 255.8, 189.4, 253.6]}, + {t: 'C', p: [189.4, 253.6, 172.8, 251, 156.2, 258.2]}, + {t: 'C', p: [156.2, 258.2, 144, 264.2, 138.6, 274.4]} + ] + }, + + { + f: null, + s: {c: '#4c0000', w: 2}, + p: [ + {t: 'M', p: [222.2, 263.4]}, + {t: 'C', p: [222.2, 263.4, 206.8, 252.4, 205.8, 251]}, + {t: 'C', p: [205.8, 251, 198.8, 240, 185.8, 239.6]}, + {t: 'C', p: [185.8, 239.6, 164.4, 240.4, 147.2, 248.4]} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [220.895, 254.407]}, + {t: 'C', p: [222.437, 255.87, 249.4, 284.8, 249.4, 284.8]}, + {t: 'C', p: [284.6, 321.401, 256.6, 287.2, 256.6, 287.2]}, + {t: 'C', p: [249, 282.4, 239.8, 263.6, 239.8, 263.6]}, + {t: 'C', p: [238.6, 260.8, 253.8, 270.8, 253.8, 270.8]}, + {t: 'C', p: [257.8, 271.6, 271.4, 290.8, 271.4, 290.8]}, + {t: 'C', p: [264.6, 288.4, 269.4, 295.6, 269.4, 295.6]}, + {t: 'C', p: [272.2, 297.6, 292.601, 313.201, 292.601, 313.201]}, + {t: 'C', p: [296.201, 317.201, 300.201, 318.801, 300.201, 318.801]}, + {t: 'C', p: [314.201, 313.601, 307.801, 326.801, 307.801, 326.801]}, + {t: 'C', p: [310.201, 333.601, 315.801, 322.001, 315.801, 322.001]}, + {t: 'C', p: [327.001, 305.2, 310.601, 307.601, 310.601, 307.601]}, + {t: 'C', p: [280.6, 310.401, 273.8, 294.4, 273.8, 294.4]}, + {t: 'C', p: [271.4, 292, 280.2, 294.4, 280.2, 294.4]}, + {t: 'C', p: [288.601, 296.4, 273, 282, 273, 282]}, + {t: 'C', p: [275.4, 282, 284.6, 288.8, 284.6, 288.8]}, + {t: 'C', p: [295.001, 298, 297.001, 296, 297.001, 296]}, + {t: 'C', p: [315.001, 287.2, 325.401, 294.8, 325.401, 294.8]}, + {t: 'C', p: [327.401, 296.4, 321.801, 303.2, 323.401, 308.401]}, + {t: 'C', p: [325.001, 313.601, 329.801, 326.001, 329.801, 326.001]}, + {t: 'C', p: [327.401, 327.601, 327.801, 338.401, 327.801, 338.401]}, + {t: 'C', p: [344.601, 361.601, 335.001, 359.601, 335.001, 359.601]}, + {t: 'C', p: [319.401, 359.201, 334.201, 366.801, 334.201, 366.801]}, + {t: 'C', p: [337.401, 368.801, 346.201, 376.001, 346.201, 376.001]}, + {t: 'C', p: [343.401, 374.801, 341.801, 380.001, 341.801, 380.001]}, + {t: 'C', p: [346.601, 384.001, 343.801, 388.801, 343.801, 388.801]}, + {t: 'C', p: [337.801, 390.001, 336.601, 394.001, 336.601, 394.001]}, + {t: 'C', p: [343.401, 402.001, 333.401, 402.401, 333.401, 402.401]}, + {t: 'C', p: [337.001, 406.801, 332.201, 418.801, 332.201, 418.801]}, + {t: 'C', p: [327.401, 418.801, 321.001, 424.401, 321.001, 424.401]}, + {t: 'C', p: [323.401, 429.201, 313.001, 434.801, 313.001, 434.801]}, + {t: 'C', p: [304.601, 436.401, 307.401, 443.201, 307.401, 443.201]}, + {t: 'C', p: [299.401, 449.201, 297.001, 465.201, 297.001, 465.201]}, + {t: 'C', p: [296.201, 475.601, 293.801, 478.801, 299.001, 476.801]}, + {t: 'C', p: [304.201, 474.801, 303.401, 462.401, 303.401, 462.401]}, + {t: 'C', p: [298.601, 446.801, 341.401, 430.801, 341.401, 430.801]}, + {t: 'C', p: [345.401, 429.201, 346.201, 424.001, 346.201, 424.001]}, + {t: 'C', p: [348.201, 424.401, 357.001, 432.001, 357.001, 432.001]}, + {t: 'C', p: [364.601, 443.201, 365.001, 434.001, 365.001, 434.001]}, + {t: 'C', p: [366.201, 430.401, 364.601, 424.401, 364.601, 424.401]}, + {t: 'C', p: [370.601, 402.801, 356.601, 396.401, 356.601, 396.401]}, + {t: 'C', p: [346.601, 362.801, 360.601, 371.201, 360.601, 371.201]}, + {t: 'C', p: [363.401, 376.801, 374.201, 382.001, 374.201, 382.001]}, + {t: 'L', p: [377.801, 379.601]}, + {t: 'C', p: [376.201, 374.801, 384.601, 368.801, 384.601, 368.801]}, + {t: 'C', p: [387.401, 375.201, 393.401, 367.201, 393.401, 367.201]}, + {t: 'C', p: [397.001, 342.801, 409.401, 357.201, 409.401, 357.201]}, + {t: 'C', p: [413.401, 358.401, 414.601, 351.601, 414.601, 351.601]}, + {t: 'C', p: [418.201, 341.201, 414.601, 327.601, 414.601, 327.601]}, + {t: 'C', p: [418.201, 327.201, 427.801, 333.201, 427.801, 333.201]}, + {t: 'C', p: [430.601, 329.601, 421.401, 312.801, 425.401, 315.201]}, + {t: 'C', p: [429.401, 317.601, 433.801, 319.201, 433.801, 319.201]}, + {t: 'C', p: [434.601, 317.201, 424.601, 304.801, 424.601, 304.801]}, + {t: 'C', p: [420.201, 302, 415.001, 281.6, 415.001, 281.6]}, + {t: 'C', p: [422.201, 285.2, 412.201, 270, 412.201, 270]}, + {t: 'C', p: [412.201, 266.8, 418.201, 255.6, 418.201, 255.6]}, + {t: 'C', p: [417.401, 248.8, 418.201, 249.2, 418.201, 249.2]}, + {t: 'C', p: [421.001, 250.4, 429.001, 252, 422.201, 245.6]}, + {t: 'C', p: [415.401, 239.2, 423.001, 234.4, 423.001, 234.4]}, + {t: 'C', p: [427.401, 231.6, 413.801, 232, 413.801, 232]}, + {t: 'C', p: [408.601, 227.6, 409.001, 223.6, 409.001, 223.6]}, + {t: 'C', p: [417.001, 225.6, 402.601, 211.2, 400.201, 207.6]}, + {t: 'C', p: [397.801, 204, 407.401, 198.8, 407.401, 198.8]}, + {t: 'C', p: [420.601, 195.2, 409.001, 192, 409.001, 192]}, + {t: 'C', p: [389.401, 192.4, 400.201, 181.6, 400.201, 181.6]}, + {t: 'C', p: [406.201, 182, 404.601, 179.6, 404.601, 179.6]}, + {t: 'C', p: [399.401, 178.4, 389.801, 172, 389.801, 172]}, + {t: 'C', p: [385.801, 168.4, 389.401, 169.2, 389.401, 169.2]}, + {t: 'C', p: [406.201, 170.4, 377.401, 159.2, 377.401, 159.2]}, + {t: 'C', p: [385.401, 159.2, 367.401, 148.8, 367.401, 148.8]}, + {t: 'C', p: [365.401, 147.2, 362.201, 139.6, 362.201, 139.6]}, + {t: 'C', p: [356.201, 134.4, 351.401, 127.6, 351.401, 127.6]}, + {t: 'C', p: [351.001, 123.2, 346.201, 118.4, 346.201, 118.4]}, + {t: 'C', p: [334.601, 104.8, 329.001, 105.2, 329.001, 105.2]}, + {t: 'C', p: [314.201, 101.6, 309.001, 102.4, 309.001, 102.4]}, + {t: 'L', p: [256.2, 106.8]}, + {t: 'C', p: [229.8, 119.6, 237.6, 140.6, 237.6, 140.6]}, + {t: 'C', p: [244, 149, 253.2, 145.2, 253.2, 145.2]}, + {t: 'C', p: [257.8, 139, 269.4, 141.2, 269.4, 141.2]}, + {t: 'C', p: [289.801, 144.4, 287.201, 140.8, 287.201, 140.8]}, + {t: 'C', p: [284.801, 136.2, 268.6, 130, 268.4, 129.4]}, + {t: 'C', p: [268.2, 128.8, 259.4, 125.4, 259.4, 125.4]}, + {t: 'C', p: [256.4, 124.2, 252, 115, 252, 115]}, + {t: 'C', p: [248.8, 111.6, 264.6, 117.4, 264.6, 117.4]}, + {t: 'C', p: [263.4, 118.4, 270.8, 122.4, 270.8, 122.4]}, + {t: 'C', p: [288.201, 121.4, 298.801, 132.2, 298.801, 132.2]}, + {t: 'C', p: [309.601, 148.8, 309.801, 140.6, 309.801, 140.6]}, + {t: 'C', p: [312.601, 131.2, 300.801, 110, 300.801, 110]}, + {t: 'C', p: [301.201, 108, 309.401, 114.6, 309.401, 114.6]}, + {t: 'C', p: [310.801, 112.6, 311.601, 118.4, 311.601, 118.4]}, + {t: 'C', p: [311.801, 120.8, 315.601, 128.8, 315.601, 128.8]}, + {t: 'C', p: [318.401, 141.8, 322.001, 134.4, 322.001, 134.4]}, + {t: 'L', p: [326.601, 143.8]}, + {t: 'C', p: [328.001, 146.4, 322.001, 154, 322.001, 154]}, + {t: 'C', p: [321.801, 156.8, 322.601, 156.6, 317.001, 164.2]}, + {t: 'C', p: [311.401, 171.8, 314.801, 176.2, 314.801, 176.2]}, + {t: 'C', p: [313.401, 182.8, 322.201, 182.4, 322.201, 182.4]}, + {t: 'C', p: [324.801, 184.6, 328.201, 184.6, 328.201, 184.6]}, + {t: 'C', p: [330.001, 186.6, 332.401, 186, 332.401, 186]}, + {t: 'C', p: [334.001, 182.2, 340.201, 184.2, 340.201, 184.2]}, + {t: 'C', p: [341.601, 181.8, 349.801, 181.4, 349.801, 181.4]}, + {t: 'C', p: [350.801, 178.8, 351.201, 177.2, 354.601, 176.6]}, + {t: 'C', p: [358.001, 176, 333.401, 133, 333.401, 133]}, + {t: 'C', p: [339.801, 132.2, 331.601, 119.8, 331.601, 119.8]}, + {t: 'C', p: [329.401, 113.2, 340.801, 127.8, 343.001, 129.2]}, + {t: 'C', p: [345.201, 130.6, 346.201, 132.8, 344.601, 132.6]}, + {t: 'C', p: [343.001, 132.4, 341.201, 134.6, 342.601, 134.8]}, + {t: 'C', p: [344.001, 135, 357.001, 150, 360.401, 160.2]}, + {t: 'C', p: [363.801, 170.4, 369.801, 174.4, 376.001, 180.4]}, + {t: 'C', p: [382.201, 186.4, 381.401, 210.6, 381.401, 210.6]}, + {t: 'C', p: [381.001, 219.4, 387.001, 230, 387.001, 230]}, + {t: 'C', p: [389.001, 233.8, 384.801, 252, 384.801, 252]}, + {t: 'C', p: [382.801, 254.2, 384.201, 255, 384.201, 255]}, + {t: 'C', p: [385.201, 256.2, 392.001, 269.4, 392.001, 269.4]}, + {t: 'C', p: [390.201, 269.2, 393.801, 272.8, 393.801, 272.8]}, + {t: 'C', p: [399.001, 278.8, 392.601, 275.8, 392.601, 275.8]}, + {t: 'C', p: [386.601, 274.2, 393.601, 284, 393.601, 284]}, + {t: 'C', p: [394.801, 285.8, 385.801, 281.2, 385.801, 281.2]}, + {t: 'C', p: [376.601, 280.6, 388.201, 287.8, 388.201, 287.8]}, + {t: 'C', p: [396.801, 295, 385.401, 290.6, 385.401, 290.6]}, + {t: 'C', p: [380.801, 288.8, 384.001, 295.6, 384.001, 295.6]}, + {t: 'C', p: [387.201, 297.2, 404.401, 304.2, 404.401, 304.2]}, + {t: 'C', p: [404.801, 308.001, 401.801, 313.001, 401.801, 313.001]}, + {t: 'C', p: [402.201, 317.001, 400.001, 320.401, 400.001, 320.401]}, + {t: 'C', p: [398.801, 328.601, 398.201, 329.401, 398.201, 329.401]}, + {t: 'C', p: [394.001, 329.601, 386.601, 343.401, 386.601, 343.401]}, + {t: 'C', p: [384.801, 346.001, 374.601, 358.001, 374.601, 358.001]}, + {t: 'C', p: [372.601, 365.001, 354.601, 357.801, 354.601, 357.801]}, + {t: 'C', p: [348.001, 361.201, 350.001, 357.801, 350.001, 357.801]}, + {t: 'C', p: [349.601, 355.601, 354.401, 349.601, 354.401, 349.601]}, + {t: 'C', p: [361.401, 347.001, 358.801, 336.201, 358.801, 336.201]}, + {t: 'C', p: [362.801, 334.801, 351.601, 332.001, 351.801, 330.801]}, + {t: 'C', p: [352.001, 329.601, 357.801, 328.201, 357.801, 328.201]}, + {t: 'C', p: [365.801, 326.201, 361.401, 323.801, 361.401, 323.801]}, + {t: 'C', p: [360.801, 319.801, 363.801, 314.201, 363.801, 314.201]}, + {t: 'C', p: [375.401, 313.401, 363.801, 297.2, 363.801, 297.2]}, + {t: 'C', p: [353.001, 289.6, 352.001, 283.8, 352.001, 283.8]}, + {t: 'C', p: [364.601, 275.6, 356.401, 263.2, 356.601, 259.6]}, + {t: 'C', p: [356.801, 256, 358.001, 234.4, 358.001, 234.4]}, + {t: 'C', p: [356.001, 228.2, 353.001, 214.6, 353.001, 214.6]}, + {t: 'C', p: [355.201, 209.4, 362.601, 196.8, 362.601, 196.8]}, + {t: 'C', p: [365.401, 192.6, 374.201, 187.8, 372.001, 184.8]}, + {t: 'C', p: [369.801, 181.8, 362.001, 183.6, 362.001, 183.6]}, + {t: 'C', p: [354.201, 182.2, 354.801, 187.4, 354.801, 187.4]}, + {t: 'C', p: [353.201, 188.4, 352.401, 193.4, 352.401, 193.4]}, + {t: 'C', p: [351.68, 201.333, 342.801, 207.6, 342.801, 207.6]}, + {t: 'C', p: [331.601, 213.8, 340.801, 217.8, 340.801, 217.8]}, + {t: 'C', p: [346.801, 224.4, 337.001, 224.6, 337.001, 224.6]}, + {t: 'C', p: [326.001, 222.8, 334.201, 233, 334.201, 233]}, + {t: 'C', p: [345.001, 245.8, 342.001, 248.6, 342.001, 248.6]}, + {t: 'C', p: [331.801, 249.6, 344.401, 258.8, 344.401, 258.8]}, + {t: 'C', p: [344.401, 258.8, 343.601, 256.8, 343.801, 258.6]}, + {t: 'C', p: [344.001, 260.4, 347.001, 264.6, 347.801, 266.6]}, + {t: 'C', p: [348.601, 268.6, 344.601, 268.8, 344.601, 268.8]}, + {t: 'C', p: [345.201, 278.4, 329.801, 274.2, 329.801, 274.2]}, + {t: 'C', p: [329.801, 274.2, 329.801, 274.2, 328.201, 274.4]}, + {t: 'C', p: [326.601, 274.6, 315.401, 273.8, 309.601, 271.6]}, + {t: 'C', p: [303.801, 269.4, 297.001, 269.4, 297.001, 269.4]}, + {t: 'C', p: [297.001, 269.4, 293.001, 271.2, 285.4, 271]}, + {t: 'C', p: [277.8, 270.8, 269.8, 273.6, 269.8, 273.6]}, + {t: 'C', p: [265.4, 273.2, 274, 268.8, 274.2, 269]}, + {t: 'C', p: [274.4, 269.2, 280, 263.6, 272, 264.2]}, + {t: 'C', p: [250.203, 265.835, 239.4, 255.6, 239.4, 255.6]}, + {t: 'C', p: [237.4, 254.2, 234.8, 251.4, 234.8, 251.4]}, + {t: 'C', p: [224.8, 249.4, 236.2, 263.8, 236.2, 263.8]}, + {t: 'C', p: [237.4, 265.2, 236, 266.2, 236, 266.2]}, + {t: 'C', p: [235.2, 264.6, 227.4, 259.2, 227.4, 259.2]}, + {t: 'C', p: [224.589, 258.227, 223.226, 256.893, 220.895, 254.407]}, + {t: 'z', p: []} + ] + }, + + { + f: '#4c0000', + s: null, + p: [ + {t: 'M', p: [197, 242.8]}, + {t: 'C', p: [197, 242.8, 208.6, 248.4, 211.2, 251.2]}, + {t: 'C', p: [213.8, 254, 227.8, 265.4, 227.8, 265.4]}, + {t: 'C', p: [227.8, 265.4, 222.4, 263.4, 219.8, 261.6]}, + {t: 'C', p: [217.2, 259.8, 206.4, 251.6, 206.4, 251.6]}, + {t: 'C', p: [206.4, 251.6, 202.6, 245.6, 197, 242.8]}, {t: 'z', p: []} + ] + }, + + { + f: '#99cc32', + s: null, + p: [ + {t: 'M', p: [138.991, 211.603]}, + {t: 'C', p: [139.328, 211.455, 138.804, 208.743, 138.6, 208.2]}, + {t: 'C', p: [137.578, 205.474, 128.6, 204, 128.6, 204]}, + {t: 'C', p: [128.373, 205.365, 128.318, 206.961, 128.424, 208.599]}, + {t: 'C', p: [128.424, 208.599, 133.292, 214.118, 138.991, 211.603]}, + {t: 'z', p: []} + ] + }, + + { + f: '#659900', + s: null, + p: [ + {t: 'M', p: [138.991, 211.403]}, + {t: 'C', p: [138.542, 211.561, 138.976, 208.669, 138.8, 208.2]}, + {t: 'C', p: [137.778, 205.474, 128.6, 203.9, 128.6, 203.9]}, + {t: 'C', p: [128.373, 205.265, 128.318, 206.861, 128.424, 208.499]}, + {t: 'C', p: [128.424, 208.499, 132.692, 213.618, 138.991, 211.403]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [134.6, 211.546]}, + {t: 'C', p: [133.975, 211.546, 133.469, 210.406, 133.469, 209]}, + {t: 'C', p: [133.469, 207.595, 133.975, 206.455, 134.6, 206.455]}, + {t: 'C', p: [135.225, 206.455, 135.732, 207.595, 135.732, 209]}, + {t: 'C', p: [135.732, 210.406, 135.225, 211.546, 134.6, 211.546]}, + {t: 'z', p: []} + ] + }, + + {f: '#000', s: null, p: [{t: 'M', p: [134.6, 209]}, {t: 'z', p: []}]}, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [89, 309.601]}, + {t: 'C', p: [89, 309.601, 83.4, 319.601, 108.2, 313.601]}, + {t: 'C', p: [108.2, 313.601, 122.2, 312.401, 124.6, 310.001]}, + {t: 'C', p: [125.8, 310.801, 134.166, 313.734, 137, 314.401]}, + {t: 'C', p: [143.8, 316.001, 152.2, 306, 152.2, 306]}, + {t: 'C', p: [152.2, 306, 156.8, 295.5, 159.6, 295.5]}, + {t: 'C', p: [162.4, 295.5, 159.2, 297.1, 159.2, 297.1]}, + {t: 'C', p: [159.2, 297.1, 152.6, 307.201, 153, 308.801]}, + {t: 'C', p: [153, 308.801, 147.8, 328.801, 131.8, 329.601]}, + {t: 'C', p: [131.8, 329.601, 115.65, 330.551, 117, 336.401]}, + {t: 'C', p: [117, 336.401, 125.8, 334.001, 128.2, 336.401]}, + {t: 'C', p: [128.2, 336.401, 139, 336.001, 131, 342.401]}, + {t: 'L', p: [124.2, 354.001]}, + {t: 'C', p: [124.2, 354.001, 124.34, 357.919, 114.2, 354.401]}, + {t: 'C', p: [104.4, 351.001, 94.1, 338.101, 94.1, 338.101]}, + {t: 'C', p: [94.1, 338.101, 78.15, 323.551, 89, 309.601]}, {t: 'z', p: []} + ] + }, + + { + f: '#e59999', + s: null, + p: [ + {t: 'M', p: [87.8, 313.601]}, + {t: 'C', p: [87.8, 313.601, 85.8, 323.201, 122.6, 312.801]}, + {t: 'C', p: [122.6, 312.801, 127, 312.801, 129.4, 313.601]}, + {t: 'C', p: [131.8, 314.401, 143.8, 317.201, 145.8, 316.001]}, + {t: 'C', p: [145.8, 316.001, 138.6, 329.601, 127, 328.001]}, + {t: 'C', p: [127, 328.001, 113.8, 329.601, 114.2, 334.401]}, + {t: 'C', p: [114.2, 334.401, 118.2, 341.601, 123, 344.001]}, + {t: 'C', p: [123, 344.001, 125.8, 346.401, 125.4, 349.601]}, + {t: 'C', p: [125, 352.801, 122.2, 354.401, 120.2, 355.201]}, + {t: 'C', p: [118.2, 356.001, 115, 352.801, 113.4, 352.801]}, + {t: 'C', p: [111.8, 352.801, 103.4, 346.401, 99, 341.601]}, + {t: 'C', p: [94.6, 336.801, 86.2, 324.801, 86.6, 322.001]}, + {t: 'C', p: [87, 319.201, 87.8, 313.601, 87.8, 313.601]}, {t: 'z', p: []} + ] + }, + + { + f: '#b26565', + s: null, + p: [ + {t: 'M', p: [91, 331.051]}, + {t: 'C', p: [93.6, 335.001, 96.8, 339.201, 99, 341.601]}, + {t: 'C', p: [103.4, 346.401, 111.8, 352.801, 113.4, 352.801]}, + {t: 'C', p: [115, 352.801, 118.2, 356.001, 120.2, 355.201]}, + {t: 'C', p: [122.2, 354.401, 125, 352.801, 125.4, 349.601]}, + {t: 'C', p: [125.8, 346.401, 123, 344.001, 123, 344.001]}, + {t: 'C', p: [119.934, 342.468, 117.194, 338.976, 115.615, 336.653]}, + {t: 'C', p: [115.615, 336.653, 115.8, 339.201, 110.6, 338.401]}, + {t: 'C', p: [105.4, 337.601, 100.2, 334.801, 98.6, 331.601]}, + {t: 'C', p: [97, 328.401, 94.6, 326.001, 96.2, 329.601]}, + {t: 'C', p: [97.8, 333.201, 100.2, 336.801, 101.8, 337.201]}, + {t: 'C', p: [103.4, 337.601, 103, 338.801, 100.6, 338.401]}, + {t: 'C', p: [98.2, 338.001, 95.4, 337.601, 91, 332.401]}, {t: 'z', p: []} + ] + }, + + { + f: '#992600', + s: null, + p: [ + {t: 'M', p: [88.4, 310.001]}, + {t: 'C', p: [88.4, 310.001, 90.2, 296.4, 91.4, 292.4]}, + {t: 'C', p: [91.4, 292.4, 90.6, 285.6, 93, 281.4]}, + {t: 'C', p: [95.4, 277.2, 97.4, 271, 100.4, 265.6]}, + {t: 'C', p: [103.4, 260.2, 103.6, 256.2, 107.6, 254.6]}, + {t: 'C', p: [111.6, 253, 117.6, 244.4, 120.4, 243.4]}, + {t: 'C', p: [123.2, 242.4, 123, 243.2, 123, 243.2]}, + {t: 'C', p: [123, 243.2, 129.8, 228.4, 143.4, 232.4]}, + {t: 'C', p: [143.4, 232.4, 127.2, 229.6, 143, 220.2]}, + {t: 'C', p: [143, 220.2, 138.2, 221.3, 141.5, 214.3]}, + {t: 'C', p: [143.701, 209.632, 143.2, 216.4, 132.2, 228.2]}, + {t: 'C', p: [132.2, 228.2, 127.2, 236.8, 122, 239.8]}, + {t: 'C', p: [116.8, 242.8, 104.8, 249.8, 103.6, 253.6]}, + {t: 'C', p: [102.4, 257.4, 99.2, 263.2, 97.2, 264.8]}, + {t: 'C', p: [95.2, 266.4, 92.4, 270.6, 92, 274]}, + {t: 'C', p: [92, 274, 90.8, 278, 89.4, 279.2]}, + {t: 'C', p: [88, 280.4, 87.8, 283.6, 87.8, 285.6]}, + {t: 'C', p: [87.8, 287.6, 85.8, 290.4, 86, 292.8]}, + {t: 'C', p: [86, 292.8, 86.8, 311.801, 86.4, 313.801]}, + {t: 'L', p: [88.4, 310.001]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: null, + p: [ + {t: 'M', p: [79.8, 314.601]}, + {t: 'C', p: [79.8, 314.601, 77.8, 313.201, 73.4, 319.201]}, + {t: 'C', p: [73.4, 319.201, 80.7, 352.201, 80.7, 353.601]}, + {t: 'C', p: [80.7, 353.601, 81.8, 351.501, 80.5, 344.301]}, + {t: 'C', p: [79.2, 337.101, 78.3, 324.401, 78.3, 324.401]}, + {t: 'L', p: [79.8, 314.601]}, {t: 'z', p: []} + ] + }, + + { + f: '#992600', + s: null, + p: [ + {t: 'M', p: [101.4, 254]}, + {t: 'C', p: [101.4, 254, 83.8, 257.2, 84.2, 286.4]}, + {t: 'L', p: [83.4, 311.201]}, + {t: 'C', p: [83.4, 311.201, 82.2, 285.6, 81, 284]}, + {t: 'C', p: [79.8, 282.4, 83.8, 271.2, 80.6, 277.2]}, + {t: 'C', p: [80.6, 277.2, 66.6, 291.2, 74.6, 312.401]}, + {t: 'C', p: [74.6, 312.401, 76.1, 315.701, 73.1, 311.101]}, + {t: 'C', p: [73.1, 311.101, 68.5, 298.5, 69.6, 292.1]}, + {t: 'C', p: [69.6, 292.1, 69.8, 289.9, 71.7, 287.1]}, + {t: 'C', p: [71.7, 287.1, 80.3, 275.4, 83, 273.1]}, + {t: 'C', p: [83, 273.1, 84.8, 258.7, 100.2, 253.5]}, + {t: 'C', p: [100.2, 253.5, 105.9, 251.2, 101.4, 254]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [240.8, 187.8]}, + {t: 'C', p: [241.46, 187.446, 241.451, 186.476, 242.031, 186.303]}, + {t: 'C', p: [243.18, 185.959, 243.344, 184.892, 243.862, 184.108]}, + {t: 'C', p: [244.735, 182.789, 244.928, 181.256, 245.51, 179.765]}, + {t: 'C', p: [245.782, 179.065, 245.809, 178.11, 245.496, 177.45]}, + {t: 'C', p: [244.322, 174.969, 243.62, 172.52, 242.178, 170.094]}, + {t: 'C', p: [241.91, 169.644, 241.648, 168.85, 241.447, 168.252]}, + {t: 'C', p: [240.984, 166.868, 239.727, 165.877, 238.867, 164.557]}, + {t: 'C', p: [238.579, 164.116, 239.104, 163.191, 238.388, 163.107]}, + {t: 'C', p: [237.491, 163.002, 236.042, 162.422, 235.809, 163.448]}, + {t: 'C', p: [235.221, 166.035, 236.232, 168.558, 237.2, 171]}, + {t: 'C', p: [236.418, 171.692, 236.752, 172.613, 236.904, 173.38]}, + {t: 'C', p: [237.614, 176.986, 236.416, 180.338, 235.655, 183.812]}, + {t: 'C', p: [235.632, 183.916, 235.974, 184.114, 235.946, 184.176]}, + {t: 'C', p: [234.724, 186.862, 233.272, 189.307, 231.453, 191.688]}, + {t: 'C', p: [230.695, 192.68, 229.823, 193.596, 229.326, 194.659]}, + {t: 'C', p: [228.958, 195.446, 228.55, 196.412, 228.8, 197.4]}, + {t: 'C', p: [225.365, 200.18, 223.115, 204.025, 220.504, 207.871]}, + {t: 'C', p: [220.042, 208.551, 220.333, 209.76, 220.884, 210.029]}, + {t: 'C', p: [221.697, 210.427, 222.653, 209.403, 223.123, 208.557]}, + {t: 'C', p: [223.512, 207.859, 223.865, 207.209, 224.356, 206.566]}, + {t: 'C', p: [224.489, 206.391, 224.31, 205.972, 224.445, 205.851]}, + {t: 'C', p: [227.078, 203.504, 228.747, 200.568, 231.2, 198.2]}, + {t: 'C', p: [233.15, 197.871, 234.687, 196.873, 236.435, 195.86]}, + {t: 'C', p: [236.743, 195.681, 237.267, 195.93, 237.557, 195.735]}, + {t: 'C', p: [239.31, 194.558, 239.308, 192.522, 239.414, 190.612]}, + {t: 'C', p: [239.464, 189.728, 239.66, 188.411, 240.8, 187.8]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [231.959, 183.334]}, + {t: 'C', p: [232.083, 183.257, 231.928, 182.834, 232.037, 182.618]}, + {t: 'C', p: [232.199, 182.294, 232.602, 182.106, 232.764, 181.782]}, + {t: 'C', p: [232.873, 181.566, 232.71, 181.186, 232.846, 181.044]}, + {t: 'C', p: [235.179, 178.597, 235.436, 175.573, 234.4, 172.6]}, + {t: 'C', p: [235.424, 171.98, 235.485, 170.718, 235.06, 169.871]}, + {t: 'C', p: [234.207, 168.171, 234.014, 166.245, 233.039, 164.702]}, + {t: 'C', p: [232.237, 163.433, 230.659, 162.189, 229.288, 163.492]}, + {t: 'C', p: [228.867, 163.892, 228.546, 164.679, 228.824, 165.391]}, + {t: 'C', p: [228.888, 165.554, 229.173, 165.7, 229.146, 165.782]}, + {t: 'C', p: [229.039, 166.106, 228.493, 166.33, 228.487, 166.602]}, + {t: 'C', p: [228.457, 168.098, 227.503, 169.609, 228.133, 170.938]}, + {t: 'C', p: [228.905, 172.567, 229.724, 174.424, 230.4, 176.2]}, + {t: 'C', p: [229.166, 178.316, 230.199, 180.765, 228.446, 182.642]}, + {t: 'C', p: [228.31, 182.788, 228.319, 183.174, 228.441, 183.376]}, + {t: 'C', p: [228.733, 183.862, 229.139, 184.268, 229.625, 184.56]}, + {t: 'C', p: [229.827, 184.681, 230.175, 184.683, 230.375, 184.559]}, + {t: 'C', p: [230.953, 184.197, 231.351, 183.71, 231.959, 183.334]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [294.771, 173.023]}, + {t: 'C', p: [296.16, 174.815, 296.45, 177.61, 294.401, 179]}, + {t: 'C', p: [294.951, 182.309, 298.302, 180.33, 300.401, 179.8]}, + {t: 'C', p: [300.292, 179.412, 300.519, 179.068, 300.802, 179.063]}, + {t: 'C', p: [301.859, 179.048, 302.539, 178.016, 303.601, 178.2]}, + {t: 'C', p: [304.035, 176.643, 305.673, 175.941, 306.317, 174.561]}, + {t: 'C', p: [308.043, 170.866, 307.452, 166.593, 304.868, 163.347]}, + {t: 'C', p: [304.666, 163.093, 304.883, 162.576, 304.759, 162.214]}, + {t: 'C', p: [304.003, 160.003, 301.935, 159.688, 300.001, 159]}, + {t: 'C', p: [298.824, 155.125, 298.163, 151.094, 296.401, 147.4]}, + {t: 'C', p: [294.787, 147.15, 294.089, 145.411, 292.752, 144.691]}, + {t: 'C', p: [291.419, 143.972, 290.851, 145.551, 290.892, 146.597]}, + {t: 'C', p: [290.899, 146.802, 291.351, 147.026, 291.181, 147.391]}, + {t: 'C', p: [291.105, 147.555, 290.845, 147.666, 290.845, 147.8]}, + {t: 'C', p: [290.846, 147.935, 291.067, 148.066, 291.201, 148.2]}, + {t: 'C', p: [290.283, 149.02, 288.86, 149.497, 288.565, 150.642]}, + {t: 'C', p: [287.611, 154.352, 290.184, 157.477, 291.852, 160.678]}, + {t: 'C', p: [292.443, 161.813, 291.707, 163.084, 290.947, 164.292]}, + {t: 'C', p: [290.509, 164.987, 290.617, 166.114, 290.893, 166.97]}, + {t: 'C', p: [291.645, 169.301, 293.236, 171.04, 294.771, 173.023]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [257.611, 191.409]}, + {t: 'C', p: [256.124, 193.26, 252.712, 195.829, 255.629, 197.757]}, + {t: 'C', p: [255.823, 197.886, 256.193, 197.89, 256.366, 197.756]}, + {t: 'C', p: [258.387, 196.191, 260.39, 195.288, 262.826, 194.706]}, + {t: 'C', p: [262.95, 194.677, 263.224, 195.144, 263.593, 194.983]}, + {t: 'C', p: [265.206, 194.28, 267.216, 194.338, 268.4, 193]}, + {t: 'C', p: [272.167, 193.224, 275.732, 192.108, 279.123, 190.8]}, + {t: 'C', p: [280.284, 190.352, 281.554, 189.793, 282.755, 189.291]}, + {t: 'C', p: [284.131, 188.715, 285.335, 187.787, 286.447, 186.646]}, + {t: 'C', p: [286.58, 186.51, 286.934, 186.6, 287.201, 186.6]}, + {t: 'C', p: [287.161, 185.737, 288.123, 185.61, 288.37, 184.988]}, + {t: 'C', p: [288.462, 184.756, 288.312, 184.36, 288.445, 184.258]}, + {t: 'C', p: [290.583, 182.628, 291.503, 180.61, 290.334, 178.233]}, + {t: 'C', p: [290.049, 177.655, 289.8, 177.037, 289.234, 176.561]}, + {t: 'C', p: [288.149, 175.65, 287.047, 176.504, 286, 176.2]}, + {t: 'C', p: [285.841, 176.828, 285.112, 176.656, 284.726, 176.854]}, + {t: 'C', p: [283.867, 177.293, 282.534, 176.708, 281.675, 177.146]}, + {t: 'C', p: [280.313, 177.841, 279.072, 178.01, 277.65, 178.387]}, + {t: 'C', p: [277.338, 178.469, 276.56, 178.373, 276.4, 179]}, + {t: 'C', p: [276.266, 178.866, 276.118, 178.632, 276.012, 178.654]}, + {t: 'C', p: [274.104, 179.05, 272.844, 179.264, 271.543, 180.956]}, + {t: 'C', p: [271.44, 181.089, 270.998, 180.91, 270.839, 181.045]}, + {t: 'C', p: [269.882, 181.853, 269.477, 183.087, 268.376, 183.759]}, + {t: 'C', p: [268.175, 183.882, 267.823, 183.714, 267.629, 183.843]}, + {t: 'C', p: [266.983, 184.274, 266.616, 184.915, 265.974, 185.362]}, + {t: 'C', p: [265.645, 185.591, 265.245, 185.266, 265.277, 185.01]}, + {t: 'C', p: [265.522, 183.063, 266.175, 181.276, 265.6, 179.4]}, + {t: 'C', p: [267.677, 176.88, 270.194, 174.931, 272, 172.2]}, + {t: 'C', p: [272.015, 170.034, 272.707, 167.888, 272.594, 165.811]}, + {t: 'C', p: [272.584, 165.618, 272.296, 164.885, 272.17, 164.538]}, + {t: 'C', p: [271.858, 163.684, 272.764, 162.618, 271.92, 161.894]}, + {t: 'C', p: [270.516, 160.691, 269.224, 161.567, 268.4, 163]}, + {t: 'C', p: [266.562, 163.39, 264.496, 164.083, 262.918, 162.849]}, + {t: 'C', p: [261.911, 162.062, 261.333, 161.156, 260.534, 160.1]}, + {t: 'C', p: [259.549, 158.798, 259.884, 157.362, 259.954, 155.798]}, + {t: 'C', p: [259.96, 155.67, 259.645, 155.534, 259.645, 155.4]}, + {t: 'C', p: [259.646, 155.265, 259.866, 155.134, 260, 155]}, + {t: 'C', p: [259.294, 154.374, 259.019, 153.316, 258, 153]}, + {t: 'C', p: [258.305, 151.908, 257.629, 151.024, 256.758, 150.722]}, + {t: 'C', p: [254.763, 150.031, 253.086, 151.943, 251.194, 152.016]}, + {t: 'C', p: [250.68, 152.035, 250.213, 150.997, 249.564, 150.672]}, + {t: 'C', p: [249.132, 150.456, 248.428, 150.423, 248.066, 150.689]}, + {t: 'C', p: [247.378, 151.193, 246.789, 151.307, 246.031, 151.512]}, + {t: 'C', p: [244.414, 151.948, 243.136, 153.042, 241.656, 153.897]}, + {t: 'C', p: [240.171, 154.754, 239.216, 156.191, 238.136, 157.511]}, + {t: 'C', p: [237.195, 158.663, 237.059, 161.077, 238.479, 161.577]}, + {t: 'C', p: [240.322, 162.227, 241.626, 159.524, 243.592, 159.85]}, + {t: 'C', p: [243.904, 159.901, 244.11, 160.212, 244, 160.6]}, + {t: 'C', p: [244.389, 160.709, 244.607, 160.48, 244.8, 160.2]}, + {t: 'C', p: [245.658, 161.219, 246.822, 161.556, 247.76, 162.429]}, + {t: 'C', p: [248.73, 163.333, 250.476, 162.915, 251.491, 163.912]}, + {t: 'C', p: [253.02, 165.414, 252.461, 168.095, 254.4, 169.4]}, + {t: 'C', p: [253.814, 170.713, 253.207, 171.99, 252.872, 173.417]}, + {t: 'C', p: [252.59, 174.623, 253.584, 175.82, 254.795, 175.729]}, + {t: 'C', p: [256.053, 175.635, 256.315, 174.876, 256.8, 173.8]}, + {t: 'C', p: [257.067, 174.067, 257.536, 174.364, 257.495, 174.58]}, + {t: 'C', p: [257.038, 176.967, 256.011, 178.96, 255.553, 181.391]}, + {t: 'C', p: [255.494, 181.708, 255.189, 181.91, 254.8, 181.8]}, + {t: 'C', p: [254.332, 185.949, 250.28, 188.343, 247.735, 191.508]}, + {t: 'C', p: [247.332, 192.01, 247.328, 193.259, 247.737, 193.662]}, + {t: 'C', p: [249.14, 195.049, 251.1, 193.503, 252.8, 193]}, + {t: 'C', p: [253.013, 191.794, 253.872, 190.852, 255.204, 190.908]}, + {t: 'C', p: [255.46, 190.918, 255.695, 190.376, 256.019, 190.246]}, + {t: 'C', p: [256.367, 190.108, 256.869, 190.332, 257.155, 190.134]}, + {t: 'C', p: [258.884, 188.939, 260.292, 187.833, 262.03, 186.644]}, + {t: 'C', p: [262.222, 186.513, 262.566, 186.672, 262.782, 186.564]}, + {t: 'C', p: [263.107, 186.402, 263.294, 186.015, 263.617, 185.83]}, + {t: 'C', p: [263.965, 185.63, 264.207, 185.92, 264.4, 186.2]}, + {t: 'C', p: [263.754, 186.549, 263.75, 187.506, 263.168, 187.708]}, + {t: 'C', p: [262.393, 187.976, 261.832, 188.489, 261.158, 188.936]}, + {t: 'C', p: [260.866, 189.129, 260.207, 188.881, 260.103, 189.06]}, + {t: 'C', p: [259.505, 190.088, 258.321, 190.526, 257.611, 191.409]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [202.2, 142]}, + {t: 'C', p: [202.2, 142, 192.962, 139.128, 181.8, 164.8]}, + {t: 'C', p: [181.8, 164.8, 179.4, 170, 177, 172]}, + {t: 'C', p: [174.6, 174, 163.4, 177.6, 161.4, 181.6]}, + {t: 'L', p: [151, 197.6]}, + {t: 'C', p: [151, 197.6, 165.8, 181.6, 169, 179.2]}, + {t: 'C', p: [169, 179.2, 177, 170.8, 173.8, 177.6]}, + {t: 'C', p: [173.8, 177.6, 159.8, 188.4, 161, 197.6]}, + {t: 'C', p: [161, 197.6, 155.4, 212, 154.6, 214]}, + {t: 'C', p: [154.6, 214, 170.6, 182, 173, 180.8]}, + {t: 'C', p: [175.4, 179.6, 176.6, 179.6, 175.4, 183.2]}, + {t: 'C', p: [174.2, 186.8, 173.8, 203.2, 171, 205.2]}, + {t: 'C', p: [171, 205.2, 179, 184.8, 178.2, 181.6]}, + {t: 'C', p: [178.2, 181.6, 181.4, 178, 183.8, 183.2]}, + {t: 'L', p: [182.6, 199.2]}, + {t: 'L', p: [187, 211.2]}, + {t: 'C', p: [187, 211.2, 184.6, 200, 186.2, 184.4]}, + {t: 'C', p: [186.2, 184.4, 184.2, 174, 188.2, 179.6]}, + {t: 'C', p: [192.2, 185.2, 201.8, 191.2, 201.8, 196]}, + {t: 'C', p: [201.8, 196, 196.6, 178.4, 187.4, 173.6]}, + {t: 'L', p: [183.4, 179.6]}, + {t: 'L', p: [182.2, 177.6]}, + {t: 'C', p: [182.2, 177.6, 178.6, 176.8, 183, 170]}, + {t: 'C', p: [187.4, 163.2, 187, 162.4, 187, 162.4]}, + {t: 'C', p: [187, 162.4, 193.4, 169.6, 195, 169.6]}, + {t: 'C', p: [195, 169.6, 208.2, 162, 209.4, 186.4]}, + {t: 'C', p: [209.4, 186.4, 216.2, 172, 207, 165.2]}, + {t: 'C', p: [207, 165.2, 192.2, 163.2, 193.4, 158]}, + {t: 'L', p: [200.6, 145.6]}, + {t: 'C', p: [204.2, 140.4, 202.6, 143.2, 202.6, 143.2]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [182.2, 158.4]}, + {t: 'C', p: [182.2, 158.4, 169.4, 158.4, 166.2, 163.6]}, + {t: 'L', p: [159, 173.2]}, + {t: 'C', p: [159, 173.2, 176.2, 163.2, 180.2, 162]}, + {t: 'C', p: [184.2, 160.8, 182.2, 158.4, 182.2, 158.4]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [142.2, 164.8]}, + {t: 'C', p: [142.2, 164.8, 140.2, 166, 139.8, 168.8]}, + {t: 'C', p: [139.4, 171.6, 137, 172, 137.8, 174.8]}, + {t: 'C', p: [138.6, 177.6, 140.6, 180, 140.6, 176]}, + {t: 'C', p: [140.6, 172, 142.2, 170, 143, 168.8]}, + {t: 'C', p: [143.8, 167.6, 145.4, 163.2, 142.2, 164.8]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [133.4, 226]}, + {t: 'C', p: [133.4, 226, 125, 222, 121.8, 218.4]}, + {t: 'C', p: [118.6, 214.8, 119.052, 219.966, 114.2, 219.6]}, + {t: 'C', p: [108.353, 219.159, 109.4, 203.2, 109.4, 203.2]}, + {t: 'L', p: [105.4, 210.8]}, + {t: 'C', p: [105.4, 210.8, 104.2, 225.2, 112.2, 222.8]}, + {t: 'C', p: [116.107, 221.628, 117.4, 223.2, 115.8, 224]}, + {t: 'C', p: [114.2, 224.8, 121.4, 225.2, 118.6, 226.8]}, + {t: 'C', p: [115.8, 228.4, 130.2, 223.2, 127.8, 233.6]}, + {t: 'L', p: [133.4, 226]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [120.8, 240.4]}, + {t: 'C', p: [120.8, 240.4, 105.4, 244.8, 101.8, 235.2]}, + {t: 'C', p: [101.8, 235.2, 97, 237.6, 99.2, 240.6]}, + {t: 'C', p: [101.4, 243.6, 102.6, 244, 102.6, 244]}, + {t: 'C', p: [102.6, 244, 108, 245.2, 107.4, 246]}, + {t: 'C', p: [106.8, 246.8, 104.4, 250.2, 104.4, 250.2]}, + {t: 'C', p: [104.4, 250.2, 114.6, 244.2, 120.8, 240.4]}, {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: null, + p: [ + {t: 'M', p: [349.201, 318.601]}, + {t: 'C', p: [348.774, 320.735, 347.103, 321.536, 345.201, 322.201]}, + {t: 'C', p: [343.284, 321.243, 340.686, 318.137, 338.801, 320.201]}, + {t: 'C', p: [338.327, 319.721, 337.548, 319.661, 337.204, 318.999]}, + {t: 'C', p: [336.739, 318.101, 337.011, 317.055, 336.669, 316.257]}, + {t: 'C', p: [336.124, 314.985, 335.415, 313.619, 335.601, 312.201]}, + {t: 'C', p: [337.407, 311.489, 338.002, 309.583, 337.528, 307.82]}, + {t: 'C', p: [337.459, 307.563, 337.03, 307.366, 337.23, 307.017]}, + {t: 'C', p: [337.416, 306.694, 337.734, 306.467, 338.001, 306.2]}, + {t: 'C', p: [337.866, 306.335, 337.721, 306.568, 337.61, 306.548]}, + {t: 'C', p: [337, 306.442, 337.124, 305.805, 337.254, 305.418]}, + {t: 'C', p: [337.839, 303.672, 339.853, 303.408, 341.201, 304.6]}, + {t: 'C', p: [341.457, 304.035, 341.966, 304.229, 342.401, 304.2]}, + {t: 'C', p: [342.351, 303.621, 342.759, 303.094, 342.957, 302.674]}, + {t: 'C', p: [343.475, 301.576, 345.104, 302.682, 345.901, 302.07]}, + {t: 'C', p: [346.977, 301.245, 348.04, 300.546, 349.118, 301.149]}, + {t: 'C', p: [350.927, 302.162, 352.636, 303.374, 353.835, 305.115]}, + {t: 'C', p: [354.41, 305.949, 354.65, 307.23, 354.592, 308.188]}, + {t: 'C', p: [354.554, 308.835, 353.173, 308.483, 352.83, 309.412]}, + {t: 'C', p: [352.185, 311.16, 354.016, 311.679, 354.772, 313.017]}, + {t: 'C', p: [354.97, 313.366, 354.706, 313.67, 354.391, 313.768]}, + {t: 'C', p: [353.98, 313.896, 353.196, 313.707, 353.334, 314.16]}, + {t: 'C', p: [354.306, 317.353, 351.55, 318.031, 349.201, 318.601]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: null, + p: [ + {t: 'M', p: [339.6, 338.201]}, + {t: 'C', p: [339.593, 336.463, 337.992, 334.707, 339.201, 333.001]}, + {t: 'C', p: [339.336, 333.135, 339.467, 333.356, 339.601, 333.356]}, + {t: 'C', p: [339.736, 333.356, 339.867, 333.135, 340.001, 333.001]}, + {t: 'C', p: [341.496, 335.217, 345.148, 336.145, 345.006, 338.991]}, + {t: 'C', p: [344.984, 339.438, 343.897, 340.356, 344.801, 341.001]}, + {t: 'C', p: [342.988, 342.349, 342.933, 344.719, 342.001, 346.601]}, + {t: 'C', p: [340.763, 346.315, 339.551, 345.952, 338.401, 345.401]}, + {t: 'C', p: [338.753, 343.915, 338.636, 342.231, 339.456, 340.911]}, + {t: 'C', p: [339.89, 340.213, 339.603, 339.134, 339.6, 338.201]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [173.4, 329.201]}, + {t: 'C', p: [173.4, 329.201, 156.542, 339.337, 170.6, 324.001]}, + {t: 'C', p: [179.4, 314.401, 189.4, 308.801, 189.4, 308.801]}, + {t: 'C', p: [189.4, 308.801, 199.8, 304.4, 203.4, 303.2]}, + {t: 'C', p: [207, 302, 222.2, 296.8, 225.4, 296.4]}, + {t: 'C', p: [228.6, 296, 238.2, 292, 245, 296]}, + {t: 'C', p: [251.8, 300, 259.8, 304.4, 259.8, 304.4]}, + {t: 'C', p: [259.8, 304.4, 243.4, 296, 239.8, 298.4]}, + {t: 'C', p: [236.2, 300.8, 229, 300.4, 223, 303.6]}, + {t: 'C', p: [223, 303.6, 208.2, 308.001, 205, 310.001]}, + {t: 'C', p: [201.8, 312.001, 191.4, 323.601, 189.8, 322.801]}, + {t: 'C', p: [188.2, 322.001, 190.2, 321.601, 191.4, 318.801]}, + {t: 'C', p: [192.6, 316.001, 190.6, 314.401, 182.6, 320.801]}, + {t: 'C', p: [174.6, 327.201, 173.4, 329.201, 173.4, 329.201]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [180.805, 323.234]}, + {t: 'C', p: [180.805, 323.234, 182.215, 310.194, 190.693, 311.859]}, + {t: 'C', p: [190.693, 311.859, 198.919, 307.689, 201.641, 305.721]}, + {t: 'C', p: [201.641, 305.721, 209.78, 304.019, 211.09, 303.402]}, + {t: 'C', p: [229.569, 294.702, 244.288, 299.221, 244.835, 298.101]}, + {t: 'C', p: [245.381, 296.982, 265.006, 304.099, 268.615, 308.185]}, + {t: 'C', p: [269.006, 308.628, 258.384, 302.588, 248.686, 300.697]}, + {t: 'C', p: [240.413, 299.083, 218.811, 300.944, 207.905, 306.48]}, + {t: 'C', p: [204.932, 307.989, 195.987, 313.773, 193.456, 313.662]}, + {t: 'C', p: [190.925, 313.55, 180.805, 323.234, 180.805, 323.234]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [177, 348.801]}, + {t: 'C', p: [177, 348.801, 161.8, 346.401, 178.6, 344.801]}, + {t: 'C', p: [178.6, 344.801, 196.6, 342.801, 200.6, 337.601]}, + {t: 'C', p: [200.6, 337.601, 214.2, 328.401, 217, 328.001]}, + {t: 'C', p: [219.8, 327.601, 249.8, 320.401, 250.2, 318.001]}, + {t: 'C', p: [250.6, 315.601, 256.2, 315.601, 257.8, 316.401]}, + {t: 'C', p: [259.4, 317.201, 258.6, 318.401, 255.8, 319.201]}, + {t: 'C', p: [253, 320.001, 221.8, 336.401, 215.4, 337.601]}, + {t: 'C', p: [209, 338.801, 197.4, 346.401, 192.6, 347.601]}, + {t: 'C', p: [187.8, 348.801, 177, 348.801, 177, 348.801]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [196.52, 341.403]}, + {t: 'C', p: [196.52, 341.403, 187.938, 340.574, 196.539, 339.755]}, + {t: 'C', p: [196.539, 339.755, 205.355, 336.331, 207.403, 333.668]}, + {t: 'C', p: [207.403, 333.668, 214.367, 328.957, 215.8, 328.753]}, + {t: 'C', p: [217.234, 328.548, 231.194, 324.861, 231.399, 323.633]}, + {t: 'C', p: [231.604, 322.404, 265.67, 309.823, 270.09, 313.013]}, + {t: 'C', p: [273.001, 315.114, 263.1, 313.437, 253.466, 317.847]}, + {t: 'C', p: [252.111, 318.467, 218.258, 333.054, 214.981, 333.668]}, + {t: 'C', p: [211.704, 334.283, 205.765, 338.174, 203.307, 338.788]}, + {t: 'C', p: [200.85, 339.403, 196.52, 341.403, 196.52, 341.403]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [188.6, 343.601]}, + {t: 'C', p: [188.6, 343.601, 193.8, 343.201, 192.6, 344.801]}, + {t: 'C', p: [191.4, 346.401, 189, 345.601, 189, 345.601]}, + {t: 'L', p: [188.6, 343.601]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [181.4, 345.201]}, + {t: 'C', p: [181.4, 345.201, 186.6, 344.801, 185.4, 346.401]}, + {t: 'C', p: [184.2, 348.001, 181.8, 347.201, 181.8, 347.201]}, + {t: 'L', p: [181.4, 345.201]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [171, 346.801]}, + {t: 'C', p: [171, 346.801, 176.2, 346.401, 175, 348.001]}, + {t: 'C', p: [173.8, 349.601, 171.4, 348.801, 171.4, 348.801]}, + {t: 'L', p: [171, 346.801]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [163.4, 347.601]}, + {t: 'C', p: [163.4, 347.601, 168.6, 347.201, 167.4, 348.801]}, + {t: 'C', p: [166.2, 350.401, 163.8, 349.601, 163.8, 349.601]}, + {t: 'L', p: [163.4, 347.601]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [201.8, 308.001]}, + {t: 'C', p: [201.8, 308.001, 206.2, 308.001, 205, 309.601]}, + {t: 'C', p: [203.8, 311.201, 200.6, 310.801, 200.6, 310.801]}, + {t: 'L', p: [201.8, 308.001]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [191.8, 313.601]}, + {t: 'C', p: [191.8, 313.601, 198.306, 311.46, 195.8, 314.801]}, + {t: 'C', p: [194.6, 316.401, 192.2, 315.601, 192.2, 315.601]}, + {t: 'L', p: [191.8, 313.601]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [180.6, 318.401]}, + {t: 'C', p: [180.6, 318.401, 185.8, 318.001, 184.6, 319.601]}, + {t: 'C', p: [183.4, 321.201, 181, 320.401, 181, 320.401]}, + {t: 'L', p: [180.6, 318.401]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [173, 324.401]}, + {t: 'C', p: [173, 324.401, 178.2, 324.001, 177, 325.601]}, + {t: 'C', p: [175.8, 327.201, 173.4, 326.401, 173.4, 326.401]}, + {t: 'L', p: [173, 324.401]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [166.2, 329.201]}, + {t: 'C', p: [166.2, 329.201, 171.4, 328.801, 170.2, 330.401]}, + {t: 'C', p: [169, 332.001, 166.6, 331.201, 166.6, 331.201]}, + {t: 'L', p: [166.2, 329.201]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [205.282, 335.598]}, + {t: 'C', p: [205.282, 335.598, 212.203, 335.066, 210.606, 337.195]}, + {t: 'C', p: [209.009, 339.325, 205.814, 338.26, 205.814, 338.26]}, + {t: 'L', p: [205.282, 335.598]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [215.682, 330.798]}, + {t: 'C', p: [215.682, 330.798, 222.603, 330.266, 221.006, 332.395]}, + {t: 'C', p: [219.409, 334.525, 216.214, 333.46, 216.214, 333.46]}, + {t: 'L', p: [215.682, 330.798]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [226.482, 326.398]}, + {t: 'C', p: [226.482, 326.398, 233.403, 325.866, 231.806, 327.995]}, + {t: 'C', p: [230.209, 330.125, 227.014, 329.06, 227.014, 329.06]}, + {t: 'L', p: [226.482, 326.398]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [236.882, 321.598]}, + {t: 'C', p: [236.882, 321.598, 243.803, 321.066, 242.206, 323.195]}, + {t: 'C', p: [240.609, 325.325, 237.414, 324.26, 237.414, 324.26]}, + {t: 'L', p: [236.882, 321.598]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [209.282, 303.598]}, + {t: 'C', p: [209.282, 303.598, 216.203, 303.066, 214.606, 305.195]}, + {t: 'C', p: [213.009, 307.325, 209.014, 307.06, 209.014, 307.06]}, + {t: 'L', p: [209.282, 303.598]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [219.282, 300.398]}, + {t: 'C', p: [219.282, 300.398, 226.203, 299.866, 224.606, 301.995]}, + {t: 'C', p: [223.009, 304.125, 218.614, 303.86, 218.614, 303.86]}, + {t: 'L', p: [219.282, 300.398]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [196.6, 340.401]}, + {t: 'C', p: [196.6, 340.401, 201.8, 340.001, 200.6, 341.601]}, + {t: 'C', p: [199.4, 343.201, 197, 342.401, 197, 342.401]}, + {t: 'L', p: [196.6, 340.401]}, {t: 'z', p: []} + ] + }, + + { + f: '#992600', + s: null, + p: [ + {t: 'M', p: [123.4, 241.2]}, + {t: 'C', p: [123.4, 241.2, 119, 250, 118.6, 253.2]}, + {t: 'C', p: [118.6, 253.2, 119.4, 244.4, 120.6, 242.4]}, + {t: 'C', p: [121.8, 240.4, 123.4, 241.2, 123.4, 241.2]}, {t: 'z', p: []} + ] + }, + + { + f: '#992600', + s: null, + p: [ + {t: 'M', p: [105, 255.2]}, + {t: 'C', p: [105, 255.2, 101.8, 269.6, 102.2, 272.4]}, + {t: 'C', p: [102.2, 272.4, 101, 260.8, 101.4, 259.6]}, + {t: 'C', p: [101.8, 258.4, 105, 255.2, 105, 255.2]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [125.8, 180.6]}, {t: 'L', p: [125.6, 183.8]}, + {t: 'L', p: [123.4, 184]}, + {t: 'C', p: [123.4, 184, 137.6, 196.6, 138.2, 204.2]}, + {t: 'C', p: [138.2, 204.2, 139, 196, 125.8, 180.6]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [129.784, 181.865]}, + {t: 'C', p: [129.353, 181.449, 129.572, 180.704, 129.164, 180.444]}, + {t: 'C', p: [128.355, 179.928, 130.462, 179.871, 130.234, 179.155]}, + {t: 'C', p: [129.851, 177.949, 130.038, 177.928, 129.916, 176.652]}, + {t: 'C', p: [129.859, 176.054, 130.447, 174.514, 130.832, 174.074]}, + {t: 'C', p: [132.278, 172.422, 130.954, 169.49, 132.594, 167.939]}, + {t: 'C', p: [132.898, 167.65, 133.274, 167.098, 133.559, 166.68]}, + {t: 'C', p: [134.218, 165.717, 135.402, 165.229, 136.352, 164.401]}, + {t: 'C', p: [136.67, 164.125, 136.469, 163.298, 137.038, 163.39]}, + {t: 'C', p: [137.752, 163.505, 138.993, 163.375, 138.948, 164.216]}, + {t: 'C', p: [138.835, 166.336, 137.506, 168.056, 136.226, 169.724]}, + {t: 'C', p: [136.677, 170.428, 136.219, 171.063, 135.935, 171.62]}, + {t: 'C', p: [134.6, 174.24, 134.789, 177.081, 134.615, 179.921]}, + {t: 'C', p: [134.61, 180.006, 134.303, 180.084, 134.311, 180.137]}, + {t: 'C', p: [134.664, 182.472, 135.248, 184.671, 136.127, 186.9]}, + {t: 'C', p: [136.493, 187.83, 136.964, 188.725, 137.114, 189.652]}, + {t: 'C', p: [137.225, 190.338, 137.328, 191.171, 136.92, 191.876]}, + {t: 'C', p: [138.955, 194.766, 137.646, 197.417, 138.815, 200.948]}, + {t: 'C', p: [139.022, 201.573, 140.714, 203.487, 140.251, 203.326]}, + {t: 'C', p: [137.738, 202.455, 137.626, 202.057, 137.449, 201.304]}, + {t: 'C', p: [137.303, 200.681, 136.973, 199.304, 136.736, 198.702]}, + {t: 'C', p: [136.672, 198.538, 136.501, 196.654, 136.423, 196.532]}, + {t: 'C', p: [134.91, 194.15, 136.268, 194.326, 134.898, 191.968]}, + {t: 'C', p: [133.47, 191.288, 132.504, 190.184, 131.381, 189.022]}, + {t: 'C', p: [131.183, 188.818, 132.326, 188.094, 132.145, 187.881]}, + {t: 'C', p: [131.053, 186.592, 129.9, 185.825, 130.236, 184.332]}, + {t: 'C', p: [130.391, 183.642, 130.528, 182.585, 129.784, 181.865]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [126.2, 183.6]}, + {t: 'C', p: [126.2, 183.6, 126.6, 190.4, 129, 192]}, + {t: 'C', p: [131.4, 193.6, 130.2, 192.8, 127, 191.6]}, + {t: 'C', p: [123.8, 190.4, 125, 189.6, 125, 189.6]}, + {t: 'C', p: [125, 189.6, 122.2, 190, 124.6, 192]}, + {t: 'C', p: [127, 194, 130.6, 196.4, 129, 196.4]}, + {t: 'C', p: [127.4, 196.4, 119.8, 192.4, 119.8, 189.6]}, + {t: 'C', p: [119.8, 186.8, 118.8, 182.7, 118.8, 182.7]}, + {t: 'C', p: [118.8, 182.7, 119.9, 181.9, 124.7, 182]}, + {t: 'C', p: [124.7, 182, 126.1, 182.7, 126.2, 183.6]}, {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [125.4, 202.2]}, + {t: 'C', p: [125.4, 202.2, 116.88, 199.409, 98.4, 202.8]}, + {t: 'C', p: [98.4, 202.8, 107.431, 200.722, 126.2, 203]}, + {t: 'C', p: [136.5, 204.25, 125.4, 202.2, 125.4, 202.2]}, {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [127.498, 202.129]}, + {t: 'C', p: [127.498, 202.129, 119.252, 198.611, 100.547, 200.392]}, + {t: 'C', p: [100.547, 200.392, 109.725, 199.103, 128.226, 202.995]}, + {t: 'C', p: [138.38, 205.131, 127.498, 202.129, 127.498, 202.129]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [129.286, 202.222]}, + {t: 'C', p: [129.286, 202.222, 121.324, 198.101, 102.539, 198.486]}, + {t: 'C', p: [102.539, 198.486, 111.787, 197.882, 129.948, 203.14]}, + {t: 'C', p: [139.914, 206.025, 129.286, 202.222, 129.286, 202.222]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [130.556, 202.445]}, + {t: 'C', p: [130.556, 202.445, 123.732, 198.138, 106.858, 197.04]}, + {t: 'C', p: [106.858, 197.04, 115.197, 197.21, 131.078, 203.319]}, + {t: 'C', p: [139.794, 206.672, 130.556, 202.445, 130.556, 202.445]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [245.84, 212.961]}, + {t: 'C', p: [245.84, 212.961, 244.91, 213.605, 245.124, 212.424]}, + {t: 'C', p: [245.339, 211.243, 273.547, 198.073, 277.161, 198.323]}, + {t: 'C', p: [277.161, 198.323, 246.913, 211.529, 245.84, 212.961]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [242.446, 213.6]}, + {t: 'C', p: [242.446, 213.6, 241.57, 214.315, 241.691, 213.121]}, + {t: 'C', p: [241.812, 211.927, 268.899, 196.582, 272.521, 196.548]}, + {t: 'C', p: [272.521, 196.548, 243.404, 212.089, 242.446, 213.6]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [239.16, 214.975]}, + {t: 'C', p: [239.16, 214.975, 238.332, 215.747, 238.374, 214.547]}, + {t: 'C', p: [238.416, 213.348, 258.233, 197.851, 268.045, 195.977]}, + {t: 'C', p: [268.045, 195.977, 250.015, 204.104, 239.16, 214.975]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [236.284, 216.838]}, + {t: 'C', p: [236.284, 216.838, 235.539, 217.532, 235.577, 216.453]}, + {t: 'C', p: [235.615, 215.373, 253.449, 201.426, 262.28, 199.74]}, + {t: 'C', p: [262.28, 199.74, 246.054, 207.054, 236.284, 216.838]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [204.6, 364.801]}, + {t: 'C', p: [204.6, 364.801, 189.4, 362.401, 206.2, 360.801]}, + {t: 'C', p: [206.2, 360.801, 224.2, 358.801, 228.2, 353.601]}, + {t: 'C', p: [228.2, 353.601, 241.8, 344.401, 244.6, 344.001]}, + {t: 'C', p: [247.4, 343.601, 263.8, 340.001, 264.2, 337.601]}, + {t: 'C', p: [264.6, 335.201, 270.6, 332.801, 272.2, 333.601]}, + {t: 'C', p: [273.8, 334.401, 273.8, 343.601, 271, 344.401]}, + {t: 'C', p: [268.2, 345.201, 249.4, 352.401, 243, 353.601]}, + {t: 'C', p: [236.6, 354.801, 225, 362.401, 220.2, 363.601]}, + {t: 'C', p: [215.4, 364.801, 204.6, 364.801, 204.6, 364.801]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [277.6, 327.401]}, + {t: 'C', p: [277.6, 327.401, 274.6, 329.001, 273.4, 331.601]}, + {t: 'C', p: [273.4, 331.601, 267, 342.201, 252.8, 345.401]}, + {t: 'C', p: [252.8, 345.401, 229.8, 354.401, 222, 356.401]}, + {t: 'C', p: [222, 356.401, 208.6, 361.401, 201.2, 360.601]}, + {t: 'C', p: [201.2, 360.601, 194.2, 360.801, 200.4, 362.401]}, + {t: 'C', p: [200.4, 362.401, 220.6, 360.401, 224, 358.601]}, + {t: 'C', p: [224, 358.601, 239.6, 353.401, 242.6, 350.801]}, + {t: 'C', p: [245.6, 348.201, 263.8, 343.201, 266, 341.201]}, + {t: 'C', p: [268.2, 339.201, 278, 330.801, 277.6, 327.401]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [218.882, 358.911]}, + {t: 'C', p: [218.882, 358.911, 224.111, 358.685, 222.958, 360.234]}, + {t: 'C', p: [221.805, 361.784, 219.357, 360.91, 219.357, 360.91]}, + {t: 'L', p: [218.882, 358.911]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [211.68, 360.263]}, + {t: 'C', p: [211.68, 360.263, 216.908, 360.037, 215.756, 361.586]}, + {t: 'C', p: [214.603, 363.136, 212.155, 362.263, 212.155, 362.263]}, + {t: 'L', p: [211.68, 360.263]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [201.251, 361.511]}, + {t: 'C', p: [201.251, 361.511, 206.48, 361.284, 205.327, 362.834]}, + {t: 'C', p: [204.174, 364.383, 201.726, 363.51, 201.726, 363.51]}, + {t: 'L', p: [201.251, 361.511]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [193.617, 362.055]}, + {t: 'C', p: [193.617, 362.055, 198.846, 361.829, 197.693, 363.378]}, + {t: 'C', p: [196.54, 364.928, 194.092, 364.054, 194.092, 364.054]}, + {t: 'L', p: [193.617, 362.055]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [235.415, 351.513]}, + {t: 'C', p: [235.415, 351.513, 242.375, 351.212, 240.84, 353.274]}, + {t: 'C', p: [239.306, 355.336, 236.047, 354.174, 236.047, 354.174]}, + {t: 'L', p: [235.415, 351.513]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [245.73, 347.088]}, + {t: 'C', p: [245.73, 347.088, 251.689, 343.787, 251.155, 348.849]}, + {t: 'C', p: [250.885, 351.405, 246.362, 349.749, 246.362, 349.749]}, + {t: 'L', p: [245.73, 347.088]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [254.862, 344.274]}, + {t: 'C', p: [254.862, 344.274, 262.021, 340.573, 260.287, 346.035]}, + {t: 'C', p: [259.509, 348.485, 255.493, 346.935, 255.493, 346.935]}, + {t: 'L', p: [254.862, 344.274]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [264.376, 339.449]}, + {t: 'C', p: [264.376, 339.449, 268.735, 334.548, 269.801, 341.21]}, + {t: 'C', p: [270.207, 343.748, 265.008, 342.11, 265.008, 342.11]}, + {t: 'L', p: [264.376, 339.449]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [226.834, 355.997]}, + {t: 'C', p: [226.834, 355.997, 232.062, 355.77, 230.91, 357.32]}, + {t: 'C', p: [229.757, 358.869, 227.308, 357.996, 227.308, 357.996]}, + {t: 'L', p: [226.834, 355.997]}, {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [262.434, 234.603]}, + {t: 'C', p: [262.434, 234.603, 261.708, 235.268, 261.707, 234.197]}, + {t: 'C', p: [261.707, 233.127, 279.191, 219.863, 288.034, 218.479]}, + {t: 'C', p: [288.034, 218.479, 271.935, 225.208, 262.434, 234.603]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [265.4, 298.4]}, + {t: 'C', p: [265.4, 298.4, 287.401, 320.801, 296.601, 324.401]}, + {t: 'C', p: [296.601, 324.401, 305.801, 335.601, 301.801, 361.601]}, + {t: 'C', p: [301.801, 361.601, 298.601, 369.201, 295.401, 348.401]}, + {t: 'C', p: [295.401, 348.401, 298.601, 323.201, 287.401, 339.201]}, + {t: 'C', p: [287.401, 339.201, 279, 329.301, 285.4, 329.601]}, + {t: 'C', p: [285.4, 329.601, 288.601, 331.601, 289.001, 330.001]}, + {t: 'C', p: [289.401, 328.401, 281.4, 314.801, 264.2, 300.4]}, + {t: 'C', p: [247, 286, 265.4, 298.4, 265.4, 298.4]}, {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [207, 337.201]}, + {t: 'C', p: [207, 337.201, 206.8, 335.401, 208.6, 336.201]}, + {t: 'C', p: [210.4, 337.001, 304.601, 343.201, 336.201, 367.201]}, + {t: 'C', p: [336.201, 367.201, 291.001, 344.001, 207, 337.201]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [217.4, 332.801]}, + {t: 'C', p: [217.4, 332.801, 217.2, 331.001, 219, 331.801]}, + {t: 'C', p: [220.8, 332.601, 357.401, 331.601, 381.001, 364.001]}, + {t: 'C', p: [381.001, 364.001, 359.001, 338.801, 217.4, 332.801]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [229, 328.801]}, + {t: 'C', p: [229, 328.801, 228.8, 327.001, 230.6, 327.801]}, + {t: 'C', p: [232.4, 328.601, 405.801, 315.601, 429.401, 348.001]}, + {t: 'C', p: [429.401, 348.001, 419.801, 322.401, 229, 328.801]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [239, 324.001]}, + {t: 'C', p: [239, 324.001, 238.8, 322.201, 240.6, 323.001]}, + {t: 'C', p: [242.4, 323.801, 364.601, 285.2, 388.201, 317.601]}, + {t: 'C', p: [388.201, 317.601, 374.801, 293, 239, 324.001]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [181, 346.801]}, + {t: 'C', p: [181, 346.801, 180.8, 345.001, 182.6, 345.801]}, + {t: 'C', p: [184.4, 346.601, 202.2, 348.801, 204.2, 387.601]}, + {t: 'C', p: [204.2, 387.601, 197, 345.601, 181, 346.801]}, {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [172.2, 348.401]}, + {t: 'C', p: [172.2, 348.401, 172, 346.601, 173.8, 347.401]}, + {t: 'C', p: [175.6, 348.201, 189.8, 343.601, 187, 382.401]}, + {t: 'C', p: [187, 382.401, 188.2, 347.201, 172.2, 348.401]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [164.2, 348.801]}, + {t: 'C', p: [164.2, 348.801, 164, 347.001, 165.8, 347.801]}, + {t: 'C', p: [167.6, 348.601, 183, 349.201, 170.6, 371.601]}, + {t: 'C', p: [170.6, 371.601, 180.2, 347.601, 164.2, 348.801]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [211.526, 304.465]}, + {t: 'C', p: [211.526, 304.465, 211.082, 306.464, 212.631, 305.247]}, + {t: 'C', p: [228.699, 292.622, 261.141, 233.72, 316.826, 228.086]}, + {t: 'C', p: [316.826, 228.086, 278.518, 215.976, 211.526, 304.465]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [222.726, 302.665]}, + {t: 'C', p: [222.726, 302.665, 221.363, 301.472, 223.231, 300.847]}, + {t: 'C', p: [225.099, 300.222, 337.541, 227.72, 376.826, 235.686]}, + {t: 'C', p: [376.826, 235.686, 349.719, 228.176, 222.726, 302.665]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [201.885, 308.767]}, + {t: 'C', p: [201.885, 308.767, 201.376, 310.366, 203.087, 309.39]}, + {t: 'C', p: [212.062, 304.27, 215.677, 247.059, 259.254, 245.804]}, + {t: 'C', p: [259.254, 245.804, 226.843, 231.09, 201.885, 308.767]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [181.962, 319.793]}, + {t: 'C', p: [181.962, 319.793, 180.885, 321.079, 182.838, 320.825]}, + {t: 'C', p: [193.084, 319.493, 214.489, 278.222, 258.928, 283.301]}, + {t: 'C', p: [258.928, 283.301, 226.962, 268.955, 181.962, 319.793]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [193.2, 313.667]}, + {t: 'C', p: [193.2, 313.667, 192.389, 315.136, 194.258, 314.511]}, + {t: 'C', p: [204.057, 311.237, 217.141, 266.625, 261.729, 263.078]}, + {t: 'C', p: [261.729, 263.078, 227.603, 255.135, 193.2, 313.667]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [174.922, 324.912]}, + {t: 'C', p: [174.922, 324.912, 174.049, 325.954, 175.631, 325.748]}, + {t: 'C', p: [183.93, 324.669, 201.268, 291.24, 237.264, 295.354]}, + {t: 'C', p: [237.264, 295.354, 211.371, 283.734, 174.922, 324.912]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [167.323, 330.821]}, + {t: 'C', p: [167.323, 330.821, 166.318, 331.866, 167.909, 331.748]}, + {t: 'C', p: [172.077, 331.439, 202.715, 298.36, 221.183, 313.862]}, + {t: 'C', p: [221.183, 313.862, 209.168, 295.139, 167.323, 330.821]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [236.855, 298.898]}, + {t: 'C', p: [236.855, 298.898, 235.654, 297.543, 237.586, 297.158]}, + {t: 'C', p: [239.518, 296.774, 360.221, 239.061, 398.184, 251.927]}, + {t: 'C', p: [398.184, 251.927, 372.243, 241.053, 236.855, 298.898]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [203.4, 363.201]}, + {t: 'C', p: [203.4, 363.201, 203.2, 361.401, 205, 362.201]}, + {t: 'C', p: [206.8, 363.001, 222.2, 363.601, 209.8, 386.001]}, + {t: 'C', p: [209.8, 386.001, 219.4, 362.001, 203.4, 363.201]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [213.8, 361.601]}, + {t: 'C', p: [213.8, 361.601, 213.6, 359.801, 215.4, 360.601]}, + {t: 'C', p: [217.2, 361.401, 235, 363.601, 237, 402.401]}, + {t: 'C', p: [237, 402.401, 229.8, 360.401, 213.8, 361.601]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [220.6, 360.001]}, + {t: 'C', p: [220.6, 360.001, 220.4, 358.201, 222.2, 359.001]}, + {t: 'C', p: [224, 359.801, 248.6, 363.201, 272.2, 395.601]}, + {t: 'C', p: [272.2, 395.601, 236.6, 358.801, 220.6, 360.001]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [228.225, 357.972]}, + {t: 'C', p: [228.225, 357.972, 227.788, 356.214, 229.678, 356.768]}, + {t: 'C', p: [231.568, 357.322, 252.002, 355.423, 290.099, 389.599]}, + {t: 'C', p: [290.099, 389.599, 243.924, 354.656, 228.225, 357.972]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [238.625, 353.572]}, + {t: 'C', p: [238.625, 353.572, 238.188, 351.814, 240.078, 352.368]}, + {t: 'C', p: [241.968, 352.922, 276.802, 357.423, 328.499, 392.399]}, + {t: 'C', p: [328.499, 392.399, 254.324, 350.256, 238.625, 353.572]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [198.2, 342.001]}, + {t: 'C', p: [198.2, 342.001, 198, 340.201, 199.8, 341.001]}, + {t: 'C', p: [201.6, 341.801, 255, 344.401, 285.4, 371.201]}, + {t: 'C', p: [285.4, 371.201, 250.499, 346.426, 198.2, 342.001]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [188.2, 346.001]}, + {t: 'C', p: [188.2, 346.001, 188, 344.201, 189.8, 345.001]}, + {t: 'C', p: [191.6, 345.801, 216.2, 349.201, 239.8, 381.601]}, + {t: 'C', p: [239.8, 381.601, 204.2, 344.801, 188.2, 346.001]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [249.503, 348.962]}, + {t: 'C', p: [249.503, 348.962, 248.938, 347.241, 250.864, 347.655]}, + {t: 'C', p: [252.79, 348.068, 287.86, 350.004, 341.981, 381.098]}, + {t: 'C', p: [341.981, 381.098, 264.317, 346.704, 249.503, 348.962]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [257.903, 346.562]}, + {t: 'C', p: [257.903, 346.562, 257.338, 344.841, 259.264, 345.255]}, + {t: 'C', p: [261.19, 345.668, 296.26, 347.604, 350.381, 378.698]}, + {t: 'C', p: [350.381, 378.698, 273.317, 343.904, 257.903, 346.562]}, + {t: 'z', p: []} + ] + }, + + { + f: '#fff', + s: {c: '#000', w: 0.1}, + p: [ + {t: 'M', p: [267.503, 341.562]}, + {t: 'C', p: [267.503, 341.562, 266.938, 339.841, 268.864, 340.255]}, + {t: 'C', p: [270.79, 340.668, 313.86, 345.004, 403.582, 379.298]}, + {t: 'C', p: [403.582, 379.298, 282.917, 338.904, 267.503, 341.562]}, + {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [156.2, 348.401]}, + {t: 'C', p: [156.2, 348.401, 161.4, 348.001, 160.2, 349.601]}, + {t: 'C', p: [159, 351.201, 156.6, 350.401, 156.6, 350.401]}, + {t: 'L', p: [156.2, 348.401]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [187, 362.401]}, + {t: 'C', p: [187, 362.401, 192.2, 362.001, 191, 363.601]}, + {t: 'C', p: [189.8, 365.201, 187.4, 364.401, 187.4, 364.401]}, + {t: 'L', p: [187, 362.401]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [178.2, 362.001]}, + {t: 'C', p: [178.2, 362.001, 183.4, 361.601, 182.2, 363.201]}, + {t: 'C', p: [181, 364.801, 178.6, 364.001, 178.6, 364.001]}, + {t: 'L', p: [178.2, 362.001]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [82.831, 350.182]}, + {t: 'C', p: [82.831, 350.182, 87.876, 351.505, 86.218, 352.624]}, + {t: 'C', p: [84.561, 353.744, 82.554, 352.202, 82.554, 352.202]}, + {t: 'L', p: [82.831, 350.182]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [84.831, 340.582]}, + {t: 'C', p: [84.831, 340.582, 89.876, 341.905, 88.218, 343.024]}, + {t: 'C', p: [86.561, 344.144, 84.554, 342.602, 84.554, 342.602]}, + {t: 'L', p: [84.831, 340.582]}, {t: 'z', p: []} + ] + }, + + { + f: '#000', + s: null, + p: [ + {t: 'M', p: [77.631, 336.182]}, + {t: 'C', p: [77.631, 336.182, 82.676, 337.505, 81.018, 338.624]}, + {t: 'C', p: [79.361, 339.744, 77.354, 338.202, 77.354, 338.202]}, + {t: 'L', p: [77.631, 336.182]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [157.4, 411.201]}, + {t: 'C', p: [157.4, 411.201, 155.8, 411.201, 151.8, 413.201]}, + {t: 'C', p: [149.8, 413.201, 138.6, 416.801, 133, 426.801]}, + {t: 'C', p: [133, 426.801, 145.4, 417.201, 157.4, 411.201]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [245.116, 503.847]}, + {t: 'C', p: [245.257, 504.105, 245.312, 504.525, 245.604, 504.542]}, + {t: 'C', p: [246.262, 504.582, 247.495, 504.883, 247.37, 504.247]}, + {t: 'C', p: [246.522, 499.941, 245.648, 495.004, 241.515, 493.197]}, + {t: 'C', p: [240.876, 492.918, 239.434, 493.331, 239.36, 494.215]}, + {t: 'C', p: [239.233, 495.739, 239.116, 497.088, 239.425, 498.554]}, + {t: 'C', p: [239.725, 499.975, 241.883, 499.985, 242.8, 498.601]}, + {t: 'C', p: [243.736, 500.273, 244.168, 502.116, 245.116, 503.847]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [234.038, 508.581]}, + {t: 'C', p: [234.786, 509.994, 234.659, 511.853, 236.074, 512.416]}, + {t: 'C', p: [236.814, 512.71, 238.664, 511.735, 238.246, 510.661]}, + {t: 'C', p: [237.444, 508.6, 237.056, 506.361, 235.667, 504.55]}, + {t: 'C', p: [235.467, 504.288, 235.707, 503.755, 235.547, 503.427]}, + {t: 'C', p: [234.953, 502.207, 233.808, 501.472, 232.4, 501.801]}, + {t: 'C', p: [231.285, 504.004, 232.433, 506.133, 233.955, 507.842]}, + {t: 'C', p: [234.091, 507.994, 233.925, 508.37, 234.038, 508.581]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [194.436, 503.391]}, + {t: 'C', p: [194.328, 503.014, 194.29, 502.551, 194.455, 502.23]}, + {t: 'C', p: [194.986, 501.197, 195.779, 500.075, 195.442, 499.053]}, + {t: 'C', p: [195.094, 497.997, 193.978, 498.179, 193.328, 498.748]}, + {t: 'C', p: [192.193, 499.742, 192.144, 501.568, 191.453, 502.927]}, + {t: 'C', p: [191.257, 503.313, 191.308, 503.886, 190.867, 504.277]}, + {t: 'C', p: [190.393, 504.698, 189.953, 506.222, 190.049, 506.793]}, + {t: 'C', p: [190.102, 507.106, 189.919, 517.014, 190.141, 516.751]}, + {t: 'C', p: [190.76, 516.018, 193.81, 506.284, 193.879, 505.392]}, + {t: 'C', p: [193.936, 504.661, 194.668, 504.196, 194.436, 503.391]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [168.798, 496.599]}, + {t: 'C', p: [171.432, 494.1, 174.222, 491.139, 173.78, 487.427]}, + {t: 'C', p: [173.664, 486.451, 171.889, 486.978, 171.702, 487.824]}, + {t: 'C', p: [170.9, 491.449, 168.861, 494.11, 166.293, 496.502]}, + {t: 'C', p: [164.097, 498.549, 162.235, 504.893, 162, 505.401]}, + {t: 'C', p: [165.697, 500.145, 167.954, 497.399, 168.798, 496.599]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [155.224, 490.635]}, + {t: 'C', p: [155.747, 490.265, 155.445, 489.774, 155.662, 489.442]}, + {t: 'C', p: [156.615, 487.984, 157.916, 486.738, 157.934, 485]}, + {t: 'C', p: [157.937, 484.723, 157.559, 484.414, 157.224, 484.638]}, + {t: 'C', p: [156.947, 484.822, 156.605, 484.952, 156.497, 485.082]}, + {t: 'C', p: [154.467, 487.531, 153.067, 490.202, 151.624, 493.014]}, + {t: 'C', p: [151.441, 493.371, 150.297, 497.862, 150.61, 497.973]}, + {t: 'C', p: [150.849, 498.058, 152.569, 493.877, 152.779, 493.763]}, + {t: 'C', p: [154.042, 493.077, 154.054, 491.462, 155.224, 490.635]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [171.957, 510.179]}, + {t: 'C', p: [172.401, 509.31, 173.977, 508.108, 173.864, 507.219]}, + {t: 'C', p: [173.746, 506.291, 174.214, 504.848, 173.302, 505.536]}, + {t: 'C', p: [172.045, 506.484, 168.596, 507.833, 168.326, 513.641]}, + {t: 'C', p: [168.3, 514.212, 171.274, 511.519, 171.957, 510.179]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [186.4, 493.001]}, + {t: 'C', p: [186.8, 492.333, 187.508, 492.806, 187.967, 492.543]}, + {t: 'C', p: [188.615, 492.171, 189.226, 491.613, 189.518, 490.964]}, + {t: 'C', p: [190.488, 488.815, 192.257, 486.995, 192.4, 484.601]}, + {t: 'C', p: [190.909, 483.196, 190.23, 485.236, 189.6, 486.201]}, + {t: 'C', p: [188.277, 484.554, 187.278, 486.428, 185.978, 486.947]}, + {t: 'C', p: [185.908, 486.975, 185.695, 486.628, 185.62, 486.655]}, + {t: 'C', p: [184.443, 487.095, 183.763, 488.176, 182.765, 488.957]}, + {t: 'C', p: [182.594, 489.091, 182.189, 488.911, 182.042, 489.047]}, + {t: 'C', p: [181.39, 489.65, 180.417, 489.975, 180.137, 490.657]}, + {t: 'C', p: [179.027, 493.364, 175.887, 495.459, 174, 503.001]}, + {t: 'C', p: [174.381, 503.91, 178.512, 496.359, 178.999, 495.661]}, + {t: 'C', p: [179.835, 494.465, 179.953, 497.322, 181.229, 496.656]}, + {t: 'C', p: [181.28, 496.629, 181.466, 496.867, 181.6, 497.001]}, + {t: 'C', p: [181.794, 496.721, 182.012, 496.492, 182.4, 496.601]}, + {t: 'C', p: [182.4, 496.201, 182.266, 495.645, 182.467, 495.486]}, + {t: 'C', p: [183.704, 494.509, 183.62, 493.441, 184.4, 492.201]}, + {t: 'C', p: [184.858, 492.99, 185.919, 492.271, 186.4, 493.001]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [246.2, 547.401]}, + {t: 'C', p: [246.2, 547.401, 253.6, 527.001, 249.2, 515.801]}, + {t: 'C', p: [249.2, 515.801, 260.6, 537.401, 256, 548.601]}, + {t: 'C', p: [256, 548.601, 255.6, 538.201, 251.6, 533.201]}, + {t: 'C', p: [251.6, 533.201, 247.6, 546.001, 246.2, 547.401]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [231.4, 544.801]}, + {t: 'C', p: [231.4, 544.801, 236.8, 536.001, 228.8, 517.601]}, + {t: 'C', p: [228.8, 517.601, 228, 538.001, 221.2, 549.001]}, + {t: 'C', p: [221.2, 549.001, 235.4, 528.801, 231.4, 544.801]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [221.4, 542.801]}, + {t: 'C', p: [221.4, 542.801, 221.2, 522.801, 221.6, 519.801]}, + {t: 'C', p: [221.6, 519.801, 217.8, 536.401, 207.6, 546.001]}, + {t: 'C', p: [207.6, 546.001, 222, 534.001, 221.4, 542.801]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [211.8, 510.801]}, + {t: 'C', p: [211.8, 510.801, 217.8, 524.401, 207.8, 542.801]}, + {t: 'C', p: [207.8, 542.801, 214.2, 530.601, 209.4, 523.601]}, + {t: 'C', p: [209.4, 523.601, 212, 520.201, 211.8, 510.801]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [192.6, 542.401]}, + {t: 'C', p: [192.6, 542.401, 191.6, 526.801, 193.4, 524.601]}, + {t: 'C', p: [193.4, 524.601, 193.6, 518.201, 193.2, 517.201]}, + {t: 'C', p: [193.2, 517.201, 197.2, 511.001, 197.4, 518.401]}, + {t: 'C', p: [197.4, 518.401, 198.8, 526.201, 201.6, 530.801]}, + {t: 'C', p: [201.6, 530.801, 205.2, 536.201, 205, 542.601]}, + {t: 'C', p: [205, 542.601, 195, 512.401, 192.6, 542.401]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [189, 514.801]}, + {t: 'C', p: [189, 514.801, 182.4, 525.601, 180.6, 544.601]}, + {t: 'C', p: [180.6, 544.601, 179.2, 538.401, 183, 524.001]}, + {t: 'C', p: [183, 524.001, 187.2, 508.601, 189, 514.801]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [167.2, 534.601]}, + {t: 'C', p: [167.2, 534.601, 172.2, 529.201, 173.6, 524.201]}, + {t: 'C', p: [173.6, 524.201, 177.2, 508.401, 170.8, 517.001]}, + {t: 'C', p: [170.8, 517.001, 171, 525.001, 162.8, 532.401]}, + {t: 'C', p: [162.8, 532.401, 167.6, 530.001, 167.2, 534.601]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [161.4, 529.601]}, + {t: 'C', p: [161.4, 529.601, 164.8, 512.201, 165.6, 511.401]}, + {t: 'C', p: [165.6, 511.401, 167.4, 508.001, 164.6, 511.201]}, + {t: 'C', p: [164.6, 511.201, 155.8, 530.401, 151.8, 537.001]}, + {t: 'C', p: [151.8, 537.001, 159.8, 527.801, 161.4, 529.601]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [155.6, 513.001]}, + {t: 'C', p: [155.6, 513.001, 167.2, 490.601, 145.4, 516.401]}, + {t: 'C', p: [145.4, 516.401, 156.4, 506.601, 155.6, 513.001]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [140.2, 498.401]}, + {t: 'C', p: [140.2, 498.401, 145, 479.601, 147.6, 479.801]}, + {t: 'C', p: [147.6, 479.801, 155.8, 470.801, 149.2, 481.401]}, + {t: 'C', p: [149.2, 481.401, 143.2, 491.001, 143.8, 500.801]}, + {t: 'C', p: [143.8, 500.801, 143.2, 491.201, 140.2, 498.401]}, + {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [470.5, 487]}, + {t: 'C', p: [470.5, 487, 458.5, 477, 456, 473.5]}, + {t: 'C', p: [456, 473.5, 469.5, 492, 469.5, 499]}, + {t: 'C', p: [469.5, 499, 472, 491.5, 470.5, 487]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [476, 465]}, {t: 'C', p: [476, 465, 455, 450, 451.5, 442.5]}, + {t: 'C', p: [451.5, 442.5, 478, 472, 478, 476.5]}, + {t: 'C', p: [478, 476.5, 478.5, 467.5, 476, 465]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [493, 311]}, {t: 'C', p: [493, 311, 481, 303, 479.5, 305]}, + {t: 'C', p: [479.5, 305, 490, 311.5, 492.5, 320]}, + {t: 'C', p: [492.5, 320, 491, 311, 493, 311]}, {t: 'z', p: []} + ] + }, + + { + f: '#ccc', + s: null, + p: [ + {t: 'M', p: [501.5, 391.5]}, {t: 'L', p: [484, 379.5]}, + {t: 'C', p: [484, 379.5, 503, 396.5, 503.5, 400.5]}, + {t: 'L', p: [501.5, 391.5]}, {t: 'z', p: []} + ] + }, + + { + f: null, + s: '#000', + p: [{t: 'M', p: [110.75, 369]}, {t: 'L', p: [132.75, 373.75]}] + }, + + { + f: null, + s: '#000', + p: [ + {t: 'M', p: [161, 531]}, {t: 'C', p: [161, 531, 160.5, 527.5, 151.5, 538]} + ] + }, + + { + f: null, + s: '#000', + p: [ + {t: 'M', p: [166.5, 536]}, + {t: 'C', p: [166.5, 536, 168.5, 529.5, 162, 534]} + ] + }, + + { + f: null, + s: '#000', + p: [ + {t: 'M', p: [220.5, 544.5]}, + {t: 'C', p: [220.5, 544.5, 222, 533.5, 210.5, 546.5]} + ] + } +]; diff --git a/closure/goog/demos/history1.html b/closure/goog/demos/history1.html new file mode 100644 index 0000000000..a8be215924 --- /dev/null +++ b/closure/goog/demos/history1.html @@ -0,0 +1,132 @@ + + + + + goog.History + + + + + + + +

goog.History

+

This page demonstrates the goog.History object which can create new browser + history entries without leaving the page. This version uses the hash portion of + the URL to make the history state available to the user. These URLs can be + bookmarked, edited, pasted in emails, etc., just like normal URLs. The browser's + back and forward buttons will navigate between the visited history states.

+ +

Try following the hash links below, or updating the location with your own + tokens. Replacing the token will update the page address without appending a + new history entry.

+ +

+ Set #fragment
+ first
+ second
+ third +

+ +

+ Set Token
+ + + + + +

+ +

+ + + +

+ +
+

The current history state:

+
+
+ +

The state should be correctly restored after you + leave the page and hit the back button.

+ +

The history object can also be created so that the history state is not + user-visible/modifiable. + See history2.html for a demo. + To see visible/modifiable history work when the goog.History code itself is + loaded inside a hidden iframe, + see history3.html. +

+ +
+ Event Log +
+
+ + + + + diff --git a/closure/goog/demos/history2.html b/closure/goog/demos/history2.html new file mode 100644 index 0000000000..5ed39a9f93 --- /dev/null +++ b/closure/goog/demos/history2.html @@ -0,0 +1,119 @@ + + + + + goog.History #2 + + + + + + +

goog.History #2

+

This page demonstrates the goog.History object which can create new browser + history entries without leaving the page. This version maintains the history + state internally, so that states are not visible or editable by the user, but + the back and forward buttons can still be used to move between history states. +

+ +

Try setting a few history tokens using the buttons and box below, then hit + the back and forward buttons to test if the tokens are correctly restored.

+ + + + +
+ + + + + + + +

+ + + +

+ +
+

The current history state:

+
+
+ +

The state should be correctly restored after you + leave the page and hit the back button.

+ +

The history object can also be created so that the history state is visible + and modifiable by the user. See history1.html for a + demo.

+ +
+ Event Log +
+
+ + + + + diff --git a/closure/goog/demos/history3.html b/closure/goog/demos/history3.html new file mode 100644 index 0000000000..f0d7f889d1 --- /dev/null +++ b/closure/goog/demos/history3.html @@ -0,0 +1,116 @@ + + + + +History Demo 3 + + + + + + +

This page demonstrates a goog.History object used in an iframe. Loading JS +code in an iframe is useful for large apps because the JS code can be sent in +bite-sized script blocks that browsers can evaluate incrementally, as they are +received over the wire.

+ +

For an introduction to the goog.History object, see history1.html and history2.html. This demo uses visible history, like +the first demo.

+ +

Try following the hash links below, or updating the location with your own +tokens. Replacing the token will update the page address without appending a +new history entry.

+ +

+ Set #fragment
+ first
+ second
+ third +

+ +

+ Set Token
+ + + + + +

+ +

+ + + +

+ +
+

The current history state:

+
+
+ +

The state should be correctly restored after you +leave the page and hit the back button.

+ +
+ Event Log +
+
+ + + + + + + + diff --git a/closure/goog/demos/history3js.html b/closure/goog/demos/history3js.html new file mode 100644 index 0000000000..a670d42591 --- /dev/null +++ b/closure/goog/demos/history3js.html @@ -0,0 +1,48 @@ + + + + +History Demo JavaScript Page + + + + + + + diff --git a/closure/goog/demos/history_blank.html b/closure/goog/demos/history_blank.html new file mode 100644 index 0000000000..06b54ca5bc --- /dev/null +++ b/closure/goog/demos/history_blank.html @@ -0,0 +1,26 @@ + + + +Intentionally left blank + + +This is a blank helper page for the goog.History demos. See +demo 1 and +demo 2. + + diff --git a/closure/goog/demos/hovercard.html b/closure/goog/demos/hovercard.html new file mode 100644 index 0000000000..5ce5e5a620 --- /dev/null +++ b/closure/goog/demos/hovercard.html @@ -0,0 +1,176 @@ + + + + + goog.ui.HoverCard + + + + + + + +

goog.ui.HoverCard

+

+ Show by mouse position:


+ Tom Smith + Dick Jones + Harry Brown + +


Show hovercard to the right:


+ Tom Smith + Dick Jones + Harry Brown + +


Show hovercard below:


+ Tom Smith + Dick Jones + Harry Brown + +


+ +

+ + + + +
+ Event Log +
+
+
+
+ + + + + + diff --git a/closure/goog/demos/hsvapalette.html b/closure/goog/demos/hsvapalette.html new file mode 100644 index 0000000000..1e980ada34 --- /dev/null +++ b/closure/goog/demos/hsvapalette.html @@ -0,0 +1,53 @@ + + + + + goog.ui.HsvaPalette + + + + + + + +

goog.ui.HsvaPalette

+ +

Normal Size

+ + + +

Smaller Size

+ + + + + diff --git a/closure/goog/demos/hsvpalette.html b/closure/goog/demos/hsvpalette.html new file mode 100644 index 0000000000..7666a71e37 --- /dev/null +++ b/closure/goog/demos/hsvpalette.html @@ -0,0 +1,54 @@ + + + + + goog.ui.HsvPalette + + + + + + + +

goog.ui.HsvPalette

+ +

Normal Size

+ + + +

Smaller Size

+ + + + + diff --git a/closure/goog/demos/html5history.html b/closure/goog/demos/html5history.html new file mode 100644 index 0000000000..1dbae95420 --- /dev/null +++ b/closure/goog/demos/html5history.html @@ -0,0 +1,87 @@ + + + + + goog.history.Html5History Demo + + + + + + +

goog.history.Html5History

+ + + +
+ +
+
+ +
+
+ +
+
+ +
+ + + + diff --git a/closure/goog/demos/imagelessbutton.html b/closure/goog/demos/imagelessbutton.html new file mode 100644 index 0000000000..8d373b293e --- /dev/null +++ b/closure/goog/demos/imagelessbutton.html @@ -0,0 +1,221 @@ + + + + + + goog.ui.ImagelessButtonRenderer Demo + + + + + + + + +

goog.ui.ImagelessButtonRenderer

+
+ + These buttons were rendered using + goog.ui.ImagelessButtonRenderer: + +
+ These buttons were created programmatically:
+
+
+ These buttons were created by decorating some DIVs, and they dispatch + state transition events (watch the event log):
+
+ +
+ Decorated Button, yay! +
+
Decorated Disabled
+
Another Button
+
+ Archive +
+ Delete +
+ Report Spam +
+
+
+ Use these ToggleButtons to hide/show and enable/disable + the middle button:
+
Enable
+ +
Show
+ +

+ Combined toggle buttons
+
+ Bold +
+ Italics +
+ Underlined +
+

+ These buttons have icons, and the second one has an extra CSS class:
+
+
+
+
+
+ +
+ Event Log +
+
+ + + + diff --git a/closure/goog/demos/imagelessmenubutton.html b/closure/goog/demos/imagelessmenubutton.html new file mode 100644 index 0000000000..62b22b611b --- /dev/null +++ b/closure/goog/demos/imagelessmenubutton.html @@ -0,0 +1,284 @@ + + + + + goog.ui.ImagelessMenuButtonRenderer Demo + + + + + + + + + + + + +

goog.ui.ImagelessMenuButtonRenderer

+ + + + + + + +
+
+ + These MenuButtons were created programmatically: +   + + + + + + + + +
+ + + Enable first button: + +   + Show second button: + +   +
+ +
+
+
+ + This MenuButton decorates an element:  + + + + + + + + +
+
+ +
+ Format + +
+
Bold
+
Italic
+
Underline
+
+
+ Strikethrough +
+
+
Font...
+
Color...
+
+
+
+ Enable button: + +   + Show button: + +   +
+ +
+
+
+ +
+ Event Log +
+
+
+
+
+ + + diff --git a/closure/goog/demos/index.html b/closure/goog/demos/index.html new file mode 100644 index 0000000000..5531656eef --- /dev/null +++ b/closure/goog/demos/index.html @@ -0,0 +1,20 @@ + + + + + Closure Demos + + + + + + + Are you kidding me? No frames?!? + + + diff --git a/closure/goog/demos/index_nav.html b/closure/goog/demos/index_nav.html new file mode 100644 index 0000000000..9f456a0f84 --- /dev/null +++ b/closure/goog/demos/index_nav.html @@ -0,0 +1,256 @@ + + + + + Closure Demos + + + + + + + +

Index

+
+ + + diff --git a/closure/goog/demos/index_splash.html b/closure/goog/demos/index_splash.html new file mode 100644 index 0000000000..b6b49d874c --- /dev/null +++ b/closure/goog/demos/index_splash.html @@ -0,0 +1,27 @@ + + + + + Closure Demos + + + + +

Welcome to Closure!

+

Use the tree in the navigation pane to view Closure demos.

+
+

New! Common UI Controls

+

Check out these widgets by clicking on the demo links on the left:

+ Common UI controls + + diff --git a/closure/goog/demos/inline_block_quirks.html b/closure/goog/demos/inline_block_quirks.html new file mode 100644 index 0000000000..9fdeba8232 --- /dev/null +++ b/closure/goog/demos/inline_block_quirks.html @@ -0,0 +1,125 @@ + + + + goog.style.setInlineBlock in quirks mode + + + + + + + +

goog.style.setInlineBlock in quirks mode

+

+ This is a demonstration of the goog-inline-block CSS style. + This page is in quirks mode. + Click here for standards mode. +

+
+ Hey, are these really +
DIV
s + inlined in my text here? I mean, I thought +
DIV
s + were block-level elements, and you couldn't inline them... + Must be that new +
goog-inline-block
+ style... (Hint: Try resizing the window to see the +
DIV
s + flow naturally.) + Arv asked for an inline-block DIV with more interesting contents, so here + goes: +
+
+ blue dot + Lorem ipsum dolor sit amet, + consectetuer adipiscing elit. + Donec rhoncus neque ut + neque porta consequat. + In tincidunt tellus vehicula tellus. Etiam ornare nunc + vel lectus. Vivamus quis nibh. Sed nunc. + On FF1.5 and FF2.0, you need to wrap the contents of your + inline-block element in a DIV or P with fixed width to get line + wrapping. +
+
+
+
+

+ These are + SPANs + with the + goog-inline-block + style applied, so you can style them like block-level elements. + For example, give them + 10px margin, a + 10px border, or + 10px padding. + I used + vertical-align: middle + to make them all line up reasonably well. + (Except on Safari 2. Go figure.) +

+

+ This is what the same content looks like without goog-inline-block: +

+

+ These are + SPANs + with the + goog-inline-block + style applied, so you can style them like block-level elements. + For example, give them + 10px margin, a + 10px border, or + 10px padding. + I used + vertical-align: middle + to make them all line up reasonably well. + (Except on Safari 2. Go figure.) +

+

+ Click here to use goog.style.setInlineBlock() to apply the inline-block style to these SPANs. +

+
+

+ Works on Internet Explorer 6 & 7, Firefox 1.5, 2.0 & 3.0 Beta, Safari 2 & 3, + Webkit nightlies, and Opera 9. + Note: DIVs nested in SPANs don't work on Opera. +

+ + diff --git a/closure/goog/demos/inline_block_standards.html b/closure/goog/demos/inline_block_standards.html new file mode 100644 index 0000000000..e406ffc2a0 --- /dev/null +++ b/closure/goog/demos/inline_block_standards.html @@ -0,0 +1,126 @@ + + + + + goog.style.setInlineBlock in standards mode + + + + + + + +

goog.style.setInlineBlock in standards mode

+

+ This is a demonstration of the goog-inline-block CSS style. + This page is in standards mode. + Click here for quirks mode. +

+
+ Hey, are these really +
DIV
s + inlined in my text here? I mean, I thought +
DIV
s + were block-level elements, and you couldn't inline them... + Must be that new +
goog-inline-block
+ style... (Hint: Try resizing the window to see the +
DIV
s + flow naturally.) + Arv asked for an inline-block DIV with more interesting contents, so here + goes: +
+
+ blue dot + Lorem ipsum dolor sit amet, + consectetuer adipiscing elit. + Donec rhoncus neque ut + neque porta consequat. + In tincidunt tellus vehicula tellus. Etiam ornare nunc + vel lectus. Vivamus quis nibh. Sed nunc. + On FF1.5 and FF2.0, you need to wrap the contents of your + inline-block element in a DIV or P with fixed width to get line + wrapping. +
+
+
+
+

+ These are + SPANs + with the + goog-inline-block + style applied, so you can style them like block-level elements. + For example, give them + 10px margin, a + 10px border, or + 10px padding. + I used + vertical-align: middle + to make them all line up reasonably well. + (Except on Safari 2. Go figure.) +

+

+ This is what the same content looks like without goog-inline-block: +

+

+ These are + SPANs + with the + goog-inline-block + style applied, so you can style them like block-level elements. + For example, give them + 10px margin, a + 10px border, or + 10px padding. + I used + vertical-align: middle + to make them all line up reasonably well. + (Except on Safari 2. Go figure.) +

+

+ Click here to use goog.style.setInlineBlock() to apply the inline-block style to these SPANs. +

+
+

+ Works on Internet Explorer 6 & 7, Firefox 1.5, 2.0 & 3.0 Beta, Safari 2 & 3, + Webkit nightlies, and Opera 9. + Note: DIVs nested in SPANs don't work on Opera. +

+ + diff --git a/closure/goog/demos/inputdatepicker.html b/closure/goog/demos/inputdatepicker.html new file mode 100644 index 0000000000..9efca6f405 --- /dev/null +++ b/closure/goog/demos/inputdatepicker.html @@ -0,0 +1,60 @@ + + + + + goog.ui.InputDatePicker + + + + + + + + +

goog.ui.InputDatePicker

+ +
+ +
+
+ +
+
+ + +
+ + + diff --git a/closure/goog/demos/inputhandler.html b/closure/goog/demos/inputhandler.html new file mode 100644 index 0000000000..18618a5469 --- /dev/null +++ b/closure/goog/demos/inputhandler.html @@ -0,0 +1,84 @@ + + + + +goog.events.InputHandler + + + + + +

goog.events.InputHandler

+

+ + +

+ + + +

+ + +

+

+ Event Log +
+
+ + + + diff --git a/closure/goog/demos/jsonprettyprinter.html b/closure/goog/demos/jsonprettyprinter.html new file mode 100644 index 0000000000..5bc215cb26 --- /dev/null +++ b/closure/goog/demos/jsonprettyprinter.html @@ -0,0 +1,80 @@ + + + + +Demo - goog.format.JsonPrettyPrinter + + + + + + + +Pretty-printed JSON. +
+
+ +Pretty-printed JSON (Formatted using CSS). +
+
+ + + diff --git a/closure/goog/demos/keyboardshortcuts.html b/closure/goog/demos/keyboardshortcuts.html new file mode 100644 index 0000000000..c26564b32a --- /dev/null +++ b/closure/goog/demos/keyboardshortcuts.html @@ -0,0 +1,112 @@ + + + + + goog.ui.KeyboardShortcutHandler + + + + + + +

goog.ui.KeyboardShortcutHandler

+
+ + + + + + +
+    Shortcuts:
+      A
+      T E S T
+      Shift+F12
+      Shift+F11 C
+      Ctrl+A
+      G O O G
+      B C
+      B D
+      Alt+Q A
+      Alt+Q Shift+A
+      Alt+Q Shift+B
+      Space
+      Home
+      Enter
+      G S
+      S
+      Meta+y
+  
+ + + + diff --git a/closure/goog/demos/keyhandler.html b/closure/goog/demos/keyhandler.html new file mode 100644 index 0000000000..0c1b70f668 --- /dev/null +++ b/closure/goog/demos/keyhandler.html @@ -0,0 +1,118 @@ + + + + +goog.events.KeyHandler + + + + + + +

goog.events.KeyHandler

+

+ +
+
+
+
+
Focusable div
+
+ +
+ No Tab inside this

+ +
+
+
+
Focusable div
+
+ +
+ + + + + diff --git a/closure/goog/demos/labelinput.html b/closure/goog/demos/labelinput.html new file mode 100644 index 0000000000..eedc0e8e16 --- /dev/null +++ b/closure/goog/demos/labelinput.html @@ -0,0 +1,42 @@ + + + + + goog.ui.LabelInput + + + + + + +

goog.ui.LabelInput

+

This component decorates an input with default text which disappears upon focus.

+
+ +
+ + +
+ + diff --git a/closure/goog/demos/menu.html b/closure/goog/demos/menu.html new file mode 100644 index 0000000000..6b79b5d8ab --- /dev/null +++ b/closure/goog/demos/menu.html @@ -0,0 +1,220 @@ + + + + + goog.ui.Menu + + + + + + + + +

goog.ui.Menu

+
+ This is a very basic menu class, it doesn't handle its display or + dismissal. It just exists, listens to keys and mouse events and can fire + events for selections or highlights. +
+ +
+
+ + + + + + + + + + + + + +
+ + + + + +
+ Here's a menu with checkbox items.
You checked:  + Bold
+ +
+ Here's a BiDi menu with checkbox items.
+ +
+ Here's a menu with an explicit content container.
+ +
+
+
+ +
+ Event Log +
+
+
+ + + diff --git a/closure/goog/demos/menubar.html b/closure/goog/demos/menubar.html new file mode 100644 index 0000000000..d188310e85 --- /dev/null +++ b/closure/goog/demos/menubar.html @@ -0,0 +1,210 @@ + + + + + goog.ui.menuBar Demo + + + + + + + + + + + + +

goog.ui.menuBar example

+ + + + + + + + + + + +
+
+ + This menu bar was created programmatically: +   + + + + + + + +
+ +
+
+
+
+
+ + This menu bar is decorated: +   + + + +
+ + +
+
+
+
+ +
+ Event Log +
+
+
+
+
+ + + diff --git a/closure/goog/demos/menubutton.html b/closure/goog/demos/menubutton.html new file mode 100644 index 0000000000..18eba816d3 --- /dev/null +++ b/closure/goog/demos/menubutton.html @@ -0,0 +1,379 @@ + + + + + goog.ui.MenuButton Demo + + + + + + + + + + + +

goog.ui.MenuButton

+ + + + + + + +
+
+ + These MenuButtons were created programmatically: +   + + + + + + + + +
+ + + Enable first button: + +   + Show second button: + +   +
+ +
+
+
+ + This MenuButton decorates an element:  + + + + + + + + +
+
+ +
+ Format + +
+
Bold
+
Italic
+
Underline
+
+
+ Strikethrough +
+
+
Font...
+
Color...
+
+
+
+ Enable button: + +   + Show button: + +   +
+ +
+
+
+ + This MenuButton accompanies a + CustomButton to form a combo button: +   + +
+ +
+
+
+ + These MenuButtons demonstrate + menu positioning options: +   + +
+ + + + + +
+
+
+ +
+ Event Log +
+
+
+
+
+ + + diff --git a/closure/goog/demos/menubutton_frame.html b/closure/goog/demos/menubutton_frame.html new file mode 100644 index 0000000000..271d81e821 --- /dev/null +++ b/closure/goog/demos/menubutton_frame.html @@ -0,0 +1,27 @@ + + + + + goog.ui.MenuButton Positioning Frame Demo + + + + + + + + + + + + diff --git a/closure/goog/demos/menuitem.html b/closure/goog/demos/menuitem.html new file mode 100644 index 0000000000..fd6eb85f9c --- /dev/null +++ b/closure/goog/demos/menuitem.html @@ -0,0 +1,163 @@ + + + + + goog.ui.MenuItem Demo + + + + + + + + + + + +

goog.ui.MenuItem

+ + + + + + +
+
+ + Use the first letter of each menuitem to activate:   + + + + + + + +
+ +
+ +
+
+
+ +
+ Event Log +
+
+
+
+
+ + + diff --git a/closure/goog/demos/mousewheelhandler.html b/closure/goog/demos/mousewheelhandler.html new file mode 100644 index 0000000000..f7dc0ef6c2 --- /dev/null +++ b/closure/goog/demos/mousewheelhandler.html @@ -0,0 +1,109 @@ + + + + +goog.events.MouseWheelHandler + + + + + + + +

goog.events.MouseWheelHandler

+ +

Use your mousewheel on the gray box below to move the cross hair. + +

+
+
+
+
+ + + + diff --git a/closure/goog/demos/onlinehandler.html b/closure/goog/demos/onlinehandler.html new file mode 100644 index 0000000000..5932918018 --- /dev/null +++ b/closure/goog/demos/onlinehandler.html @@ -0,0 +1,76 @@ + + + + +goog.net.OnlineHandler + + + + + + +

This page reports whether your browser is online or offline. It will detect +changes to the reported state and fire events when this changes. The +OnlineHandler acts as a wrapper around the HTML5 events online and +offline and emulates these for older browsers.

+ +

Try changing File -> Work Offline in your browser.

+ +

+ + + + diff --git a/closure/goog/demos/palette.html b/closure/goog/demos/palette.html new file mode 100644 index 0000000000..5713304970 --- /dev/null +++ b/closure/goog/demos/palette.html @@ -0,0 +1,299 @@ + + + + + goog.ui.Palette & goog.ui.ColorPalette + + + + + + + +

goog.ui.Palette & goog.ui.ColorPalette

+ + + + + + + +
+
+ Demo of the goog.ui.Palette: +
+ + +
+ Note that if you don't specify any dimensions, the palette will auto-size + to fit your items in the smallest square.
+
+
+
+
+ Demo of the goog.ui.ColorPalette: +
+

The color you selected was: + +   + + +

+
+
+
+
+ Demo of the goog.ui.CustomColorPalette: +
+

The color you selected was: + +   + + +

+
+
+ +
+ Event Log +
+
+
+
+
+ + + diff --git a/closure/goog/demos/pastehandler.html b/closure/goog/demos/pastehandler.html new file mode 100644 index 0000000000..7ad72bc5ff --- /dev/null +++ b/closure/goog/demos/pastehandler.html @@ -0,0 +1,53 @@ + + + + + PasteHandler Test + + + + + +

Demo of goog.events.PasteHandler

+ +
+ Demo of the goog.events.PasteHandler: + + +
+ +
+ Event Log +
+
+ + + + diff --git a/closure/goog/demos/pixeldensitymonitor.html b/closure/goog/demos/pixeldensitymonitor.html new file mode 100644 index 0000000000..99fa7f9e00 --- /dev/null +++ b/closure/goog/demos/pixeldensitymonitor.html @@ -0,0 +1,51 @@ + + + + + goog.labs.style.PixelDensityMonitor + + + + + +

goog.labs.style.PixelDensityMonitor

+
+ Move between high dpi and normal screens to see density change events. +
+
+ Event log +
+
+ + + diff --git a/closure/goog/demos/plaintextspellchecker.html b/closure/goog/demos/plaintextspellchecker.html new file mode 100644 index 0000000000..dc660e5dfa --- /dev/null +++ b/closure/goog/demos/plaintextspellchecker.html @@ -0,0 +1,118 @@ + + + + +Plain Text Spell Checker + + + + + + +

Plain Text Spell Checker

+

+ The words "test", "words", "a", and "few" are set to be valid words, + all others are considered spelling mistakes. +

+

+ The following keyboard shortcuts can be used to navigate inside the editor: +

    +
  • Previous misspelled word: ctrl + left-arrow
  • +
  • next misspelled word: ctrl + right-arrow
  • +
  • Open suggestions menu: down arrow
  • +
+

+

+ + + + +

+ + + + + + + diff --git a/closure/goog/demos/popup.html b/closure/goog/demos/popup.html new file mode 100644 index 0000000000..f4c1f63c89 --- /dev/null +++ b/closure/goog/demos/popup.html @@ -0,0 +1,206 @@ + + + + + goog.ui.Popup + + + + + + + +

goog.ui.Popup

+ + +

Positioning relative to an anchor element

+
+ Button Corner + + + + +
+ Popup Corner + + + + + +
+ Margin + Top: + Right: + Bottom: + Left: + + +
+
+
+
+ + + +
+
+ +

Iframe to test cross frame dismissal

+ + +
+
+ +
+

Positioning at coordinates

+
+ + + + diff --git a/closure/goog/demos/popupcolorpicker.html b/closure/goog/demos/popupcolorpicker.html new file mode 100644 index 0000000000..76063f91bd --- /dev/null +++ b/closure/goog/demos/popupcolorpicker.html @@ -0,0 +1,49 @@ + + + + + goog.ui.PopupColorPicker + + + + + + + +

goog.ui.PopupColorPicker

+ Show 1 + Show 2 + + + diff --git a/closure/goog/demos/popupdatepicker.html b/closure/goog/demos/popupdatepicker.html new file mode 100644 index 0000000000..b1c8b8da42 --- /dev/null +++ b/closure/goog/demos/popupdatepicker.html @@ -0,0 +1,53 @@ + + + + + goog.ui.PopupDatePicker + + + + + + + + +

goog.ui.PopupDatePicker

+ + Show 1 + Show 2 + + + + diff --git a/closure/goog/demos/popupemojipicker.html b/closure/goog/demos/popupemojipicker.html new file mode 100644 index 0000000000..9655e5e62c --- /dev/null +++ b/closure/goog/demos/popupemojipicker.html @@ -0,0 +1,408 @@ + + + + + Popup Emoji Picker + + + + + + + + + +

Popup Emoji Picker Demo

+This is a demo of popupemojipickers and docked emoji pickers. Selecting an +emoji inserts a pseudo image tag into the text area with the id of that emoji. + +

Sprited Emojipicker (contains a mix of sprites and non-sprites):

+
+ +

Sprited Progressively-rendered Emojipicker (contains a mix of sprites and + non-sprites):

+
+

Popup Emoji:

+Gimme some emoji +
+ +

Fast-load Progressive Sprited Emojipicker

+
+ +

Fast-load Non-progressive Sprited Emojipicker

+
+ +
+ +

Docked emoji:

+
+ +

Single Page of Emoji

+
+ +

Delayed load popup picker:

+More emoji + +

Delayed load docked picker:

+ + Click to load + +
+ + + + + + diff --git a/closure/goog/demos/popupmenu.html b/closure/goog/demos/popupmenu.html new file mode 100644 index 0000000000..d1bd962634 --- /dev/null +++ b/closure/goog/demos/popupmenu.html @@ -0,0 +1,116 @@ + + + + + goog.ui.PopupMenu + + + + + + + + + +

goog.ui.PopupMenu

+
+ This shows a 2 popup menus, each menu has been attached to two targets. +

+
+ +
+ Event log +
+
+
+
+ Hello there I'm italic! +
+
+
+ + + + + + + + + diff --git a/closure/goog/demos/progressbar.html b/closure/goog/demos/progressbar.html new file mode 100644 index 0000000000..aa1b74d3c9 --- /dev/null +++ b/closure/goog/demos/progressbar.html @@ -0,0 +1,97 @@ + + + + + goog.ui.ProgressBar + + + + + + +

goog.ui.ProgressBar

+
+
+ +
+
+
+ Decorated element +
+
+ + + + diff --git a/closure/goog/demos/prompt.html b/closure/goog/demos/prompt.html new file mode 100644 index 0000000000..5a432c26be --- /dev/null +++ b/closure/goog/demos/prompt.html @@ -0,0 +1,92 @@ + + + + + goog.ui.Prompt + + + + + + + +

goog.ui.Prompt

+ +

The default text is selected when the prompt displays

+ +

You can use 'Enter' or 'Esc' to click 'Ok' or 'Cancel' respectively

+ +

+ + Prompt + +

+ + + diff --git a/closure/goog/demos/quadtree.html b/closure/goog/demos/quadtree.html new file mode 100644 index 0000000000..de53326d7e --- /dev/null +++ b/closure/goog/demos/quadtree.html @@ -0,0 +1,107 @@ + + + + +QuadTree Demo + + + + + +
+
+

Click on the area to the left to add a point to the quadtree, clicking on + a point will remove it from the tree.

+

+
+ + + diff --git a/closure/goog/demos/ratings.html b/closure/goog/demos/ratings.html new file mode 100644 index 0000000000..895008b7df --- /dev/null +++ b/closure/goog/demos/ratings.html @@ -0,0 +1,130 @@ + + + + +Ratings Widget + + + + + + +
+ + +
+
+
+
+ + + diff --git a/closure/goog/demos/richtextspellchecker.html b/closure/goog/demos/richtextspellchecker.html new file mode 100644 index 0000000000..9e56529727 --- /dev/null +++ b/closure/goog/demos/richtextspellchecker.html @@ -0,0 +1,101 @@ + + + + + goog.ui.RichTextSpellChecker + + + + + + + +

goog.ui.RichTextSpellChecker

+

+ The words "test", "words", "a", and "few" are set to be valid words, all others are considered spelling mistakes. +

+

+ If keyboard navigation is enabled, then the following shortcuts can be used + inside the editor: +

    +
  • Previous misspelled word: ctrl + left-arrow
  • +
  • next misspelled word: ctrl + right-arrow
  • +
  • Open suggestions menu: down arrow
  • +
+

+

+ +

+ + + + + + + diff --git a/closure/goog/demos/roundedpanel.html b/closure/goog/demos/roundedpanel.html new file mode 100644 index 0000000000..b8e871badf --- /dev/null +++ b/closure/goog/demos/roundedpanel.html @@ -0,0 +1,138 @@ + + + + + + goog.ui.RoundedPanel Demo + + + + + + + +
+
+
+ Panel Width:
+ +
+
+ Panel Height:
+ +
+
+ Border Width:
+ +
+
+ Border Color:
+ +
+
+ Radius:
+ +
+
+ Background Color:
+ +
+
+ Corners:
+ +
+
Rendering Time:
+
+
+ + + diff --git a/closure/goog/demos/samplecomponent.html b/closure/goog/demos/samplecomponent.html new file mode 100644 index 0000000000..7d52a8523f --- /dev/null +++ b/closure/goog/demos/samplecomponent.html @@ -0,0 +1,75 @@ + + + + + goog.ui.Component + + + + + + + + + + +

goog.ui.Component

+ + +
+

Click on this big, colored box:

+
+ +
+ +
+

Or this box:

+ +
Label from decorated DIV.
+
+ +
+

This box's label keeps changing:

+ +
+ + + diff --git a/closure/goog/demos/samplecomponent.js b/closure/goog/demos/samplecomponent.js new file mode 100644 index 0000000000..b85f092f15 --- /dev/null +++ b/closure/goog/demos/samplecomponent.js @@ -0,0 +1,174 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A simple, sample component. + */ +goog.provide('goog.demos.SampleComponent'); + +goog.require('goog.dom'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.classlist'); +goog.require('goog.events.EventType'); +goog.require('goog.events.KeyCodes'); +goog.require('goog.events.KeyHandler'); +goog.require('goog.ui.Component'); +goog.requireType('goog.events.Event'); + + + +/** + * A simple box that changes colour when clicked. This class demonstrates the + * goog.ui.Component API, and is keyboard accessible, as per + * http://wiki/Main/ClosureKeyboardAccessible + * @final + * @unrestricted + */ +goog.demos.SampleComponent = class extends goog.ui.Component { + /** + * @param {string=} opt_label A label to display. Defaults to "Click Me" if + * none provided. + * @param {goog.dom.DomHelper=} opt_domHelper DOM helper to use. + */ + constructor(opt_label, opt_domHelper) { + super(opt_domHelper); + + /** + * The label to display. + * @type {string} + * @private + */ + this.initialLabel_ = opt_label || 'Click Me'; + + /** + * The current color. + * @type {string} + * @private + */ + this.color_ = 'red'; + + /** + * Keyboard handler for this object. This object is created once the + * component's DOM element is known. + * + * @type {goog.events.KeyHandler?} + * @private + */ + this.kh_ = null; + } + + /** + * Changes the color of the element. + * @private + */ + changeColor_() { + if (this.color_ == 'red') { + this.color_ = 'green'; + } else if (this.color_ == 'green') { + this.color_ = 'blue'; + } else { + this.color_ = 'red'; + } + this.getElement().style.backgroundColor = this.color_; + } + + /** + * Creates an initial DOM representation for the component. + * @override + */ + createDom() { + this.decorateInternal(this.dom_.createElement(goog.dom.TagName.DIV)); + } + + /** + * Decorates an existing HTML DIV element as a SampleComponent. + * + * @param {Element} element The DIV element to decorate. The element's + * text, if any will be used as the component's label. + * @override + * @suppress {strictMissingProperties} missing tabIndex prop + */ + decorateInternal(element) { + super.decorateInternal(element); + if (!this.getLabelText()) { + this.setLabelText(this.initialLabel_); + } + + const elem = this.getElement(); + goog.dom.classlist.add(elem, goog.getCssName('goog-sample-component')); + elem.style.backgroundColor = this.color_; + elem.tabIndex = 0; + + this.kh_ = new goog.events.KeyHandler(elem); + this.getHandler().listen( + this.kh_, goog.events.KeyHandler.EventType.KEY, this.onKey_); + } + + /** @override */ + disposeInternal() { + super.disposeInternal(); + if (this.kh_) { + this.kh_.dispose(); + } + } + + /** + * Called when component's element is known to be in the document. + * @override + */ + enterDocument() { + super.enterDocument(); + this.getHandler().listen( + this.getElement(), goog.events.EventType.CLICK, this.onDivClicked_); + } + + /** + * Gets the current label text. + * + * @return {string} The current text set into the label, or empty string if + * none set. + */ + getLabelText() { + if (!this.getElement()) { + return ''; + } + return goog.dom.getTextContent(this.getElement()); + } + + /** + * Handles DIV element clicks, causing the DIV's colour to change. + * @param {goog.events.Event} event The click event. + * @private + */ + onDivClicked_(event) { + this.changeColor_(); + } + + /** + * Fired when user presses a key while the DIV has focus. If the user presses + * space or enter, the color will be changed. + * @param {goog.events.Event} event The key event. + * @private + * @suppress {strictMissingProperties} missing 'keyCode' prop + */ + onKey_(event) { + const keyCodes = goog.events.KeyCodes; + if (event.keyCode == keyCodes.SPACE || event.keyCode == keyCodes.ENTER) { + this.changeColor_(); + } + } + + /** + * Sets the current label text. Has no effect if component is not rendered. + * + * @param {string} text The text to set as the label. + */ + setLabelText(text) { + if (this.getElement()) { + goog.dom.setTextContent(this.getElement(), text); + } + } +}; diff --git a/closure/goog/demos/scrollfloater.html b/closure/goog/demos/scrollfloater.html new file mode 100644 index 0000000000..0f8a2a1904 --- /dev/null +++ b/closure/goog/demos/scrollfloater.html @@ -0,0 +1,136 @@ + + + + +ScrollFloater + + + + + + + +
+ + + + + + +
+
+ This content does not float. +
+
+ This content does not float. +
+
+ This floater is constrained within a container and has a top offset of 50px. +
+
+
+ This content does not float. +
+
+
+
+
+ This content does not float. +
+ +
+
+ This floater is very tall. +

+ This tall floater is pinned to the bottom of the window when + your window is shorter and floats at the top when it is taller. +

+
+
+
+

This is the bottom of the page.

+
+ + + + diff --git a/closure/goog/demos/select.html b/closure/goog/demos/select.html new file mode 100644 index 0000000000..22bcd98cfb --- /dev/null +++ b/closure/goog/demos/select.html @@ -0,0 +1,323 @@ + + + + + goog.ui.Select & goog.ui.Option + + + + + + + + + + + +

goog.ui.Select & goog.ui.Option

+
+ Demo of the goog.ui.Select component: +
+   + +
+
+   + +
+
+  (This control doesn't auto-highlight; it only dispatches + ENTER and LEAVE events.) +
+
+ Click here to add a new option for the best movie, + here + to hide/show the select component for the best movie, or + here + to set the worst movie of all time to "Catwoman." +
+
+
+
+ This goog.ui.Select was decorated: +
+   + +
+
+
+
+
+ +
+ + Demo of goog.ui.Select using + goog.ui.FlatMenuButtonRenderer: + +
+   + +
+
+   + +
+
+  (This control doesn't auto-highlight; it only dispatches + ENTER and LEAVE events.) +
+
+ Click here + to add a new option for the best Arnold movie, + here + to hide/show the select component for the best Arnold movie, or + here + to set the worst Arnold movie to "Jingle All the Way." +
+
+
+
+ This Flat goog.ui.Select was decorated: +
+   + +
+
+
+
+
+ + +
+ Event Log +
+
+
+
+ + + diff --git a/closure/goog/demos/selectionmenubutton.html b/closure/goog/demos/selectionmenubutton.html new file mode 100644 index 0000000000..14ffdfa7dd --- /dev/null +++ b/closure/goog/demos/selectionmenubutton.html @@ -0,0 +1,185 @@ + + + + + goog.ui.SelectionMenuButton Demo + + + + + + + +

goog.ui.SelectionMenuButton

+ + + + + + + +
+
+ + This SelectionMenuButton was created programmatically: +   + + + + + + + + +
+ + + Enable button: + +   +
+ +
+
+
+ + This SelectionMenuButton decorates an element:  + + + + + + + + +
+
+ + + +
+
All
+
None
+
+
Starred
+
+ Unstarred +
+
+
Read
+
Unread
+
+
+
+ Enable button: + +   + Show button: + +   +
+ +
+
+
+ +
+ Event Log +
+
+
+
+
+ + + diff --git a/closure/goog/demos/serverchart.html b/closure/goog/demos/serverchart.html new file mode 100644 index 0000000000..c10d68cd24 --- /dev/null +++ b/closure/goog/demos/serverchart.html @@ -0,0 +1,122 @@ + + + + + goog.ui.ServerChart + + + + + + +

goog.ui.ServerChart

+
+

Line Chart:

+
+
+

Finance Chart: Add a Line +

+
+
+

Pie Chart:

+
+
+

Filled Line Chart:

+
+
+

Bar Chart:

+
+
+

Venn Diagram:

+
+ + diff --git a/closure/goog/demos/slider.html b/closure/goog/demos/slider.html new file mode 100644 index 0000000000..7ab8210927 --- /dev/null +++ b/closure/goog/demos/slider.html @@ -0,0 +1,128 @@ + + + + + goog.ui.Slider + + + + + + + +

goog.ui.Slider

+ +
+ Horizontal Slider +
+ +
+
+
+ + MoveToPointEnabled + + Enable +
+ + +
+ +
+ Vertical Slider, inserted w/ script + + + Enable +
+ + +
+ + + diff --git a/closure/goog/demos/splitpane.html b/closure/goog/demos/splitpane.html new file mode 100644 index 0000000000..c7fb3195fe --- /dev/null +++ b/closure/goog/demos/splitpane.html @@ -0,0 +1,243 @@ + + + + + goog.ui.SplitPane + + + + + + + +

goog.ui.SplitPane

+ Left Component Size: + Width: + Height: +
+ First One + Second One + + +

+

+
+ Left Frame +
+
+ +
+
+
+ First Component Width: + +
+ +
+

+ + +

+

+ + + + + diff --git a/closure/goog/demos/stopevent.html b/closure/goog/demos/stopevent.html new file mode 100644 index 0000000000..6f45ae492f --- /dev/null +++ b/closure/goog/demos/stopevent.html @@ -0,0 +1,171 @@ + + + + Stop Event Propagation + + + + + + + + +

Stop Event

+

Test the cancelling of capture and bubbling events. Click + one of the nodes to see the event trace, then use the check boxes to cancel + the capture or bubble at a given branch. (Double click the text area to clear + it)

+ +
+ +
+ + + + + diff --git a/closure/goog/demos/submenus.html b/closure/goog/demos/submenus.html new file mode 100644 index 0000000000..64388c5fd3 --- /dev/null +++ b/closure/goog/demos/submenus.html @@ -0,0 +1,130 @@ + + + + + goog.ui.SubMenu + + + + + + + + + +

goog.ui.SubMenu

+

Demonstration of different of hierarchical menus.

+

+ +

+ Here's a menu (with submenus) defined in markup: +

+
+
Open...
+
Open Recent +
+
Annual Report.pdf
+
Quarterly Update.pdf
+
Enemies List.txt
+
More +
+
Foo.txt
+
Bar.txt
+
+
+
+
+
+ + + + + + diff --git a/closure/goog/demos/submenus2.html b/closure/goog/demos/submenus2.html new file mode 100644 index 0000000000..542b66cf90 --- /dev/null +++ b/closure/goog/demos/submenus2.html @@ -0,0 +1,150 @@ + + + + + goog.ui.SubMenu + + + + + + + + + +

goog.ui.SubMenu

+

Demonstration of different hierarchical menus which share its submenus. + A flyweight pattern demonstration for submenus.

+

+ + +
+
Google
+
Yahoo
+
MSN
+
+
Bla...
+
+ + + + + diff --git a/closure/goog/demos/tabbar.html b/closure/goog/demos/tabbar.html new file mode 100644 index 0000000000..be5130d30f --- /dev/null +++ b/closure/goog/demos/tabbar.html @@ -0,0 +1,288 @@ + + + + + goog.ui.TabBar + + + + + + + + + +

goog.ui.TabBar

+

+ A goog.ui.TabBar is a subclass of goog.ui.Container, + designed to host one or more goog.ui.Tabs. The tabs in the + first four tab bars on this demo page were decorated using the default + tab renderer. Tabs in the last two tab bars were decorated using the + rounded tab renderer (goog.ui.RoundedTabRenderer). +

+ + + + + + + + + + + + + + + + + + +
+ Above tab content:

+
+
Hello
+
Settings
+
More
+
Advanced
+
+ +
+
+ Use the keyboard or the mouse to switch tabs. +
+ + +
+ Below tab content:

+
+ Use the keyboard or the mouse to switch tabs. +
+ +
+
+
Hello
+
Settings
+
More
+
Advanced
+
+ +
+ + +
+ Before tab content:

+
+
Hello
+
Settings
+
More
+
Advanced
+
+
+ Use the keyboard or the mouse to switch tabs. +
+ +
+ + +
+ After tab content:

+
+
Hello
+
Settings
+
More
+
Advanced
+
+
+ Use the keyboard or the mouse to switch tabs. +
+ +
+ + +
+ Above tab content (rounded corners):

+
+
Hello
+
Settings +
+
More
+
Advanced +
+
+ +
+
+ Use the keyboard or the mouse to switch tabs. +
+ + +
+ Before tab content (rounded corners):

+
+
Hello
+
Settings
+
More
+
Advanced +
+
+
+ Use the keyboard or the mouse to switch tabs. +
+ +
+ + +
+ +
+ Event Log +
+
+
+
+
+ + + diff --git a/closure/goog/demos/tablesorter.html b/closure/goog/demos/tablesorter.html new file mode 100644 index 0000000000..014de223c5 --- /dev/null +++ b/closure/goog/demos/tablesorter.html @@ -0,0 +1,116 @@ + + + + + goog.ui.TableSorter + + + + + + + +

goog.ui.TableSorter

+

+ Number sorts numerically, month sorts alphabetically, and days sorts + numerically in reverse. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NumberMonthDays (non-leap year)
1January31
2Februrary28
3March31
4April30
5May31
6June30
7July31
8August31
9September30
10October31
11November30
12December31
+
+ + + diff --git a/closure/goog/demos/tabpane.html b/closure/goog/demos/tabpane.html new file mode 100644 index 0000000000..70565ba219 --- /dev/null +++ b/closure/goog/demos/tabpane.html @@ -0,0 +1,302 @@ + + + + + goog.ui.TabPane + + + + + + + +

goog.ui.TabPane

+ +
+ selected in tab pane 1.

+ +

Bottom tabs

+
+
+

Initial page

+

+ Page created automatically from tab pane child node. +

+
+
+ +

Left tabs

+
+
+

Front page!

+

+ Page created automatically from tab pane child node. +

+
+
+ +

Right tabs

+
+
+

Right 1

+

+ Page created automatically from tab pane child node. +

+
+
+

Right 2

+

+ So was this page. +

+
+
+ +
+

Page 1

+

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Duis ac augue sed + massa placerat iaculis. Aliquam tempor dictum massa. Quisque vehicula justo + ut tellus. Integer urna. Aliquam libero justo, ornare at, pretium ac, + vulputate quis, ante. Sed arcu. Etiam sit amet turpis. Maecenas pede. Sed + turpis. Sed ultricies commodo nisl. Morbi eget magna quis nisi euismod + porttitor. Vivamus lacinia massa et sem. Donec consequat ligula sed tellus. + Suspendisse enim sapien, vestibulum nec, eleifend id, placerat sit amet, + risus. Mauris in pede ac lorem varius facilisis. Donec dui. Nam mollis nisi + eu neque. Cras luctus nisl at sapien. Ut eleifend, odio id luctus + pellentesque, lorem diam dictum velit, ac gravida lectus magna vel velit. +

+

+ Etiam tempus, ante semper iaculis ultrices, ligula eros lobortis tellus, sit + amet luctus dolor nisl sit amet dolor. Donec in velit. Vivamus facilisis. + Proin nisi felis, commodo ut, porta dignissim, vestibulum quis, ligula. Ut + egestas porttitor tortor. Ut porttitor diam a est. Sed placerat. Aliquam + luctus est a risus. Aenean blandit nibh et justo. Phasellus vel lectus ut + leo dictum consequat. Nam tincidunt facilisis nulla. Nunc nonummy tempus + quam. Aliquam id enim. Sed rhoncus cursus lorem. Curabitur ultricies, enim + quis eleifend mattis, est velit dapibus dolor, quis laoreet arcu tortor + volutpat tortor. Pellentesque habitant morbi tristique senectus et netus et + malesuada fames ac turpis egestas. Curabitur nec mauris et purus aliquam + mattis. Cras rhoncus posuere sapien. Class aptent taciti sociosqu ad litora + torquent per conubia nostra, per inceptos hymenaeos. +

+

+ Mauris lacinia ornare nunc. Donec molestie. Sed nulla libero, tincidunt vel, + porta sit amet, nonummy eget, augue. Class aptent taciti sociosqu ad litora + torquent per conubia nostra, per inceptos hymenaeos. Donec ac risus. Cras + euismod congue orci. Mauris mattis, ipsum at vestibulum bibendum, odio est + rhoncus nisi, vel aliquam ante velit quis neque. Duis nonummy tortor id + ante. Aenean auctor odio non nulla. Fusce hendrerit, mi et fringilla + venenatis, sem ipsum fermentum lorem, vel posuere urna eros eget massa. +

+

+ Nulla nec sapien eget mauris pretium tempor. Phasellus scelerisque quam id + mauris. Cras erat ante, pretium ut, vestibulum ac, tincidunt ut, nunc. + Vivamus velit sapien, feugiat ac, elementum ac, viverra non, leo. Phasellus + imperdiet, magna at placerat consectetuer, enim urna aliquam augue, nec + tincidunt justo lectus nec lectus. Nam neque. Nullam ullamcorper euismod + augue. Maecenas arcu purus, sollicitudin nec, consequat a, gravida quis, + massa. Nullam bibendum viverra risus. Sed nibh. Morbi dapibus pellentesque + erat. +

+

+ Cras non tellus. Maecenas nulla est, tincidunt sed, porta sit amet, placerat + sed, diam. Morbi pulvinar. Vestibulum ante ipsum primis in faucibus orci + luctus et ultrices posuere cubilia Curae; Praesent felis lacus, pretium at, + egestas sed, fermentum at, est. Pellentesque sagittis feugiat orci. Nam + augue. Sed eget dolor. Proin vitae metus scelerisque massa fermentum tempus. + Nulla facilisi. Pellentesque habitant morbi tristique senectus et netus et + malesuada fames ac turpis egestas. Aenean eleifend, leo gravida mollis + tempor, tellus ipsum porttitor leo, eget condimentum tellus neque sit amet + orci. Sed non lectus. Suspendisse nonummy purus ac massa. Sed quis elit + dapibus nunc semper porta. Maecenas risus eros, euismod quis, sagittis eget, + aliquet eget, dui. Donec vel nibh. Vivamus nunc purus, euismod in, feugiat + in, sodales vitae, nunc. Nulla lobortis. +

+
+ +
+

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Cras et nisi id + lorem tempor semper. Suspendisse ante. Integer ligula urna, venenatis quis, + placerat vitae, commodo quis, sapien. Quisque nec lectus. Sed non dolor. Sed + congue, nisi in pharetra consequat, odio diam pulvinar arcu, in laoreet elit + risus id ipsum. Class aptent taciti sociosqu ad litora torquent per conubia + nostra, per inceptos hymenaeos. Praesent tellus enim, imperdiet a, sagittis + id, pulvinar vel, tortor. Integer nulla. Sed nulla augue, lacinia id, + vulputate eu, rhoncus non, ante. Integer lobortis eros vitae quam. Phasellus + sagittis, ipsum sollicitudin bibendum laoreet, arcu erat luctus lacus, vel + pharetra felis metus tincidunt diam. Cras ac augue in enim ultricies + aliquam. +

+ + + +
+ +
+

Page 5

+

+ This is page 5. +

+
+ + + + diff --git a/closure/goog/demos/textarea.html b/closure/goog/demos/textarea.html new file mode 100644 index 0000000000..99c0dac48d --- /dev/null +++ b/closure/goog/demos/textarea.html @@ -0,0 +1,128 @@ + + + + + goog.ui.Textarea + + + + + +

goog.ui.Textarea

+
+ + The first Textarea was created programmatically, + the second by decorating a <textarea> + element:  + +
+ +
+
+ +
+ +
+
+ +
+ + This is a textarea with a minHeight set to 200px. + + +
+ +
+
+ +
+ + This is a textarea with a padding-bottom of 3em. + + +
+ + +
+ Event Log +
+
+ + + + diff --git a/closure/goog/demos/timers.html b/closure/goog/demos/timers.html new file mode 100644 index 0000000000..a61827a6e5 --- /dev/null +++ b/closure/goog/demos/timers.html @@ -0,0 +1,291 @@ + + + + + goog.Timer, goog.async.Throttle goog.async.Delay + + + + + + + + +

A Collection of Time Based Utilities

+

goog.async.Delay

+

An action can be invoked after some delay.

+ Delay (seconds): + + +
+ Delay Status: Not Set + +

goog.async.Throttle

+ A throttle prevents the action from being called more than once per time + interval. +
+ 'Create' the Throttle, then hit the 'Do Throttle' button a lot + of times. Notice the number of 'Hits' increasing with each button press. +
+ The action will be invoked no more than once per time interval. +

+ Throttle interval (seconds): + + + +
+ Throttle Hits: +
+ Throttle Action Called: +

+

goog.Timer

+ A timer can be set up to call a timeout function on every 'tick' of the timer. +

+ Timer interval (seconds): + + + + +
+ Timer Status: Not Set +

+

goog.Timer.callOnce

+ Timer also has a useful utility function that can call an action after some + timeout. +
+ This a shortcut/replacement for window.setTimeout, and has a + corresponding goog.Timer.clear as well, which stops the action. +

+ Timeout (seconds): + + +
+ Do Once Status: +

+ + + diff --git a/closure/goog/demos/toolbar.html b/closure/goog/demos/toolbar.html new file mode 100644 index 0000000000..b76105f855 --- /dev/null +++ b/closure/goog/demos/toolbar.html @@ -0,0 +1,703 @@ + + + + + goog.ui.Toolbar + + + + + + + + + + +

goog.ui.Toolbar

+
+ These toolbars were created programmatically: + +   + +   + +
+
+
+
+
+
+ +   + +   + +
+
+
+
+
+
+
+
+
+ This toolbar was created by decorating a bunch of DIVs: + +   + +   + +
+
+
+
Button
+
+ Fancy Button +
+
+
+ Disabled Button +
+ +
+
+ Toggle Button +
+
+
+
+
+
+
+
+ +   + +   + +
+
+
+
Button
+
+
Fancy Button
+
+
+
+ Disabled Button +
+
+ Menu Button +
+
Foo
+
Bar
+
????... + Ctrl+P
+
???? ?-HTML (????? ZIP)
+
+
Cake
+
+
+
+
+ Toggle Button +
+
+ +
 
+
+
+
+
+
+
+
+
+ This is starting to look like an editor toolbar: + +   + +   + +   + +
+
+
+
+ Select font +
+
Normal
+
Times
+
Courier New
+
Georgia
+
Trebuchet
+
Verdana
+
+
+
+ Size +
+
7pt
+
10pt
+
14pt
+
18pt
+
24pt
+
36pt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Red
+
Green
+
Blue
+
+
+
+
+
+
Red
+
Green
+
Blue
+
+
+
+
Style
+
+
Clear formatting
+
+
Normal paragraph text
+
Minor heading (H3)
+
Sub-heading (H2)
+
Heading (H1)
+
+
Indent more
+
Indent less
+
Blockquote
+
+
+
+
+
+   + Insert +
+
+
Picture
+
Drawing
+
Other...
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Check spelling
+
+
+
+
+
+
+
+ +
+ Event Log +
+ Warning! On Gecko, the event log may cause + the page to flicker when mousing over toolbar items. This is a Gecko + issue triggered by scrolling in the event log. + +

+ Enable logging: +
+
+
+
+ + + diff --git a/closure/goog/demos/tooltip.html b/closure/goog/demos/tooltip.html new file mode 100644 index 0000000000..e2f2dce810 --- /dev/null +++ b/closure/goog/demos/tooltip.html @@ -0,0 +1,93 @@ + + + + + goog.ui.Tooltip + + + + + + + +

goog.ui.Tooltip

+

+ + + + +

+ +

+ Demo tooltips in text and and of nested + tooltips, where an element triggers + one tooltip and an element inside the first element triggers another + one. +

+ +
+
+ +
+
+ + + + + + diff --git a/closure/goog/demos/tracer.html b/closure/goog/demos/tracer.html new file mode 100644 index 0000000000..bd209ae1da --- /dev/null +++ b/closure/goog/demos/tracer.html @@ -0,0 +1,92 @@ + + + + +goog.debug.Tracer + + + + + + +
+ +
+ + + + + + + diff --git a/closure/goog/demos/tree/demo.html b/closure/goog/demos/tree/demo.html new file mode 100644 index 0000000000..46ce201b4b --- /dev/null +++ b/closure/goog/demos/tree/demo.html @@ -0,0 +1,120 @@ + + + + + + goog.ui.tree.TreeControl + + + + + + + + +

goog.ui.tree.TreeControl

+
+ +

+ + + + + +

+ + +

+ + + + diff --git a/closure/goog/demos/tree/testdata.js b/closure/goog/demos/tree/testdata.js new file mode 100644 index 0000000000..df8e1931b2 --- /dev/null +++ b/closure/goog/demos/tree/testdata.js @@ -0,0 +1,240 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ +const testData = [ + 'Countries', + [ + [ + 'A', + [ + ['Afghanistan'], ['Albania'], ['Algeria'], ['American Samoa'], + ['Andorra'], ['Angola'], ['Anguilla'], ['Antarctica'], + ['Antigua and Barbuda'], ['Argentina'], ['Armenia'], ['Aruba'], + ['Australia'], ['Austria'], ['Azerbaijan'] + ] + ], + [ + 'B', + [ + ['Bahamas'], + ['Bahrain'], + ['Bangladesh'], + ['Barbados'], + ['Belarus'], + ['Belgium'], + ['Belize'], + ['Benin'], + ['Bermuda'], + ['Bhutan'], + ['Bolivia'], + ['Bosnia and Herzegovina'], + ['Botswana'], + ['Bouvet Island'], + ['Brazil'], + ['British Indian Ocean Territory'], + ['Brunei Darussalam'], + ['Bulgaria'], + ['Burkina Faso'], + ['Burundi'] + ] + ], + [ + 'C', + [ + ['Cambodia'], + ['Cameroon'], + ['Canada'], + ['Cape Verde'], + ['Cayman Islands'], + ['Central African Republic'], + ['Chad'], + ['Chile'], + ['China'], + ['Christmas Island'], + ['Cocos (Keeling) Islands'], + ['Colombia'], + ['Comoros'], + ['Congo'], + ['Congo, the Democratic Republic of the'], + ['Cook Islands'], + ['Costa Rica'], + ['Croatia'], + ['Cuba'], + ['Cyprus'], + ['Czech Republic'], + ['C\u00f4te d\u2019Ivoire'] + ] + ], + ['D', [['Denmark'], ['Djibouti'], ['Dominica'], ['Dominican Republic']]], + [ + 'E', + [ + ['Ecuador'], ['Egypt'], ['El Salvador'], ['Equatorial Guinea'], + ['Eritrea'], ['Estonia'], ['Ethiopia'] + ] + ], + [ + 'F', + [ + ['Falkland Islands (Malvinas)'], ['Faroe Islands'], ['Fiji'], + ['Finland'], ['France'], ['French Guiana'], ['French Polynesia'], + ['French Southern Territories'] + ] + ], + [ + 'G', + [ + ['Gabon'], ['Gambia'], ['Georgia'], ['Germany'], ['Ghana'], + ['Gibraltar'], ['Greece'], ['Greenland'], ['Grenada'], ['Guadeloupe'], + ['Guam'], ['Guatemala'], ['Guernsey'], ['Guinea'], ['Guinea-Bissau'], + ['Guyana'] + ] + ], + [ + 'H', + [ + ['Haiti'], ['Heard Island and McDonald Islands'], + ['Holy See (Vatican City State)'], ['Honduras'], ['Hong Kong'], + ['Hungary'] + ] + ], + [ + 'I', + [ + ['Iceland'], ['India'], ['Indonesia'], ['Iran, Islamic Republic of'], + ['Iraq'], ['Ireland'], ['Isle of Man'], ['Israel'], ['Italy'] + ] + ], + ['J', [['Jamaica'], ['Japan'], ['Jersey'], ['Jordan']]], + [ + 'K', + [ + ['Kazakhstan'], ['Kenya'], ['Kiribati'], + ['Korea, Democratic People\u2019s Republic of'], ['Korea, Republic of'], + ['Kuwait'], ['Kyrgyzstan'] + ] + ], + [ + 'L', + [ + ['Lao People\u2019s Democratic Republic'], ['Latvia'], ['Lebanon'], + ['Lesotho'], ['Liberia'], ['Libyan Arab Jamahiriya'], ['Liechtenstein'], + ['Lithuania'], ['Luxembourg'] + ] + ], + [ + 'M', + [ + ['Macao'], + ['Macedonia, the former Yugoslav Republic of'], + ['Madagascar'], + ['Malawi'], + ['Malaysia'], + ['Maldives'], + ['Mali'], + ['Malta'], + ['Marshall Islands'], + ['Martinique'], + ['Mauritania'], + ['Mauritius'], + ['Mayotte'], + ['Mexico'], + ['Micronesia, Federated States of'], + ['Moldova, Republic of'], + ['Monaco'], + ['Mongolia'], + ['Montenegro'], + ['Montserrat'], + ['Morocco'], + ['Mozambique'], + ['Myanmar'] + ] + ], + [ + 'N', + [ + ['Namibia'], ['Nauru'], ['Nepal'], ['Netherlands'], + ['Netherlands Antilles'], ['New Caledonia'], ['New Zealand'], + ['Nicaragua'], ['Niger'], ['Nigeria'], ['Niue'], ['Norfolk Island'], + ['Northern Mariana Islands'], ['Norway'] + ] + ], + ['O', [['Oman']]], + [ + 'P', + [ + ['Pakistan'], ['Palau'], ['Palestinian Territory, Occupied'], + ['Panama'], ['Papua New Guinea'], ['Paraguay'], ['Peru'], + ['Philippines'], ['Pitcairn'], ['Poland'], ['Portugal'], ['Puerto Rico'] + ] + ], + ['Q', [['Qatar']]], + ['R', [['Romania'], ['Russian Federation'], ['Rwanda'], ['R\u00e9union']]], + [ + 'S', + [ + ['Saint Barth\u00e9lemy'], + ['Saint Helena'], + ['Saint Kitts and Nevis'], + ['Saint Lucia'], + ['Saint Martin (French part)'], + ['Saint Pierre and Miquelon'], + ['Saint Vincent and the Grenadines'], + ['Samoa'], + ['San Marino'], + ['Sao Tome and Principe'], + ['Saudi Arabia'], + ['Senegal'], + ['Serbia'], + ['Seychelles'], + ['Sierra Leone'], + ['Singapore'], + ['Slovakia'], + ['Slovenia'], + ['Solomon Islands'], + ['Somalia'], + ['South Africa'], + ['South Georgia and the South Sandwich Islands'], + ['Spain'], + ['Sri Lanka'], + ['Sudan'], + ['Suriname'], + ['Svalbard and Jan Mayen'], + ['Swaziland'], + ['Sweden'], + ['Switzerland'], + ['Syrian Arab Republic'] + ] + ], + [ + 'T', + [ + ['Taiwan, Province of China'], ['Tajikistan'], + ['Tanzania, United Republic of'], ['Thailand'], ['Timor-Leste'], + ['Togo'], ['Tokelau'], ['Tonga'], ['Trinidad and Tobago'], ['Tunisia'], + ['Turkey'], ['Turkmenistan'], ['Turks and Caicos Islands'], ['Tuvalu'] + ] + ], + [ + 'U', + [ + ['Uganda'], ['Ukraine'], ['United Arab Emirates'], ['United Kingdom'], + ['United States'], ['United States Minor Outlying Islands'], + ['Uruguay'], ['Uzbekistan'] + ] + ], + [ + 'V', + [ + ['Vanuatu'], ['Venezuela'], ['Viet Nam'], ['Virgin Islands, British'], + ['Virgin Islands, U.S.'] + ] + ], + ['W', [['Wallis and Futuna'], ['Western Sahara']]], + ['Y', [['Yemen']]], + ['Z', [['Zambia'], ['Zimbabwe']]], + ['\u00c5', [['\u00c5land Islands']]] + ] +]; diff --git a/closure/goog/demos/tweakui.html b/closure/goog/demos/tweakui.html new file mode 100644 index 0000000000..4c20a7aee1 --- /dev/null +++ b/closure/goog/demos/tweakui.html @@ -0,0 +1,121 @@ + + + + + goog.tweak.TweakUi + + + + + +

goog.ui.TweakUi

+The goog.tweak package provides a convenient and flexible way to add +configurable settings to an app. These settings: +
    +
  • can be set at compile time +
  • can be set in code (using goog.tweak.overrideDefaultValue) +
  • can be set by query parameters +
  • can be set through the TweakUi interface +
+Tweaks IDs are checked by the compiler and tweaks can be fully removed when +tweakProcessing=STRIP. Tweaks are great for toggling debugging facilities. + +

A collapsible menu

+

An expanded menu

+
    +
  • When "Apply Tweaks" is clicked, all non-default values are encoded into + query parameters and the page is refreshed. +
  • Blue entries are ones where the value of the tweak will change without + clicking apply tweaks (the value of goog.tweak.get*() will change) +
+ + + diff --git a/closure/goog/demos/twothumbslider.html b/closure/goog/demos/twothumbslider.html new file mode 100644 index 0000000000..d04e1cd8bc --- /dev/null +++ b/closure/goog/demos/twothumbslider.html @@ -0,0 +1,121 @@ + + + + + goog.ui.TwoThumbSlider + + + + + +

goog.ui.TwoThumbSlider

+
+
+ +
+
+
+
+ +
+ +
+ + +
+ + + + diff --git a/closure/goog/demos/useragent.html b/closure/goog/demos/useragent.html new file mode 100644 index 0000000000..a8e7a98484 --- /dev/null +++ b/closure/goog/demos/useragent.html @@ -0,0 +1,198 @@ + + + + + goog.userAgent + + + + + + + + +

goog.userAgent

+ +
+ + + +
+
+ +
+ + + +
+
+ + + + + + diff --git a/closure/goog/demos/viewportsizemonitor.html b/closure/goog/demos/viewportsizemonitor.html new file mode 100644 index 0000000000..e9b22a1fb1 --- /dev/null +++ b/closure/goog/demos/viewportsizemonitor.html @@ -0,0 +1,71 @@ + + + + + goog.dom.ViewportSizeMonitor + + + + + + +

goog.dom.ViewportSizeMonitor

+
+ Current Size: Loading... +
+ + + + diff --git a/closure/goog/demos/wheelhandler.html b/closure/goog/demos/wheelhandler.html new file mode 100644 index 0000000000..47369a4669 --- /dev/null +++ b/closure/goog/demos/wheelhandler.html @@ -0,0 +1,145 @@ + + + + +goog.events.WheelHandler + + + + + + + +

goog.events.WheelHandler

+ +

Use your mousewheel on the gray box below to move the cross hair. + +

+
+
+
+
+ +
+
+ + + + diff --git a/closure/goog/demos/xpc/blank.html b/closure/goog/demos/xpc/blank.html new file mode 100644 index 0000000000..307ced4227 --- /dev/null +++ b/closure/goog/demos/xpc/blank.html @@ -0,0 +1,7 @@ + + diff --git a/closure/goog/demos/xpc/index.html b/closure/goog/demos/xpc/index.html new file mode 100644 index 0000000000..f6230d4caa --- /dev/null +++ b/closure/goog/demos/xpc/index.html @@ -0,0 +1,87 @@ + + + + + + + + + + + + + +
+ + +

+this page: +

+ + +

+select transport:
+Auto | +Native messaging | +Frame element method | +Iframe relay | +Iframe polling | + +Fragment URL +

+ +

+
+
+ + + +

+Out [clear]:
+
+ + + +
+ + +
+ + + + + diff --git a/closure/goog/demos/xpc/inner.html b/closure/goog/demos/xpc/inner.html new file mode 100644 index 0000000000..6de8bd6e4d --- /dev/null +++ b/closure/goog/demos/xpc/inner.html @@ -0,0 +1,58 @@ + + + + + + + + + + + +

this page:

+ + +
+ +(n = )
+ +mousemove-forwarding: + + +
Click me!
+
+ + + +
+
+ + + + +

+Out [clear]:
+
+ + + + diff --git a/closure/goog/demos/xpc/minimal/blank.html b/closure/goog/demos/xpc/minimal/blank.html new file mode 100644 index 0000000000..307ced4227 --- /dev/null +++ b/closure/goog/demos/xpc/minimal/blank.html @@ -0,0 +1,7 @@ + + diff --git a/closure/goog/demos/xpc/minimal/index.html b/closure/goog/demos/xpc/minimal/index.html new file mode 100644 index 0000000000..9aa08301cc --- /dev/null +++ b/closure/goog/demos/xpc/minimal/index.html @@ -0,0 +1,106 @@ + + + + + + + + + + + +
+ +

+ +

+ + +

+ +
+
+ +
+ + + diff --git a/closure/goog/demos/xpc/minimal/inner.html b/closure/goog/demos/xpc/minimal/inner.html new file mode 100644 index 0000000000..6815efd206 --- /dev/null +++ b/closure/goog/demos/xpc/minimal/inner.html @@ -0,0 +1,76 @@ + + + + + + + + + + + + +

+ +

+ + +

+ +
+ + + + diff --git a/closure/goog/demos/xpc/minimal/relay.html b/closure/goog/demos/xpc/minimal/relay.html new file mode 100644 index 0000000000..c20fb1e5ef --- /dev/null +++ b/closure/goog/demos/xpc/minimal/relay.html @@ -0,0 +1,7 @@ + + diff --git a/closure/goog/demos/xpc/relay.html b/closure/goog/demos/xpc/relay.html new file mode 100644 index 0000000000..b61ca90eb0 --- /dev/null +++ b/closure/goog/demos/xpc/relay.html @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/closure/goog/demos/xpc/xpcdemo.js b/closure/goog/demos/xpc/xpcdemo.js new file mode 100644 index 0000000000..390b3b9fff --- /dev/null +++ b/closure/goog/demos/xpc/xpcdemo.js @@ -0,0 +1,329 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Contains application code for the XPC demo. + * This script is used in both the container page and the iframe. + */ +goog.provide('xpcdemo'); + +goog.require('goog.Uri'); +goog.require('goog.asserts'); +goog.require('goog.dom'); +goog.require('goog.dom.TagName'); +goog.require('goog.events'); +goog.require('goog.events.EventType'); +goog.require('goog.html.SafeHtml'); +goog.require('goog.log'); +goog.require('goog.log.Level'); +goog.require('goog.net.xpc.CfgFields'); +goog.require('goog.net.xpc.CrossPageChannel'); +goog.requireType('goog.events.BrowserEvent'); + +/** + * Global function to kick off initialization in the containing document. + */ +goog.global.initOuter = function() { + 'use strict'; + goog.events.listen(window, 'load', function() { + 'use strict'; + xpcdemo.initOuter(); + }); +}; + + +/** + * Global function to kick off initialization in the iframe. + */ +goog.global.initInner = function() { + 'use strict'; + goog.events.listen(window, 'load', function() { + 'use strict'; + xpcdemo.initInner(); + }); +}; + + +/** + * Initializes XPC in the containing page. + */ +xpcdemo.initOuter = function() { + 'use strict'; + // Build the configuration object. + var cfg = {}; + + var ownUri = new goog.Uri(window.location.href); + var relayUri = ownUri.resolve(new goog.Uri('relay.html')); + var pollUri = ownUri.resolve(new goog.Uri('blank.html')); + + // Determine the peer domain. Uses the value of the URI-parameter + // 'peerdomain'. If that parameter is not present, it falls back to + // the own domain so that the demo will work out of the box (but + // communication will of course not cross domain-boundaries). For + // real cross-domain communication, the easiest way is to point two + // different host-names to the same webserver and then hit the + // following URI: + // http://host1.com/path/to/closure/demos/xpc/index.html?peerdomain=host2.com + var peerDomain = ownUri.getParameterValue('peerdomain') || ownUri.getDomain(); + + cfg[goog.net.xpc.CfgFields.LOCAL_RELAY_URI] = relayUri.toString(); + cfg[goog.net.xpc.CfgFields.PEER_RELAY_URI] = + relayUri.setDomain(peerDomain).toString(); + + cfg[goog.net.xpc.CfgFields.LOCAL_POLL_URI] = pollUri.toString(); + cfg[goog.net.xpc.CfgFields.PEER_POLL_URI] = + pollUri.setDomain(peerDomain).toString(); + + + // Force transport to be used if tp-parameter is set. + var tp = ownUri.getParameterValue('tp'); + if (tp) { + cfg[goog.net.xpc.CfgFields.TRANSPORT] = parseInt(tp, 10); + } + + + // Construct the URI of the peer page. + + var peerUri = + ownUri.resolve(new goog.Uri('inner.html')).setDomain(peerDomain); + // Passthrough of verbose and compiled flags. + if (ownUri.getParameterValue('verbose') !== undefined) { + peerUri.setParameterValue('verbose', ''); + } + if (ownUri.getParameterValue('compiled') !== undefined) { + peerUri.setParameterValue('compiled', ''); + } + + cfg[goog.net.xpc.CfgFields.PEER_URI] = peerUri; + + // Instantiate the channel. + xpcdemo.channel = new goog.net.xpc.CrossPageChannel(cfg); + + // Create the peer iframe. + xpcdemo.peerIframe = xpcdemo.channel.createPeerIframe( + goog.asserts.assert(goog.dom.getElement('iframeContainer'))); + + xpcdemo.initCommon_(); + + goog.dom.getElement('inactive').style.display = 'none'; + goog.dom.getElement('active').style.display = ''; +}; + + +/** + * Initialization in the iframe. + */ +xpcdemo.initInner = function() { + 'use strict'; + // Get the channel configuration passed by the containing document. + var cfg = JSON.parse( + (new goog.Uri(window.location.href)).getParameterValue('xpc') || ''); + + xpcdemo.channel = new goog.net.xpc.CrossPageChannel( + /** @type {Object} */ (cfg)); + + xpcdemo.initCommon_(); +}; + + +/** + * Initializes the demo. + * Registers service-handlers and connects the channel. + * @private + */ +xpcdemo.initCommon_ = function() { + 'use strict'; + var xpcLogger = goog.log.getLogger( + 'goog.net.xpc', + window.location.href.match(/verbose/) ? goog.log.Level.ALL : + goog.log.Level.INFO); + goog.log.addHandler(xpcLogger, function(logRecord) { + 'use strict'; + xpcdemo.log('[XPC] ' + logRecord.getMessage()); + }); + + // Register services. + // The functions will only receive strings but takes an optional third + // parameter that causes the function to receive an Object to cast to the + // expected type, but it would be better to change the API or add + // overload support to the compiler. + xpcdemo.channel.registerService( + 'log', + /** @type {function((!Object|string)): ?} */ (xpcdemo.log)); + xpcdemo.channel.registerService( + 'ping', + /** @type {function((!Object|string)): ?} */ (xpcdemo.pingHandler_)); + xpcdemo.channel.registerService( + 'events', + /** @type {function((!Object|string)): ?} */ (xpcdemo.eventsMsgHandler_)); + + // Connect the channel. + xpcdemo.channel.connect(function() { + 'use strict'; + xpcdemo.channel.send('log', 'Hi from ' + window.location.host); + goog.events.listen( + goog.dom.getElement('clickfwd'), 'click', xpcdemo.mouseEventHandler_); + }); +}; + + +/** + * Kills the peer iframe and the disposes the channel. + */ +xpcdemo.teardown = function() { + 'use strict'; + goog.events.unlisten( + goog.dom.getElement('clickfwd'), goog.events.EventType.CLICK, + xpcdemo.mouseEventHandler_); + + xpcdemo.channel.dispose(); + delete xpcdemo.channel; + + goog.dom.removeNode(xpcdemo.peerIframe); + xpcdemo.peerIframe = null; + + goog.dom.getElement('inactive').style.display = ''; + goog.dom.getElement('active').style.display = 'none'; +}; + + +/** + * Logging function. Inserts log-message into element with it id 'console'. + * @param {string} msgString The log-message. + */ +xpcdemo.log = function(msgString) { + 'use strict'; + xpcdemo.consoleElm || (xpcdemo.consoleElm = goog.dom.getElement('console')); + var msgElm = goog.html.SafeHtml.create( + goog.dom.TagName.DIV, {}, goog.html.SafeHtml.htmlEscape(msgString)); + xpcdemo.consoleElm.insertBefore(msgElm, xpcdemo.consoleElm.firstChild); +}; + + +/** + * Sends a ping request to the peer. + */ +xpcdemo.ping = function() { + 'use strict'; + // send current time + xpcdemo.channel.send('ping', Date.now() + ''); +}; + + +/** + * The handler function for incoming pings (messages sent to the service + * called 'ping'); + * @param {string} payload The message payload. + * @private + */ +xpcdemo.pingHandler_ = function(payload) { + 'use strict'; + // is the incoming message a response to a ping we sent? + if (payload.charAt(0) == '#') { + // calculate roundtrip time and log + var dt = Date.now() - parseInt(payload.substring(1), 10); + xpcdemo.log('roundtrip: ' + dt + 'ms'); + } else { + // incoming message is a ping initiated from peer + // -> prepend with '#' and send back + xpcdemo.channel.send('ping', '#' + payload); + xpcdemo.log('ping reply sent'); + } +}; + + +/** + * Counter for mousemove events. + * @type {number} + * @private + */ +xpcdemo.mmCount_ = 0; + + +/** + * Holds timestamp when the last mousemove rate has been logged. + * @type {number} + * @private + */ +xpcdemo.mmLastRateOutput_ = 0; + + +/** + * Start mousemove event forwarding. Registers a listener on the document which + * sends them over the channel. + */ +xpcdemo.startMousemoveForwarding = function() { + 'use strict'; + goog.events.listen( + document, goog.events.EventType.MOUSEMOVE, xpcdemo.mouseEventHandler_); + xpcdemo.mmLastRateOutput_ = Date.now(); +}; + + +/** + * Stop mousemove event forwarding. + */ +xpcdemo.stopMousemoveForwarding = function() { + 'use strict'; + goog.events.unlisten( + document, goog.events.EventType.MOUSEMOVE, xpcdemo.mouseEventHandler_); +}; + + +/** + * Function to be used as handler for mouse-events. + * @param {goog.events.BrowserEvent} e The mouse event. + * @private + */ +xpcdemo.mouseEventHandler_ = function(e) { + 'use strict'; + xpcdemo.channel.send( + 'events', [e.type, e.clientX, e.clientY, Date.now()].join(',')); +}; + + +/** + * Handler for the 'events' service. + * @param {string} payload The string returned from the xpcdemo. + * @private + */ +xpcdemo.eventsMsgHandler_ = function(payload) { + 'use strict'; + var now = Date.now(); + var args = payload.split(','); + var type = args[0]; + var pageX = args[1]; + var pageY = args[2]; + var time = parseInt(args[3], 10); + + var msg = type + ': (' + pageX + ',' + pageY + '), latency: ' + (now - time); + xpcdemo.log(msg); + + if (type == goog.events.EventType.MOUSEMOVE) { + xpcdemo.mmCount_++; + var dt = now - xpcdemo.mmLastRateOutput_; + if (dt > 1000) { + msg = 'RATE (mousemove/s): ' + (1000 * xpcdemo.mmCount_ / dt); + xpcdemo.log(msg); + xpcdemo.mmLastRateOutput_ = now; + xpcdemo.mmCount_ = 0; + } + } +}; + + +/** + * Send multiple messages. + * @param {number} n The number of messages to send. + */ +xpcdemo.sendN = function(n) { + 'use strict'; + xpcdemo.count_ || (xpcdemo.count_ = 1); + + for (var i = 0; i < n; i++) { + xpcdemo.channel.send('log', '' + xpcdemo.count_++); + } +}; diff --git a/closure/goog/demos/zippy.html b/closure/goog/demos/zippy.html new file mode 100644 index 0000000000..68a8ba51c6 --- /dev/null +++ b/closure/goog/demos/zippy.html @@ -0,0 +1,148 @@ + + + + + goog.ui.Zippy + + + + + + + +

goog.ui.Zippy

+ +

Zippy 1

+

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Cras et nisi id + lorem tempor semper. Suspendisse ante. Integer ligula urna, venenatis quis, + placerat vitae, commodo quis, sapien. Quisque nec lectus. Sed non dolor. Sed + congue, nisi in pharetra consequat, odio diam pulvinar arcu, in laoreet elit + risus id ipsum. Class aptent taciti sociosqu ad litora torquent per conubia + nostra, per inceptos hymenaeos. Praesent tellus enim, imperdiet a, sagittis + id, pulvinar vel, tortor. Integer nulla. Sed nulla augue, lacinia id, + vulputate eu, rhoncus non, ante. Integer lobortis eros vitae quam. Phasellus + sagittis, ipsum sollicitudin bibendum laoreet, arcu erat luctus lacus, vel + pharetra felis metus tincidunt diam. Cras ac augue in enim ultricies aliquam. +

+ +
+

Zippy 2

+

+ Nunc et eros. Aliquam felis lectus, sagittis ac, sagittis eu, accumsan + vitae, leo. Maecenas suscipit, arcu eget elementum tincidunt, erat ligula + porttitor dui, quis ornare nisi turpis at ipsum. Vivamus magna tortor, + porttitor eu, cursus ut, vulputate in, nulla. Quisque nonummy feugiat + turpis. Cras lobortis lobortis elit. Aliquam congue, pede suscipit + condimentum convallis, diam purus dictum lacus, eu scelerisque mi est + molestie libero. Duis luctus dapibus nibh. Sed condimentum iaculis metus. + Pellentesque habitant morbi tristique senectus et netus et malesuada fames + ac turpis egestas. In pharetra dolor porta eros facilisis pellentesque. + Proin quam mi, sodales vel, tincidunt sit amet, convallis vel, eros. + Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere + cubilia Curae; Phasellus velit augue, rutrum sit amet, posuere nec, euismod + ac, elit. Etiam nisi. +

+
+ +
+

Zippy 3

+

+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas commodo + convallis nisi. Cras rhoncus elit non dolor. Vivamus gravida ultricies arcu. + Praesent ipsum erat, vehicula et, ultrices at, dignissim at, ipsum. Aenean + venenatis. Fusce blandit laoreet urna. Aliquam et pede condimentum lorem + posuere molestie. Pellentesque habitant morbi tristique senectus et netus et + malesuada fames ac turpis egestas. Fusce euismod, justo in feugiat feugiat, + urna metus sagittis felis, in varius neque mauris vitae dui. Nunc vel sapien + in diam laoreet euismod. Mauris quis felis ut ipsum auctor feugiat. Nulla + facilisi. Proin vitae urna. Quisque dignissim commodo nisl. Curabitur + bibendum. +

+
+ +
+ Zippy 2 sets the expanded state of zippy 3 to the inverted expanded state of + itself. Hence expanding zippy 2 collapses zippy 3 and vice verse. +
+
+ Zippy 2 and 3 are animated, zippy 1 is not. +
+ +
+
+ + + diff --git a/closure/goog/disposable/BUILD b/closure/goog/disposable/BUILD new file mode 100644 index 0000000000..36e33b3b6c --- /dev/null +++ b/closure/goog/disposable/BUILD @@ -0,0 +1,22 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "disposable", + srcs = [ + "disposable.js", + "dispose.js", + "disposeall.js", + ], + lenient = True, + deps = [":idisposable"], +) + +closure_js_library( + name = "idisposable", + srcs = ["idisposable.js"], + lenient = True, +) diff --git a/closure/goog/disposable/disposable.js b/closure/goog/disposable/disposable.js new file mode 100644 index 0000000000..5934a6e7cd --- /dev/null +++ b/closure/goog/disposable/disposable.js @@ -0,0 +1,285 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Implements the disposable interface. + */ + +goog.provide('goog.Disposable'); + +goog.require('goog.disposable.IDisposable'); +goog.require('goog.dispose'); +/** + * TODO(user): Remove this require. + * @suppress {extraRequire} + */ +goog.require('goog.disposeAll'); + +/** + * Class that provides the basic implementation for disposable objects. If your + * class holds references or resources that can't be collected by standard GC, + * it should extend this class or implement the disposable interface (defined + * in goog.disposable.IDisposable). See description of + * goog.disposable.IDisposable for examples of cleanup. + * @constructor + * @implements {goog.disposable.IDisposable} + */ +goog.Disposable = function() { + 'use strict'; + /** + * If monitoring the goog.Disposable instances is enabled, stores the creation + * stack trace of the Disposable instance. + * @type {string|undefined} + */ + this.creationStack; + + if (goog.Disposable.MONITORING_MODE != goog.Disposable.MonitoringMode.OFF) { + if (goog.Disposable.INCLUDE_STACK_ON_CREATION) { + this.creationStack = new Error().stack; + } + goog.Disposable.instances_[goog.getUid(this)] = this; + } + // Support sealing + this.disposed_ = this.disposed_; + this.onDisposeCallbacks_ = this.onDisposeCallbacks_; +}; + + +/** + * @enum {number} Different monitoring modes for Disposable. + */ +goog.Disposable.MonitoringMode = { + /** + * No monitoring. + */ + OFF: 0, + /** + * Creating and disposing the goog.Disposable instances is monitored. All + * disposable objects need to call the `goog.Disposable` base + * constructor. The PERMANENT mode must be switched on before creating any + * goog.Disposable instances. + */ + PERMANENT: 1, + /** + * INTERACTIVE mode can be switched on and off on the fly without producing + * errors. It also doesn't warn if the disposable objects don't call the + * `goog.Disposable` base constructor. + */ + INTERACTIVE: 2 +}; + + +/** + * @define {number} The monitoring mode of the goog.Disposable + * instances. Default is OFF. Switching on the monitoring is only + * recommended for debugging because it has a significant impact on + * performance and memory usage. If switched off, the monitoring code + * compiles down to 0 bytes. + */ +goog.Disposable.MONITORING_MODE = + goog.define('goog.Disposable.MONITORING_MODE', 0); + + +/** + * @define {boolean} Whether to attach creation stack to each created disposable + * instance; This is only relevant for when MonitoringMode != OFF. + */ +goog.Disposable.INCLUDE_STACK_ON_CREATION = + goog.define('goog.Disposable.INCLUDE_STACK_ON_CREATION', true); + + +/** + * Maps the unique ID of every undisposed `goog.Disposable` object to + * the object itself. + * @type {!Object} + * @private + */ +goog.Disposable.instances_ = {}; + + +/** + * @return {!Array} All `goog.Disposable` objects that + * haven't been disposed of. + */ +goog.Disposable.getUndisposedObjects = function() { + 'use strict'; + var ret = []; + for (var id in goog.Disposable.instances_) { + if (goog.Disposable.instances_.hasOwnProperty(id)) { + ret.push(goog.Disposable.instances_[Number(id)]); + } + } + return ret; +}; + + +/** + * Clears the registry of undisposed objects but doesn't dispose of them. + */ +goog.Disposable.clearUndisposedObjects = function() { + 'use strict'; + goog.Disposable.instances_ = {}; +}; + + +/** + * Whether the object has been disposed of. + * @type {boolean} + * @private + */ +goog.Disposable.prototype.disposed_ = false; + + +/** + * Callbacks to invoke when this object is disposed. + * @type {Array} + * @private + */ +goog.Disposable.prototype.onDisposeCallbacks_; + + +/** + * @return {boolean} Whether the object has been disposed of. + * @override + */ +goog.Disposable.prototype.isDisposed = function() { + 'use strict'; + return this.disposed_; +}; + + +/** + * @return {boolean} Whether the object has been disposed of. + * @deprecated Use {@link #isDisposed} instead. + */ +goog.Disposable.prototype.getDisposed = goog.Disposable.prototype.isDisposed; + + +/** + * Disposes of the object. If the object hasn't already been disposed of, calls + * {@link #disposeInternal}. Classes that extend `goog.Disposable` should + * override {@link #disposeInternal} in order to cleanup references, resources + * and other disposable objects. Reentrant. + * + * @return {void} Nothing. + * @override + */ +goog.Disposable.prototype.dispose = function() { + 'use strict'; + if (!this.disposed_) { + // Set disposed_ to true first, in case during the chain of disposal this + // gets disposed recursively. + this.disposed_ = true; + this.disposeInternal(); + if (goog.Disposable.MONITORING_MODE != goog.Disposable.MonitoringMode.OFF) { + var uid = goog.getUid(this); + if (goog.Disposable.MONITORING_MODE == + goog.Disposable.MonitoringMode.PERMANENT && + !goog.Disposable.instances_.hasOwnProperty(uid)) { + throw new Error( + this + ' did not call the goog.Disposable base ' + + 'constructor or was disposed of after a clearUndisposedObjects ' + + 'call'); + } + if (goog.Disposable.MONITORING_MODE != + goog.Disposable.MonitoringMode.OFF && + this.onDisposeCallbacks_ && this.onDisposeCallbacks_.length > 0) { + throw new Error( + this + ' did not empty its onDisposeCallbacks queue. This ' + + 'probably means it overrode dispose() or disposeInternal() ' + + 'without calling the superclass\' method.'); + } + delete goog.Disposable.instances_[uid]; + } + } +}; + + +/** + * Associates a disposable object with this object so that they will be disposed + * together. + * @param {goog.disposable.IDisposable} disposable that will be disposed when + * this object is disposed. + */ +goog.Disposable.prototype.registerDisposable = function(disposable) { + 'use strict'; + this.addOnDisposeCallback(goog.partial(goog.dispose, disposable)); +}; + + +/** + * Invokes a callback function when this object is disposed. Callbacks are + * invoked in the order in which they were added. If a callback is added to + * an already disposed Disposable, it will be called immediately. + * @param {function(this:T):?} callback The callback function. + * @param {T=} opt_scope An optional scope to call the callback in. + * @template T + */ +goog.Disposable.prototype.addOnDisposeCallback = function(callback, opt_scope) { + 'use strict'; + if (this.disposed_) { + opt_scope !== undefined ? callback.call(opt_scope) : callback(); + return; + } + if (!this.onDisposeCallbacks_) { + this.onDisposeCallbacks_ = []; + } + + this.onDisposeCallbacks_.push( + opt_scope !== undefined ? goog.bind(callback, opt_scope) : callback); +}; + + +/** + * Performs appropriate cleanup. See description of goog.disposable.IDisposable + * for examples. Classes that extend `goog.Disposable` should override this + * method. Not reentrant. To avoid calling it twice, it must only be called from + * the subclass' `disposeInternal` method. Everywhere else the public `dispose` + * method must be used. For example: + * + *
+ * mypackage.MyClass = function() {
+ * mypackage.MyClass.base(this, 'constructor');
+ *     // Constructor logic specific to MyClass.
+ *     ...
+ *   };
+ *   goog.inherits(mypackage.MyClass, goog.Disposable);
+ *
+ *   mypackage.MyClass.prototype.disposeInternal = function() {
+ *     // Dispose logic specific to MyClass.
+ *     ...
+ *     // Call superclass's disposeInternal at the end of the subclass's, like
+ *     // in C++, to avoid hard-to-catch issues.
+ *     mypackage.MyClass.base(this, 'disposeInternal');
+ *   };
+ * 
+ * + * @protected + */ +goog.Disposable.prototype.disposeInternal = function() { + 'use strict'; + if (this.onDisposeCallbacks_) { + while (this.onDisposeCallbacks_.length) { + this.onDisposeCallbacks_.shift()(); + } + } +}; + + +/** + * Returns True if we can verify the object is disposed. + * Calls `isDisposed` on the argument if it supports it. If obj + * is not an object with an isDisposed() method, return false. + * @param {*} obj The object to investigate. + * @return {boolean} True if we can verify the object is disposed. + */ +goog.Disposable.isDisposed = function(obj) { + 'use strict'; + if (obj && typeof obj.isDisposed == 'function') { + return obj.isDisposed(); + } + return false; +}; diff --git a/closure/goog/disposable/disposable_test.js b/closure/goog/disposable/disposable_test.js new file mode 100644 index 0000000000..b8a3c5f7e7 --- /dev/null +++ b/closure/goog/disposable/disposable_test.js @@ -0,0 +1,346 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.DisposableTest'); +goog.setTestOnly(); + +const Disposable = goog.require('goog.Disposable'); +const dispose = goog.require('goog.dispose'); +const disposeAll = goog.require('goog.disposeAll'); +const recordFunction = goog.require('goog.testing.recordFunction'); +const testSuite = goog.require('goog.testing.testSuite'); + +let d1; +let d2; + +// Sample subclass of goog.Disposable. +class DisposableTest extends Disposable { + constructor() { + super(); + this.element = document.getElementById('someElement'); + } + + disposeInternal() { + super.disposeInternal(); + delete this.element; + } +} + +// Class that doesn't inherit from goog.Disposable, but implements the +// disposable interface via duck typing. +class DisposableDuck { + constructor() { + this.element = document.getElementById('someElement'); + } + + dispose() { + delete this.element; + } +} + +// Class which calls dispose recursively. +class RecursiveDisposable extends Disposable { + constructor() { + super(); + this.disposedCount = 0; + } + + disposeInternal() { + ++this.disposedCount; + assertEquals('Disposed too many times', 1, this.disposedCount); + this.dispose(); + } +} + +// Test methods. + +testSuite({ + setUp() { + d1 = new Disposable(); + d2 = new DisposableTest(); + }, + + tearDown() { + /** Use computed properties to avoid compiler checks of defines. */ + Disposable['MONITORING_MODE'] = Disposable.MonitoringMode.OFF; + + /** Use computed properties to avoid compiler checks of defines. */ + Disposable['INCLUDE_STACK_ON_CREATION'] = true; + + /** @suppress {visibility} suppression added to enable type checking */ + Disposable.instances_ = {}; + d1.dispose(); + d2.dispose(); + }, + + testConstructor() { + assertFalse(d1.isDisposed()); + assertFalse(d2.isDisposed()); + assertEquals(document.getElementById('someElement'), d2.element); + }, + + testDispose() { + assertFalse(d1.isDisposed()); + d1.dispose(); + assertTrue( + 'goog.Disposable instance should have been disposed of', + d1.isDisposed()); + + assertFalse(d2.isDisposed()); + d2.dispose(); + assertTrue( + 'goog.DisposableTest instance should have been disposed of', + d2.isDisposed()); + }, + + testDisposeInternal() { + assertNotUndefined(d2.element); + d2.dispose(); + assertUndefined( + 'goog.DisposableTest.prototype.disposeInternal should ' + + 'have deleted the element reference', + d2.element); + }, + + testDisposeAgain() { + d2.dispose(); + assertUndefined( + 'goog.DisposableTest.prototype.disposeInternal should ' + + 'have deleted the element reference', + d2.element); + // Manually reset the element to a non-null value, and call dispose(). + // Because the object is already marked disposed, disposeInternal won't + // be called again. + d2.element = document.getElementById('someElement'); + d2.dispose(); + assertNotUndefined( + 'disposeInternal should not be called again if the ' + + 'object has already been marked disposed', + d2.element); + }, + + testDisposeWorksRecursively() { + new RecursiveDisposable().dispose(); + }, + + testStaticDispose() { + assertFalse(d1.isDisposed()); + dispose(d1); + assertTrue( + 'goog.Disposable instance should have been disposed of', + d1.isDisposed()); + + assertFalse(d2.isDisposed()); + dispose(d2); + assertTrue( + 'goog.DisposableTest instance should have been disposed of', + d2.isDisposed()); + + const duck = new DisposableDuck(); + assertNotUndefined(duck.element); + dispose(duck); + assertUndefined( + 'goog.dispose should have disposed of object that ' + + 'implements the disposable interface', + duck.element); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testStaticDisposeOnNonDisposableType() { + // Call goog.dispose() with various types and make sure no errors are + // thrown. + dispose(true); + dispose(false); + dispose(null); + dispose(undefined); + dispose(''); + dispose([]); + dispose({}); + + function A() {} + dispose(new A()); + }, + + testMonitoringFailure() { + function BadDisposable() {} + goog.inherits(BadDisposable, Disposable); + + /** Use computed properties to avoid compiler checks of defines. */ + Disposable['MONITORING_MODE'] = Disposable.MonitoringMode.PERMANENT; + + /** @suppress {checkTypes} suppression added to enable type checking */ + const badDisposable = new BadDisposable; + assertArrayEquals( + 'no disposable objects registered', [], + Disposable.getUndisposedObjects()); + assertThrows( + 'the base ctor should have been called', + goog.bind(badDisposable.dispose, badDisposable)); + }, + + testGetUndisposedObjects() { + /** Use computed properties to avoid compiler checks of defines. */ + Disposable['MONITORING_MODE'] = Disposable.MonitoringMode.PERMANENT; + + const d1 = new DisposableTest(); + const d2 = new DisposableTest(); + assertSameElements( + 'the undisposed instances', [d1, d2], + Disposable.getUndisposedObjects()); + + d1.dispose(); + assertSameElements( + '1 undisposed instance left', [d2], Disposable.getUndisposedObjects()); + + d1.dispose(); + assertSameElements( + 'second disposal of the same object is no-op', [d2], + Disposable.getUndisposedObjects()); + + d2.dispose(); + assertSameElements( + 'all objects have been disposed of', [], + Disposable.getUndisposedObjects()); + }, + + testClearUndisposedObjects() { + /** Use computed properties to avoid compiler checks of defines. */ + Disposable['MONITORING_MODE'] = Disposable.MonitoringMode.PERMANENT; + + const d1 = new DisposableTest(); + const d2 = new DisposableTest(); + d2.dispose(); + Disposable.clearUndisposedObjects(); + assertSameElements( + 'no undisposed object in the registry', [], + Disposable.getUndisposedObjects()); + + assertThrows('disposal after clearUndisposedObjects()', () => { + d1.dispose(); + }); + + // d2 is already disposed of, the redisposal shouldn't throw error. + d2.dispose(); + }, + + testRegisterDisposable() { + const d1 = new DisposableTest(); + const d2 = new DisposableTest(); + + d1.registerDisposable(d2); + d1.dispose(); + + assertTrue('d2 should be disposed when d1 is disposed', d2.isDisposed()); + }, + + testDisposeAll() { + const d1 = new DisposableTest(); + const d2 = new DisposableTest(); + + disposeAll(d1, d2); + + assertTrue('d1 should be disposed', d1.isDisposed()); + assertTrue('d2 should be disposed', d2.isDisposed()); + }, + + testDisposeAllRecursive() { + const d1 = new DisposableTest(); + const d2 = new DisposableTest(); + const d3 = new DisposableTest(); + const d4 = new DisposableTest(); + + disposeAll(d1, [[d2], d3, d4]); + + assertTrue('d1 should be disposed', d1.isDisposed()); + assertTrue('d2 should be disposed', d2.isDisposed()); + assertTrue('d3 should be disposed', d3.isDisposed()); + assertTrue('d4 should be disposed', d4.isDisposed()); + }, + + testCreationStack() { + if (!new Error().stack) return; + /** Use computed properties to avoid compiler checks of defines. */ + Disposable['MONITORING_MODE'] = Disposable.MonitoringMode.PERMANENT; + const disposableStack = new DisposableTest().creationStack; + // Check that the name of this test function occurs in the stack trace. + assertNotEquals(-1, disposableStack.indexOf('testCreationStack')); + }, + + testMonitoredWithoutCreationStack() { + if (!new Error().stack) return; + + /** Use computed properties to avoid compiler checks of defines. */ + Disposable['MONITORING_MODE'] = Disposable.MonitoringMode.PERMANENT; + + /** Use computed properties to avoid compiler checks of defines. */ + Disposable['INCLUDE_STACK_ON_CREATION'] = false; + + const d1 = new DisposableTest(); + + // Check that it is tracked, but not with a creation stack. + assertUndefined(d1.creationStack); + assertSameElements( + 'the undisposed instance', [d1], Disposable.getUndisposedObjects()); + }, + + testOnDisposeCallback() { + const callback = recordFunction(); + d1.addOnDisposeCallback(callback); + assertEquals('callback called too early', 0, callback.getCallCount()); + d1.dispose(); + assertEquals( + 'callback should be called once on dispose', 1, + callback.getCallCount()); + }, + + testOnDisposeCallbackOrder() { + const invocations = []; + const callback = (str) => { + invocations.push(str); + }; + d1.addOnDisposeCallback(goog.partial(callback, 'a')); + d1.addOnDisposeCallback(goog.partial(callback, 'b')); + dispose(d1); + assertArrayEquals( + 'callbacks should be called in chronological order', ['a', 'b'], + invocations); + }, + + testAddOnDisposeCallbackAfterDispose() { + const callback = recordFunction(); + const scope = {}; + dispose(d1); + d1.addOnDisposeCallback(callback, scope); + assertEquals( + 'Callback should be immediately called if already disposed', 1, + callback.getCallCount()); + assertEquals( + 'Callback scope should be respected', scope, + callback.getLastCall().getThis()); + }, + + testInteractiveMonitoring() { + const d1 = new DisposableTest(); + + /** Use computed properties to avoid compiler checks of defines. */ + Disposable['MONITORING_MODE'] = Disposable.MonitoringMode.INTERACTIVE; + + const d2 = new DisposableTest(); + + assertSameElements( + 'only 1 undisposed instance tracked', [d2], + Disposable.getUndisposedObjects()); + + // No errors should be thrown. + d1.dispose(); + + assertSameElements( + '1 undisposed instance left', [d2], Disposable.getUndisposedObjects()); + + d2.dispose(); + assertSameElements('all disposed', [], Disposable.getUndisposedObjects()); + }, +}); diff --git a/closure/goog/disposable/disposable_test_dom.html b/closure/goog/disposable/disposable_test_dom.html new file mode 100644 index 0000000000..185e5468fa --- /dev/null +++ b/closure/goog/disposable/disposable_test_dom.html @@ -0,0 +1,9 @@ + +
+ Hello! +
\ No newline at end of file diff --git a/closure/goog/disposable/dispose.js b/closure/goog/disposable/dispose.js new file mode 100644 index 0000000000..73900b1667 --- /dev/null +++ b/closure/goog/disposable/dispose.js @@ -0,0 +1,25 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview The dispose method is used to clean up references and + * resources. + */ + +goog.module('goog.dispose'); +goog.module.declareLegacyNamespace(); + +/** + * Calls `dispose` on the argument if it supports it. If obj is not an + * object with a dispose() method, this is a no-op. + * @param {*} obj The object to dispose of. + */ +function dispose(obj) { + if (obj && typeof obj.dispose == 'function') { + obj.dispose(); + } +} +exports = dispose; diff --git a/closure/goog/disposable/disposeall.js b/closure/goog/disposable/disposeall.js new file mode 100644 index 0000000000..d209e470ee --- /dev/null +++ b/closure/goog/disposable/disposeall.js @@ -0,0 +1,34 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview The disposeAll method is used to clean up references and + * resources. + */ + +goog.module('goog.disposeAll'); +goog.module.declareLegacyNamespace(); + +const dispose = goog.require('goog.dispose'); + +/** + * Calls `dispose` on each member of the list that supports it. (If the + * member is an ArrayLike, then `goog.disposeAll()` will be called + * recursively on each of its members.) If the member is not an object with a + * `dispose()` method, then it is ignored. + * @param {...*} var_args The list. + */ +function disposeAll(var_args) { + for (let i = 0, len = arguments.length; i < len; ++i) { + const disposable = arguments[i]; + if (goog.isArrayLike(disposable)) { + disposeAll.apply(null, disposable); + } else { + dispose(disposable); + } + } +} +exports = disposeAll; diff --git a/closure/goog/disposable/idisposable.js b/closure/goog/disposable/idisposable.js new file mode 100644 index 0000000000..52db6af321 --- /dev/null +++ b/closure/goog/disposable/idisposable.js @@ -0,0 +1,48 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Definition of the disposable interface. A disposable object + * has a dispose method to to clean up references and resources. + */ + + +goog.provide('goog.disposable.IDisposable'); + + + +/** + * Interface for a disposable object. If a instance requires cleanup, it should + * implement this interface (it may subclass goog.Disposable). + * + * Examples of cleanup that can be done in `dispose` method: + * 1. Remove event listeners. + * 2. Cancel timers (setTimeout, setInterval, goog.Timer). + * 3. Call `dispose` on other disposable objects hold by current object. + * 4. Close connections (e.g. WebSockets). + * + * Note that it's not required to delete properties (e.g. DOM nodes) or set them + * to null as garbage collector will collect them assuming that references to + * current object will be lost after it is disposed. + * + * See also http://go/mdn/JavaScript/Memory_Management. + * + * @record + */ +goog.disposable.IDisposable = function() {}; + + +/** + * Disposes of the object and its resources. + * @return {void} Nothing. + */ +goog.disposable.IDisposable.prototype.dispose = goog.abstractMethod; + + +/** + * @return {boolean} Whether the object has been disposed of. + */ +goog.disposable.IDisposable.prototype.isDisposed = goog.abstractMethod; diff --git a/closure/goog/dom/BUILD b/closure/goog/dom/BUILD new file mode 100644 index 0000000000..a1694bae2e --- /dev/null +++ b/closure/goog/dom/BUILD @@ -0,0 +1,427 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +alias( + name = "abstractmultirange", + actual = ":range", +) + +closure_js_library( + name = "abstractrange", + srcs = [ + "abstractrange.js", + "savedrange.js", + ], + lenient = True, + deps = [ + ":dom", + ":nodetype", + ":tagiterator", + "//closure/goog/disposable", + "//closure/goog/log", + "//closure/goog/math:coordinate", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "annotate", + srcs = ["annotate.js"], + lenient = True, + deps = [ + ":dom", + ":nodetype", + ":safe", + ":tagname", + "//closure/goog/array", + "//closure/goog/asserts", + "//closure/goog/html:safehtml", + "//closure/goog/object", + ], +) + +closure_js_library( + name = "asserts", + srcs = ["asserts.js"], + lenient = True, + deps = ["//closure/goog/asserts"], +) + +closure_js_library( + name = "attr", + srcs = ["attr.js"], + lenient = True, +) + +closure_js_library( + name = "browserfeature", + srcs = ["browserfeature.js"], + lenient = True, + deps = ["//closure/goog/useragent"], +) + +closure_js_library( + name = "bufferedviewportsizemonitor", + srcs = ["bufferedviewportsizemonitor.js"], + lenient = True, + deps = [ + ":dom", + ":viewportsizemonitor", + "//closure/goog/asserts", + "//closure/goog/async:delay", + "//closure/goog/events", + "//closure/goog/events:eventtarget", + "//closure/goog/events:eventtype", + "//closure/goog/math:size", + ], +) + +closure_js_library( + name = "classes", + srcs = ["classes.js"], + lenient = True, + deps = ["//closure/goog/array"], +) + +closure_js_library( + name = "classlist", + srcs = ["classlist.js"], + lenient = True, + deps = ["//closure/goog/array"], +) + +alias( + name = "controlrange", + actual = ":range", +) + +closure_js_library( + name = "dataset", + srcs = ["dataset.js"], + lenient = True, + deps = [ + "//closure/goog/labs/useragent:browser", + "//closure/goog/string", + "//closure/goog/useragent:product", + ], +) + +closure_js_library( + name = "dom", + srcs = ["dom.js"], + lenient = True, + deps = [ + ":browserfeature", + ":nodetype", + ":safe", + ":tagname", + "//closure/goog/array", + "//closure/goog/asserts", + "//closure/goog/asserts:dom", + "//closure/goog/html:safehtml", + "//closure/goog/html:uncheckedconversions", + "//closure/goog/math:coordinate", + "//closure/goog/math:size", + "//closure/goog/object", + "//closure/goog/string", + "//closure/goog/string:const", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "element", + srcs = ["element.js"], + lenient = True, + deps = [ + ":nodetype", + ":tagname", + ], +) + +closure_js_library( + name = "fontsizemonitor", + srcs = ["fontsizemonitor.js"], + lenient = True, + deps = [ + ":dom", + ":tagname", + "//closure/goog/events", + "//closure/goog/events:browserevent", + "//closure/goog/events:eventtarget", + "//closure/goog/events:eventtype", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "forms", + srcs = ["forms.js"], + lenient = True, + deps = [ + ":inputtype", + ":safe", + ":tagname", + "//closure/goog/structs:map", + "//closure/goog/window", + ], +) + +closure_js_library( + name = "fullscreen", + srcs = ["fullscreen.js"], + lenient = True, + deps = [":dom"], +) + +closure_js_library( + name = "htmlelement", + srcs = ["htmlelement.js"], + lenient = True, +) + +closure_js_library( + name = "iframe", + srcs = ["iframe.js"], + lenient = True, + deps = [ + ":dom", + ":safe", + ":tagname", + "//closure/goog/html:safehtml", + "//closure/goog/html:safestyle", + "//closure/goog/html:trustedresourceurl", + "//closure/goog/string:const", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "inputtype", + srcs = ["inputtype.js"], + lenient = True, +) + +closure_js_library( + name = "iter", + srcs = ["iter.js"], + lenient = True, + deps = ["//closure/goog/iter"], +) + +alias( + name = "multirange", + actual = ":range", +) + +closure_js_library( + name = "nodeiterator", + srcs = ["nodeiterator.js"], + lenient = True, + deps = [ + ":tagiterator", + "//closure/goog/iter", + ], +) + +closure_js_library( + name = "nodeoffset", + srcs = ["nodeoffset.js"], + lenient = True, + deps = [ + ":tagname", + "//closure/goog/disposable", + ], +) + +closure_js_library( + name = "nodetype", + srcs = ["nodetype.js"], + lenient = True, +) + +closure_js_library( + name = "range", + srcs = [ + "abstractmultirange.js", + "controlrange.js", + "multirange.js", + "range.js", + "savedcaretrange.js", + "textrange.js", + ], + lenient = True, + deps = [ + ":abstractrange", + ":browserfeature", + ":dom", + ":nodetype", + ":tagiterator", + ":tagname", + ":textrangeiterator", + "//closure/goog/array", + "//closure/goog/dom/browserrange", + "//closure/goog/dom/browserrange:abstractrange", + "//closure/goog/iter", + "//closure/goog/log", + "//closure/goog/string", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "rangeendpoint", + srcs = ["rangeendpoint.js"], + lenient = True, +) + +closure_js_library( + name = "safe", + srcs = ["safe.js"], + deprecation = "Please use 'safevalues/dom' instead", + lenient = True, + deps = [ + ":asserts", + "//closure/goog/asserts", + "//closure/goog/asserts:dom", + "//closure/goog/functions", + "//closure/goog/html:safehtml", + "//closure/goog/html:safescript", + "//closure/goog/html:safestyle", + "//closure/goog/html:safeurl", + "//closure/goog/html:trustedresourceurl", + "//closure/goog/html:uncheckedconversions", + "//closure/goog/string:const", + "//closure/goog/string:internal", + ], +) + +alias( + name = "savedcaretrange", + actual = ":range", +) + +alias( + name = "savedrange", + actual = ":abstractrange", +) + +closure_js_library( + name = "selection", + srcs = ["selection.js"], + lenient = True, + deps = [ + ":inputtype", + "//closure/goog/string", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "tagiterator", + srcs = ["tagiterator.js"], + lenient = True, + deps = [ + ":dom", + ":nodetype", + "//closure/goog/iter", + ], +) + +closure_js_library( + name = "tagname", + srcs = ["tagname.js"], + lenient = True, + deps = [":htmlelement"], +) + +closure_js_library( + name = "tags", + srcs = ["tags.js"], + lenient = True, + deps = ["//closure/goog/object"], +) + +closure_js_library( + name = "textassert", + srcs = ["textassert.js"], + lenient = True, + deps = [ + ":dom", + ":tagname", + "//closure/goog/asserts", + ], +) + +alias( + name = "textrange", + actual = ":range", +) + +closure_js_library( + name = "textrangeiterator", + srcs = ["textrangeiterator.js"], + lenient = True, + deps = [ + ":abstractrange", + ":dom", + ":nodetype", + ":tagname", + "//closure/goog/array", + "//closure/goog/iter", + ], +) + +closure_js_library( + name = "uri", + srcs = ["uri.js"], + lenient = True, + deps = [ + ":dom", + ":safe", + ":tagname", + "//closure/goog/html:uncheckedconversions", + "//closure/goog/string:const", + ], +) + +closure_js_library( + name = "vendor", + srcs = ["vendor.js"], + lenient = True, + deps = [ + "//closure/goog/string", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "viewportsizemonitor", + srcs = ["viewportsizemonitor.js"], + lenient = True, + deps = [ + ":dom", + "//closure/goog/disposable", + "//closure/goog/events", + "//closure/goog/events:event", + "//closure/goog/events:eventtarget", + "//closure/goog/events:eventtype", + "//closure/goog/math:size", + ], +) + +closure_js_library( + name = "xml", + srcs = ["xml.js"], + lenient = True, + deps = [ + ":dom", + ":nodetype", + ":safe", + "//closure/goog/html:legacyconversions", + "//closure/goog/useragent", + ], +) diff --git a/closure/goog/dom/abstractmultirange.js b/closure/goog/dom/abstractmultirange.js new file mode 100644 index 0000000000..6296614733 --- /dev/null +++ b/closure/goog/dom/abstractmultirange.js @@ -0,0 +1,82 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for working with ranges comprised of multiple + * sub-ranges. + */ + + +goog.provide('goog.dom.AbstractMultiRange'); + +goog.require('goog.array'); +goog.require('goog.dom'); +goog.require('goog.dom.AbstractRange'); +goog.require('goog.dom.TextRange'); + + + +/** + * Creates a new multi range with no properties. Do not use this + * constructor: use one of the goog.dom.Range.createFrom* methods instead. + * @constructor + * @extends {goog.dom.AbstractRange} + * @abstract + */ +goog.dom.AbstractMultiRange = function() {}; +goog.inherits(goog.dom.AbstractMultiRange, goog.dom.AbstractRange); + + +/** @override */ +goog.dom.AbstractMultiRange.prototype.containsRange = function( + otherRange, opt_allowPartial) { + 'use strict'; + // TODO(user): This will incorrectly return false if two (or more) adjacent + // elements are both in the control range, and are also in the text range + // being compared to. + var /** !Array */ ranges = this.getTextRanges(); + var otherRanges = otherRange.getTextRanges(); + + var fn = opt_allowPartial ? goog.array.some : goog.array.every; + return fn(otherRanges, function(otherRange) { + 'use strict'; + return goog.array.some(ranges, function(range) { + 'use strict'; + return range.containsRange(otherRange, opt_allowPartial); + }); + }); +}; + + +/** @override */ +goog.dom.AbstractMultiRange.prototype.containsNode = function( + node, opt_allowPartial) { + 'use strict'; + return this.containsRange( + goog.dom.TextRange.createFromNodeContents(node), opt_allowPartial); +}; + + + +/** @override */ +goog.dom.AbstractMultiRange.prototype.insertNode = function(node, before) { + 'use strict'; + if (before) { + goog.dom.insertSiblingBefore(node, this.getStartNode()); + } else { + goog.dom.insertSiblingAfter(node, this.getEndNode()); + } + return node; +}; + + +/** @override */ +goog.dom.AbstractMultiRange.prototype.surroundWithNodes = function( + startNode, endNode) { + 'use strict'; + this.insertNode(startNode, true); + this.insertNode(endNode, false); +}; diff --git a/closure/goog/dom/abstractrange.js b/closure/goog/dom/abstractrange.js new file mode 100644 index 0000000000..0f940ea627 --- /dev/null +++ b/closure/goog/dom/abstractrange.js @@ -0,0 +1,496 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Interface definitions for working with ranges + * in HTML documents. + */ + + +goog.provide('goog.dom.AbstractRange'); +goog.provide('goog.dom.RangeIterator'); +goog.provide('goog.dom.RangeType'); + +goog.require('goog.dom'); +goog.require('goog.dom.NodeType'); +goog.require('goog.dom.TagIterator'); +goog.require('goog.userAgent'); +goog.requireType('goog.dom.AbstractSavedCaretRange'); +goog.requireType('goog.dom.SavedRange'); +goog.requireType('goog.math.Coordinate'); + +/** + * Types of ranges. + * @enum {string} + */ +goog.dom.RangeType = { + TEXT: 'text', + CONTROL: 'control', + MULTI: 'mutli' +}; + + + +/** + * Creates a new selection with no properties. Do not use this constructor - + * use one of the goog.dom.Range.from* methods instead. + * @constructor + * @abstract + */ +goog.dom.AbstractRange = function() {}; + + +/** + * Gets the browser native selection object from the given window. + * @param {Window} win The window to get the selection object from. + * @return {Object} The browser native selection object, or null if it could + * not be retrieved. + * @deprecated use window#getSelection instead. + */ +goog.dom.AbstractRange.getBrowserSelectionForWindow = function(win) { + 'use strict'; + return win.getSelection(); +}; + + +/** + * Tests if the given Object is a controlRange. + * @param {Object} range The range object to test. + * @return {boolean} Whether the given Object is a controlRange. + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.dom.AbstractRange.isNativeControlRange = function(range) { + 'use strict'; + // For now, tests for presence of a control range function. + return !!range && !!range.addElement; +}; + + +/** + * @return {!goog.dom.AbstractRange} A clone of this range. + */ +goog.dom.AbstractRange.prototype.clone = goog.abstractMethod; + + +/** + * @return {goog.dom.RangeType} The type of range represented by this object. + */ +goog.dom.AbstractRange.prototype.getType = goog.abstractMethod; + + +/** + * @return {Range|TextRange} The native browser range object. + */ +goog.dom.AbstractRange.prototype.getBrowserRangeObject = goog.abstractMethod; + + +/** + * Sets the native browser range object, overwriting any state this range was + * storing. + * @param {Range|TextRange} nativeRange The native browser range object. + * @return {boolean} Whether the given range was accepted. If not, the caller + * will need to call goog.dom.Range.createFromBrowserRange to create a new + * range object. + */ +goog.dom.AbstractRange.prototype.setBrowserRangeObject = function(nativeRange) { + 'use strict'; + return false; +}; + + +/** + * @return {number} The number of text ranges in this range. + */ +goog.dom.AbstractRange.prototype.getTextRangeCount = goog.abstractMethod; + + +/** + * Get the i-th text range in this range. The behavior is undefined if + * i >= getTextRangeCount or i < 0. + * @param {number} i The range number to retrieve. + * @return {?goog.dom.AbstractRange} The i-th text range. + */ +goog.dom.AbstractRange.prototype.getTextRange = goog.abstractMethod; + + +/** + * Gets an array of all text ranges this range is comprised of. For non-multi + * ranges, returns a single element array containing this. + * @return {!Array} Array of text ranges. + */ +goog.dom.AbstractRange.prototype.getTextRanges = function() { + 'use strict'; + var output = []; + for (var i = 0, len = this.getTextRangeCount(); i < len; i++) { + output.push(this.getTextRange(i)); + } + return output; +}; + + +/** + * @return {Node} The deepest node that contains the entire range. + */ +goog.dom.AbstractRange.prototype.getContainer = goog.abstractMethod; + + +/** + * Returns the deepest element in the tree that contains the entire range. + * @return {Element} The deepest element that contains the entire range. + */ +goog.dom.AbstractRange.prototype.getContainerElement = function() { + 'use strict'; + var node = this.getContainer(); + return /** @type {Element} */ ( + node.nodeType == goog.dom.NodeType.ELEMENT ? node : node.parentNode); +}; + + +/** + * @return {Node} The element or text node the range starts in. For text + * ranges, the range comprises all text between the start and end position. + * For other types of range, start and end give bounds of the range but + * do not imply all nodes in those bounds are selected. + */ +goog.dom.AbstractRange.prototype.getStartNode = goog.abstractMethod; + + +/** + * @return {number} The offset into the node the range starts in. For text + * nodes, this is an offset into the node value. For elements, this is + * an offset into the childNodes array. + */ +goog.dom.AbstractRange.prototype.getStartOffset = goog.abstractMethod; + + +/** + * @return {goog.math.Coordinate} The coordinate of the selection start node + * and offset. + */ +goog.dom.AbstractRange.prototype.getStartPosition = goog.abstractMethod; + + +/** + * @return {Node} The element or text node the range ends in. + */ +goog.dom.AbstractRange.prototype.getEndNode = goog.abstractMethod; + + +/** + * @return {number} The offset into the node the range ends in. For text + * nodes, this is an offset into the node value. For elements, this is + * an offset into the childNodes array. + */ +goog.dom.AbstractRange.prototype.getEndOffset = goog.abstractMethod; + + +/** + * @return {goog.math.Coordinate} The coordinate of the selection end + * node and offset. + */ +goog.dom.AbstractRange.prototype.getEndPosition = goog.abstractMethod; + + +/** + * @return {Node} The element or text node the range is anchored at. + */ +goog.dom.AbstractRange.prototype.getAnchorNode = function() { + 'use strict'; + return this.isReversed() ? this.getEndNode() : this.getStartNode(); +}; + + +/** + * @return {number} The offset into the node the range is anchored at. For + * text nodes, this is an offset into the node value. For elements, this + * is an offset into the childNodes array. + */ +goog.dom.AbstractRange.prototype.getAnchorOffset = function() { + 'use strict'; + return this.isReversed() ? this.getEndOffset() : this.getStartOffset(); +}; + + +/** + * @return {Node} The element or text node the range is focused at - i.e. where + * the cursor is. + */ +goog.dom.AbstractRange.prototype.getFocusNode = function() { + 'use strict'; + return this.isReversed() ? this.getStartNode() : this.getEndNode(); +}; + + +/** + * @return {number} The offset into the node the range is focused at - i.e. + * where the cursor is. For text nodes, this is an offset into the node + * value. For elements, this is an offset into the childNodes array. + */ +goog.dom.AbstractRange.prototype.getFocusOffset = function() { + 'use strict'; + return this.isReversed() ? this.getStartOffset() : this.getEndOffset(); +}; + + +/** + * @return {boolean} Whether the selection is reversed. + */ +goog.dom.AbstractRange.prototype.isReversed = function() { + 'use strict'; + return false; +}; + + +/** + * @return {!Document} The document this selection is a part of. + */ +goog.dom.AbstractRange.prototype.getDocument = function() { + 'use strict'; + // Using start node in IE was crashing the browser in some cases so use + // getContainer for that browser. It's also faster for IE, but still slower + // than start node for other browsers so we continue to use getStartNode when + // it is not problematic. See bug 1687309. + return goog.dom.getOwnerDocument( + goog.userAgent.IE ? this.getContainer() : this.getStartNode()); +}; + + +/** + * @return {!Window} The window this selection is a part of. + */ +goog.dom.AbstractRange.prototype.getWindow = function() { + 'use strict'; + return goog.dom.getWindow(this.getDocument()); +}; + + +/** + * Tests if this range contains the given range. + * @param {goog.dom.AbstractRange} range The range to test. + * @param {boolean=} opt_allowPartial If true, the range can be partially + * contained in the selection, otherwise the range must be entirely + * contained. + * @return {boolean} Whether this range contains the given range. + */ +goog.dom.AbstractRange.prototype.containsRange = goog.abstractMethod; + + +/** + * Tests if this range contains the given node. + * @param {Node} node The node to test for. + * @param {boolean=} opt_allowPartial If not set or false, the node must be + * entirely contained in the selection for this function to return true. + * @return {boolean} Whether this range contains the given node. + */ +goog.dom.AbstractRange.prototype.containsNode = goog.abstractMethod; + + + +/** + * Tests whether this range is valid (i.e. whether its endpoints are still in + * the document). A range becomes invalid when, after this object was created, + * either one or both of its endpoints are removed from the document. Use of + * an invalid range can lead to runtime errors, particularly in IE. + * @return {boolean} Whether the range is valid. + */ +goog.dom.AbstractRange.prototype.isRangeInDocument = goog.abstractMethod; + + +/** + * @return {boolean} Whether the range is collapsed. + */ +goog.dom.AbstractRange.prototype.isCollapsed = goog.abstractMethod; + + +/** + * @return {string} The text content of the range. + */ +goog.dom.AbstractRange.prototype.getText = goog.abstractMethod; + + +/** + * Returns the HTML fragment this range selects. This is slow on all browsers. + * The HTML fragment may not be valid HTML, for instance if the user selects + * from a to b inclusively in the following html: + * + * <div>a</div>b + * + * This method will return + * + * a</div>b + * + * If you need valid HTML, use {@link #getValidHtml} instead. + * + * @return {string} HTML fragment of the range, does not include context + * containing elements. + */ +goog.dom.AbstractRange.prototype.getHtmlFragment = goog.abstractMethod; + + +/** + * Returns valid HTML for this range. This is fast on IE, and semi-fast on + * other browsers. + * @return {string} Valid HTML of the range, including context containing + * elements. + */ +goog.dom.AbstractRange.prototype.getValidHtml = goog.abstractMethod; + + +/** + * Returns pastable HTML for this range. This guarantees that any child items + * that must have specific ancestors will have them, for instance all TDs will + * be contained in a TR in a TBODY in a TABLE and all LIs will be contained in + * a UL or OL as appropriate. This is semi-fast on all browsers. + * @return {string} Pastable HTML of the range, including context containing + * elements. + */ +goog.dom.AbstractRange.prototype.getPastableHtml = goog.abstractMethod; + + +/** + * Returns a RangeIterator over the contents of the range. Regardless of the + * direction of the range, the iterator will move in document order. + * @param {boolean=} opt_keys Unused for this iterator. + * @return {!goog.dom.RangeIterator} An iterator over tags in the range. + */ +goog.dom.AbstractRange.prototype.__iterator__ = goog.abstractMethod; + + +// RANGE ACTIONS + + +/** + * Sets this range as the selection in its window. + */ +goog.dom.AbstractRange.prototype.select = goog.abstractMethod; + + +/** + * Removes the contents of the range from the document. + */ +goog.dom.AbstractRange.prototype.removeContents = goog.abstractMethod; + + +/** + * Inserts a node before (or after) the range. The range may be disrupted + * beyond recovery because of the way this splits nodes. + * @param {Node} node The node to insert. + * @param {boolean} before True to insert before, false to insert after. + * @return {Node} The node added to the document. This may be different + * than the node parameter because on IE we have to clone it. + */ +goog.dom.AbstractRange.prototype.insertNode = goog.abstractMethod; + + +/** + * Replaces the range contents with (possibly a copy of) the given node. The + * range may be disrupted beyond recovery because of the way this splits nodes. + * @param {Node} node The node to insert. + * @return {Node} The node added to the document. This may be different + * than the node parameter because on IE we have to clone it. + */ +goog.dom.AbstractRange.prototype.replaceContentsWithNode = function(node) { + 'use strict'; + if (!this.isCollapsed()) { + this.removeContents(); + } + + return this.insertNode(node, true); +}; + + +/** + * Surrounds this range with the two given nodes. The range may be disrupted + * beyond recovery because of the way this splits nodes. + * @param {Element} startNode The node to insert at the start. + * @param {Element} endNode The node to insert at the end. + */ +goog.dom.AbstractRange.prototype.surroundWithNodes = goog.abstractMethod; + + +// SAVE/RESTORE + + +/** + * Saves the range so that if the start and end nodes are left alone, it can + * be restored. + * @return {!goog.dom.SavedRange} A range representation that can be restored + * as long as the endpoint nodes of the selection are not modified. + */ +goog.dom.AbstractRange.prototype.saveUsingDom = goog.abstractMethod; + + +/** + * Saves the range using HTML carets. As long as the carets remained in the + * HTML, the range can be restored...even when the HTML is copied across + * documents. + * @return {?goog.dom.AbstractSavedCaretRange} A range representation that can + * be restored as long as carets are not removed. Returns null if carets + * could not be created. + * @abstract + */ +goog.dom.AbstractRange.prototype.saveUsingCarets = function() {}; + + +// RANGE MODIFICATION + + +/** + * Collapses the range to one of its boundary points. + * @param {boolean} toAnchor Whether to collapse to the anchor of the range. + */ +goog.dom.AbstractRange.prototype.collapse = goog.abstractMethod; + +// RANGE ITERATION + + + +/** + * Subclass of goog.dom.TagIterator that iterates over a DOM range. It + * adds functions to determine the portion of each text node that is selected. + * @param {Node} node The node to start traversal at. When null, creates an + * empty iterator. + * @param {boolean=} opt_reverse Whether to traverse nodes in reverse. + * @constructor + * @extends {goog.dom.TagIterator} + */ +goog.dom.RangeIterator = function(node, opt_reverse) { + 'use strict'; + goog.dom.TagIterator.call(this, node, opt_reverse, true); +}; +goog.inherits(goog.dom.RangeIterator, goog.dom.TagIterator); + + +/** + * @return {number} The offset into the current node, or -1 if the current node + * is not a text node. + */ +goog.dom.RangeIterator.prototype.getStartTextOffset = goog.abstractMethod; + + +/** + * @return {number} The end offset into the current node, or -1 if the current + * node is not a text node. + */ +goog.dom.RangeIterator.prototype.getEndTextOffset = goog.abstractMethod; + + +/** + * @return {Node} node The iterator's start node. + */ +goog.dom.RangeIterator.prototype.getStartNode = goog.abstractMethod; + + +/** + * @return {Node} The iterator's end node. + */ +goog.dom.RangeIterator.prototype.getEndNode = goog.abstractMethod; + + +/** + * @return {boolean} Whether a call to next will fail. + */ +goog.dom.RangeIterator.prototype.isLast = goog.abstractMethod; diff --git a/closure/goog/dom/abstractrange_test.js b/closure/goog/dom/abstractrange_test.js new file mode 100644 index 0000000000..f0d194d7b3 --- /dev/null +++ b/closure/goog/dom/abstractrange_test.js @@ -0,0 +1,99 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.AbstractRangeTest'); +goog.setTestOnly(); + +const AbstractRange = goog.require('goog.dom.AbstractRange'); +const Const = goog.require('goog.string.Const'); +const Range = goog.require('goog.dom.Range'); +const TagName = goog.require('goog.dom.TagName'); +const TrustedResourceUrl = goog.require('goog.html.TrustedResourceUrl'); +const dom = goog.require('goog.dom'); +const safe = goog.require('goog.dom.safe'); +const testSuite = goog.require('goog.testing.testSuite'); + + +testSuite({ + + testCorrectDocument() { + const aFrame = createTestFrame(); + document.body.appendChild(aFrame); + const bFrame = createTestFrame(); + document.body.appendChild(bFrame); + try { + const a = aFrame.contentWindow; + const b = bFrame.contentWindow; + a.document.body.setAttribute('contenteditable', true); + a.document.body.textContent = 'asdf'; + b.document.body.setAttribute('contenteditable', true); + b.document.body.textContent = 'asdf'; + + a.document.body.focus(); + let selection = AbstractRange.getBrowserSelectionForWindow(a); + assertNotNull('Selection must not be null', selection); + /** @suppress {checkTypes} suppression added to enable type checking */ + let range = Range.createFromBrowserSelection(selection); + assertEquals( + 'getBrowserSelectionForWindow must return selection in the ' + + 'correct document', + a.document, range.getDocument()); + + // This is intended to trip up Internet Explorer -- + // see http://b/2048934 + b.document.body.focus(); + selection = /** @type {?{rangeCount: number}} */ ( + AbstractRange.getBrowserSelectionForWindow(a)); + // Some (non-IE) browsers keep a separate selection state for each + // document in the same browser window. That's fine, as long as the + // selection object requested from the window object is correctly + // associated with that window's document. + if (selection != null && selection.rangeCount != 0) { + range = Range.createFromBrowserSelection(selection); + assertEquals( + 'getBrowserSelectionForWindow must return selection in ' + + 'the correct document', + a.document, range.getDocument()); + } else { + assertTrue(selection == null || selection.rangeCount == 0); + } + } finally { + dom.removeNode(aFrame); + dom.removeNode(bFrame); + } + }, + + testSelectionIsControlRange() { + const frame = createTestFrame(); + document.body.appendChild(frame); + try { + const c = frame.contentWindow; + c.document.body.setAttribute('contenteditable', true); + c.document.body.appendChild(c.document.createElement('img')); + + // Only IE supports control ranges + if (c.document.body.createControlRange) { + const controlRange = c.document.body.createControlRange(); + controlRange.add(dom.getElementsByTagName(TagName.IMG, c.document)[0]); + controlRange.select(); + const selection = AbstractRange.getBrowserSelectionForWindow(c); + assertNotNull('Selection must not be null', selection); + } + } finally { + dom.removeNode(frame); + } + }, +}); + +/** + * @return {!HTMLIFrameElement} + */ +function createTestFrame() { + const frame = dom.createDom(TagName.IFRAME); + safe.setIframeSrc( + frame, TrustedResourceUrl.fromConstant(Const.from('about:blank'))); + return frame; +} \ No newline at end of file diff --git a/closure/goog/dom/animationframe/BUILD b/closure/goog/dom/animationframe/BUILD new file mode 100644 index 0000000000..34fa567eaa --- /dev/null +++ b/closure/goog/dom/animationframe/BUILD @@ -0,0 +1,18 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "animationframe", + srcs = ["animationframe.js"], + lenient = True, + deps = [":polyfill"], +) + +closure_js_library( + name = "polyfill", + srcs = ["polyfill.js"], + lenient = True, +) diff --git a/closure/goog/dom/animationframe/animationframe.js b/closure/goog/dom/animationframe/animationframe.js new file mode 100644 index 0000000000..6e71e069bf --- /dev/null +++ b/closure/goog/dom/animationframe/animationframe.js @@ -0,0 +1,271 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview goog.dom.animationFrame permits work to be done in-sync with + * the render refresh rate of the browser and to divide work up globally based + * on whether the intent is to measure or to mutate the DOM. The latter avoids + * repeated style recalculation which can be really slow. + * + * Goals of the API: + *
    + *
  • Make it easy to schedule work for the next animation frame. + *
  • Make it easy to only do work once per animation frame, even if two + * events fire that trigger the same work. + *
  • Make it easy to do all work in two phases to avoid repeated style + * recalculation caused by interleaved reads and writes. + *
  • Avoid creating closures per schedule operation. + *
+ * + * + * Programmatic: + *
+ * let animationTask = goog.dom.animationFrame.createTask(
+ *     {
+ *       measure: function(state) {
+ *         state.width = goog.style.getSize(elem).width;
+ *         this.animationTask();
+ *       },
+ *       mutate: function(state) {
+ *         goog.style.setWidth(elem, Math.floor(state.width / 2));
+ *       },
+ *     },
+ *     this);
+ * 
+ * + * See also + * https://developer.mozilla.org/en-US/docs/Web/API/window.requestAnimationFrame + */ + +goog.provide('goog.dom.animationFrame'); +goog.provide('goog.dom.animationFrame.Spec'); +goog.provide('goog.dom.animationFrame.State'); + +goog.require('goog.dom.animationFrame.polyfill'); + +// Install the polyfill. +goog.dom.animationFrame.polyfill.install(); + + +/** + * @typedef {{ + * id: number, + * fn: !Function, + * context: (!Object|undefined) + * }} + * @private + */ +goog.dom.animationFrame.Task_; + + +/** + * @typedef {{ + * measureTask: goog.dom.animationFrame.Task_, + * mutateTask: goog.dom.animationFrame.Task_, + * state: (!Object|undefined), + * args: (!Array|undefined), + * isScheduled: boolean + * }} + * @private + */ +goog.dom.animationFrame.TaskSet_; + + +/** + * @typedef {{ + * measure: (!Function|undefined), + * mutate: (!Function|undefined) + * }} + */ +goog.dom.animationFrame.Spec; + + + +/** + * A type to represent state. Users may add properties as desired. + * @constructor + * @final + */ +goog.dom.animationFrame.State = function() {}; + + +/** + * Saves a set of tasks to be executed in the next requestAnimationFrame phase. + * This list is initialized once before any event firing occurs. It is not + * affected by the fired events or the requestAnimationFrame processing (unless + * a new event is created during the processing). + * @private {!Array>} + */ +goog.dom.animationFrame.tasks_ = [[], []]; + + +/** + * Values are 0 or 1, for whether the first or second array should be used to + * lookup or add tasks. + * @private {number} + */ +goog.dom.animationFrame.doubleBufferIndex_ = 0; + + +/** + * Whether we have already requested an animation frame that hasn't happened + * yet. + * @private {boolean} + */ +goog.dom.animationFrame.requestedFrame_ = false; + + +/** + * Counter to generate IDs for tasks. + * @private {number} + */ +goog.dom.animationFrame.taskId_ = 0; + + +/** + * Whether the animationframe runTasks_ loop is currently running. + * @private {boolean} + */ +goog.dom.animationFrame.running_ = false; + + +/** + * Returns a function that schedules the two passed-in functions to be run upon + * the next animation frame. Calling the function again during the same + * animation frame does nothing. + * + * The function under the "measure" key will run first and together with all + * other functions scheduled under this key and the function under "mutate" will + * run after that. + * + * @param {{ + * measure: (function(this:THIS, !goog.dom.animationFrame.State)|undefined), + * mutate: (function(this:THIS, !goog.dom.animationFrame.State)|undefined) + * }} spec + * @param {THIS=} opt_context Context in which to run the function. + * @return {function(...?)} + * @template THIS + */ +goog.dom.animationFrame.createTask = function(spec, opt_context) { + 'use strict'; + const id = goog.dom.animationFrame.taskId_++; + const measureTask = {id: id, fn: spec.measure, context: opt_context}; + const mutateTask = {id: id, fn: spec.mutate, context: opt_context}; + + const taskSet = { + measureTask: measureTask, + mutateTask: mutateTask, + state: {}, + args: undefined, + isScheduled: false + }; + + return function() { + 'use strict'; + // Save args and state. + if (arguments.length > 0) { + // The state argument goes last. That is kinda horrible. + if (!taskSet.args) { + taskSet.args = []; + } + taskSet.args.length = 0; + taskSet.args.push.apply(taskSet.args, arguments); + taskSet.args.push(taskSet.state); + } else { + if (!taskSet.args || taskSet.args.length == 0) { + taskSet.args = [taskSet.state]; + } else { + taskSet.args[0] = taskSet.state; + taskSet.args.length = 1; + } + } + if (!taskSet.isScheduled) { + taskSet.isScheduled = true; + const tasksArray = + goog.dom.animationFrame + .tasks_[goog.dom.animationFrame.doubleBufferIndex_]; + tasksArray.push( + /** @type {goog.dom.animationFrame.TaskSet_} */ (taskSet)); + } + goog.dom.animationFrame.requestAnimationFrame_(); + }; +}; + + +/** + * Run scheduled tasks. + * @private + */ +goog.dom.animationFrame.runTasks_ = function() { + 'use strict'; + goog.dom.animationFrame.running_ = true; + goog.dom.animationFrame.requestedFrame_ = false; + const tasksArray = goog.dom.animationFrame + .tasks_[goog.dom.animationFrame.doubleBufferIndex_]; + const taskLength = tasksArray.length; + + // During the runTasks_, if there is a recursive call to queue up more + // task(s) for the next frame, we use double-buffering for that. + goog.dom.animationFrame.doubleBufferIndex_ = + (goog.dom.animationFrame.doubleBufferIndex_ + 1) % 2; + + let task; + + // Run all the measure tasks first. + for (let i = 0; i < taskLength; ++i) { + task = tasksArray[i]; + const measureTask = task.measureTask; + task.isScheduled = false; + if (measureTask.fn) { + // TODO (perumaal): Handle any exceptions thrown by the lambda. + measureTask.fn.apply(measureTask.context, task.args); + } + } + + // Run the mutate tasks next. + for (let i = 0; i < taskLength; ++i) { + task = tasksArray[i]; + const mutateTask = task.mutateTask; + task.isScheduled = false; + if (mutateTask.fn) { + // TODO (perumaal): Handle any exceptions thrown by the lambda. + mutateTask.fn.apply(mutateTask.context, task.args); + } + + // Clear state for next vsync. + task.state = {}; + } + + // Clear the tasks array as we have finished processing all the tasks. + tasksArray.length = 0; + goog.dom.animationFrame.running_ = false; +}; + + +/** + * @return {boolean} Whether the animationframe is currently running. For use + * by callers who need not to delay tasks scheduled during runTasks_ for an + * additional frame. + */ +goog.dom.animationFrame.isRunning = function() { + 'use strict'; + return goog.dom.animationFrame.running_; +}; + + +/** + * Request {@see goog.dom.animationFrame.runTasks_} to be called upon the + * next animation frame if we haven't done so already. + * @private + */ +goog.dom.animationFrame.requestAnimationFrame_ = function() { + 'use strict'; + if (goog.dom.animationFrame.requestedFrame_) { + return; + } + goog.dom.animationFrame.requestedFrame_ = true; + window.requestAnimationFrame(goog.dom.animationFrame.runTasks_); +}; diff --git a/closure/goog/dom/animationframe/animationframe_test.js b/closure/goog/dom/animationframe/animationframe_test.js new file mode 100644 index 0000000000..82981d1241 --- /dev/null +++ b/closure/goog/dom/animationframe/animationframe_test.js @@ -0,0 +1,249 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @fileoverview Tests for animationFrame. */ + +goog.module('goog.dom.AnimationFrameTest'); +goog.setTestOnly(); + +const MockClock = goog.require('goog.testing.MockClock'); +const animationFrame = goog.require('goog.dom.animationFrame'); +const testSuite = goog.require('goog.testing.testSuite'); + +const NEXT_FRAME = MockClock.REQUEST_ANIMATION_FRAME_TIMEOUT; +let mockClock; +let t0; +let t1; + +let result; + +testSuite({ + setUp() { + mockClock = new MockClock(true); + result = ''; + t0 = animationFrame.createTask({ + measure: function() { + result += 'me0'; + }, + mutate: function() { + result += 'mu0'; + }, + }); + t1 = animationFrame.createTask({ + measure: function() { + result += 'me1'; + }, + mutate: function() { + result += 'mu1'; + }, + }); + assertEquals('', result); + }, + + tearDown() { + mockClock.dispose(); + }, + + testCreateTask_one() { + t0(); + assertEquals('', result); + mockClock.tick(NEXT_FRAME); + assertEquals('me0mu0', result); + mockClock.tick(NEXT_FRAME); + assertEquals('me0mu0', result); + t0(); + t0(); // Should do nothing. + mockClock.tick(NEXT_FRAME); + assertEquals('me0mu0me0mu0', result); + }, + + testCreateTask_onlyMutate() { + t0 = animationFrame.createTask({ + mutate: function() { + result += 'mu0'; + } + }); + t0(); + assertEquals('', result); + mockClock.tick(NEXT_FRAME); + assertEquals('mu0', result); + }, + + testCreateTask_onlyMeasure() { + t0 = animationFrame.createTask({ + mutate: function() { + result += 'me0'; + } + }); + t0(); + assertEquals('', result); + mockClock.tick(NEXT_FRAME); + assertEquals('me0', result); + }, + + testCreateTask_two() { + t0(); + t1(); + assertEquals('', result); + mockClock.tick(NEXT_FRAME); + assertEquals('me0me1mu0mu1', result); + mockClock.tick(NEXT_FRAME); + assertEquals('me0me1mu0mu1', result); + t0(); + t1(); + t0(); + t1(); + mockClock.tick(NEXT_FRAME); + assertEquals('me0me1mu0mu1me0me1mu0mu1', result); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testCreateTask_recurse() { + let stop = false; + const recurse = animationFrame.createTask({ + measure: function() { + if (!stop) { + recurse(); + } + result += 're0'; + }, + mutate: function() { + result += 'ru0'; + }, + }); + recurse(); + mockClock.tick(NEXT_FRAME); + assertEquals('re0ru0', result); + mockClock.tick(NEXT_FRAME); + assertEquals('re0ru0re0ru0', result); + mockClock.tick(NEXT_FRAME); + assertEquals('re0ru0re0ru0re0ru0', result); + t0(); + stop = true; + mockClock.tick(NEXT_FRAME); + assertEquals('re0ru0re0ru0re0ru0re0me0ru0mu0', result); + + // Recursion should have stopped now. + mockClock.tick(NEXT_FRAME); + assertEquals('re0ru0re0ru0re0ru0re0me0ru0mu0', result); + assertFalse(animationFrame.requestedFrame_); + mockClock.tick(NEXT_FRAME); + assertEquals('re0ru0re0ru0re0ru0re0me0ru0mu0', result); + assertFalse(animationFrame.requestedFrame_); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testCreateTask_recurseTwoMethodsWithState() { + let stop = false; + const recurse1 = animationFrame.createTask({ + measure: function(state) { + if (!stop) { + recurse2(); + } + result += 'r1e0'; + state.text = 'T0'; + }, + mutate: function(state) { + result += 'r1u0' + state.text; + }, + }); + const recurse2 = animationFrame.createTask({ + measure: function(state) { + if (!stop) { + recurse1(); + } + result += 'r2e0'; + state.text = 'T1'; + }, + mutate: function(state) { + result += 'r2u0' + state.text; + }, + }); + + /** @suppress {visibility} suppression added to enable type checking */ + const taskLength = animationFrame.tasks_[0].length; + + recurse1(); + mockClock.tick(NEXT_FRAME); + // Only recurse1 executed. + assertEquals('r1e0r1u0T0', result); + + mockClock.tick(NEXT_FRAME); + // Recurse2 executed and queueup recurse1. + assertEquals('r1e0r1u0T0r2e0r2u0T1', result); + + mockClock.tick(NEXT_FRAME); + // Recurse1 executed and queueup recurse2. + assertEquals('r1e0r1u0T0r2e0r2u0T1r1e0r1u0T0', result); + + stop = true; + mockClock.tick(NEXT_FRAME); + // Recurse2 executed and should have stopped. + assertEquals('r1e0r1u0T0r2e0r2u0T1r1e0r1u0T0r2e0r2u0T1', result); + assertFalse(animationFrame.requestedFrame_); + + mockClock.tick(NEXT_FRAME); + assertEquals('r1e0r1u0T0r2e0r2u0T1r1e0r1u0T0r2e0r2u0T1', result); + assertFalse(animationFrame.requestedFrame_); + + mockClock.tick(NEXT_FRAME); + assertEquals('r1e0r1u0T0r2e0r2u0T1r1e0r1u0T0r2e0r2u0T1', result); + assertFalse(animationFrame.requestedFrame_); + }, + + testCreateTask_args() { + const context = {context: true}; + const s = animationFrame.createTask( + { + measure: function(state) { + assertEquals(context, this); + assertUndefined(state.foo); + state.foo = 'foo'; + }, + mutate: function(state) { + assertEquals(context, this); + result += state.foo; + }, + }, + context); + s(); + mockClock.tick(NEXT_FRAME); + assertEquals('foo', result); + + const moreArgs = animationFrame.createTask({ + measure: function(event, state) { + assertEquals('event', event); + state.baz = 'baz'; + }, + mutate: function(event, state) { + assertEquals('event', event); + result += state.baz; + }, + }); + moreArgs('event'); + mockClock.tick(NEXT_FRAME); + assertEquals('foobaz', result); + }, + + testIsRunning() { + let result = ''; + const task = animationFrame.createTask({ + measure: function() { + result += 'me'; + assertTrue(animationFrame.isRunning()); + }, + mutate: function() { + result += 'mu'; + assertTrue(animationFrame.isRunning()); + }, + }); + task(); + assertFalse(animationFrame.isRunning()); + mockClock.tick(NEXT_FRAME); + assertFalse(animationFrame.isRunning()); + assertEquals('memu', result); + }, +}); diff --git a/closure/goog/dom/animationframe/polyfill.js b/closure/goog/dom/animationframe/polyfill.js new file mode 100644 index 0000000000..7da0e39137 --- /dev/null +++ b/closure/goog/dom/animationframe/polyfill.js @@ -0,0 +1,60 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A polyfill for window.requestAnimationFrame and + * window.cancelAnimationFrame. + * Code based on https://gist.github.com/paulirish/1579671 + */ + +goog.provide('goog.dom.animationFrame.polyfill'); + + +/** + * @define {boolean} If true, will install the requestAnimationFrame polyfill. + */ +goog.dom.animationFrame.polyfill.ENABLED = + goog.define('goog.dom.animationFrame.polyfill.ENABLED', true); + + +/** + * Installs the requestAnimationFrame (and cancelAnimationFrame) polyfill. + */ +goog.dom.animationFrame.polyfill.install = function() { + 'use strict'; + if (goog.dom.animationFrame.polyfill.ENABLED) { + const vendors = ['ms', 'moz', 'webkit', 'o']; + let v; + for (let i = 0; v = vendors[i] && !goog.global.requestAnimationFrame; ++i) { + goog.global.requestAnimationFrame = + goog.global[v + 'RequestAnimationFrame']; + goog.global.cancelAnimationFrame = + goog.global[v + 'CancelAnimationFrame'] || + goog.global[v + 'CancelRequestAnimationFrame']; + } + + if (!goog.global.requestAnimationFrame) { + let lastTime = 0; + goog.global.requestAnimationFrame = function(callback) { + 'use strict'; + const currTime = new Date().getTime(); + const timeToCall = Math.max(0, 16 - (currTime - lastTime)); + lastTime = currTime + timeToCall; + return goog.global.setTimeout(function() { + 'use strict'; + callback(currTime + timeToCall); + }, timeToCall); + }; + + if (!goog.global.cancelAnimationFrame) { + goog.global.cancelAnimationFrame = function(id) { + 'use strict'; + clearTimeout(id); + }; + } + } + } +}; diff --git a/closure/goog/dom/annotate.js b/closure/goog/dom/annotate.js new file mode 100644 index 0000000000..2673ead830 --- /dev/null +++ b/closure/goog/dom/annotate.js @@ -0,0 +1,355 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Methods for annotating occurrences of query terms in text or + * in a DOM tree. Adapted from Gmail code. + */ + +goog.provide('goog.dom.annotate'); +goog.provide('goog.dom.annotate.AnnotateFn'); + +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.dom'); +goog.require('goog.dom.NodeType'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.safe'); +goog.require('goog.html.SafeHtml'); +goog.require('goog.object'); + + +/** + * A function that takes: + * (1) the number of the term that is "hit", + * (2) the HTML (search term) to be annotated, + * and returns the annotated term as an HTML. + * @typedef {function(number, !goog.html.SafeHtml): !goog.html.SafeHtml} + */ +goog.dom.annotate.AnnotateFn; + + +/** + * Calls `annotateFn` for each occurrence of a search term in text nodes + * under `node`. Returns the number of hits. + * + * @param {Node} node A DOM node. + * @param {Array>} terms + * An array of [searchTerm, matchWholeWordOnly] tuples. + * The matchWholeWordOnly value is a per-term attribute because some terms + * may be CJK, while others are not. (For correctness, matchWholeWordOnly + * should always be false for CJK terms.). + * @param {goog.dom.annotate.AnnotateFn} annotateFn + * @param {*=} opt_ignoreCase Whether to ignore the case of the query + * terms when looking for matches. + * @param {Array=} opt_classesToSkip Nodes with one of these CSS class + * names (and its descendants) will be skipped. + * @param {number=} opt_maxMs Number of milliseconds after which this function, + * if still annotating, should stop and return. + * + * @return {boolean} Whether any terms were annotated. + */ +goog.dom.annotate.annotateTerms = function( + node, terms, annotateFn, opt_ignoreCase, opt_classesToSkip, opt_maxMs) { + 'use strict'; + if (opt_ignoreCase) { + terms = goog.dom.annotate.lowercaseTerms_(terms); + } + var stopTime = +opt_maxMs > 0 ? Date.now() + opt_maxMs : 0; + + return goog.dom.annotate.annotateTermsInNode_( + node, terms, annotateFn, opt_ignoreCase, opt_classesToSkip || [], + stopTime, 0); +}; + + +/** + * The maximum recursion depth allowed. Any DOM nodes deeper than this are + * ignored. + * @type {number} + * @private + */ +goog.dom.annotate.MAX_RECURSION_ = 200; + + +/** + * The node types whose descendants should not be affected by annotation. + * @private {!Object} + */ +goog.dom.annotate.NODES_TO_SKIP_ = goog.object.createSet( + goog.dom.TagName.SCRIPT, goog.dom.TagName.STYLE, goog.dom.TagName.TEXTAREA); + + +/** + * Recursive helper function. + * + * @param {Node} node A DOM node. + * @param {Array>} terms + * An array of [searchTerm, matchWholeWordOnly] tuples. + * The matchWholeWordOnly value is a per-term attribute because some terms + * may be CJK, while others are not. (For correctness, matchWholeWordOnly + * should always be false for CJK terms.). + * @param {goog.dom.annotate.AnnotateFn} annotateFn + * @param {*} ignoreCase Whether to ignore the case of the query terms + * when looking for matches. + * @param {Array} classesToSkip Nodes with one of these CSS class + * names will be skipped (as will their descendants). + * @param {number} stopTime Deadline for annotation operation (ignored if 0). + * @param {number} recursionLevel How deep this recursive call is; pass the + * value 0 in the initial call. + * @return {boolean} Whether any terms were annotated. + * @private + */ +goog.dom.annotate.annotateTermsInNode_ = function( + node, terms, annotateFn, ignoreCase, classesToSkip, stopTime, + recursionLevel) { + 'use strict'; + if ((stopTime > 0 && Date.now() >= stopTime) || + recursionLevel > goog.dom.annotate.MAX_RECURSION_) { + return false; + } + + var annotated = false; + + if (node.nodeType == goog.dom.NodeType.TEXT) { + var html = goog.dom.annotate.helpAnnotateText_( + node.nodeValue, terms, annotateFn, ignoreCase); + if (html != null) { + // Replace the text with the annotated html. First we put the html into + // a temporary node, to get its DOM structure. To avoid adding a wrapper + // element as a side effect, we'll only actually use the temporary node's + // children. + var tempNode = + goog.dom.getDomHelper(node).createElement(goog.dom.TagName.SPAN); + goog.dom.safe.setInnerHtml(tempNode, html); + + var parentNode = node.parentNode; + var nodeToInsert; + while ((nodeToInsert = tempNode.firstChild) != null) { + // Each parentNode.insertBefore call removes the inserted node from + // tempNode's list of children. + parentNode.insertBefore(/** @type {!Node} */ (nodeToInsert), node); + } + + parentNode.removeChild(node); + annotated = true; + } + } else if ( + node.hasChildNodes() && + !goog.dom.annotate + .NODES_TO_SKIP_[/** @type {!Element} */ (node).tagName]) { + var classes = /** @type {!Element} */ (node).className.split(/\s+/); + var skip = goog.array.some(classes, function(className) { + 'use strict'; + return goog.array.contains(classesToSkip, className); + }); + + if (!skip) { + ++recursionLevel; + var curNode = node.firstChild; + while (curNode) { + var nextNode = curNode.nextSibling; + var curNodeAnnotated = goog.dom.annotate.annotateTermsInNode_( + curNode, terms, annotateFn, ignoreCase, classesToSkip, stopTime, + recursionLevel); + annotated = annotated || curNodeAnnotated; + curNode = nextNode; + } + } + } + + return annotated; +}; + + +/** + * Regular expression that matches non-word characters. + * + * Performance note: Testing a one-character string using this regex is as fast + * as the equivalent string test ("a-zA-Z0-9_".indexOf(c) < 0), give or take a + * few percent. (The regex is about 5% faster in IE 6 and about 4% slower in + * Firefox 1.5.) If performance becomes critical, it may be better to convert + * the character to a numerical char code and check whether it falls in the + * word character ranges. A quick test suggests that could be 33% faster. + * + * @type {RegExp} + * @private + */ +goog.dom.annotate.NONWORD_RE_ = /\W/; + + +/** + * Annotates occurrences of query terms in plain text. This process consists of + * identifying all occurrences of all query terms, calling a provided function + * to get the appropriate replacement HTML for each occurrence, and + * HTML-escaping all the text. + * + * @param {string} text The plain text to be searched. + * @param {Array>} terms An array of + * [{string} searchTerm, {boolean} matchWholeWordOnly] tuples. + * The matchWholeWordOnly value is a per-term attribute because some terms + * may be CJK, while others are not. (For correctness, matchWholeWordOnly + * should always be false for CJK terms.). + * @param {goog.dom.annotate.AnnotateFn} annotateFn + * @param {*=} opt_ignoreCase Whether to ignore the case of the query + * terms when looking for matches. + * @return {goog.html.SafeHtml} The HTML equivalent of `text` with terms + * annotated, or null if the text did not contain any of the terms. + */ +goog.dom.annotate.annotateText = function( + text, terms, annotateFn, opt_ignoreCase) { + 'use strict'; + if (opt_ignoreCase) { + terms = goog.dom.annotate.lowercaseTerms_(terms); + } + return goog.dom.annotate.helpAnnotateText_( + text, terms, annotateFn, opt_ignoreCase); +}; + + +/** + * Annotates occurrences of query terms in plain text. This process consists of + * identifying all occurrences of all query terms, calling a provided function + * to get the appropriate replacement HTML for each occurrence, and + * HTML-escaping all the text. + * + * @param {string} text The plain text to be searched. + * @param {Array>} terms An array of + * [{string} searchTerm, {boolean} matchWholeWordOnly] tuples. + * If `ignoreCase` is true, each search term must already be lowercase. + * The matchWholeWordOnly value is a per-term attribute because some terms + * may be CJK, while others are not. (For correctness, matchWholeWordOnly + * should always be false for CJK terms.). + * @param {goog.dom.annotate.AnnotateFn} annotateFn + * @param {*} ignoreCase Whether to ignore the case of the query terms + * when looking for matches. + * @return {goog.html.SafeHtml} The HTML equivalent of `text` with terms + * annotated, or null if the text did not contain any of the terms. + * @private + */ +goog.dom.annotate.helpAnnotateText_ = function( + text, terms, annotateFn, ignoreCase) { + 'use strict'; + var hit = false; + var textToSearch = ignoreCase ? text.toLowerCase() : text; + var textLen = textToSearch.length; + var numTerms = terms.length; + + // Each element will be an array of hit positions for the term. + var termHits = new Array(numTerms); + + // First collect all the hits into allHits. + for (var i = 0; i < numTerms; i++) { + var term = terms[i]; + var hits = []; + var termText = term[0]; + if (termText != '') { + var matchWholeWordOnly = term[1]; + var termLen = termText.length; + var pos = 0; + // Find each hit for term t and append to termHits. + while (pos < textLen) { + var hitPos = textToSearch.indexOf(termText, pos); + if (hitPos == -1) { + break; + } else { + var prevCharPos = hitPos - 1; + var nextCharPos = hitPos + termLen; + if (!matchWholeWordOnly || + ((prevCharPos < 0 || + goog.dom.annotate.NONWORD_RE_.test( + textToSearch.charAt(prevCharPos))) && + (nextCharPos >= textLen || + goog.dom.annotate.NONWORD_RE_.test( + textToSearch.charAt(nextCharPos))))) { + hits.push(hitPos); + hit = true; + } + pos = hitPos + termLen; + } + } + } + termHits[i] = hits; + } + + if (hit) { + var html = []; + var pos = 0; + + while (true) { + // First determine which of the n terms is the next hit. + var termIndexOfNextHit; + var posOfNextHit = -1; + + for (var i = 0; i < numTerms; i++) { + var hits = termHits[i]; + // pull off the position of the next hit of term t + // (it's always the first in the array because we're shifting + // hits off the front of the array as we process them) + // this is the next candidate to consider for the next overall hit + if (!goog.array.isEmpty(hits)) { + var hitPos = hits[0]; + + // Discard any hits embedded in the previous hit. + while (hitPos >= 0 && hitPos < pos) { + hits.shift(); + hitPos = goog.array.isEmpty(hits) ? -1 : hits[0]; + } + + if (hitPos >= 0 && (posOfNextHit < 0 || hitPos < posOfNextHit)) { + termIndexOfNextHit = i; + posOfNextHit = hitPos; + } + } + } + + // Quit if there are no more hits. + if (posOfNextHit < 0) break; + goog.asserts.assertNumber(termIndexOfNextHit); + + // Remove the next hit from our hit list. + termHits[termIndexOfNextHit].shift(); + + // Append everything from the end of the last hit up to this one. + html.push(text.slice(pos, posOfNextHit)); + + // Append the annotated term. + var termLen = terms[termIndexOfNextHit][0].length; + var termHtml = goog.html.SafeHtml.htmlEscape( + text.slice(posOfNextHit, posOfNextHit + termLen)); + html.push( + annotateFn(goog.asserts.assertNumber(termIndexOfNextHit), termHtml)); + + pos = posOfNextHit + termLen; + } + + // Append everything after the last hit. + html.push(text.slice(pos)); + return goog.html.SafeHtml.concat(html); + } else { + return null; + } +}; + + +/** + * Converts terms to lowercase. + * + * @param {Array>} terms An array of + * [{string} searchTerm, {boolean} matchWholeWordOnly] tuples. + * @return {!Array>} An array of + * [{string} searchTerm, {boolean} matchWholeWordOnly] tuples. + * @private + */ +goog.dom.annotate.lowercaseTerms_ = function(terms) { + 'use strict'; + var lowercaseTerms = []; + for (var i = 0; i < terms.length; ++i) { + var term = terms[i]; + lowercaseTerms[i] = [term[0].toLowerCase(), term[1]]; + } + return lowercaseTerms; +}; diff --git a/closure/goog/dom/annotate_test.js b/closure/goog/dom/annotate_test.js new file mode 100644 index 0000000000..f1f7aa903d --- /dev/null +++ b/closure/goog/dom/annotate_test.js @@ -0,0 +1,196 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.annotateTest'); +goog.setTestOnly(); + +const SafeHtml = goog.require('goog.html.SafeHtml'); +const TagName = goog.require('goog.dom.TagName'); +const annotate = goog.require('goog.dom.annotate'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); + +const $ = dom.getElement; + +const TEXT = 'This little piggy cried "Wee! Wee! Wee!" all the way home.'; + +function doAnnotation(termIndex, termHtml) { + return SafeHtml.create('span', {'class': `c${termIndex}`}, termHtml); +} + +testSuite({ + // goog.dom.annotate.annotateText tests + testAnnotateText() { + let terms = [['pig', true]]; + let html = annotate.annotateText(TEXT, terms, doAnnotation); + assertEquals(null, html); + + terms = [['pig', false]]; + html = annotate.annotateText(TEXT, terms, doAnnotation); + /** @suppress {checkTypes} suppression added to enable type checking */ + html = SafeHtml.unwrap(html); + assertEquals( + 'This little piggy cried ' + + '"Wee! Wee! Wee!" all the way home.', + html); + + terms = [[' piggy ', true]]; + html = annotate.annotateText(TEXT, terms, doAnnotation); + assertEquals(null, html); + + terms = [[' piggy ', false]]; + html = annotate.annotateText(TEXT, terms, doAnnotation); + /** @suppress {checkTypes} suppression added to enable type checking */ + html = SafeHtml.unwrap(html); + assertEquals( + 'This little piggy cried ' + + '"Wee! Wee! Wee!" all the way home.', + html); + + terms = [['goose', true], ['piggy', true]]; + html = annotate.annotateText(TEXT, terms, doAnnotation); + /** @suppress {checkTypes} suppression added to enable type checking */ + html = SafeHtml.unwrap(html); + assertEquals( + 'This little piggy cried ' + + '"Wee! Wee! Wee!" all the way home.', + html); + }, + + testAnnotateTextHtmlEscaping() { + let terms = [['a', false]]; + let html = annotate.annotateText('&a', terms, doAnnotation); + /** @suppress {checkTypes} suppression added to enable type checking */ + html = SafeHtml.unwrap(html); + assertEquals('&a', html); + + terms = [['a', false]]; + html = annotate.annotateText('a&', terms, doAnnotation); + /** @suppress {checkTypes} suppression added to enable type checking */ + html = SafeHtml.unwrap(html); + assertEquals('a&', html); + + terms = [['&', false]]; + html = annotate.annotateText('&', terms, doAnnotation); + /** @suppress {checkTypes} suppression added to enable type checking */ + html = SafeHtml.unwrap(html); + assertEquals('&', html); + }, + + testAnnotateTextIgnoreCase() { + let terms = [['wEe', true]]; + let html = annotate.annotateText(TEXT, terms, doAnnotation, true); + /** @suppress {checkTypes} suppression added to enable type checking */ + html = SafeHtml.unwrap(html); + assertEquals( + 'This little piggy cried "Wee! ' + + 'Wee! Wee!' + + '" all the way home.', + html); + + terms = [['WEE!', true], ['HE', false]]; + html = annotate.annotateText(TEXT, terms, doAnnotation, true); + /** @suppress {checkTypes} suppression added to enable type checking */ + html = SafeHtml.unwrap(html); + assertEquals( + 'This little piggy cried "Wee! ' + + 'Wee! Wee!' + + '" all the way home.', + html); + }, + + testAnnotateTextOverlappingTerms() { + const terms = [['tt', false], ['little', false]]; + let html = annotate.annotateText(TEXT, terms, doAnnotation); + /** @suppress {checkTypes} suppression added to enable type checking */ + html = SafeHtml.unwrap(html); + assertEquals( + 'This little piggy cried "Wee! ' + + 'Wee! Wee!" all the way home.', + html); + }, + + // goog.dom.annotate.annotateTerms tests + testAnnotateTerms() { + let terms = [['pig', true]]; + assertFalse(annotate.annotateTerms($('p'), terms, doAnnotation)); + assertEquals('Tom & Jerry', $('p').innerHTML); + + terms = [['Tom', true]]; + assertTrue(annotate.annotateTerms($('p'), terms, doAnnotation)); + const spans = dom.getElementsByTagNameAndClass(TagName.SPAN, 'c0', $('p')); + assertEquals(1, spans.length); + assertEquals('Tom', spans[0].innerHTML); + assertEquals(' & Jerry', spans[0].nextSibling.nodeValue); + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testAnnotateTermsInTable() { + const terms = [['pig', false]]; + assertTrue(annotate.annotateTerms($('q'), terms, doAnnotation)); + const spans = dom.getElementsByTagNameAndClass(TagName.SPAN, 'c0', $('q')); + assertEquals(2, spans.length); + assertEquals('pig', spans[0].innerHTML); + assertEquals('gy', spans[0].nextSibling.nodeValue); + assertEquals('pig', spans[1].innerHTML); + assertEquals(String(TagName.I), spans[1].parentNode.tagName); + }, + + testAnnotateTermsWithClassExclusions() { + const terms = [['pig', false]]; + const classesToIgnore = ['s']; + assertTrue(annotate.annotateTerms( + $('r'), terms, doAnnotation, false, classesToIgnore)); + const spans = dom.getElementsByTagNameAndClass(TagName.SPAN, 'c0', $('r')); + assertEquals(1, spans.length); + assertEquals('pig', spans[0].innerHTML); + assertEquals('gy', spans[0].nextSibling.nodeValue); + }, + + testAnnotateTermsIgnoreCase() { + const terms1 = [['pig', false]]; + assertTrue(annotate.annotateTerms($('t'), terms1, doAnnotation, true)); + let spans = dom.getElementsByTagNameAndClass(TagName.SPAN, 'c0', $('t')); + assertEquals(2, spans.length); + assertEquals('pig', spans[0].innerHTML); + assertEquals('gy', spans[0].nextSibling.nodeValue); + assertEquals('Pig', spans[1].innerHTML); + + const terms2 = [['Pig', false]]; + assertTrue(annotate.annotateTerms($('u'), terms2, doAnnotation, true)); + spans = dom.getElementsByTagNameAndClass(TagName.SPAN, 'c0', $('u')); + assertEquals(2, spans.length); + assertEquals('pig', spans[0].innerHTML); + assertEquals('gy', spans[0].nextSibling.nodeValue); + assertEquals('Pig', spans[1].innerHTML); + }, + + testAnnotateTermsInObject() { + const terms = [['object', true]]; + assertTrue(annotate.annotateTerms($('o'), terms, doAnnotation)); + const spans = dom.getElementsByTagNameAndClass(TagName.SPAN, 'c0', $('o')); + assertEquals(1, spans.length); + assertEquals('object', spans[0].innerHTML); + }, + + testAnnotateTermsInScript() { + const terms = [['variable', true]]; + assertFalse(annotate.annotateTerms($('script'), terms, doAnnotation)); + }, + + testAnnotateTermsInStyle() { + const terms = [['color', true]]; + assertFalse(annotate.annotateTerms($('style'), terms, doAnnotation)); + }, + + testAnnotateTermsInHtmlComment() { + const terms = [['note', true]]; + assertFalse(annotate.annotateTerms($('comment'), terms, doAnnotation)); + }, +}); diff --git a/closure/goog/dom/annotate_test_dom.html b/closure/goog/dom/annotate_test_dom.html new file mode 100644 index 0000000000..6403c4857e --- /dev/null +++ b/closure/goog/dom/annotate_test_dom.html @@ -0,0 +1,45 @@ + + + +Tom & Jerry + + + + + + + + + + + + + + + + + +
This little piggyThat little piggy
This little piggyThat little piggy
This little piggyThat little Piggy
This little piggyThat little Piggy
+ +
+ + + + + + + Your browser cannot display this object. + +
+ + + + diff --git a/closure/goog/dom/asserts.js b/closure/goog/dom/asserts.js new file mode 100644 index 0000000000..45c42e5476 --- /dev/null +++ b/closure/goog/dom/asserts.js @@ -0,0 +1,117 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('goog.dom.asserts'); + +goog.require('goog.asserts'); + +/** + * @fileoverview Custom assertions to ensure that an element has the appropriate + * type. + * + * Using a goog.dom.safe wrapper on an object on the incorrect type (via an + * incorrect static type cast) can result in security bugs: For instance, + * g.d.s.setAnchorHref ensures that the URL assigned to the .href attribute + * satisfies the SafeUrl contract, i.e., is safe to dereference as a hyperlink. + * However, the value assigned to a HTMLLinkElement's .href property requires + * the stronger TrustedResourceUrl contract, since it can refer to a stylesheet. + * Thus, using g.d.s.setAnchorHref on an (incorrectly statically typed) object + * of type HTMLLinkElement can result in a security vulnerability. + * Assertions of the correct run-time type help prevent such incorrect use. + * + * In some cases, code using the DOM API is tested using mock objects (e.g., a + * plain object such as {'href': url} instead of an actual Location object). + * To allow such mocking, the assertions permit objects of types that are not + * relevant DOM API objects at all (for instance, not Element or Location). + * + * Note that instanceof checks don't work straightforwardly in older versions of + * IE, or across frames (see, + * http://stackoverflow.com/questions/384286/javascript-isdom-how-do-you-check-if-a-javascript-object-is-a-dom-object, + * http://stackoverflow.com/questions/26248599/instanceof-htmlelement-in-iframe-is-not-element-or-object). + * + * Hence, these assertions may pass vacuously in such scenarios. The resulting + * risk of security bugs is limited by the following factors: + * - A bug can only arise in scenarios involving incorrect static typing (the + * wrapper methods are statically typed to demand objects of the appropriate, + * precise type). + * - Typically, code is tested and exercised in multiple browsers. + */ + +/** + * Asserts that a given object is a Location. + * + * To permit this assertion to pass in the context of tests where DOM APIs might + * be mocked, also accepts any other type except for subtypes of {!Element}. + * This is to ensure that, for instance, HTMLLinkElement is not being used in + * place of a Location, since this could result in security bugs due to stronger + * contracts required for assignments to the href property of the latter. + * + * @param {?Object} o The object whose type to assert. + * @return {!Location} + */ +goog.dom.asserts.assertIsLocation = function(o) { + 'use strict'; + if (goog.asserts.ENABLE_ASSERTS) { + var win = goog.dom.asserts.getWindow_(o); + if (win) { + if (!o || (!(o instanceof win.Location) && o instanceof win.Element)) { + goog.asserts.fail( + 'Argument is not a Location (or a non-Element mock); got: %s', + goog.dom.asserts.debugStringForType_(o)); + } + } + } + return /** @type {!Location} */ (o); +}; + + +/** + * Returns a string representation of a value's type. + * + * @param {*} value An object, or primitive. + * @return {string} The best display name for the value. + * @private + */ +goog.dom.asserts.debugStringForType_ = function(value) { + 'use strict'; + if (goog.isObject(value)) { + try { + return /** @type {string|undefined} */ (value.constructor.displayName) || + value.constructor.name || Object.prototype.toString.call(value); + } catch (e) { + return ''; + } + } else { + return value === undefined ? 'undefined' : + value === null ? 'null' : typeof value; + } +}; + +/** + * Gets window of element. + * @param {?Object} o + * @return {?Window} + * @private + * @suppress {strictMissingProperties} ownerDocument not defined on Object + */ +goog.dom.asserts.getWindow_ = function(o) { + 'use strict'; + try { + var doc = o && o.ownerDocument; + // This can throw “Blocked a frame with origin "chrome-extension://..." from + // accessing a cross-origin frame” in Chrome extension. + var win = + doc && /** @type {?Window} */ (doc.defaultView || doc.parentWindow); + win = win || /** @type {!Window} */ (goog.global); + // This can throw “Permission denied to access property "Element" on + // cross-origin object”. + if (win.Element && win.Location) { + return win; + } + } catch (ex) { + } + return null; +}; diff --git a/closure/goog/dom/asserts_test.js b/closure/goog/dom/asserts_test.js new file mode 100644 index 0000000000..dff5c0f615 --- /dev/null +++ b/closure/goog/dom/asserts_test.js @@ -0,0 +1,49 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.assertsTest'); +goog.setTestOnly(); + +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const StrictMock = goog.require('goog.testing.StrictMock'); +const asserts = goog.require('goog.dom.asserts'); +const testSuite = goog.require('goog.testing.testSuite'); + +let stubs; + +testSuite({ + setUpPage() { + stubs = new PropertyReplacer(); + }, + + tearDown() { + stubs.reset(); + }, + + testAssertIsLocation() { + assertNotThrows(() => { + asserts.assertIsLocation(window.location); + }); + + // Ad-hoc mock objects are allowed. + const o = {foo: 'bar'}; + assertNotThrows(() => { + asserts.assertIsLocation(o); + }); + + // So are fancy mocks. + const mock = new StrictMock(window.location); + assertNotThrows(() => { + asserts.assertIsLocation(mock); + }); + + const linkElement = document.createElement('LINK'); + const ex = assertThrows(() => { + asserts.assertIsLocation(linkElement); + }); + assertContains('Argument is not a Location', ex.message); + }, +}); diff --git a/closure/goog/dom/attr.js b/closure/goog/dom/attr.js new file mode 100644 index 0000000000..f4783d3151 --- /dev/null +++ b/closure/goog/dom/attr.js @@ -0,0 +1,198 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + + +goog.provide('goog.dom.Attr'); + + +/** + * Enum of all html attribute names specified by the HTML specifications. + * @enum {string} + */ +goog.dom.Attr = { + ACCEPT: 'accept', + ACCEPT_CHARSET: 'accept-charset', + ACCESSKEY: 'accesskey', + ACTION: 'action', + ALIGN: 'align', + ALT: 'alt', + ARIA_ACTIVEDESCENDANT: 'aria-activedescendant', + ARIA_ATOMIC: 'aria-atomic', + ARIA_AUTOCOMPLETE: 'aria-autocomplete', + ARIA_BUSY: 'aria-busy', + ARIA_CHECKED: 'aria-checked', + ARIA_COLCOUNT: 'aria-colcount', + ARIA_COLINDEX: 'aria-colindex', + ARIA_COLSPAN: 'aria-colspan', + ARIA_CONTROLS: 'aria-controls', + ARIA_CURRENT: 'aria-current', + ARIA_DESCRIBEDBY: 'aria-describedby', + ARIA_DETAILS: 'aria-details', + ARIA_DISABLED: 'aria-disabled', + ARIA_DROPEFFECT: 'aria-dropeffect', + ARIA_ERRORMESSAGE: 'aria-errormessage', + ARIA_EXPANDED: 'aria-expanded', + ARIA_FLOWTO: 'aria-flowto', + ARIA_GRABBED: 'aria-grabbed', + ARIA_HASPOPUP: 'aria-haspopup', + ARIA_HIDDEN: 'aria-hidden', + ARIA_INVALID: 'aria-invalid', + ARIA_KEYSHORTCUTS: 'aria-keyshortcuts', + ARIA_LABEL: 'aria-label', + ARIA_LABELLEDBY: 'aria-labelledby', + ARIA_LEVEL: 'aria-level', + ARIA_LIVE: 'aria-live', + ARIA_MODAL: 'aria-modal', + ARIA_MULTILINE: 'aria-multiline', + ARIA_MULTISELECTABLE: 'aria-multiselectable', + ARIA_ORIENTATION: 'aria-orientation', + ARIA_OWNS: 'aria-owns', + ARIA_PLACEHOLDER: 'aria-placeholder', + ARIA_POSINSET: 'aria-posinset', + ARIA_PRESSED: 'aria-pressed', + ARIA_READONLY: 'aria-readonly', + ARIA_RELEVANT: 'aria-relevant', + ARIA_REQUIRED: 'aria-required', + ARIA_ROLEDESCRIPTION: 'aria-roledescription', + ARIA_ROWCOUNT: 'aria-rowcount', + ARIA_ROWINDEX: 'aria-rowindex', + ARIA_ROWSPAN: 'aria-rowspan', + ARIA_SELECTED: 'aria-selected', + ARIA_SETSIZE: 'aria-setsize', + ARIA_SORT: 'aria-sort', + ARIA_VALUEMAX: 'aria-valuemax', + ARIA_VALUEMIN: 'aria-valuemin', + ARIA_VALUENOW: 'aria-valuenow', + ARIA_VALUETEXT: 'aria-valuetext', + ASYNC: 'async', + AUTOCOMPLETE: 'autocomplete', + AUTOFOCUS: 'autofocus', + AUTOPLAY: 'autoplay', + AUTOSAVE: 'autosave', + BGCOLOR: 'bgcolor', + BORDER: 'border', + BUFFERED: 'buffered', + CHALLENGE: 'challenge', + CELLPADDING: 'cellpadding', + CELLSPACING: 'cellspacing', + CHARSET: 'charset', + CHECKED: 'checked', + CITE: 'cite', + CLASS: 'class', + CODE: 'code', + CODEBASE: 'codebase', + COLOR: 'color', + COLS: 'cols', + COLSPAN: 'colspan', + CONTENT: 'content', + CONTENTEDITABLE: 'contenteditable', + CONTEXTMENU: 'contextmenu', + CONTROLS: 'controls', + COORDS: 'coords', + DATA: 'data', + DATETIME: 'datetime', + DEFAULT: 'default', + DEFER: 'defer', + DIR: 'dir', + DIRNAME: 'dirname', + DISABLED: 'disabled', + DOWNLOAD: 'download', + DRAGGABLE: 'draggable', + DROPZONE: 'dropzone', + ENCTYPE: 'enctype', + FOR: 'for', + FORM: 'form', + FORMACTION: 'formaction', + HEADERS: 'headers', + HEIGHT: 'height', + HIDDEN: 'hidden', + HIGH: 'high', + HREF: 'href', + HREFLANG: 'hreflang', + HTTP_EQUIV: 'http-equiv', + ICON: 'icon', + ID: 'id', + ISMAP: 'ismap', + ITEMPROP: 'itemprop', + KEYTYPE: 'keytype', + KIND: 'kind', + LABEL: 'label', + LANG: 'lang', + LANGUAGE: 'language', + LIST: 'list', + LOOP: 'loop', + LOW: 'low', + MANIFEST: 'manifest', + MAX: 'max', + MAXLENGTH: 'maxlength', + MEDIA: 'media', + METHOD: 'method', + MIN: 'min', + MULTIPLE: 'multiple', + MUTED: 'muted', + NAME: 'name', + NOVALIDATE: 'novalidate', + ONBLUR: 'onblur', + ONCHANGE: 'onchange', + ONCLICK: 'onclick', + ONDBLCLICK: 'ondblclick', + ONFOCUS: 'onfocus', + ONKEYDOWN: 'onkeydown', + ONKEYPRESS: 'onkeypress', + ONKEYUP: 'onkeyup', + ONLOAD: 'onload', + ONMOUSEDOWN: 'onmousedown', + ONMOUSEMOVE: 'onmousemove', + ONMOUSEOUT: 'onmouseout', + ONMOUSEOVER: 'onmouseover', + ONMOUSEUP: 'onmouseup', + ONRESET: 'onreset', + ONSELECT: 'onselect', + ONSUBMIT: 'onsubmit', + ONUNLOAD: 'onunload', + OPEN: 'open', + OPTIMUM: 'optimum', + PATTERN: 'pattern', + PING: 'ping', + PLACEHOLDER: 'placeholder', + POSTER: 'poster', + PRELOAD: 'preload', + RADIOGROUP: 'radiogroup', + READONLY: 'readonly', + REL: 'rel', + REQUIRED: 'required', + REVERSED: 'reversed', + ROLE: 'role', + ROWS: 'rows', + ROWSPAN: 'rowspan', + SANDBOX: 'sandbox', + SCOPE: 'scope', + SCOPED: 'scoped', + SEAMLESS: 'seamless', + SELECTED: 'selected', + SHAPE: 'shape', + SIZE: 'size', + SIZES: 'sizes', + SPAN: 'span', + SPELLCHECK: 'spellcheck', + SRC: 'src', + SRCDOC: 'srcdoc', + SRCLANG: 'srclang', + SRCSET: 'srcset', + START: 'start', + STEP: 'step', + STYLE: 'style', + SUMMARY: 'summary', + TABINDEX: 'tabindex', + TARGET: 'target', + TITLE: 'title', + TRANSLATE: 'translate', + TYPE: 'type', + USEMAP: 'usemap', + VALUE: 'value', + WIDTH: 'width', + WRAP: 'wrap' +}; diff --git a/closure/goog/dom/browserfeature.js b/closure/goog/dom/browserfeature.js new file mode 100644 index 0000000000..5580a9dd9a --- /dev/null +++ b/closure/goog/dom/browserfeature.js @@ -0,0 +1,94 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Browser capability checks for the dom package. + */ + + +goog.provide('goog.dom.BrowserFeature'); + +goog.require('goog.userAgent'); + + +/** + * @define {boolean} Whether we know at compile time that the browser doesn't + * support OffscreenCanvas. + */ +goog.dom.BrowserFeature.ASSUME_NO_OFFSCREEN_CANVAS = + goog.define('goog.dom.ASSUME_NO_OFFSCREEN_CANVAS', false); + +/** + * @define {boolean} Whether we know at compile time that the browser supports + * all OffscreenCanvas contexts. + */ +// TODO(user): Eventually this should default to "FEATURESET_YEAR >= 202X". +goog.dom.BrowserFeature.ASSUME_OFFSCREEN_CANVAS = + goog.define('goog.dom.ASSUME_OFFSCREEN_CANVAS', false); + +/** + * Detects if a particular OffscreenCanvas context is supported. + * @param {string} contextName name of the context to test. + * @return {boolean} Whether the browser supports this OffscreenCanvas context. + * @private + */ +goog.dom.BrowserFeature.detectOffscreenCanvas_ = function(contextName) { + 'use strict'; + // This code only gets removed because we forced @nosideeffects on + // the functions. See: b/138802376 + try { + return Boolean(new self.OffscreenCanvas(0, 0).getContext(contextName)); + } catch (ex) { + } + return false; +}; + +/** + * Whether the browser supports OffscreenCanvas 2D context. + * @const {boolean} + */ +goog.dom.BrowserFeature.OFFSCREEN_CANVAS_2D = + !goog.dom.BrowserFeature.ASSUME_NO_OFFSCREEN_CANVAS && + (goog.dom.BrowserFeature.ASSUME_OFFSCREEN_CANVAS || + goog.dom.BrowserFeature.detectOffscreenCanvas_('2d')); + +/** + * Whether attributes 'name' and 'type' can be added to an element after it's + * created. False in Internet Explorer prior to version 9. + * @const {boolean} + */ +goog.dom.BrowserFeature.CAN_ADD_NAME_OR_TYPE_ATTRIBUTES = true; + +/** + * Whether we can use element.children to access an element's Element + * children. Available since Gecko 1.9.1, IE 9. (IE<9 also includes comment + * nodes in the collection.) + * @const {boolean} + */ +goog.dom.BrowserFeature.CAN_USE_CHILDREN_ATTRIBUTE = true; + +/** + * Opera, Safari 3, and Internet Explorer 9 all support innerText but they + * include text nodes in script and style tags. Not document-mode-dependent. + * @const {boolean} + */ +goog.dom.BrowserFeature.CAN_USE_INNER_TEXT = false; + +/** + * MSIE, Opera, and Safari>=4 support element.parentElement to access an + * element's parent if it is an Element. + * @const {boolean} + */ +goog.dom.BrowserFeature.CAN_USE_PARENT_ELEMENT_PROPERTY = + goog.userAgent.IE || goog.userAgent.WEBKIT; + +/** + * Whether NoScope elements need a scoped element written before them in + * innerHTML. + * MSDN: http://msdn.microsoft.com/en-us/library/ms533897(VS.85).aspx#1 + * @const {boolean} + */ +goog.dom.BrowserFeature.INNER_HTML_NEEDS_SCOPED_ELEMENT = goog.userAgent.IE; diff --git a/closure/goog/dom/browserfeature_test.js b/closure/goog/dom/browserfeature_test.js new file mode 100644 index 0000000000..1ea6052e5f --- /dev/null +++ b/closure/goog/dom/browserfeature_test.js @@ -0,0 +1,42 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.BrowserFeatureTest'); +goog.setTestOnly(); + +const BrowserFeature = goog.require('goog.dom.BrowserFeature'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); + +let context2d = null; +let contextwebgl = null; + +testSuite({ + setUp() { + try { + const canvas = new window.OffscreenCanvas(0, 0); + context2d = canvas.getContext('2d'); + } catch (ex) { + } + }, + + testOffscreenCanvasSupport() { + assertEquals(Boolean(context2d), BrowserFeature.OFFSCREEN_CANVAS_2D); + }, + + testOffscreenCanvas2DUsage() { + if (!BrowserFeature.OFFSCREEN_CANVAS_2D) { + return; + } + + assertNotNullNorUndefined(window.OffscreenCanvas); + const canvas = new window.OffscreenCanvas(1, 1); + assertNotNullNorUndefined(canvas); + + const ctx = canvas.getContext('2d'); + assertNotNullNorUndefined(ctx); + }, +}); diff --git a/closure/goog/dom/browserrange/BUILD b/closure/goog/dom/browserrange/BUILD new file mode 100644 index 0000000000..df5b8d3bf3 --- /dev/null +++ b/closure/goog/dom/browserrange/BUILD @@ -0,0 +1,76 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "abstractrange", + srcs = ["abstractrange.js"], + lenient = True, + deps = [ + "//closure/goog/array", + "//closure/goog/asserts", + "//closure/goog/dom", + "//closure/goog/dom:abstractrange", + "//closure/goog/dom:nodetype", + "//closure/goog/dom:rangeendpoint", + "//closure/goog/dom:tagname", + "//closure/goog/dom:textrangeiterator", + "//closure/goog/iter", + "//closure/goog/math:coordinate", + "//closure/goog/string", + "//closure/goog/string:stringbuffer", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "browserrange", + srcs = ["browserrange.js"], + lenient = True, + deps = [ + ":abstractrange", + ":geckorange", + ":w3crange", + ":webkitrange", + "//closure/goog/dom", + "//closure/goog/dom:browserfeature", + "//closure/goog/dom:nodetype", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "geckorange", + srcs = ["geckorange.js"], + lenient = True, + deps = [":w3crange"], +) + +closure_js_library( + name = "w3crange", + srcs = ["w3crange.js"], + lenient = True, + deps = [ + ":abstractrange", + "//closure/goog/array", + "//closure/goog/dom", + "//closure/goog/dom:nodetype", + "//closure/goog/dom:rangeendpoint", + "//closure/goog/dom:tagname", + "//closure/goog/string", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "webkitrange", + srcs = ["webkitrange.js"], + lenient = True, + deps = [ + ":w3crange", + "//closure/goog/dom:rangeendpoint", + "//closure/goog/useragent", + ], +) diff --git a/closure/goog/dom/browserrange/abstractrange.js b/closure/goog/dom/browserrange/abstractrange.js new file mode 100644 index 0000000000..751e63fe6a --- /dev/null +++ b/closure/goog/dom/browserrange/abstractrange.js @@ -0,0 +1,361 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Definition of the browser range interface. + * + * DO NOT USE THIS FILE DIRECTLY. Use goog.dom.Range instead. + */ + + +goog.provide('goog.dom.browserrange.AbstractRange'); + +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.dom'); +goog.require('goog.dom.NodeType'); +goog.require('goog.dom.RangeEndpoint'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.TextRangeIterator'); +goog.require('goog.iter'); +goog.require('goog.math.Coordinate'); +goog.require('goog.string'); +goog.require('goog.string.StringBuffer'); +goog.require('goog.userAgent'); +goog.requireType('goog.dom.RangeIterator'); + + + +/** + * The constructor for abstract ranges. Don't call this from subclasses. + * @constructor + */ +goog.dom.browserrange.AbstractRange = function() {}; + + +/** + * @return {goog.dom.browserrange.AbstractRange} A clone of this range. + */ +goog.dom.browserrange.AbstractRange.prototype.clone = goog.abstractMethod; + + +/** + * Returns the browser native implementation of the range. Please refrain from + * using this function - if you find you need the range please add wrappers for + * the functionality you need rather than just using the native range. + * @return {Range|TextRange} The browser native range object. + */ +goog.dom.browserrange.AbstractRange.prototype.getBrowserRange = + goog.abstractMethod; + + +/** + * Returns the deepest node in the tree that contains the entire range. + * @return {Node} The deepest node that contains the entire range. + */ +goog.dom.browserrange.AbstractRange.prototype.getContainer = + goog.abstractMethod; + + +/** + * Returns the node the range starts in. + * @return {Node} The element or text node the range starts in. + */ +goog.dom.browserrange.AbstractRange.prototype.getStartNode = + goog.abstractMethod; + + +/** + * Returns the offset into the node the range starts in. + * @return {number} The offset into the node the range starts in. For text + * nodes, this is an offset into the node value. For elements, this is + * an offset into the childNodes array. + */ +goog.dom.browserrange.AbstractRange.prototype.getStartOffset = + goog.abstractMethod; + + +/** + * @return {goog.math.Coordinate} The coordinate of the selection start node + * and offset. + */ +goog.dom.browserrange.AbstractRange.prototype.getStartPosition = function() { + 'use strict'; + return this.getPosition_(true); +}; + + +/** + * Returns the node the range ends in. + * @return {Node} The element or text node the range ends in. + */ +goog.dom.browserrange.AbstractRange.prototype.getEndNode = goog.abstractMethod; + + +/** + * Returns the offset into the node the range ends in. + * @return {number} The offset into the node the range ends in. For text + * nodes, this is an offset into the node value. For elements, this is + * an offset into the childNodes array. + */ +goog.dom.browserrange.AbstractRange.prototype.getEndOffset = + goog.abstractMethod; + + +/** + * @return {goog.math.Coordinate} The coordinate of the selection end node + * and offset. + */ +goog.dom.browserrange.AbstractRange.prototype.getEndPosition = function() { + 'use strict'; + return this.getPosition_(false); +}; + + +/** + * @param {boolean} start Whether to get the position of the start or end. + * @return {goog.math.Coordinate} The coordinate of the selection point. + * @private + * @suppress {missingProperties} circular definitions + */ +goog.dom.browserrange.AbstractRange.prototype.getPosition_ = function(start) { + 'use strict'; + goog.asserts.assert( + this.range_.getClientRects, + 'Getting selection coordinates is not supported.'); + + var rects = this.range_.getClientRects(); + if (rects.length) { + var r = start ? rects[0] : goog.array.peek(rects); + return new goog.math.Coordinate( + start ? r.left : r.right, start ? r.top : r.bottom); + } + return null; +}; + + +/** + * Compares one endpoint of this range with the endpoint of another browser + * native range object. + * @param {Range|TextRange} range The browser native range to compare against. + * @param {goog.dom.RangeEndpoint} thisEndpoint The endpoint of this range + * to compare with. + * @param {goog.dom.RangeEndpoint} otherEndpoint The endpoint of the other + * range to compare with. + * @return {number} 0 if the endpoints are equal, negative if this range + * endpoint comes before the other range endpoint, and positive otherwise. + */ +goog.dom.browserrange.AbstractRange.prototype.compareBrowserRangeEndpoints = + goog.abstractMethod; + + +/** + * Tests if this range contains the given range. + * @param {goog.dom.browserrange.AbstractRange} abstractRange The range to test. + * @param {boolean=} opt_allowPartial If not set or false, the range must be + * entirely contained in the selection for this function to return true. + * @return {boolean} Whether this range contains the given range. + */ +goog.dom.browserrange.AbstractRange.prototype.containsRange = function( + abstractRange, opt_allowPartial) { + 'use strict'; + // IE sometimes misreports the boundaries for collapsed ranges. So if the + // other range is collapsed, make sure the whole range is contained. This is + // logically equivalent, and works around IE's bug. + var checkPartial = opt_allowPartial && !abstractRange.isCollapsed(); + + var range = abstractRange.getBrowserRange(); + var start = goog.dom.RangeEndpoint.START, end = goog.dom.RangeEndpoint.END; + + try { + if (checkPartial) { + // There are two ways to not overlap. Being before, and being after. + // Before is represented by this.end before range.start: comparison < 0. + // After is represented by this.start after range.end: comparison > 0. + // The below is the negation of not overlapping. + return this.compareBrowserRangeEndpoints(range, end, start) >= 0 && + this.compareBrowserRangeEndpoints(range, start, end) <= 0; + + } else { + // Return true if this range bounds the parameter range from both sides. + return this.compareBrowserRangeEndpoints(range, end, end) >= 0 && + this.compareBrowserRangeEndpoints(range, start, start) <= 0; + } + } catch (e) { + if (!goog.userAgent.IE) { + throw e; + } + // IE sometimes throws exceptions when one range is invalid, i.e. points + // to a node that has been removed from the document. Return false in this + // case. + return false; + } +}; + + +/** + * Tests if this range contains the given node. + * @param {Node} node The node to test. + * @param {boolean=} opt_allowPartial If not set or false, the node must be + * entirely contained in the selection for this function to return true. + * @return {boolean} Whether this range contains the given node. + * @suppress {missingRequire,missingProperties} Cannot depend on + * goog.dom.browserrange because it creates a circular dependency. + */ +goog.dom.browserrange.AbstractRange.prototype.containsNode = function( + node, opt_allowPartial) { + 'use strict'; + /** @suppress {missingRequire} Circular dep with browserrange */ + return this.containsRange( + goog.dom.browserrange.createRangeFromNodeContents(node), + opt_allowPartial); +}; + + +/** + * Tests if the selection is collapsed - i.e. is just a caret. + * @return {boolean} Whether the range is collapsed. + */ +goog.dom.browserrange.AbstractRange.prototype.isCollapsed = goog.abstractMethod; + + +/** + * @return {string} The text content of the range. + */ +goog.dom.browserrange.AbstractRange.prototype.getText = goog.abstractMethod; + + +/** + * Returns the HTML fragment this range selects. This is slow on all browsers. + * @return {string} HTML fragment of the range, does not include context + * containing elements. + * @suppress {missingProperties} + */ +goog.dom.browserrange.AbstractRange.prototype.getHtmlFragment = function() { + 'use strict'; + var output = new goog.string.StringBuffer(); + goog.iter.forEach(this, function(node, ignore, it) { + 'use strict'; + if (node.nodeType == goog.dom.NodeType.TEXT) { + output.append( + goog.string.htmlEscape( + node.nodeValue.substring( + it.getStartTextOffset(), it.getEndTextOffset()))); + } else if (node.nodeType == goog.dom.NodeType.ELEMENT) { + if (it.isEndTag()) { + if (goog.dom.canHaveChildren(node)) { + output.append(''); + } + } else { + var shallow = node.cloneNode(false); + var html = goog.dom.getOuterHtml(shallow); + if (goog.userAgent.IE && node.tagName == goog.dom.TagName.LI) { + // For an LI, IE just returns "
  • " with no closing tag + output.append(html); + } else { + var index = html.lastIndexOf('<'); + // if index is -1, then this appends nothing. + // if index is 0, then the entire HTML content should be added. + // if the index is > 0, then only the portion of the html before the + // last open tag is appended. + if (index !== -1) { + output.append(index > 0 ? html.slice(0, index) : html); + } + } + } + } + }, this); + + return output.toString(); +}; + + +/** + * Returns valid HTML for this range. This is fast on IE, and semi-fast on + * other browsers. + * @return {string} Valid HTML of the range, including context containing + * elements. + */ +goog.dom.browserrange.AbstractRange.prototype.getValidHtml = + goog.abstractMethod; + + +/** + * Returns a RangeIterator over the contents of the range. Regardless of the + * direction of the range, the iterator will move in document order. + * @param {boolean=} opt_keys Unused for this iterator. + * @return {!goog.dom.RangeIterator} An iterator over tags in the range. + */ +goog.dom.browserrange.AbstractRange.prototype.__iterator__ = function( + opt_keys) { + 'use strict'; + return new goog.dom.TextRangeIterator( + this.getStartNode(), this.getStartOffset(), this.getEndNode(), + this.getEndOffset()); +}; + + +// SELECTION MODIFICATION + + +/** + * Set this range as the selection in its window. + * @param {boolean=} opt_reverse Whether to select the range in reverse, + * if possible. + */ +goog.dom.browserrange.AbstractRange.prototype.select = goog.abstractMethod; + + +/** + * Removes the contents of the range from the document. As a side effect, the + * selection will be collapsed. The behavior of content removal is normalized + * across browsers. For instance, IE sometimes creates extra text nodes that + * a W3C browser does not. That behavior is corrected for. + */ +goog.dom.browserrange.AbstractRange.prototype.removeContents = + goog.abstractMethod; + + +/** + * Surrounds the text range with the specified element (on Mozilla) or with a + * clone of the specified element (on IE). Returns a reference to the + * surrounding element if the operation was successful; returns null if the + * operation failed. + * @param {Element} element The element with which the selection is to be + * surrounded. + * @return {Element} The surrounding element (same as the argument on Mozilla, + * but not on IE), or null if unsuccessful. + */ +goog.dom.browserrange.AbstractRange.prototype.surroundContents = + goog.abstractMethod; + + +/** + * Inserts a node before (or after) the range. The range may be disrupted + * beyond recovery because of the way this splits nodes. + * @param {Node} node The node to insert. + * @param {boolean} before True to insert before, false to insert after. + * @return {Node} The node added to the document. This may be different + * than the node parameter because on IE we have to clone it. + */ +goog.dom.browserrange.AbstractRange.prototype.insertNode = goog.abstractMethod; + + +/** + * Surrounds this range with the two given nodes. The range may be disrupted + * beyond recovery because of the way this splits nodes. + * @param {Element} startNode The node to insert at the start. + * @param {Element} endNode The node to insert at the end. + */ +goog.dom.browserrange.AbstractRange.prototype.surroundWithNodes = + goog.abstractMethod; + + +/** + * Collapses the range to one of its boundary points. + * @param {boolean} toStart Whether to collapse to the start of the range. + */ +goog.dom.browserrange.AbstractRange.prototype.collapse = goog.abstractMethod; diff --git a/closure/goog/dom/browserrange/browserrange.js b/closure/goog/dom/browserrange/browserrange.js new file mode 100644 index 0000000000..375488b832 --- /dev/null +++ b/closure/goog/dom/browserrange/browserrange.js @@ -0,0 +1,122 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Definition of the browser range namespace and interface, as + * well as several useful utility functions. + * + * DO NOT USE THIS FILE DIRECTLY. Use goog.dom.Range instead. + */ + + +goog.provide('goog.dom.browserrange'); +goog.provide('goog.dom.browserrange.Error'); + +goog.require('goog.dom'); +goog.require('goog.dom.NodeType'); +goog.require('goog.dom.browserrange.GeckoRange'); +goog.require('goog.dom.browserrange.W3cRange'); +goog.require('goog.dom.browserrange.WebKitRange'); +goog.require('goog.userAgent'); +goog.requireType('goog.dom.browserrange.AbstractRange'); + + +/** + * Common error constants. + * @enum {string} + */ +goog.dom.browserrange.Error = { + NOT_IMPLEMENTED: 'Not Implemented' +}; + + +// NOTE(robbyw): While it would be nice to eliminate the duplicate switches +// below, doing so uncovers bugs in the JsCompiler in which +// necessary code is stripped out. + + +/** + * Static method that returns the proper type of browser range. + * @param {Range|TextRange} range A browser range object. + * @return {!goog.dom.browserrange.AbstractRange} A wrapper object. + */ +goog.dom.browserrange.createRange = function(range) { + 'use strict'; + if (goog.userAgent.WEBKIT) { + return new goog.dom.browserrange.WebKitRange( + /** @type {Range} */ (range)); + } else if (goog.userAgent.GECKO) { + return new goog.dom.browserrange.GeckoRange( + /** @type {Range} */ (range)); + } else { + // Default other browsers, including Opera, to W3c ranges. + return new goog.dom.browserrange.W3cRange( + /** @type {Range} */ (range)); + } +}; + + +/** + * Static method that returns the proper type of browser range. + * @param {Node} node The node to select. + * @return {!goog.dom.browserrange.AbstractRange} A wrapper object. + */ +goog.dom.browserrange.createRangeFromNodeContents = function(node) { + 'use strict'; + if (goog.userAgent.WEBKIT) { + return goog.dom.browserrange.WebKitRange.createFromNodeContents(node); + } else if (goog.userAgent.GECKO) { + return goog.dom.browserrange.GeckoRange.createFromNodeContents(node); + } else { + // Default other browsers to W3c ranges. + return goog.dom.browserrange.W3cRange.createFromNodeContents(node); + } +}; + + +/** + * Static method that returns the proper type of browser range. + * @param {Node} startNode The node to start with. + * @param {number} startOffset The offset within the node to start. This is + * either the index into the childNodes array for element startNodes or + * the index into the character array for text startNodes. + * @param {Node} endNode The node to end with. + * @param {number} endOffset The offset within the node to end. This is + * either the index into the childNodes array for element endNodes or + * the index into the character array for text endNodes. + * @return {!goog.dom.browserrange.AbstractRange} A wrapper object. + */ +goog.dom.browserrange.createRangeFromNodes = function( + startNode, startOffset, endNode, endOffset) { + 'use strict'; + if (goog.userAgent.WEBKIT) { + return goog.dom.browserrange.WebKitRange.createFromNodes( + startNode, startOffset, endNode, endOffset); + } else if (goog.userAgent.GECKO) { + return goog.dom.browserrange.GeckoRange.createFromNodes( + startNode, startOffset, endNode, endOffset); + } else { + // Default other browsers to W3c ranges. + return goog.dom.browserrange.W3cRange.createFromNodes( + startNode, startOffset, endNode, endOffset); + } +}; + + +/** + * Tests whether the given node can contain a range end point. + * @param {Node} node The node to check. + * @return {boolean} Whether the given node can contain a range end point. + */ +goog.dom.browserrange.canContainRangeEndpoint = function(node) { + 'use strict'; + // NOTE(user): This is not complete, as divs with style - + // 'display:inline-block' or 'position:absolute' can also not contain range + // endpoints. A more complete check is to see if that element can be partially + // selected (can be container) or not. + return goog.dom.canHaveChildren(node) || + node.nodeType == goog.dom.NodeType.TEXT; +}; diff --git a/closure/goog/dom/browserrange/browserrange_test.js b/closure/goog/dom/browserrange/browserrange_test.js new file mode 100644 index 0000000000..c42fb5e350 --- /dev/null +++ b/closure/goog/dom/browserrange/browserrange_test.js @@ -0,0 +1,621 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.browserrangeTest'); +goog.setTestOnly(); + +const NodeType = goog.require('goog.dom.NodeType'); +const Range = goog.require('goog.dom.Range'); +const RangeEndpoint = goog.require('goog.dom.RangeEndpoint'); +const TagName = goog.require('goog.dom.TagName'); +const browserrange = goog.require('goog.dom.browserrange'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); +const testing = goog.require('goog.html.testing'); +const testingDom = goog.require('goog.testing.dom'); + +let test1; +let test2; +let cetest; +let empty; +let dynamic; +let onlybrdiv; + +/** + * @param {string} str + * @return {string} + */ +function normalizeHtml(str) { + return str.toLowerCase().replace(/[\n\r\f"]/g, ''); +} + +// TODO(robbyw): We really need tests for (and code fixes for) +// createRangeFromNodes in the following cases: +// * BR boundary (before + after) + +testSuite({ + setUpPage() { + test1 = dom.getElement('test1'); + test2 = dom.getElement('test2'); + cetest = dom.getElement('cetest'); + empty = dom.getElement('empty'); + dynamic = dom.getElement('dynamic'); + onlybrdiv = dom.getElement('onlybr'); + }, + + testCreate() { + assertNotNull( + 'Browser range object can be created for node', + browserrange.createRangeFromNodeContents(test1)); + }, + + testRangeEndPoints() { + const container = cetest.firstChild; + const range = browserrange.createRangeFromNodes(container, 2, container, 2); + range.select(); + + const selRange = Range.createFromWindow(); + const startNode = selRange.getStartNode(); + const endNode = selRange.getEndNode(); + const startOffset = selRange.getStartOffset(); + const endOffset = selRange.getEndOffset(); + if (startNode.nodeType == NodeType.TEXT) { + // Special case for Safari. + assertEquals( + 'Start node should have text: abc', 'abc', startNode.nodeValue); + assertEquals('End node should have text: abc', 'abc', endNode.nodeValue); + assertEquals('Start offset should be 3', 3, startOffset); + assertEquals('End offset should be 3', 3, endOffset); + } else { + assertEquals('Start node should be the first div', container, startNode); + assertEquals('End node should be the first div', container, endNode); + assertEquals('Start offset should be 2', 2, startOffset); + assertEquals('End offset should be 2', 2, endOffset); + } + }, + + testCreateFromNodeContents() { + const range = Range.createFromNodeContents(onlybrdiv); + testingDom.assertRangeEquals(onlybrdiv, 0, onlybrdiv, 1, range); + }, + + testCreateFromNodes() { + const start = test1.firstChild; + const range = + browserrange.createRangeFromNodes(start, 2, test2.firstChild, 2); + assertNotNull( + 'Browser range object can be created for W3C node range', range); + + assertEquals( + 'Start node should be selected at start endpoint', start, + range.getStartNode()); + assertEquals( + 'Selection should start at offset 2', 2, range.getStartOffset()); + + assertEquals( + 'Text node should be selected at end endpoint', test2.firstChild, + range.getEndNode()); + assertEquals('Selection should end at offset 2', 2, range.getEndOffset()); + + assertTrue( + 'Text content should be "xt\\s*ab"', /xt\s*ab/.test(range.getText())); + assertFalse('Nodes range is not collapsed', range.isCollapsed()); + assertEquals( + 'Should contain correct html fragment', 'xt
    ab', + normalizeHtml(range.getHtmlFragment())); + assertEquals( + 'Should contain correct valid html', + '
    xt
    ab
    ', + normalizeHtml(range.getValidHtml())); + }, + + testTextNode() { + const range = browserrange.createRangeFromNodeContents(test1.firstChild); + + assertEquals( + 'Text node should be selected at start endpoint', 'Text', + range.getStartNode().nodeValue); + assertEquals( + 'Selection should start at offset 0', 0, range.getStartOffset()); + + assertEquals( + 'Text node should be selected at end endpoint', 'Text', + range.getEndNode().nodeValue); + assertEquals( + 'Selection should end at offset 4', 'Text'.length, + range.getEndOffset()); + + assertEquals( + 'Container should be text node', NodeType.TEXT, + range.getContainer().nodeType); + + assertEquals('Text content should be "Text"', 'Text', range.getText()); + assertFalse('Text range is not collapsed', range.isCollapsed()); + assertEquals( + 'Should contain correct html fragment', 'Text', + range.getHtmlFragment()); + assertEquals( + 'Should contain correct valid html', 'Text', range.getValidHtml()); + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testTextNodes() { + dom.removeChildren(dynamic); + dynamic.appendChild(dom.createTextNode('Part1')); + dynamic.appendChild(dom.createTextNode('Part2')); + const range = browserrange.createRangeFromNodes( + dynamic.firstChild, 0, dynamic.lastChild, 5); + + assertEquals( + 'Text node 1 should be selected at start endpoint', 'Part1', + range.getStartNode().nodeValue); + assertEquals( + 'Selection should start at offset 0', 0, range.getStartOffset()); + + assertEquals( + 'Text node 2 should be selected at end endpoint', 'Part2', + range.getEndNode().nodeValue); + assertEquals( + 'Selection should end at offset 5', 'Part2'.length, + range.getEndOffset()); + + assertEquals( + 'Container should be DIV', String(TagName.DIV), + range.getContainer().tagName); + + assertEquals( + 'Text content should be "Part1Part2"', 'Part1Part2', range.getText()); + assertFalse('Text range is not collapsed', range.isCollapsed()); + assertEquals( + 'Should contain correct html fragment', 'Part1Part2', + range.getHtmlFragment()); + assertEquals( + 'Should contain correct valid html', 'part1part2', + normalizeHtml(range.getValidHtml())); + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testDiv() { + const range = browserrange.createRangeFromNodeContents(test2); + + assertEquals( + 'Text node "abc" should be selected at start endpoint', 'abc', + range.getStartNode().nodeValue); + assertEquals( + 'Selection should start at offset 0', 0, range.getStartOffset()); + + assertEquals( + 'Text node "def" should be selected at end endpoint', 'def', + range.getEndNode().nodeValue); + assertEquals( + 'Selection should end at offset 3', 'def'.length, range.getEndOffset()); + + assertEquals( + 'Container should be DIV', 'DIV', range.getContainer().tagName); + + assertTrue( + 'Div text content should be "abc\\s*def"', + /abc\s*def/.test(range.getText())); + assertEquals( + 'Should contain correct html fragment', 'abc
    def', + normalizeHtml(range.getHtmlFragment())); + assertEquals( + 'Should contain correct valid html', + '
    abc
    def
    ', + normalizeHtml(range.getValidHtml())); + assertFalse('Div range is not collapsed', range.isCollapsed()); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testEmptyNodeHtmlInsert() { + const range = browserrange.createRangeFromNodeContents(empty); + const html = 'hello'; + range.insertNode(dom.safeHtmlToNode(testing.newSafeHtmlForTest(html))); + assertEquals( + 'Html is not inserted correctly', html, normalizeHtml(empty.innerHTML)); + dom.removeChildren(empty); + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testEmptyNode() { + const range = browserrange.createRangeFromNodeContents(empty); + + assertEquals( + 'DIV be selected at start endpoint', 'DIV', + range.getStartNode().tagName); + assertEquals( + 'Selection should start at offset 0', 0, range.getStartOffset()); + + assertEquals( + 'DIV should be selected at end endpoint', 'DIV', + range.getEndNode().tagName); + assertEquals('Selection should end at offset 0', 0, range.getEndOffset()); + + assertEquals( + 'Container should be DIV', 'DIV', range.getContainer().tagName); + + assertEquals('Empty text content should be ""', '', range.getText()); + assertTrue('Empty range is collapsed', range.isCollapsed()); + assertEquals( + 'Should contain correct valid html', '
    ', + normalizeHtml(range.getValidHtml())); + assertEquals( + 'Should contain no html fragment', '', range.getHtmlFragment()); + }, + + testRemoveContents() { + const outer = dom.getElement('removeTest'); + const range = browserrange.createRangeFromNodeContents(outer.firstChild); + + range.removeContents(); + + assertEquals('Removed range content should be ""', '', range.getText()); + assertTrue('Removed range is now collapsed', range.isCollapsed()); + assertEquals('Outer div has 1 child now', 1, outer.childNodes.length); + assertEquals('Inner div is empty', 0, outer.firstChild.childNodes.length); + }, + + testRemoveContentsEmptyNode() { + const outer = dom.getElement('removeTestEmptyNode'); + const range = browserrange.createRangeFromNodeContents(outer); + + range.removeContents(); + + assertEquals('Removed range content should be ""', '', range.getText()); + assertTrue('Removed range is now collapsed', range.isCollapsed()); + assertEquals( + 'Outer div should have 0 children now', 0, outer.childNodes.length); + }, + + testRemoveContentsSingleNode() { + const outer = dom.getElement('removeTestSingleNode'); + const range = browserrange.createRangeFromNodeContents(outer.firstChild); + + range.removeContents(); + + assertEquals('Removed range content should be ""', '', range.getText()); + assertTrue('Removed range is now collapsed', range.isCollapsed()); + assertEquals('', dom.getTextContent(outer)); + }, + + testRemoveContentsMidNode() { + const outer = dom.getElement('removeTestMidNode'); + const textNode = outer.firstChild.firstChild; + const range = browserrange.createRangeFromNodes(textNode, 1, textNode, 4); + + assertEquals( + 'Previous range content should be "123"', '123', range.getText()); + range.removeContents(); + + assertEquals( + 'Removed range content should be "0456789"', '0456789', + dom.getTextContent(outer)); + }, + + testRemoveContentsMidMultipleNodes() { + const outer = dom.getElement('removeTestMidMultipleNodes'); + const firstTextNode = outer.firstChild.firstChild; + const lastTextNode = outer.lastChild.firstChild; + const range = + browserrange.createRangeFromNodes(firstTextNode, 1, lastTextNode, 4); + + assertEquals( + 'Previous range content', '1234567890123', + range.getText().replace(/\s/g, '')); + range.removeContents(); + + assertEquals( + 'Removed range content should be "0456789"', '0456789', + dom.getTextContent(outer).replace(/\s/g, '')); + }, + + testRemoveDivCaretRange() { + const outer = dom.getElement('sandbox'); + outer.innerHTML = '
    Test1
    '; + const range = browserrange.createRangeFromNodes( + outer.lastChild, 0, outer.lastChild, 0); + + range.removeContents(); + range.insertNode(dom.createDom(TagName.SPAN, undefined, 'Hello'), true); + + assertEquals( + 'Resulting contents', 'Test1Hello', + dom.getTextContent(outer).replace(/\s/g, '')); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testCollapse() { + let range = browserrange.createRangeFromNodeContents(test2); + assertFalse('Div range is not collapsed', range.isCollapsed()); + range.collapse(); + assertTrue( + 'Div range is collapsed after call to empty()', range.isCollapsed()); + + range = browserrange.createRangeFromNodeContents(empty); + assertTrue('Empty range is collapsed', range.isCollapsed()); + range.collapse(); + assertTrue('Empty range is still collapsed', range.isCollapsed()); + }, + + testIdWithSpecialCharacters() { + dom.removeChildren(dynamic); + dynamic.appendChild(dom.createTextNode('1')); + dynamic.appendChild(dom.createDom(TagName.DIV, {id: '<>'})); + dynamic.appendChild(dom.createTextNode('2')); + const range = browserrange.createRangeFromNodes( + dynamic.firstChild, 0, dynamic.lastChild, 1); + + // Difference in special character handling is ok. + assertEquals( + 'Should have correct html fragment', '1
    >
    2', + normalizeHtml(range.getHtmlFragment())); + }, + + testEndOfChildren() { + dynamic.innerHTML = + '123
    456
    text'; + const range = browserrange.createRangeFromNodes( + dom.getElement('a'), 3, dom.getElement('b'), 1); + assertEquals('Should have correct text.', 'text', range.getText()); + }, + + testEndOfDiv() { + dynamic.innerHTML = '
    abc
    def
    '; + const a = dom.getElement('a'); + const range = browserrange.createRangeFromNodes(a, 1, a, 1); + const expectedStartNode = a; + const expectedStartOffset = 1; + const expectedEndNode = a; + const expectedEndOffset = 1; + assertEquals('startNode is wrong', expectedStartNode, range.getStartNode()); + assertEquals( + 'startOffset is wrong', expectedStartOffset, range.getStartOffset()); + assertEquals('endNode is wrong', expectedEndNode, range.getEndNode()); + assertEquals('endOffset is wrong', expectedEndOffset, range.getEndOffset()); + }, + + testRangeEndingWithBR() { + dynamic.innerHTML = '123
    456
    '; + const spanElem = dom.getElement('a'); + const range = browserrange.createRangeFromNodes(spanElem, 0, spanElem, 2); + const htmlText = range.getValidHtml().toLowerCase(); + assertContains('Should include BR in HTML.', 'br', htmlText); + assertEquals('Should have correct text.', '123', range.getText()); + + range.select(); + + const selRange = Range.createFromWindow(); + const startNode = selRange.getStartNode(); + if (startNode.nodeType == NodeType.TEXT) { + // Special case for Safari. + assertEquals( + 'Startnode should have text:123', '123', startNode.nodeValue); + } else { + assertEquals('Start node should be span', spanElem, startNode); + } + assertEquals('Startoffset should be 0', 0, selRange.getStartOffset()); + const endNode = selRange.getEndNode(); + assertEquals('Endnode should be span', spanElem, endNode); + assertEquals('Endoffset should be 2', 2, selRange.getEndOffset()); + }, + + testRangeEndingWithBR2() { + dynamic.innerHTML = '123
    '; + const spanElem = dom.getElement('a'); + const range = browserrange.createRangeFromNodes(spanElem, 0, spanElem, 2); + const htmlText = range.getValidHtml().toLowerCase(); + assertContains('Should include BR in HTML.', 'br', htmlText); + assertEquals('Should have correct text.', '123', range.getText()); + + range.select(); + + const selRange = Range.createFromWindow(); + const startNode = selRange.getStartNode(); + const endNode = selRange.getEndNode(); + if (startNode.nodeType == NodeType.TEXT) { + // Special case for Safari. + assertEquals( + 'Start node should have text:123', '123', startNode.nodeValue); + } else { + assertEquals('Start node should be span', spanElem, startNode); + } + assertEquals('Startoffset should be 0', 0, selRange.getStartOffset()); + if (endNode.nodeType == NodeType.TEXT) { + // Special case for Safari. + assertEquals('Endnode should have text', '123', endNode.nodeValue); + assertEquals('Endoffset should be 3', 3, selRange.getEndOffset()); + } else { + assertEquals('Endnode should be span', spanElem, endNode); + assertEquals('Endoffset should be 2', 2, selRange.getEndOffset()); + } + }, + + testRangeEndingBeforeBR() { + dynamic.innerHTML = '123
    456
    '; + const spanElem = dom.getElement('a'); + const range = browserrange.createRangeFromNodes(spanElem, 0, spanElem, 1); + const htmlText = range.getValidHtml().toLowerCase(); + assertNotContains('Should not include BR in HTML.', 'br', htmlText); + assertEquals('Should have correct text.', '123', range.getText()); + range.select(); + + const selRange = Range.createFromWindow(); + const startNode = selRange.getStartNode(); + if (startNode.nodeType == NodeType.TEXT) { + // Special case for Safari. + assertEquals( + 'Startnode should have text:123', '123', startNode.nodeValue); + } else { + assertEquals('Start node should be span', spanElem, startNode); + } + assertEquals('Startoffset should be 0', 0, selRange.getStartOffset()); + const endNode = selRange.getEndNode(); + if (endNode.nodeType == NodeType.TEXT) { + // Special case for Safari. + assertEquals('Endnode should have text:123', '123', endNode.nodeValue); + assertEquals('Endoffset should be 3', 3, selRange.getEndOffset()); + } else { + assertEquals('Endnode should be span', spanElem, endNode); + assertEquals('Endoffset should be 1', 1, selRange.getEndOffset()); + } + }, + + testRangeStartingWithBR() { + dynamic.innerHTML = '123
    456
    '; + const spanElem = dom.getElement('a'); + const range = browserrange.createRangeFromNodes(spanElem, 1, spanElem, 3); + const htmlText = range.getValidHtml().toLowerCase(); + assertContains('Should include BR in HTML.', 'br', htmlText); + // Firefox returns '456' as the range text while IE returns '\r\n456'. + // Therefore skipping the text check. + + range.select(); + const selRange = Range.createFromWindow(); + const startNode = selRange.getStartNode(); + const endNode = selRange.getEndNode(); + if (startNode.nodeType == NodeType.TEXT) { + // Special case for Safari. + assertEquals('Start node should be text:123', '123', startNode.nodeValue); + assertEquals('Startoffset should be 1', 1, selRange.getStartOffset()); + } else { + assertEquals('Start node should be span', spanElem, startNode); + assertEquals('Startoffset should be 1', 1, selRange.getStartOffset()); + } + if (endNode.nodeType == NodeType.TEXT) { + assertEquals('Endnode should have text:456', '456', endNode.nodeValue); + assertEquals('Endoffset should be 3', 3, selRange.getEndOffset()); + } else { + assertEquals('Endnode should be span', spanElem, endNode); + assertEquals('Endoffset should be 3', 3, selRange.getEndOffset()); + } + }, + + testRangeStartingAfterBR() { + dynamic.innerHTML = '123
    4567
    '; + const spanElem = dom.getElement('a'); + const range = browserrange.createRangeFromNodes(spanElem, 2, spanElem, 3); + const htmlText = range.getValidHtml().toLowerCase(); + assertNotContains('Should not include BR in HTML.', 'br', htmlText); + assertEquals('Should have correct text.', '4567', range.getText()); + + range.select(); + + const selRange = Range.createFromWindow(); + const startNode = selRange.getStartNode(); + if (startNode.nodeType == NodeType.TEXT) { + // Special case for Safari. + assertEquals( + 'Startnode should have text:4567', '4567', startNode.nodeValue); + assertEquals('Startoffset should be 0', 0, selRange.getStartOffset()); + } else { + assertEquals('Start node should be span', spanElem, startNode); + assertEquals('Startoffset should be 2', 2, selRange.getStartOffset()); + } + const endNode = selRange.getEndNode(); + if (startNode.nodeType == NodeType.TEXT) { + // Special case for Safari. + assertEquals('Endnode should have text:4567', '4567', endNode.nodeValue); + assertEquals('Endoffset should be 4', 4, selRange.getEndOffset()); + } else { + assertEquals('Endnode should be span', spanElem, endNode); + assertEquals('Endoffset should be 3', 3, selRange.getEndOffset()); + } + }, + + testCollapsedRangeBeforeBR() { + dynamic.innerHTML = '123
    456
    '; + const range = browserrange.createRangeFromNodes( + dom.getElement('a'), 1, dom.getElement('a'), 1); + // Firefox returns as the range HTML while IE returns + // empty string. Therefore skipping the HTML check. + assertEquals('Should have no text.', '', range.getText()); + }, + + testCollapsedRangeAfterBR() { + dynamic.innerHTML = '123
    456
    '; + const range = browserrange.createRangeFromNodes( + dom.getElement('a'), 2, dom.getElement('a'), 2); + // Firefox returns as the range HTML while IE returns + // empty string. Therefore skipping the HTML check. + assertEquals('Should have no text.', '', range.getText()); + }, + + testCompareBrowserRangeEndpoints() { + const outer = dom.getElement('outer'); + const inner = dom.getElement('inner'); + const range_outer = browserrange.createRangeFromNodeContents(outer); + const range_inner = browserrange.createRangeFromNodeContents(inner); + + assertEquals( + 'The start of the inner selection should be after the outer.', 1, + range_inner.compareBrowserRangeEndpoints( + range_outer.getBrowserRange(), RangeEndpoint.START, + RangeEndpoint.START)); + + assertEquals( + 'The start of the inner selection should be before the outer\'s end.', + -1, + range_inner.compareBrowserRangeEndpoints( + range_outer.getBrowserRange(), RangeEndpoint.START, + RangeEndpoint.END)); + + assertEquals( + 'The end of the inner selection should be after the outer\'s start.', 1, + range_inner.compareBrowserRangeEndpoints( + range_outer.getBrowserRange(), RangeEndpoint.END, + RangeEndpoint.START)); + + assertEquals( + 'The end of the inner selection should be before the outer\'s end.', -1, + range_inner.compareBrowserRangeEndpoints( + range_outer.getBrowserRange(), RangeEndpoint.END, + RangeEndpoint.END)); + }, + + testSelectOverwritesOldSelection() { + browserrange.createRangeFromNodes(test1, 0, test1, 1).select(); + browserrange.createRangeFromNodes(test2, 0, test2, 1).select(); + assertEquals( + 'The old selection must be replaced with the new one', 'abc', + Range.createFromWindow().getText()); + }, + + testGetContainerInTextNodesAroundEmptySpan() { + dynamic.innerHTML = 'abcdef'; + const abc = dynamic.firstChild; + const def = dynamic.lastChild; + + let range; + range = browserrange.createRangeFromNodes(abc, 1, abc, 1); + assertEquals( + 'textNode abc should be the range container', abc, + range.getContainer()); + assertEquals( + 'textNode abc should be the range start node', abc, + range.getStartNode()); + assertEquals( + 'textNode abc should be the range end node', abc, range.getEndNode()); + + range = browserrange.createRangeFromNodes(def, 1, def, 1); + assertEquals( + 'textNode def should be the range container', def, + range.getContainer()); + assertEquals( + 'textNode def should be the range start node', def, + range.getStartNode()); + assertEquals( + 'textNode def should be the range end node', def, range.getEndNode()); + }, +}); diff --git a/closure/goog/dom/browserrange/browserrange_test_dom.html b/closure/goog/dom/browserrange/browserrange_test_dom.html new file mode 100644 index 0000000000..af15683a1e --- /dev/null +++ b/closure/goog/dom/browserrange/browserrange_test_dom.html @@ -0,0 +1,18 @@ + +
    +
    Text
    abc
    def
    +
    abc
    +
    +
    Text that
    will be deleted
    +
    +
    Text Text
    +
    0123456789
    +
    0123456789
    0123456789
    +
    outer
    inner
    outer2
    +
    +

    diff --git a/closure/goog/dom/browserrange/geckorange.js b/closure/goog/dom/browserrange/geckorange.js new file mode 100644 index 0000000000..c2553d0001 --- /dev/null +++ b/closure/goog/dom/browserrange/geckorange.js @@ -0,0 +1,82 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Definition of the Gecko specific range wrapper. Inherits most + * functionality from W3CRange, but adds exceptions as necessary. + * + * DO NOT USE THIS FILE DIRECTLY. Use goog.dom.Range instead. + */ + + +goog.provide('goog.dom.browserrange.GeckoRange'); + +goog.require('goog.dom.browserrange.W3cRange'); + + + +/** + * The constructor for Gecko specific browser ranges. + * @param {Range} range The range object. + * @constructor + * @extends {goog.dom.browserrange.W3cRange} + * @final + */ +goog.dom.browserrange.GeckoRange = function(range) { + 'use strict'; + goog.dom.browserrange.W3cRange.call(this, range); +}; +goog.inherits(goog.dom.browserrange.GeckoRange, goog.dom.browserrange.W3cRange); + + +/** + * Creates a range object that selects the given node's text. + * @param {Node} node The node to select. + * @return {!goog.dom.browserrange.GeckoRange} A Gecko range wrapper object. + */ +goog.dom.browserrange.GeckoRange.createFromNodeContents = function(node) { + 'use strict'; + return new goog.dom.browserrange.GeckoRange( + goog.dom.browserrange.W3cRange.getBrowserRangeForNode(node)); +}; + + +/** + * Creates a range object that selects between the given nodes. + * @param {Node} startNode The node to start with. + * @param {number} startOffset The offset within the node to start. + * @param {Node} endNode The node to end with. + * @param {number} endOffset The offset within the node to end. + * @return {!goog.dom.browserrange.GeckoRange} A wrapper object. + */ +goog.dom.browserrange.GeckoRange.createFromNodes = function( + startNode, startOffset, endNode, endOffset) { + 'use strict'; + return new goog.dom.browserrange.GeckoRange( + goog.dom.browserrange.W3cRange.getBrowserRangeForNodes( + startNode, startOffset, endNode, endOffset)); +}; + + +/** @override */ +goog.dom.browserrange.GeckoRange.prototype.selectInternal = function( + selection, reversed) { + 'use strict'; + if (!reversed || this.isCollapsed()) { + // The base implementation for select() is more robust, and works fine for + // collapsed and forward ranges. This works around + // https://bugzilla.mozilla.org/show_bug.cgi?id=773137, and is tested by + // range_test.html's testFocusedElementDisappears. + goog.dom.browserrange.GeckoRange.base( + this, 'selectInternal', selection, reversed); + } else { + // Reversed selection -- start with a caret on the end node, and extend it + // back to the start. Unfortunately, collapse() fails when focus is + // invalid. + selection.collapse(this.getEndNode(), this.getEndOffset()); + selection.extend(this.getStartNode(), this.getStartOffset()); + } +}; diff --git a/closure/goog/dom/browserrange/w3crange.js b/closure/goog/dom/browserrange/w3crange.js new file mode 100644 index 0000000000..ba93e2c659 --- /dev/null +++ b/closure/goog/dom/browserrange/w3crange.js @@ -0,0 +1,418 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Definition of the W3C spec following range wrapper. + * + * DO NOT USE THIS FILE DIRECTLY. Use goog.dom.Range instead. + */ + + +goog.provide('goog.dom.browserrange.W3cRange'); + +goog.require('goog.dom'); +goog.require('goog.dom.NodeType'); +goog.require('goog.dom.RangeEndpoint'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.browserrange.AbstractRange'); +goog.require('goog.string'); +goog.require('goog.userAgent'); + + + +/** + * The constructor for W3C specific browser ranges. + * @param {Range} range The range object. + * @constructor + * @extends {goog.dom.browserrange.AbstractRange} + */ +goog.dom.browserrange.W3cRange = function(range) { + 'use strict'; + this.range_ = range; +}; +goog.inherits( + goog.dom.browserrange.W3cRange, goog.dom.browserrange.AbstractRange); + + +/** + * Returns a browser range spanning the given node's contents. + * @param {Node} node The node to select. + * @return {!Range} A browser range spanning the node's contents. + * @protected + * @suppress {missingProperties} circular definitions + */ +goog.dom.browserrange.W3cRange.getBrowserRangeForNode = function(node) { + 'use strict'; + var nodeRange = goog.dom.getOwnerDocument(node).createRange(); + + if (node.nodeType == goog.dom.NodeType.TEXT) { + nodeRange.setStart(node, 0); + nodeRange.setEnd(node, node.length); + } else { + /** @suppress {missingRequire} */ + if (!goog.dom.browserrange.canContainRangeEndpoint(node)) { + var rangeParent = node.parentNode; + var rangeStartOffset = + Array.prototype.indexOf.call(rangeParent.childNodes, node); + nodeRange.setStart(rangeParent, rangeStartOffset); + nodeRange.setEnd(rangeParent, rangeStartOffset + 1); + } else { + var tempNode, leaf = node; + while ((tempNode = leaf.firstChild) && + /** @suppress {missingRequire} */ + goog.dom.browserrange.canContainRangeEndpoint(tempNode)) { + leaf = tempNode; + } + nodeRange.setStart(leaf, 0); + + leaf = node; + /** @suppress {missingRequire} Circular dep with browserrange */ + while ((tempNode = leaf.lastChild) && + goog.dom.browserrange.canContainRangeEndpoint(tempNode)) { + leaf = tempNode; + } + nodeRange.setEnd( + leaf, leaf.nodeType == goog.dom.NodeType.ELEMENT ? + leaf.childNodes.length : + leaf.length); + } + } + + return nodeRange; +}; + + +/** + * Returns a browser range spanning the given nodes. + * @param {Node} startNode The node to start with - should not be a BR. + * @param {number} startOffset The offset within the start node. + * @param {Node} endNode The node to end with - should not be a BR. + * @param {number} endOffset The offset within the end node. + * @return {!Range} A browser range spanning the node's contents. + * @protected + */ +goog.dom.browserrange.W3cRange.getBrowserRangeForNodes = function( + startNode, startOffset, endNode, endOffset) { + 'use strict'; + // Create and return the range. + var nodeRange = goog.dom.getOwnerDocument(startNode).createRange(); + nodeRange.setStart(startNode, startOffset); + nodeRange.setEnd(endNode, endOffset); + return nodeRange; +}; + + +/** + * Creates a range object that selects the given node's text. + * @param {Node} node The node to select. + * @return {!goog.dom.browserrange.W3cRange} A Gecko range wrapper object. + */ +goog.dom.browserrange.W3cRange.createFromNodeContents = function(node) { + 'use strict'; + return new goog.dom.browserrange.W3cRange( + goog.dom.browserrange.W3cRange.getBrowserRangeForNode(node)); +}; + + +/** + * Creates a range object that selects between the given nodes. + * @param {Node} startNode The node to start with. + * @param {number} startOffset The offset within the start node. + * @param {Node} endNode The node to end with. + * @param {number} endOffset The offset within the end node. + * @return {!goog.dom.browserrange.W3cRange} A wrapper object. + */ +goog.dom.browserrange.W3cRange.createFromNodes = function( + startNode, startOffset, endNode, endOffset) { + 'use strict'; + return new goog.dom.browserrange.W3cRange( + goog.dom.browserrange.W3cRange.getBrowserRangeForNodes( + startNode, startOffset, endNode, endOffset)); +}; + + +/** + * @return {!goog.dom.browserrange.W3cRange} A clone of this range. + * @override + */ +goog.dom.browserrange.W3cRange.prototype.clone = function() { + 'use strict'; + return new this.constructor(this.range_.cloneRange()); +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.getBrowserRange = function() { + 'use strict'; + return this.range_; +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.getContainer = function() { + 'use strict'; + return this.range_.commonAncestorContainer; +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.getStartNode = function() { + 'use strict'; + return this.range_.startContainer; +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.getStartOffset = function() { + 'use strict'; + return this.range_.startOffset; +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.getEndNode = function() { + 'use strict'; + return this.range_.endContainer; +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.getEndOffset = function() { + 'use strict'; + return this.range_.endOffset; +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.compareBrowserRangeEndpoints = + function(range, thisEndpoint, otherEndpoint) { + 'use strict'; + return this.range_.compareBoundaryPoints( + otherEndpoint == goog.dom.RangeEndpoint.START ? + (thisEndpoint == goog.dom.RangeEndpoint.START ? + goog.global['Range'].START_TO_START : + goog.global['Range'].START_TO_END) : + (thisEndpoint == goog.dom.RangeEndpoint.START ? + goog.global['Range'].END_TO_START : + goog.global['Range'].END_TO_END), + /** @type {Range} */ (range)); +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.isCollapsed = function() { + 'use strict'; + return this.range_.collapsed; +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.getText = function() { + 'use strict'; + return this.range_.toString(); +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.getValidHtml = function() { + 'use strict'; + var div = goog.dom.getDomHelper(this.range_.startContainer) + .createDom(goog.dom.TagName.DIV); + div.appendChild(/** @type {!Node} */ (this.range_.cloneContents())); + var result = div.innerHTML; + + if (goog.string.startsWith(result, '<') || + !this.isCollapsed() && !goog.string.contains(result, '<')) { + // We attempt to mimic IE, which returns no containing element when a + // only text nodes are selected, does return the containing element when + // the selection is empty, and does return the element when multiple nodes + // are selected. + return result; + } + + var container = this.getContainer(); + container = container.nodeType == goog.dom.NodeType.ELEMENT ? + container : + container.parentNode; + + var html = goog.dom.getOuterHtml( + /** @type {!Element} */ (container.cloneNode(false))); + return html.replace('>', '>' + result); +}; + + +// SELECTION MODIFICATION + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.select = function(reverse) { + 'use strict'; + var win = goog.dom.getWindow(goog.dom.getOwnerDocument(this.getStartNode())); + this.selectInternal(win.getSelection(), reverse); +}; + + +/** + * Select this range. + * @param {Selection} selection Browser selection object. + * @param {*} reverse Whether to select this range in reverse. + * @protected + */ +goog.dom.browserrange.W3cRange.prototype.selectInternal = function( + selection, reverse) { + 'use strict'; + // Browser-specific tricks are needed to create reversed selections + // programatically. For this generic W3C codepath, ignore the reverse + // parameter. + selection.removeAllRanges(); + selection.addRange(this.range_); +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.removeContents = function() { + 'use strict'; + var range = this.range_; + range.extractContents(); + + if (range.startContainer.hasChildNodes()) { + // Remove any now empty nodes surrounding the extracted contents. + var rangeStartContainer = + range.startContainer.childNodes[range.startOffset]; + if (rangeStartContainer) { + var rangePrevious = rangeStartContainer.previousSibling; + + if (goog.dom.getRawTextContent(rangeStartContainer) == '') { + goog.dom.removeNode(rangeStartContainer); + } + + if (rangePrevious && goog.dom.getRawTextContent(rangePrevious) == '') { + goog.dom.removeNode(rangePrevious); + } + } + } + + if (goog.userAgent.EDGE_OR_IE) { + // Unfortunately, when deleting a portion of a single text node, IE creates + // an extra text node instead of modifying the nodeValue of the start node. + // We normalize for that behavior here, similar to code in + // goog.dom.browserrange.IeRange#removeContents + // See https://connect.microsoft.com/IE/feedback/details/746591 + var startNode = this.getStartNode(); + var startOffset = this.getStartOffset(); + var endNode = this.getEndNode(); + var endOffset = this.getEndOffset(); + var sibling = startNode.nextSibling; + if (startNode == endNode && startNode.parentNode && + startNode.nodeType == goog.dom.NodeType.TEXT && sibling && + sibling.nodeType == goog.dom.NodeType.TEXT) { + startNode.nodeValue += sibling.nodeValue; + goog.dom.removeNode(sibling); + + // Modifying the node value clears the range offsets. Reselect the + // position in the modified start node. + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + } + } +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.surroundContents = function(element) { + 'use strict'; + this.range_.surroundContents(element); + return element; +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.insertNode = function(node, before) { + 'use strict'; + var range = this.range_.cloneRange(); + range.collapse(before); + range.insertNode(node); + range.detach(); + + return node; +}; + + +/** + * @override + * @suppress {missingProperties} circular definitions + */ +goog.dom.browserrange.W3cRange.prototype.surroundWithNodes = function( + startNode, endNode) { + 'use strict'; + var win = goog.dom.getWindow(goog.dom.getOwnerDocument(this.getStartNode())); + /** @suppress {missingRequire,missingProperties} */ + var selectionRange = goog.dom.Range.createFromWindow(win); + if (selectionRange) { + var sNode = selectionRange.getStartNode(); + var eNode = selectionRange.getEndNode(); + var sOffset = selectionRange.getStartOffset(); + var eOffset = selectionRange.getEndOffset(); + } + + var clone1 = this.range_.cloneRange(); + var clone2 = this.range_.cloneRange(); + + clone1.collapse(false); + clone2.collapse(true); + + clone1.insertNode(endNode); + clone2.insertNode(startNode); + + clone1.detach(); + clone2.detach(); + + if (selectionRange) { + // There are 4 ways that surroundWithNodes can wreck the saved + // selection object. All of them happen when an inserted node splits + // a text node, and one of the end points of the selection was in the + // latter half of that text node. + // + // Clients of this library should use saveUsingCarets to avoid this + // problem. Unfortunately, saveUsingCarets uses this method, so that's + // not really an option for us. :( We just recompute the offsets. + var isInsertedNode = function(n) { + 'use strict'; + return n == startNode || n == endNode; + }; + if (sNode.nodeType == goog.dom.NodeType.TEXT) { + while (sOffset > sNode.length) { + sOffset -= sNode.length; + do { + sNode = sNode.nextSibling; + } while (isInsertedNode(sNode)); + } + } + + if (eNode.nodeType == goog.dom.NodeType.TEXT) { + while (eOffset > eNode.length) { + eOffset -= eNode.length; + do { + eNode = eNode.nextSibling; + } while (isInsertedNode(eNode)); + } + } + + /** @suppress {missingRequire} */ + goog.dom.Range + .createFromNodes( + sNode, /** @type {number} */ (sOffset), eNode, + /** @type {number} */ (eOffset)) + .select(); + } +}; + + +/** @override */ +goog.dom.browserrange.W3cRange.prototype.collapse = function(toStart) { + 'use strict'; + this.range_.collapse(toStart); +}; diff --git a/closure/goog/dom/browserrange/webkitrange.js b/closure/goog/dom/browserrange/webkitrange.js new file mode 100644 index 0000000000..68509b2dc5 --- /dev/null +++ b/closure/goog/dom/browserrange/webkitrange.js @@ -0,0 +1,88 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Definition of the WebKit specific range wrapper. Inherits most + * functionality from W3CRange, but adds exceptions as necessary. + * + * DO NOT USE THIS FILE DIRECTLY. Use goog.dom.Range instead. + */ + + +goog.provide('goog.dom.browserrange.WebKitRange'); + +goog.require('goog.dom.browserrange.W3cRange'); + + + +/** + * The constructor for WebKit specific browser ranges. + * @param {Range} range The range object. + * @constructor + * @extends {goog.dom.browserrange.W3cRange} + * @final + */ +goog.dom.browserrange.WebKitRange = function(range) { + 'use strict'; + goog.dom.browserrange.W3cRange.call(this, range); +}; +goog.inherits( + goog.dom.browserrange.WebKitRange, goog.dom.browserrange.W3cRange); + + +/** + * Creates a range object that selects the given node's text. + * @param {Node} node The node to select. + * @return {!goog.dom.browserrange.WebKitRange} A WebKit range wrapper object. + */ +goog.dom.browserrange.WebKitRange.createFromNodeContents = function(node) { + 'use strict'; + return new goog.dom.browserrange.WebKitRange( + goog.dom.browserrange.W3cRange.getBrowserRangeForNode(node)); +}; + + +/** + * Creates a range object that selects between the given nodes. + * @param {Node} startNode The node to start with. + * @param {number} startOffset The offset within the start node. + * @param {Node} endNode The node to end with. + * @param {number} endOffset The offset within the end node. + * @return {!goog.dom.browserrange.WebKitRange} A wrapper object. + */ +goog.dom.browserrange.WebKitRange.createFromNodes = function( + startNode, startOffset, endNode, endOffset) { + 'use strict'; + return new goog.dom.browserrange.WebKitRange( + goog.dom.browserrange.W3cRange.getBrowserRangeForNodes( + startNode, startOffset, endNode, endOffset)); +}; + + +/** @override */ +goog.dom.browserrange.WebKitRange.prototype.compareBrowserRangeEndpoints = + function(range, thisEndpoint, otherEndpoint) { + 'use strict'; + return ( + goog.dom.browserrange.WebKitRange.superClass_.compareBrowserRangeEndpoints + .call(this, range, thisEndpoint, otherEndpoint)); +}; + + +/** @override */ +goog.dom.browserrange.WebKitRange.prototype.selectInternal = function( + selection, reversed) { + 'use strict'; + if (reversed) { + selection.setBaseAndExtent( + this.getEndNode(), this.getEndOffset(), this.getStartNode(), + this.getStartOffset()); + } else { + selection.setBaseAndExtent( + this.getStartNode(), this.getStartOffset(), this.getEndNode(), + this.getEndOffset()); + } +}; diff --git a/closure/goog/dom/bufferedviewportsizemonitor.js b/closure/goog/dom/bufferedviewportsizemonitor.js new file mode 100644 index 0000000000..f1649fe13d --- /dev/null +++ b/closure/goog/dom/bufferedviewportsizemonitor.js @@ -0,0 +1,190 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A viewport size monitor that buffers RESIZE events until the + * window size has stopped changing, within a specified period of time. For + * every RESIZE event dispatched, this will dispatch up to two *additional* + * events: + * - {@link #EventType.RESIZE_WIDTH} if the viewport's width has changed since + * the last buffered dispatch. + * - {@link #EventType.RESIZE_HEIGHT} if the viewport's height has changed since + * the last buffered dispatch. + * You likely only need to listen to one of the three events. But if you need + * more, just be cautious of duplicating effort. + */ + +goog.provide('goog.dom.BufferedViewportSizeMonitor'); + +goog.require('goog.asserts'); +goog.require('goog.async.Delay'); +goog.require('goog.events'); +goog.require('goog.events.EventTarget'); +goog.require('goog.events.EventType'); +goog.requireType('goog.dom'); +goog.requireType('goog.dom.ViewportSizeMonitor'); +goog.requireType('goog.math.Size'); + + + +/** + * Creates a new BufferedViewportSizeMonitor. + * @param {!goog.dom.ViewportSizeMonitor} viewportSizeMonitor The + * underlying viewport size monitor. + * @param {number=} opt_bufferMs The buffer time, in ms. If not specified, this + * value defaults to {@link #RESIZE_EVENT_DELAY_MS_}. + * @constructor + * @extends {goog.events.EventTarget} + * @final + */ +goog.dom.BufferedViewportSizeMonitor = function( + viewportSizeMonitor, opt_bufferMs) { + 'use strict'; + goog.dom.BufferedViewportSizeMonitor.base(this, 'constructor'); + + /** + * Delay for the resize event. + * @private {goog.async.Delay} + */ + this.resizeDelay_; + + /** + * The underlying viewport size monitor. + * @type {goog.dom.ViewportSizeMonitor} + * @private + */ + this.viewportSizeMonitor_ = viewportSizeMonitor; + + /** + * The current size of the viewport. + * @type {goog.math.Size} + * @private + */ + this.currentSize_ = this.viewportSizeMonitor_.getSize(); + + /** + * The resize buffer time in ms. + * @type {number} + * @private + */ + this.resizeBufferMs_ = opt_bufferMs || + goog.dom.BufferedViewportSizeMonitor.RESIZE_EVENT_DELAY_MS_; + + /** + * Listener key for the viewport size monitor. + * @type {goog.events.Key} + * @private + */ + this.listenerKey_ = goog.events.listen( + viewportSizeMonitor, goog.events.EventType.RESIZE, this.handleResize_, + false, this); +}; +goog.inherits(goog.dom.BufferedViewportSizeMonitor, goog.events.EventTarget); + + +/** + * Additional events to dispatch. + * @enum {string} + */ +goog.dom.BufferedViewportSizeMonitor.EventType = { + RESIZE_HEIGHT: goog.events.getUniqueId('resizeheight'), + RESIZE_WIDTH: goog.events.getUniqueId('resizewidth') +}; + + +/** + * Default number of milliseconds to wait after a resize event to relayout the + * page. + * @type {number} + * @const + * @private + */ +goog.dom.BufferedViewportSizeMonitor.RESIZE_EVENT_DELAY_MS_ = 100; + + +/** @override */ +goog.dom.BufferedViewportSizeMonitor.prototype.disposeInternal = function() { + 'use strict'; + goog.events.unlistenByKey(this.listenerKey_); + goog.dom.BufferedViewportSizeMonitor.base(this, 'disposeInternal'); +}; + + +/** + * Handles resize events on the underlying ViewportMonitor. + * @private + */ +goog.dom.BufferedViewportSizeMonitor.prototype.handleResize_ = function() { + 'use strict'; + // Lazily create when needed. + if (!this.resizeDelay_) { + this.resizeDelay_ = + new goog.async.Delay(this.onWindowResize_, this.resizeBufferMs_, this); + this.registerDisposable(this.resizeDelay_); + } + this.resizeDelay_.start(); +}; + + +/** + * Window resize callback that determines whether to reflow the view contents. + * @private + */ +goog.dom.BufferedViewportSizeMonitor.prototype.onWindowResize_ = function() { + 'use strict'; + if (this.viewportSizeMonitor_.isDisposed()) { + return; + } + + var previousSize = this.currentSize_; + var currentSize = this.viewportSizeMonitor_.getSize(); + + goog.asserts.assert(currentSize, 'Viewport size should be set at this point'); + + this.currentSize_ = currentSize; + + if (previousSize) { + var resized = false; + + // Width has changed + if (previousSize.width != currentSize.width) { + this.dispatchEvent( + goog.dom.BufferedViewportSizeMonitor.EventType.RESIZE_WIDTH); + resized = true; + } + + // Height has changed + if (previousSize.height != currentSize.height) { + this.dispatchEvent( + goog.dom.BufferedViewportSizeMonitor.EventType.RESIZE_HEIGHT); + resized = true; + } + + // If either has changed, this is a resize event. + if (resized) { + this.dispatchEvent(goog.events.EventType.RESIZE); + } + + } else { + // If we didn't have a previous size, we consider all events to have + // changed. + this.dispatchEvent( + goog.dom.BufferedViewportSizeMonitor.EventType.RESIZE_HEIGHT); + this.dispatchEvent( + goog.dom.BufferedViewportSizeMonitor.EventType.RESIZE_WIDTH); + this.dispatchEvent(goog.events.EventType.RESIZE); + } +}; + + +/** + * Returns the current size of the viewport. + * @return {goog.math.Size?} The current viewport size. + */ +goog.dom.BufferedViewportSizeMonitor.prototype.getSize = function() { + 'use strict'; + return this.currentSize_ ? this.currentSize_.clone() : null; +}; diff --git a/closure/goog/dom/bufferedviewportsizemonitor_test.js b/closure/goog/dom/bufferedviewportsizemonitor_test.js new file mode 100644 index 0000000000..c5d9766bb7 --- /dev/null +++ b/closure/goog/dom/bufferedviewportsizemonitor_test.js @@ -0,0 +1,115 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Tests for BufferedViewportSizeMonitor. + */ + +/** @suppress {extraProvide} */ +goog.module('goog.dom.BufferedViewportSizeMonitorTest'); +goog.setTestOnly(); + +const BufferedViewportSizeMonitor = goog.require('goog.dom.BufferedViewportSizeMonitor'); +const EventType = goog.require('goog.events.EventType'); +const GoogTestingEvent = goog.require('goog.testing.events.Event'); +const MockClock = goog.require('goog.testing.MockClock'); +const Size = goog.require('goog.math.Size'); +const ViewportSizeMonitor = goog.require('goog.dom.ViewportSizeMonitor'); +const events = goog.require('goog.events'); +const testSuite = goog.require('goog.testing.testSuite'); +const testingEvents = goog.require('goog.testing.events'); + +/** @suppress {visibility} suppression added to enable type checking */ +const RESIZE_DELAY = BufferedViewportSizeMonitor.RESIZE_EVENT_DELAY_MS_; +const INITIAL_SIZE = new Size(111, 111); + +let mockControl; +let viewportSizeMonitor; +let bufferedVsm; +const timer = new MockClock(); +let resizeEventCount = 0; +let size; + +const resizeCallback = () => { + resizeEventCount++; +}; + +function resize(width, height) { + size = new Size(width, height); + testingEvents.fireBrowserEvent( + new GoogTestingEvent(EventType.RESIZE, viewportSizeMonitor)); +} +testSuite({ + setUp() { + timer.install(); + + size = INITIAL_SIZE; + viewportSizeMonitor = new ViewportSizeMonitor(); + viewportSizeMonitor.getSize = () => size; + bufferedVsm = new BufferedViewportSizeMonitor(viewportSizeMonitor); + + events.listen(bufferedVsm, EventType.RESIZE, resizeCallback); + }, + + tearDown() { + events.unlisten(bufferedVsm, EventType.RESIZE, resizeCallback); + resizeEventCount = 0; + timer.uninstall(); + }, + + testInitialSizes() { + assertTrue(Size.equals(INITIAL_SIZE, bufferedVsm.getSize())); + }, + + testWindowResize() { + assertEquals(0, resizeEventCount); + resize(100, 100); + timer.tick(RESIZE_DELAY - 1); + assertEquals( + 'No resize expected before the delay is fired', 0, resizeEventCount); + timer.tick(1); + assertEquals('Expected resize after delay', 1, resizeEventCount); + assertTrue(Size.equals(new Size(100, 100), bufferedVsm.getSize())); + }, + + testWindowResize_eventBatching() { + assertEquals( + 'No resize calls expected before resize events', 0, resizeEventCount); + resize(100, 100); + timer.tick(RESIZE_DELAY - 1); + resize(200, 200); + assertEquals( + 'No resize expected before the delay is fired', 0, resizeEventCount); + timer.tick(1); + assertEquals( + 'No resize expected when delay is restarted', 0, resizeEventCount); + timer.tick(RESIZE_DELAY); + assertEquals('Expected resize after delay', 1, resizeEventCount); + }, + + testWindowResize_noChange() { + resize(100, 100); + timer.tick(RESIZE_DELAY); + assertEquals(1, resizeEventCount); + resize(100, 100); + timer.tick(RESIZE_DELAY); + assertEquals( + 'No resize expected when size doesn\'t change', 1, resizeEventCount); + assertTrue(Size.equals(new Size(100, 100), bufferedVsm.getSize())); + }, + + testWindowResize_previousSize() { + resize(100, 100); + timer.tick(RESIZE_DELAY); + assertEquals(1, resizeEventCount); + assertTrue(Size.equals(new Size(100, 100), bufferedVsm.getSize())); + + resize(200, 200); + timer.tick(RESIZE_DELAY); + assertEquals(2, resizeEventCount); + assertTrue(Size.equals(new Size(200, 200), bufferedVsm.getSize())); + }, +}); diff --git a/closure/goog/dom/classes.js b/closure/goog/dom/classes.js new file mode 100644 index 0000000000..1372db1dc6 --- /dev/null +++ b/closure/goog/dom/classes.js @@ -0,0 +1,241 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for adding, removing and setting classes. Prefer + * {@link goog.dom.classlist} over these utilities since goog.dom.classlist + * conforms closer to the semantics of Element.classList, is faster (uses + * native methods rather than parsing strings on every call) and compiles + * to smaller code as a result. + * + * Note: these utilities are meant to operate on HTMLElements and + * will not work on elements with differing interfaces (such as SVGElements). + */ + + +goog.provide('goog.dom.classes'); + +goog.require('goog.array'); + + +/** + * Sets the entire class name of an element. + * @param {Node} element DOM node to set class of. + * @param {string} className Class name(s) to apply to element. + * @deprecated Use goog.dom.classlist.set instead. + */ +goog.dom.classes.set = function(element, className) { + 'use strict'; + /** @type {!HTMLElement} */ (element).className = className; +}; + + +/** + * Gets an array of class names on an element + * @param {Node} element DOM node to get class of. + * @return {!Array} Class names on `element`. Some browsers add extra + * properties to the array. Do not depend on any of these! + * @deprecated Use goog.dom.classlist.get instead. + */ +goog.dom.classes.get = function(element) { + 'use strict'; + var className = /** @type {!Element} */ (element).className; + // Some types of elements don't have a className in IE (e.g. iframes). + // Furthermore, in Firefox, className is not a string when the element is + // an SVG element. + return typeof className === 'string' && className.match(/\S+/g) || []; +}; + + +/** + * Adds a class or classes to an element. Does not add multiples of class names. + * @param {Node} element DOM node to add class to. + * @param {...string} var_args Class names to add. + * @return {boolean} Whether class was added (or all classes were added). + * @deprecated Use goog.dom.classlist.add or goog.dom.classlist.addAll instead. + */ +goog.dom.classes.add = function(element, var_args) { + 'use strict'; + var classes = goog.dom.classes.get(element); + var args = Array.prototype.slice.call(arguments, 1); + var expectedCount = classes.length + args.length; + goog.dom.classes.add_(classes, args); + goog.dom.classes.set(element, classes.join(' ')); + return classes.length == expectedCount; +}; + + +/** + * Removes a class or classes from an element. + * @param {Node} element DOM node to remove class from. + * @param {...string} var_args Class name(s) to remove. + * @return {boolean} Whether all classes in `var_args` were found and + * removed. + * @deprecated Use goog.dom.classlist.remove or goog.dom.classlist.removeAll + * instead. + */ +goog.dom.classes.remove = function(element, var_args) { + 'use strict'; + var classes = goog.dom.classes.get(element); + var args = Array.prototype.slice.call(arguments, 1); + var newClasses = goog.dom.classes.getDifference_(classes, args); + goog.dom.classes.set(element, newClasses.join(' ')); + return newClasses.length == classes.length - args.length; +}; + + +/** + * Helper method for {@link goog.dom.classes.add} and + * {@link goog.dom.classes.addRemove}. Adds one or more classes to the supplied + * classes array. + * @param {Array} classes All class names for the element, will be + * updated to have the classes supplied in `args` added. + * @param {Array} args Class names to add. + * @private + */ +goog.dom.classes.add_ = function(classes, args) { + 'use strict'; + for (var i = 0; i < args.length; i++) { + if (!goog.array.contains(classes, args[i])) { + classes.push(args[i]); + } + } +}; + + +/** + * Helper method for {@link goog.dom.classes.remove} and + * {@link goog.dom.classes.addRemove}. Calculates the difference of two arrays. + * @param {!Array} arr1 First array. + * @param {!Array} arr2 Second array. + * @return {!Array} The first array without the elements of the second + * array. + * @private + */ +goog.dom.classes.getDifference_ = function(arr1, arr2) { + 'use strict'; + return arr1.filter(function(item) { + 'use strict'; + return !goog.array.contains(arr2, item); + }); +}; + + +/** + * Switches a class on an element from one to another without disturbing other + * classes. If the fromClass isn't removed, the toClass won't be added. + * @param {Node} element DOM node to swap classes on. + * @param {string} fromClass Class to remove. + * @param {string} toClass Class to add. + * @return {boolean} Whether classes were switched. + * @deprecated Use goog.dom.classlist.swap instead. + */ +goog.dom.classes.swap = function(element, fromClass, toClass) { + 'use strict'; + var classes = goog.dom.classes.get(element); + + var removed = false; + for (var i = 0; i < classes.length; i++) { + if (classes[i] == fromClass) { + classes.splice(i--, 1); + removed = true; + } + } + + if (removed) { + classes.push(toClass); + goog.dom.classes.set(element, classes.join(' ')); + } + + return removed; +}; + + +/** + * Adds zero or more classes to an element and removes zero or more as a single + * operation. Unlike calling {@link goog.dom.classes.add} and + * {@link goog.dom.classes.remove} separately, this is more efficient as it only + * parses the class property once. + * + * If a class is in both the remove and add lists, it will be added. Thus, + * you can use this instead of {@link goog.dom.classes.swap} when you have + * more than two class names that you want to swap. + * + * @param {Node} element DOM node to swap classes on. + * @param {?(string|Array)} classesToRemove Class or classes to + * remove, if null no classes are removed. + * @param {?(string|Array)} classesToAdd Class or classes to add, if + * null no classes are added. + * @deprecated Use goog.dom.classlist.addRemove instead. + */ +goog.dom.classes.addRemove = function(element, classesToRemove, classesToAdd) { + 'use strict'; + var classes = goog.dom.classes.get(element); + if (typeof classesToRemove === 'string') { + goog.array.remove(classes, classesToRemove); + } else if (Array.isArray(classesToRemove)) { + classes = goog.dom.classes.getDifference_(classes, classesToRemove); + } + + if (typeof classesToAdd === 'string' && + !goog.array.contains(classes, classesToAdd)) { + classes.push(classesToAdd); + } else if (Array.isArray(classesToAdd)) { + goog.dom.classes.add_(classes, classesToAdd); + } + + goog.dom.classes.set(element, classes.join(' ')); +}; + + +/** + * Returns true if an element has a class. + * @param {Node} element DOM node to test. + * @param {string} className Class name to test for. + * @return {boolean} Whether element has the class. + * @deprecated Use goog.dom.classlist.contains instead. + */ +goog.dom.classes.has = function(element, className) { + 'use strict'; + return goog.array.contains(goog.dom.classes.get(element), className); +}; + + +/** + * Adds or removes a class depending on the enabled argument. + * @param {Node} element DOM node to add or remove the class on. + * @param {string} className Class name to add or remove. + * @param {boolean} enabled Whether to add or remove the class (true adds, + * false removes). + * @deprecated Use goog.dom.classlist.enable or goog.dom.classlist.enableAll + * instead. + */ +goog.dom.classes.enable = function(element, className, enabled) { + 'use strict'; + if (enabled) { + goog.dom.classes.add(element, className); + } else { + goog.dom.classes.remove(element, className); + } +}; + + +/** + * Removes a class if an element has it, and adds it the element doesn't have + * it. Won't affect other classes on the node. + * @param {Node} element DOM node to toggle class on. + * @param {string} className Class to toggle. + * @return {boolean} True if class was added, false if it was removed + * (in other words, whether element has the class after this function has + * been called). + * @deprecated Use goog.dom.classlist.toggle instead. + */ +goog.dom.classes.toggle = function(element, className) { + 'use strict'; + var add = !goog.dom.classes.has(element, className); + goog.dom.classes.enable(element, className, add); + return add; +}; diff --git a/closure/goog/dom/classes_test.js b/closure/goog/dom/classes_test.js new file mode 100644 index 0000000000..ed694a1823 --- /dev/null +++ b/closure/goog/dom/classes_test.js @@ -0,0 +1,223 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Shared code for classes_test.html & classes_quirks_test.html. + */ + +goog.module('goog.dom.classes_test'); +goog.setTestOnly(); + +const TagName = goog.require('goog.dom.TagName'); +const classes = goog.require('goog.dom.classes'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); + +testSuite({ + testGet() { + const el = dom.createElement(TagName.DIV); + assertArrayEquals([], classes.get(el)); + el.className = 'C'; + assertArrayEquals(['C'], classes.get(el)); + el.className = 'C D'; + assertArrayEquals(['C', 'D'], classes.get(el)); + el.className = 'C\nD'; + assertArrayEquals(['C', 'D'], classes.get(el)); + el.className = ' C '; + assertArrayEquals(['C'], classes.get(el)); + }, + + testSetAddHasRemove() { + const el = dom.getElement('p1'); + classes.set(el, 'SOMECLASS'); + assertTrue('Should have SOMECLASS', classes.has(el, 'SOMECLASS')); + + classes.set(el, 'OTHERCLASS'); + assertTrue('Should have OTHERCLASS', classes.has(el, 'OTHERCLASS')); + assertFalse('Should not have SOMECLASS', classes.has(el, 'SOMECLASS')); + + classes.add(el, 'WOOCLASS'); + assertTrue('Should have OTHERCLASS', classes.has(el, 'OTHERCLASS')); + assertTrue('Should have WOOCLASS', classes.has(el, 'WOOCLASS')); + + classes.add(el, 'ACLASS', 'BCLASS', 'CCLASS'); + assertTrue('Should have OTHERCLASS', classes.has(el, 'OTHERCLASS')); + assertTrue('Should have WOOCLASS', classes.has(el, 'WOOCLASS')); + assertTrue('Should have ACLASS', classes.has(el, 'ACLASS')); + assertTrue('Should have BCLASS', classes.has(el, 'BCLASS')); + assertTrue('Should have CCLASS', classes.has(el, 'CCLASS')); + + classes.remove(el, 'CCLASS'); + assertTrue('Should have OTHERCLASS', classes.has(el, 'OTHERCLASS')); + assertTrue('Should have WOOCLASS', classes.has(el, 'WOOCLASS')); + assertTrue('Should have ACLASS', classes.has(el, 'ACLASS')); + assertTrue('Should have BCLASS', classes.has(el, 'BCLASS')); + assertFalse('Should not have CCLASS', classes.has(el, 'CCLASS')); + + classes.remove(el, 'ACLASS', 'BCLASS'); + assertTrue('Should have OTHERCLASS', classes.has(el, 'OTHERCLASS')); + assertTrue('Should have WOOCLASS', classes.has(el, 'WOOCLASS')); + assertFalse('Should not have ACLASS', classes.has(el, 'ACLASS')); + assertFalse('Should not have BCLASS', classes.has(el, 'BCLASS')); + }, + + // While support for this isn't implied in the method documentation, + // this is a frequently used pattern. + testAddWithSpacesInClassName() { + const el = dom.getElement('p1'); + classes.add(el, 'CLASS1 CLASS2', 'CLASS3 CLASS4'); + assertTrue('Should have CLASS1', classes.has(el, 'CLASS1')); + assertTrue('Should have CLASS2', classes.has(el, 'CLASS2')); + assertTrue('Should have CLASS3', classes.has(el, 'CLASS3')); + assertTrue('Should have CLASS4', classes.has(el, 'CLASS4')); + }, + + testSwap() { + const el = dom.getElement('p1'); + classes.set(el, 'SOMECLASS FIRST'); + + assertTrue('Should have FIRST class', classes.has(el, 'FIRST')); + assertTrue('Should have FIRST class', classes.has(el, 'SOMECLASS')); + assertFalse('Should not have second class', classes.has(el, 'second')); + + classes.swap(el, 'FIRST', 'second'); + + assertFalse('Should not have FIRST class', classes.has(el, 'FIRST')); + assertTrue('Should have FIRST class', classes.has(el, 'SOMECLASS')); + assertTrue('Should have second class', classes.has(el, 'second')); + + classes.swap(el, 'second', 'FIRST'); + + assertTrue('Should have FIRST class', classes.has(el, 'FIRST')); + assertTrue('Should have FIRST class', classes.has(el, 'SOMECLASS')); + assertFalse('Should not have second class', classes.has(el, 'second')); + }, + + testEnable() { + const el = dom.getElement('p1'); + classes.set(el, 'SOMECLASS FIRST'); + + assertTrue('Should have FIRST class', classes.has(el, 'FIRST')); + assertTrue('Should have SOMECLASS class', classes.has(el, 'SOMECLASS')); + + classes.enable(el, 'FIRST', false); + + assertFalse('Should not have FIRST class', classes.has(el, 'FIRST')); + assertTrue('Should have SOMECLASS class', classes.has(el, 'SOMECLASS')); + + classes.enable(el, 'FIRST', true); + + assertTrue('Should have FIRST class', classes.has(el, 'FIRST')); + assertTrue('Should have SOMECLASS class', classes.has(el, 'SOMECLASS')); + }, + + testToggle() { + const el = dom.getElement('p1'); + classes.set(el, 'SOMECLASS FIRST'); + + assertTrue('Should have FIRST class', classes.has(el, 'FIRST')); + assertTrue('Should have SOMECLASS class', classes.has(el, 'SOMECLASS')); + + classes.toggle(el, 'FIRST'); + + assertFalse('Should not have FIRST class', classes.has(el, 'FIRST')); + assertTrue('Should have SOMECLASS class', classes.has(el, 'SOMECLASS')); + + classes.toggle(el, 'FIRST'); + + assertTrue('Should have FIRST class', classes.has(el, 'FIRST')); + assertTrue('Should have SOMECLASS class', classes.has(el, 'SOMECLASS')); + }, + + testAddNotAddingMultiples() { + const el = dom.getElement('span6'); + assertTrue(classes.add(el, 'A')); + assertEquals('A', el.className); + assertFalse(classes.add(el, 'A')); + assertEquals('A', el.className); + assertFalse(classes.add(el, 'B', 'B')); + assertEquals('A B', el.className); + }, + + testAddRemoveString() { + const el = dom.getElement('span6'); + el.className = 'A'; + + classes.addRemove(el, 'A', 'B'); + assertEquals('B', el.className); + + classes.addRemove(el, null, 'C'); + assertEquals('B C', el.className); + + classes.addRemove(el, 'C', 'D'); + assertEquals('B D', el.className); + + classes.addRemove(el, 'D', null); + assertEquals('B', el.className); + }, + + testAddRemoveArray() { + const el = dom.getElement('span6'); + el.className = 'A'; + + classes.addRemove(el, ['A'], ['B']); + assertEquals('B', el.className); + + classes.addRemove(el, [], ['C']); + assertEquals('B C', el.className); + + classes.addRemove(el, ['C'], ['D']); + assertEquals('B D', el.className); + + classes.addRemove(el, ['D'], []); + assertEquals('B', el.className); + }, + + testAddRemoveMultiple() { + const el = dom.getElement('span6'); + el.className = 'A'; + + classes.addRemove(el, ['A'], ['B', 'C', 'D']); + assertEquals('B C D', el.className); + + classes.addRemove(el, [], ['E', 'F']); + assertEquals('B C D E F', el.className); + + classes.addRemove(el, ['C', 'E'], []); + assertEquals('B D F', el.className); + + classes.addRemove(el, ['B'], ['G']); + assertEquals('D F G', el.className); + }, + + // While support for this isn't implied in the method documentation, + // this is a frequently used pattern. + testAddRemoveWithSpacesInClassName() { + const el = dom.getElement('p1'); + classes.addRemove(el, '', 'CLASS1 CLASS2'); + assertTrue('Should have CLASS1', classes.has(el, 'CLASS1')); + assertTrue('Should have CLASS2', classes.has(el, 'CLASS2')); + }, + + testHasWithNewlines() { + const el = dom.getElement('p3'); + assertTrue('Should have SOMECLASS', classes.has(el, 'SOMECLASS')); + assertTrue('Should also have OTHERCLASS', classes.has(el, 'OTHERCLASS')); + assertFalse('Should not have WEIRDCLASS', classes.has(el, 'WEIRDCLASS')); + }, + + testEmptyClassNames() { + const el = dom.getElement('span1'); + // At the very least, make sure these do not error out. + assertFalse('Should not have an empty class', classes.has(el, '')); + classes.add(el, ''); + classes.toggle(el, ''); + assertFalse('Should not remove an empty class', classes.remove(el, '')); + classes.swap(el, '', 'OTHERCLASS'); + classes.swap(el, 'TEST1', ''); + classes.addRemove(el, '', ''); + }, +}); diff --git a/closure/goog/dom/classes_test_dom.html b/closure/goog/dom/classes_test_dom.html new file mode 100644 index 0000000000..d185676014 --- /dev/null +++ b/closure/goog/dom/classes_test_dom.html @@ -0,0 +1,62 @@ + + +
    + + Test Element + +
    +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + +

    +

    +
    +
    +
    +
    +
    +
    +

    + + a + + c + + d + + e + + f + + g + +

    +

    + h +

    + \ No newline at end of file diff --git a/closure/goog/dom/classlist.js b/closure/goog/dom/classlist.js new file mode 100644 index 0000000000..125ce7b63b --- /dev/null +++ b/closure/goog/dom/classlist.js @@ -0,0 +1,315 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for detecting, adding and removing classes. Prefer + * this over goog.dom.classes for new code since it attempts to use classList + * (DOMTokenList: http://dom.spec.whatwg.org/#domtokenlist) which is faster + * and requires less code. + * + * Note: these utilities are meant to operate on HTMLElements and SVGElements + * and may have unexpected behavior on elements with differing interfaces. + */ + + +goog.provide('goog.dom.classlist'); + +goog.require('goog.array'); + + +/** + * Override this define at build-time if you know your target supports it. + * @define {boolean} Whether to use the classList property (DOMTokenList). + */ +goog.dom.classlist.ALWAYS_USE_DOM_TOKEN_LIST = + goog.define('goog.dom.classlist.ALWAYS_USE_DOM_TOKEN_LIST', false); + + +/** + * A wrapper which ensures correct functionality when interacting with + * SVGElements + * @param {?Element} element DOM node to get the class name of. + * @return {string} + * @private + */ +goog.dom.classlist.getClassName_ = function(element) { + 'use strict'; + // If className is an instance of SVGAnimatedString use getAttribute + return typeof element.className == 'string' ? + element.className : + element.getAttribute && element.getAttribute('class') || ''; +}; + + +/** + * Gets an array-like object of class names on an element. + * @param {Element} element DOM node to get the classes of. + * @return {!IArrayLike} Class names on `element`. + */ +goog.dom.classlist.get = function(element) { + 'use strict'; + if (goog.dom.classlist.ALWAYS_USE_DOM_TOKEN_LIST || element.classList) { + return element.classList; + } + + return goog.dom.classlist.getClassName_(element).match(/\S+/g) || []; +}; + + +/** + * Sets the entire class name of an element. + * @param {Element} element DOM node to set class of. + * @param {string} className Class name(s) to apply to element. + */ +goog.dom.classlist.set = function(element, className) { + 'use strict'; + // If className is an instance of SVGAnimatedString use setAttribute + if ((typeof element.className) == 'string') { + element.className = className; + return; + } else if (element.setAttribute) { + element.setAttribute('class', className); + } +}; + + +/** + * Returns true if an element has a class. This method may throw a DOM + * exception for an invalid or empty class name if DOMTokenList is used. + * @param {Element} element DOM node to test. + * @param {string} className Class name to test for. + * @return {boolean} Whether element has the class. + */ +goog.dom.classlist.contains = function(element, className) { + 'use strict'; + if (goog.dom.classlist.ALWAYS_USE_DOM_TOKEN_LIST || element.classList) { + return element.classList.contains(className); + } + return goog.array.contains(goog.dom.classlist.get(element), className); +}; + + +/** + * Adds a class to an element. Does not add multiples of class names. This + * method may throw a DOM exception for an invalid or empty class name if + * DOMTokenList is used. + * @param {Element} element DOM node to add class to. + * @param {string} className Class name to add. + */ +goog.dom.classlist.add = function(element, className) { + 'use strict'; + if (goog.dom.classlist.ALWAYS_USE_DOM_TOKEN_LIST || element.classList) { + element.classList.add(className); + return; + } + + if (!goog.dom.classlist.contains(element, className)) { + // Ensure we add a space if this is not the first class name added. + var oldClassName = goog.dom.classlist.getClassName_(element); + goog.dom.classlist.set( + element, + oldClassName + + (oldClassName.length > 0 ? (' ' + className) : className)); + } +}; + + +/** + * Convenience method to add a number of class names at once. + * @param {Element} element The element to which to add classes. + * @param {IArrayLike} classesToAdd An array-like object + * containing a collection of class names to add to the element. + * This method may throw a DOM exception if classesToAdd contains invalid + * or empty class names. + */ +goog.dom.classlist.addAll = function(element, classesToAdd) { + 'use strict'; + if (goog.dom.classlist.ALWAYS_USE_DOM_TOKEN_LIST || element.classList) { + Array.prototype.forEach.call(classesToAdd, function(className) { + 'use strict'; + goog.dom.classlist.add(element, className); + }); + return; + } + + var classMap = {}; + + // Get all current class names into a map. + Array.prototype.forEach.call( + goog.dom.classlist.get(element), function(className) { + 'use strict'; + classMap[className] = true; + }); + + // Add new class names to the map. + Array.prototype.forEach.call(classesToAdd, function(className) { + 'use strict'; + classMap[className] = true; + }); + + // Flatten the keys of the map into the className. + var newClassName = ''; + for (var className in classMap) { + newClassName += newClassName.length > 0 ? (' ' + className) : className; + } + goog.dom.classlist.set(element, newClassName); +}; + + +/** + * Removes a class from an element. This method may throw a DOM exception + * for an invalid or empty class name if DOMTokenList is used. + * @param {Element} element DOM node to remove class from. + * @param {string} className Class name to remove. + */ +goog.dom.classlist.remove = function(element, className) { + 'use strict'; + if (goog.dom.classlist.ALWAYS_USE_DOM_TOKEN_LIST || element.classList) { + element.classList.remove(className); + return; + } + + if (goog.dom.classlist.contains(element, className)) { + // Filter out the class name. + goog.dom.classlist.set( + element, + Array.prototype.filter + .call( + goog.dom.classlist.get(element), + function(c) { + 'use strict'; + return c != className; + }) + .join(' ')); + } +}; + + +/** + * Removes a set of classes from an element. Prefer this call to + * repeatedly calling `goog.dom.classlist.remove` if you want to remove + * a large set of class names at once. + * @param {Element} element The element from which to remove classes. + * @param {IArrayLike} classesToRemove An array-like object + * containing a collection of class names to remove from the element. + * This method may throw a DOM exception if classesToRemove contains invalid + * or empty class names. + */ +goog.dom.classlist.removeAll = function(element, classesToRemove) { + 'use strict'; + if (goog.dom.classlist.ALWAYS_USE_DOM_TOKEN_LIST || element.classList) { + Array.prototype.forEach.call(classesToRemove, function(className) { + 'use strict'; + goog.dom.classlist.remove(element, className); + }); + return; + } + + // Filter out those classes in classesToRemove. + goog.dom.classlist.set( + element, + Array.prototype.filter + .call( + goog.dom.classlist.get(element), + function(className) { + 'use strict'; + // If this class is not one we are trying to remove, + // add it to the array of new class names. + return !goog.array.contains(classesToRemove, className); + }) + .join(' ')); +}; + + +/** + * Adds or removes a class depending on the enabled argument. This method + * may throw a DOM exception for an invalid or empty class name if DOMTokenList + * is used. + * @param {Element} element DOM node to add or remove the class on. + * @param {string} className Class name to add or remove. + * @param {boolean} enabled Whether to add or remove the class (true adds, + * false removes). + */ +goog.dom.classlist.enable = function(element, className, enabled) { + 'use strict'; + if (enabled) { + goog.dom.classlist.add(element, className); + } else { + goog.dom.classlist.remove(element, className); + } +}; + + +/** + * Adds or removes a set of classes depending on the enabled argument. This + * method may throw a DOM exception for an invalid or empty class name if + * DOMTokenList is used. + * @param {!Element} element DOM node to add or remove the class on. + * @param {?IArrayLike} classesToEnable An array-like object + * containing a collection of class names to add or remove from the element. + * @param {boolean} enabled Whether to add or remove the classes (true adds, + * false removes). + */ +goog.dom.classlist.enableAll = function(element, classesToEnable, enabled) { + 'use strict'; + var f = enabled ? goog.dom.classlist.addAll : goog.dom.classlist.removeAll; + f(element, classesToEnable); +}; + + +/** + * Switches a class on an element from one to another without disturbing other + * classes. If the fromClass isn't removed, the toClass won't be added. This + * method may throw a DOM exception if the class names are empty or invalid. + * @param {Element} element DOM node to swap classes on. + * @param {string} fromClass Class to remove. + * @param {string} toClass Class to add. + * @return {boolean} Whether classes were switched. + */ +goog.dom.classlist.swap = function(element, fromClass, toClass) { + 'use strict'; + if (goog.dom.classlist.contains(element, fromClass)) { + goog.dom.classlist.remove(element, fromClass); + goog.dom.classlist.add(element, toClass); + return true; + } + return false; +}; + + +/** + * Removes a class if an element has it, and adds it the element doesn't have + * it. Won't affect other classes on the node. This method may throw a DOM + * exception if the class name is empty or invalid. + * @param {Element} element DOM node to toggle class on. + * @param {string} className Class to toggle. + * @return {boolean} True if class was added, false if it was removed + * (in other words, whether element has the class after this function has + * been called). + */ +goog.dom.classlist.toggle = function(element, className) { + 'use strict'; + var add = !goog.dom.classlist.contains(element, className); + goog.dom.classlist.enable(element, className, add); + return add; +}; + + +/** + * Adds and removes a class of an element. Unlike + * {@link goog.dom.classlist.swap}, this method adds the classToAdd regardless + * of whether the classToRemove was present and had been removed. This method + * may throw a DOM exception if the class names are empty or invalid. + * + * @param {Element} element DOM node to swap classes on. + * @param {string} classToRemove Class to remove. + * @param {string} classToAdd Class to add. + */ +goog.dom.classlist.addRemove = function(element, classToRemove, classToAdd) { + 'use strict'; + goog.dom.classlist.remove(element, classToRemove); + goog.dom.classlist.add(element, classToAdd); +}; diff --git a/closure/goog/dom/classlist_test.js b/closure/goog/dom/classlist_test.js new file mode 100644 index 0000000000..5d7a603060 --- /dev/null +++ b/closure/goog/dom/classlist_test.js @@ -0,0 +1,335 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @fileoverview Shared code for classlist_test.html. */ + +goog.module('goog.dom.classlist_test'); +goog.setTestOnly(); + +const ExpectedFailures = goog.require('goog.testing.ExpectedFailures'); +const TagName = goog.require('goog.dom.TagName'); +const classlist = goog.require('goog.dom.classlist'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); + +let expectedFailures; + +testSuite({ + setUpPage() { + expectedFailures = new ExpectedFailures(); + }, + + tearDown() { + expectedFailures.handleTearDown(); + }, + + testGet() { + const el = dom.createElement(TagName.DIV); + assertTrue(classlist.get(el).length == 0); + el.className = 'C'; + assertElementsEquals(['C'], classlist.get(el)); + el.className = 'C D'; + assertElementsEquals(['C', 'D'], classlist.get(el)); + el.className = 'C\nD'; + assertElementsEquals(['C', 'D'], classlist.get(el)); + el.className = ' C '; + assertElementsEquals(['C'], classlist.get(el)); + }, + + testGetSvg() { + const el = dom.createElement(TagName.SVG); + assertTrue(classlist.get(el).length == 0); + el.setAttribute('class', 'C'); + assertElementsEquals(['C'], classlist.get(el)); + el.setAttribute('class', 'C D'); + assertElementsEquals(['C', 'D'], classlist.get(el)); + el.setAttribute('class', 'C\nD'); + assertElementsEquals(['C', 'D'], classlist.get(el)); + el.setAttribute('class', ' C '); + assertElementsEquals(['C'], classlist.get(el)); + }, + + testContainsWithNewlines() { + const el = dom.getElement('p1'); + assertTrue('Should have SOMECLASS', classlist.contains(el, 'SOMECLASS')); + assertTrue( + 'Should also have OTHERCLASS', classlist.contains(el, 'OTHERCLASS')); + assertFalse( + 'Should not have WEIRDCLASS', classlist.contains(el, 'WEIRDCLASS')); + }, + + testContainsCaseSensitive() { + const el = dom.getElement('p2'); + assertFalse( + 'Should not have camelcase', classlist.contains(el, 'camelcase')); + assertFalse( + 'Should not have CAMELCASE', classlist.contains(el, 'CAMELCASE')); + assertTrue('Should have camelCase', classlist.contains(el, 'camelCase')); + }, + + testAddNotAddingMultiples() { + const el = dom.createElement(TagName.DIV); + classlist.add(el, 'A'); + assertEquals('A', el.className); + classlist.add(el, 'A'); + assertEquals('A', el.className); + classlist.add(el, 'B'); + assertEquals('A B', el.className); + }, + + testAddNotAddingMultiplesSvg() { + const el = dom.createElement(TagName.SVG); + classlist.add(el, 'A'); + assertEquals('A', el.getAttribute('class')); + classlist.add(el, 'A'); + assertEquals('A', el.getAttribute('class')); + classlist.add(el, 'B'); + assertEquals('A B', el.getAttribute('class')); + }, + + testAddCaseSensitive() { + const el = dom.createElement(TagName.DIV); + classlist.add(el, 'A'); + assertTrue(classlist.contains(el, 'A')); + assertFalse(classlist.contains(el, 'a')); + classlist.add(el, 'a'); + assertTrue(classlist.contains(el, 'A')); + assertTrue(classlist.contains(el, 'a')); + assertEquals('A a', el.className); + }, + + testAddCaseSensitiveSvg() { + const el = dom.createElement(TagName.SVG); + classlist.add(el, 'A'); + assertTrue(classlist.contains(el, 'A')); + assertFalse(classlist.contains(el, 'a')); + classlist.add(el, 'a'); + assertTrue(classlist.contains(el, 'A')); + assertTrue(classlist.contains(el, 'a')); + assertEquals('A a', el.getAttribute('class')); + }, + + testAddAll() { + const elem = dom.createElement(TagName.DIV); + elem.className = 'foo goog-bar'; + + classlist.addAll(elem, ['goog-baz', 'foo']); + assertEquals(3, classlist.get(elem).length); + assertTrue(classlist.contains(elem, 'foo')); + assertTrue(classlist.contains(elem, 'goog-bar')); + assertTrue(classlist.contains(elem, 'goog-baz')); + }, + + testAddAllSvg() { + const elem = dom.createElement(TagName.SVG); + elem.setAttribute('class', 'foo goog-bar'); + + classlist.addAll(elem, ['goog-baz', 'foo']); + assertEquals(3, classlist.get(elem).length); + assertTrue(classlist.contains(elem, 'foo')); + assertTrue(classlist.contains(elem, 'goog-bar')); + assertTrue(classlist.contains(elem, 'goog-baz')); + }, + + testAddAllEmpty() { + const classes = 'foo bar'; + const elem = dom.createElement(TagName.DIV); + elem.className = classes; + + classlist.addAll(elem, []); + assertEquals(elem.className, classes); + }, + + testRemove() { + const el = dom.createElement(TagName.DIV); + el.className = 'A B C'; + classlist.remove(el, 'B'); + assertEquals('A C', el.className); + }, + + testRemoveSvg() { + const el = dom.createElement(TagName.SVG); + el.setAttribute('class', 'A B C'); + classlist.remove(el, 'B'); + assertEquals('A C', el.getAttribute('class')); + }, + + testRemoveCaseSensitive() { + const el = dom.createElement(TagName.DIV); + el.className = 'A B C'; + classlist.remove(el, 'b'); + assertEquals('A B C', el.className); + }, + + testRemoveAll() { + const elem = dom.createElement(TagName.DIV); + elem.className = 'foo bar baz'; + + classlist.removeAll(elem, ['bar', 'foo']); + assertFalse(classlist.contains(elem, 'foo')); + assertFalse(classlist.contains(elem, 'bar')); + assertTrue(classlist.contains(elem, 'baz')); + }, + + testRemoveAllSvg() { + const elem = dom.createElement(TagName.SVG); + elem.setAttribute('class', 'foo bar baz'); + + classlist.removeAll(elem, ['bar', 'foo']); + assertFalse(classlist.contains(elem, 'foo')); + assertFalse(classlist.contains(elem, 'bar')); + assertTrue(classlist.contains(elem, 'baz')); + }, + + testRemoveAllOne() { + const elem = dom.createElement(TagName.DIV); + elem.className = 'foo bar baz'; + + classlist.removeAll(elem, ['bar']); + assertFalse(classlist.contains(elem, 'bar')); + assertTrue(classlist.contains(elem, 'foo')); + assertTrue(classlist.contains(elem, 'baz')); + }, + + testRemoveAllSomeNotPresent() { + const elem = dom.createElement(TagName.DIV); + elem.className = 'foo bar baz'; + + classlist.removeAll(elem, ['a', 'bar']); + assertTrue(classlist.contains(elem, 'foo')); + assertFalse(classlist.contains(elem, 'bar')); + assertTrue(classlist.contains(elem, 'baz')); + }, + + testRemoveAllCaseSensitive() { + const elem = dom.createElement(TagName.DIV); + elem.className = 'foo bar baz'; + + classlist.removeAll(elem, ['BAR', 'foo']); + assertFalse(classlist.contains(elem, 'foo')); + assertTrue(classlist.contains(elem, 'bar')); + assertTrue(classlist.contains(elem, 'baz')); + }, + + testEnable() { + const el = dom.getElement('p1'); + classlist.set(el, 'SOMECLASS FIRST'); + + assertTrue('Should have FIRST class', classlist.contains(el, 'FIRST')); + assertTrue( + 'Should have SOMECLASS class', classlist.contains(el, 'SOMECLASS')); + + classlist.enable(el, 'FIRST', false); + + assertFalse('Should not have FIRST class', classlist.contains(el, 'FIRST')); + assertTrue( + 'Should have SOMECLASS class', classlist.contains(el, 'SOMECLASS')); + + classlist.enable(el, 'FIRST', true); + + assertTrue('Should have FIRST class', classlist.contains(el, 'FIRST')); + assertTrue( + 'Should have SOMECLASS class', classlist.contains(el, 'SOMECLASS')); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testEnableNotAddingMultiples() { + const el = dom.createElement(TagName.DIV); + classlist.enable(el, 'A', true); + assertEquals('A', el.className); + classlist.enable(el, 'A', true); + assertEquals('A', el.className); + classlist.enable(el, 'B', 'B', true); + assertEquals('A B', el.className); + }, + + testEnableAllRemove() { + const elem = dom.createElement(TagName.DIV); + elem.className = 'foo bar baz'; + + // Test removing some classes (some not present). + classlist.enableAll(elem, ['a', 'bar'], false /* enable */); + assertTrue(classlist.contains(elem, 'foo')); + assertFalse(classlist.contains(elem, 'bar')); + assertTrue(classlist.contains(elem, 'baz')); + assertFalse(classlist.contains(elem, 'a')); + }, + + testEnableAllAdd() { + const elem = dom.createElement(TagName.DIV); + elem.className = 'foo bar'; + + // Test adding some classes (some duplicate). + classlist.enableAll(elem, ['a', 'bar', 'baz'], true /* enable */); + assertTrue(classlist.contains(elem, 'foo')); + assertTrue(classlist.contains(elem, 'bar')); + assertTrue(classlist.contains(elem, 'baz')); + assertTrue(classlist.contains(elem, 'a')); + }, + + testSwap() { + const el = dom.getElement('p1'); + classlist.set(el, 'SOMECLASS FIRST'); + + assertTrue('Should have FIRST class', classlist.contains(el, 'FIRST')); + assertTrue('Should have FIRST class', classlist.contains(el, 'SOMECLASS')); + assertFalse( + 'Should not have second class', classlist.contains(el, 'second')); + + classlist.swap(el, 'FIRST', 'second'); + + assertFalse('Should not have FIRST class', classlist.contains(el, 'FIRST')); + assertTrue('Should have FIRST class', classlist.contains(el, 'SOMECLASS')); + assertTrue('Should have second class', classlist.contains(el, 'second')); + + classlist.swap(el, 'second', 'FIRST'); + + assertTrue('Should have FIRST class', classlist.contains(el, 'FIRST')); + assertTrue('Should have FIRST class', classlist.contains(el, 'SOMECLASS')); + assertFalse( + 'Should not have second class', classlist.contains(el, 'second')); + }, + + testToggle() { + const el = dom.getElement('p1'); + classlist.set(el, 'SOMECLASS FIRST'); + + assertTrue('Should have FIRST class', classlist.contains(el, 'FIRST')); + assertTrue( + 'Should have SOMECLASS class', classlist.contains(el, 'SOMECLASS')); + + let ret = classlist.toggle(el, 'FIRST'); + + assertFalse('Should not have FIRST class', classlist.contains(el, 'FIRST')); + assertTrue( + 'Should have SOMECLASS class', classlist.contains(el, 'SOMECLASS')); + assertFalse('Return value should have been false', ret); + + ret = classlist.toggle(el, 'FIRST'); + + assertTrue('Should have FIRST class', classlist.contains(el, 'FIRST')); + assertTrue( + 'Should have SOMECLASS class', classlist.contains(el, 'SOMECLASS')); + assertTrue('Return value should have been true', ret); + }, + + testAddRemoveString() { + const el = dom.createElement(TagName.DIV); + el.className = 'A'; + + classlist.addRemove(el, 'A', 'B'); + assertEquals('B', el.className); + + classlist.addRemove(el, 'Z', 'C'); + assertEquals('B C', el.className); + + classlist.addRemove(el, 'C', 'D'); + assertEquals('B D', el.className); + + classlist.addRemove(el, 'D', 'B'); + assertEquals('B', el.className); + }, +}); diff --git a/closure/goog/dom/classlist_test_dom.html b/closure/goog/dom/classlist_test_dom.html new file mode 100644 index 0000000000..db8ffc3877 --- /dev/null +++ b/closure/goog/dom/classlist_test_dom.html @@ -0,0 +1,19 @@ + + +

    +

    +

    +

    + + + + \ No newline at end of file diff --git a/closure/goog/dom/controlrange.js b/closure/goog/dom/controlrange.js new file mode 100644 index 0000000000..53e59fdf09 --- /dev/null +++ b/closure/goog/dom/controlrange.js @@ -0,0 +1,546 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for working with IE control ranges. + * + * @suppress {strictMissingProperties} + */ + + + +// TODO(user): We're trying to migrate all ES5 subclasses of Closure +// Library to ES6. In ES6 this cannot be referenced before super is called. This +// file has at least one this before a super call (in ES5) and cannot be +// automatically upgraded to ES6 as a result. Please fix this if you have a +// chance. Note: This can sometimes be caused by not calling the super +// constructor at all. You can run the conversion tool yourself to see what it +// does on this file: blaze run //javascript/refactoring/es6_classes:convert. + +goog.provide('goog.dom.ControlRange'); +goog.provide('goog.dom.ControlRangeIterator'); + +goog.require('goog.array'); +goog.require('goog.dom'); +goog.require('goog.dom.AbstractMultiRange'); +goog.require('goog.dom.AbstractRange'); +goog.require('goog.dom.RangeIterator'); +goog.require('goog.dom.RangeType'); +goog.require('goog.dom.SavedCaretRange'); +goog.require('goog.dom.SavedRange'); +goog.require('goog.dom.TagWalkType'); +goog.require('goog.dom.TextRange'); +goog.require('goog.iter'); +goog.require('goog.userAgent'); + + + +/** + * Create a new control selection with no properties. Do not use this + * constructor: use one of the goog.dom.Range.createFrom* methods instead. + * @constructor + * @extends {goog.dom.AbstractMultiRange} + * @final + */ +goog.dom.ControlRange = function() { + 'use strict'; + /** + * The IE control range obejct. + * @private {?Object} + */ + this.range_ = null; + + /** + * Cached list of elements. + * @private {?Array} + */ + this.elements_ = null; + + /** + * Cached sorted list of elements. + * @private {?Array} + */ + this.sortedElements_ = null; +}; +goog.inherits(goog.dom.ControlRange, goog.dom.AbstractMultiRange); + + +/** + * Create a new range wrapper from the given browser range object. Do not use + * this method directly - please use goog.dom.Range.createFrom* instead. + * @param {Object} controlRange The browser range object. + * @return {!goog.dom.ControlRange} A range wrapper object. + */ +goog.dom.ControlRange.createFromBrowserRange = function(controlRange) { + 'use strict'; + var range = new goog.dom.ControlRange(); + range.range_ = controlRange; + return range; +}; + + +/** + * Create a new range wrapper that selects the given element. Do not use + * this method directly - please use goog.dom.Range.createFrom* instead. + * @param {...Element} var_args The element(s) to select. + * @return {!goog.dom.ControlRange} A range wrapper object. + */ +goog.dom.ControlRange.createFromElements = function(var_args) { + 'use strict'; + var range = goog.dom.getOwnerDocument(arguments[0]).body.createControlRange(); + for (var i = 0, len = arguments.length; i < len; i++) { + range.addElement(arguments[i]); + } + return goog.dom.ControlRange.createFromBrowserRange(range); +}; + + +// Method implementations + + +/** + * Clear cached values. + * @private + */ +goog.dom.ControlRange.prototype.clearCachedValues_ = function() { + 'use strict'; + this.elements_ = null; + this.sortedElements_ = null; +}; + + +/** @override */ +goog.dom.ControlRange.prototype.clone = function() { + 'use strict'; + return goog.dom.ControlRange.createFromElements.apply( + this, this.getElements()); +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getType = function() { + 'use strict'; + return goog.dom.RangeType.CONTROL; +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getBrowserRangeObject = function() { + 'use strict'; + return this.range_ || document.body.createControlRange(); +}; + + +/** @override */ +goog.dom.ControlRange.prototype.setBrowserRangeObject = function(nativeRange) { + 'use strict'; + if (!goog.dom.AbstractRange.isNativeControlRange(nativeRange)) { + return false; + } + this.range_ = nativeRange; + return true; +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getTextRangeCount = function() { + 'use strict'; + return this.range_ ? this.range_.length : 0; +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getTextRange = function(i) { + 'use strict'; + return goog.dom.TextRange.createFromNodeContents(this.range_.item(i)); +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getContainer = function() { + 'use strict'; + return goog.dom.findCommonAncestor.apply(null, this.getElements()); +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getStartNode = function() { + 'use strict'; + return this.getSortedElements()[0]; +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getStartOffset = function() { + 'use strict'; + return 0; +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getEndNode = function() { + 'use strict'; + var sorted = this.getSortedElements(); + var startsLast = /** @type {Node} */ (goog.array.peek(sorted)); + return /** @type {Node} */ (sorted.find(function(el) { + 'use strict'; + return goog.dom.contains(el, startsLast); + })); +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getEndOffset = function() { + 'use strict'; + return this.getEndNode().childNodes.length; +}; + + +// TODO(robbyw): Figure out how to unify getElements with TextRange API. +/** + * @return {!Array} Array of elements in the control range. + */ +goog.dom.ControlRange.prototype.getElements = function() { + 'use strict'; + if (!this.elements_) { + this.elements_ = []; + if (this.range_) { + for (var i = 0; i < this.range_.length; i++) { + this.elements_.push(this.range_.item(i)); + } + } + } + + return this.elements_; +}; + + +/** + * @return {!Array} Array of elements comprising the control range, + * sorted by document order. + */ +goog.dom.ControlRange.prototype.getSortedElements = function() { + 'use strict'; + if (!this.sortedElements_) { + this.sortedElements_ = this.getElements().concat(); + this.sortedElements_.sort(function(a, b) { + 'use strict'; + return a.sourceIndex - b.sourceIndex; + }); + } + + return this.sortedElements_; +}; + + +/** @override */ +goog.dom.ControlRange.prototype.isRangeInDocument = function() { + 'use strict'; + var returnValue = false; + + try { + returnValue = this.getElements().every(function(element) { + 'use strict'; + // On IE, this throws an exception when the range is detached. + return goog.userAgent.IE ? + !!element.parentNode : + goog.dom.contains(element.ownerDocument.body, element); + }); + } catch (e) { + // IE sometimes throws Invalid Argument errors for detached elements. + // Note: trying to return a value from the above try block can cause IE + // to crash. It is necessary to use the local returnValue. + } + + return returnValue; +}; + + +/** @override */ +goog.dom.ControlRange.prototype.isCollapsed = function() { + 'use strict'; + return !this.range_ || !this.range_.length; +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getText = function() { + 'use strict'; + // TODO(robbyw): What about for table selections? Should those have text? + return ''; +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getHtmlFragment = function() { + 'use strict'; + return this.getSortedElements().map(goog.dom.getOuterHtml).join(''); +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getValidHtml = function() { + 'use strict'; + return this.getHtmlFragment(); +}; + + +/** @override */ +goog.dom.ControlRange.prototype.getPastableHtml = + goog.dom.ControlRange.prototype.getValidHtml; + + +/** @override */ +goog.dom.ControlRange.prototype.__iterator__ = function(opt_keys) { + 'use strict'; + return new goog.dom.ControlRangeIterator(this); +}; + + +// RANGE ACTIONS + + +/** @override */ +goog.dom.ControlRange.prototype.select = function() { + 'use strict'; + if (this.range_) { + this.range_.select(); + } +}; + + +/** @override */ +goog.dom.ControlRange.prototype.removeContents = function() { + 'use strict'; + // TODO(robbyw): Test implementing with execCommand('Delete') + if (this.range_) { + var nodes = []; + for (var i = 0, len = this.range_.length; i < len; i++) { + nodes.push(this.range_.item(i)); + } + nodes.forEach(goog.dom.removeNode); + + this.collapse(false); + } +}; + + +/** @override */ +goog.dom.ControlRange.prototype.replaceContentsWithNode = function(node) { + 'use strict'; + // Control selections have to have the node inserted before removing the + // selection contents because a collapsed control range doesn't have start or + // end nodes. + var result = this.insertNode(node, true); + + if (!this.isCollapsed()) { + this.removeContents(); + } + + return result; +}; + + +// SAVE/RESTORE + + +/** @override */ +goog.dom.ControlRange.prototype.saveUsingDom = function() { + 'use strict'; + return new goog.dom.DomSavedControlRange_(this); +}; + +/** @override */ +goog.dom.ControlRange.prototype.saveUsingCarets = function() { + 'use strict'; + return (this.getStartNode() && this.getEndNode()) ? + new goog.dom.SavedCaretRange(this) : + null; +}; + +// RANGE MODIFICATION + + +/** @override */ +goog.dom.ControlRange.prototype.collapse = function(toAnchor) { + 'use strict'; + // TODO(robbyw): Should this return a text range? If so, API needs to change. + this.range_ = null; + this.clearCachedValues_(); +}; + + +// SAVED RANGE OBJECTS + + + +/** + * A SavedRange implementation using DOM endpoints. + * @param {goog.dom.ControlRange} range The range to save. + * @constructor + * @extends {goog.dom.SavedRange} + * @private + */ +goog.dom.DomSavedControlRange_ = function(range) { + 'use strict'; + /** + * The element list. + * @type {Array} + * @private + */ + this.elements_ = range.getElements(); +}; +goog.inherits(goog.dom.DomSavedControlRange_, goog.dom.SavedRange); + + +/** @override */ +goog.dom.DomSavedControlRange_.prototype.restoreInternal = function() { + 'use strict'; + var doc = this.elements_.length ? + goog.dom.getOwnerDocument(this.elements_[0]) : + document; + var controlRange = doc.body.createControlRange(); + for (var i = 0, len = this.elements_.length; i < len; i++) { + controlRange.addElement(this.elements_[i]); + } + return goog.dom.ControlRange.createFromBrowserRange(controlRange); +}; + + +/** @override */ +goog.dom.DomSavedControlRange_.prototype.disposeInternal = function() { + 'use strict'; + goog.dom.DomSavedControlRange_.superClass_.disposeInternal.call(this); + delete this.elements_; +}; + + +// RANGE ITERATION + + + +/** + * Subclass of goog.dom.TagIterator that iterates over a DOM range. It + * adds functions to determine the portion of each text node that is selected. + * + * @param {goog.dom.ControlRange?} range The range to traverse. + * @constructor + * @extends {goog.dom.RangeIterator} + * @final + */ +goog.dom.ControlRangeIterator = function(range) { + 'use strict'; + /** + * The first node in the selection. + * @private {?Node} + */ + this.startNode_ = null; + + /** + * The last node in the selection. + * @private {?Node} + */ + this.endNode_ = null; + + /** + * The list of elements left to traverse. + * @private {Array?} + */ + this.elements_ = null; + + if (range) { + this.elements_ = range.getSortedElements(); + this.startNode_ = this.elements_.shift(); + this.endNode_ = /** @type {Node} */ (goog.array.peek(this.elements_)) || + this.startNode_; + } + + goog.dom.ControlRangeIterator.base( + this, 'constructor', this.startNode_, false); +}; +goog.inherits(goog.dom.ControlRangeIterator, goog.dom.RangeIterator); + + +/** @override */ +goog.dom.ControlRangeIterator.prototype.getStartTextOffset = function() { + 'use strict'; + return 0; +}; + + +/** @override */ +goog.dom.ControlRangeIterator.prototype.getEndTextOffset = function() { + 'use strict'; + return 0; +}; + + +/** @override */ +goog.dom.ControlRangeIterator.prototype.getStartNode = function() { + 'use strict'; + return this.startNode_; +}; + + +/** @override */ +goog.dom.ControlRangeIterator.prototype.getEndNode = function() { + 'use strict'; + return this.endNode_; +}; + + +/** @override */ +goog.dom.ControlRangeIterator.prototype.isLast = function() { + 'use strict'; + return !this.depth && !this.elements_.length; +}; + + +/** + * Move to the next position in the selection. + * Throws `goog.iter.StopIteration` when it passes the end of the range. + * @return {!IIterableResult} The node at the next position. + * @override + */ +goog.dom.ControlRangeIterator.prototype.next = function() { + 'use strict'; + // Iterate over each element in the range, and all of its children. + if (this.isLast()) { + return goog.iter.ES6_ITERATOR_DONE; + } else if (!this.depth) { + var el = this.elements_.shift(); + this.setPosition( + el, goog.dom.TagWalkType.START_TAG, goog.dom.TagWalkType.START_TAG); + return goog.iter.createEs6IteratorYield(/** @type {!Node} */ (el)); + } + + // Call the super function. + return goog.dom.ControlRangeIterator.superClass_.next.call(this); +}; + + +/** @override */ +goog.dom.ControlRangeIterator.prototype.copyFrom = function(other) { + 'use strict'; + var that = /** @type {!goog.dom.ControlRangeIterator} */ (other); + this.elements_ = that.elements_; + this.startNode_ = that.startNode_; + this.endNode_ = that.endNode_; + + goog.dom.ControlRangeIterator.superClass_.copyFrom.call(this, that); +}; + + +/** + * @return {!goog.dom.ControlRangeIterator} An identical iterator. + * @override + */ +goog.dom.ControlRangeIterator.prototype.clone = function() { + 'use strict'; + var copy = new goog.dom.ControlRangeIterator(null); + copy.copyFrom(this); + return copy; +}; diff --git a/closure/goog/dom/controlrange_test.js b/closure/goog/dom/controlrange_test.js new file mode 100644 index 0000000000..8a49fc8a96 --- /dev/null +++ b/closure/goog/dom/controlrange_test.js @@ -0,0 +1,236 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.ControlRangeTest'); +goog.setTestOnly(); + +const DomControlRange = goog.require('goog.dom.ControlRange'); +const DomTextRange = goog.require('goog.dom.TextRange'); +const RangeType = goog.require('goog.dom.RangeType'); +const TagName = goog.require('goog.dom.TagName'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); +const testingDom = goog.require('goog.testing.dom'); +const userAgent = goog.require('goog.userAgent'); + +let logo; +let table; + +function helpTestBounds(range) { + assertEquals('Start node is logo', logo, range.getStartNode()); + assertEquals('Start offset is 0', 0, range.getStartOffset()); + assertEquals('End node is table', table, range.getEndNode()); + assertEquals('End offset is 1', 1, range.getEndOffset()); +} + +testSuite({ + setUpPage() { + logo = dom.getElement('logo'); + table = dom.getElement('table'); + }, + + testCreateFromElement() { + if (!userAgent.IE) { + return; + } + assertNotNull( + 'Control range object can be created for element', + DomControlRange.createFromElements(logo)); + }, + + testCreateFromRange() { + if (!userAgent.IE) { + return; + } + const range = document.body.createControlRange(); + range.addElement(table); + assertNotNull( + 'Control range object can be created for element', + DomControlRange.createFromBrowserRange(range)); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testControlRangeIterator() { + if (!userAgent.IE) { + return; + } + // Each node is included twice - once as a start tag, once as an end. + const expectedContent = [ + '#logo', '#logo', '#table', '#tbody', '#tr1', '#td11', 'a', '#td11', + '#td12', 'b', '#td12', '#tr1', '#tr2', '#td21', 'c', '#td21', + '#td22', 'd', '#td22', '#tr2', '#tbody', '#table', + ]; + testingDom.assertNodesMatch( + DomControlRange.createFromElements(logo, table), expectedContent, true); + testingDom.assertNodesMatch( + DomControlRange.createFromElements(logo, table), expectedContent, + false); + }, + + testBounds() { + if (!userAgent.IE) { + return; + } + + // Initialize in both orders. + helpTestBounds(DomControlRange.createFromElements(logo, table)); + helpTestBounds(DomControlRange.createFromElements(table, logo)); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testCollapse() { + if (!userAgent.IE) { + return; + } + + const range = DomControlRange.createFromElements(logo, table); + assertFalse('Not initially collapsed', range.isCollapsed()); + range.collapse(); + assertTrue('Successfully collapsed', range.isCollapsed()); + }, + + testGetContainer() { + if (!userAgent.IE) { + return; + } + + let range = DomControlRange.createFromElements(logo); + assertEquals( + 'Single element range is contained by itself', logo, + range.getContainer()); + + range = DomControlRange.createFromElements(logo, table); + assertEquals( + 'Two element range is contained by body', document.body, + range.getContainer()); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testSave() { + if (!userAgent.IE) { + return; + } + + let range = DomControlRange.createFromElements(logo, table); + const savedRange = range.saveUsingDom(); + + range.collapse(); + assertTrue('Successfully collapsed', range.isCollapsed()); + + range = savedRange.restore(); + assertEquals( + 'Restored a control range', RangeType.CONTROL, range.getType()); + assertFalse('Not collapsed after restore', range.isCollapsed()); + helpTestBounds(range); + }, + + testRemoveContents() { + if (!userAgent.IE) { + return; + } + + const img = dom.createDom(TagName.IMG); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + img.src = logo.src; + + const div = dom.getElement('test1'); + dom.removeChildren(div); + div.appendChild(img); + assertEquals('Div has 1 child', 1, div.childNodes.length); + + const range = DomControlRange.createFromElements(img); + range.removeContents(); + assertEquals('Div has 0 children', 0, div.childNodes.length); + assertTrue('Range is collapsed', range.isCollapsed()); + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testReplaceContents() { + // Test a control range. + if (!userAgent.IE) { + return; + } + + const outer = dom.getElement('test1'); + outer.innerHTML = '
    ' + + 'Hello ' + + '
    '; + const range = DomControlRange.createFromElements( + dom.getElementsByTagName(TagName.INPUT, outer)[0]); + DomControlRange.createFromElements(table); + range.replaceContentsWithNode(dom.createTextNode('World')); + assertEquals('Hello World', outer.firstChild.innerHTML); + }, + + testContainsRange() { + if (!userAgent.IE) { + return; + } + + const table2 = dom.getElement('table2'); + const table2td = dom.getElement('table2td'); + const logo2 = dom.getElement('logo2'); + + let range = DomControlRange.createFromElements(logo, table); + let range2 = DomControlRange.createFromElements(logo); + assertTrue( + 'Control range contains the other control range', + range.containsRange(range2)); + assertTrue( + 'Control range partially contains the other control range', + range2.containsRange(range, true)); + + range2 = DomControlRange.createFromElements(table2); + assertFalse( + 'Control range does not contain the other control range', + range.containsRange(range2)); + + range = DomControlRange.createFromElements(table2); + range2 = DomTextRange.createFromNodeContents(table2td); + assertTrue( + 'Control range contains text range', range.containsRange(range2)); + + range2 = DomTextRange.createFromNodeContents(table); + assertFalse( + 'Control range does not contain text range', + range.containsRange(range2)); + + range = DomControlRange.createFromElements(logo2); + range2 = DomTextRange.createFromNodeContents(table2); + assertFalse( + 'Control range does not fully contain text range', + range.containsRange(range2, false)); + + range2 = DomControlRange.createFromElements(table2); + assertTrue( + 'Control range contains the other control range (2)', + range2.containsRange(range)); + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testCloneRange() { + if (!userAgent.IE) { + return; + } + const range = DomControlRange.createFromElements(logo); + assertNotNull('Control range object created for element', range); + + const cloneRange = range.clone(); + assertNotNull('Cloned control range object', cloneRange); + assertArrayEquals( + 'Control range and clone have same elements', range.getElements(), + cloneRange.getElements()); + }, +}); diff --git a/closure/goog/dom/controlrange_test_dom.html b/closure/goog/dom/controlrange_test_dom.html new file mode 100644 index 0000000000..2685362239 --- /dev/null +++ b/closure/goog/dom/controlrange_test_dom.html @@ -0,0 +1,26 @@ + +
    +
    + +
    + +
    ab
    cd
    + + + + + + + +
    moof
    + foo + + bar +
    diff --git a/closure/goog/dom/dataset.js b/closure/goog/dom/dataset.js new file mode 100644 index 0000000000..c9726762ed --- /dev/null +++ b/closure/goog/dom/dataset.js @@ -0,0 +1,196 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for adding, removing and setting values in + * an Element's dataset. + * See {@link http://www.w3.org/TR/html5/Overview.html#dom-dataset}. + */ + +goog.provide('goog.dom.dataset'); + +goog.require('goog.labs.userAgent.browser'); +goog.require('goog.string'); +goog.require('goog.userAgent.product'); + + +/** + * Whether using the dataset property is allowed. + * + * In IE (up to and including IE 11), setting element.dataset in JS does not + * propagate values to CSS, breaking expressions such as + * `content: attr(data-content)` that would otherwise work. + * See {@link https://github.com/google/closure-library/issues/396}. + * + * In Safari >= 9, reading from element.dataset sometimes returns + * undefined, even though the corresponding data- attribute has a value. + * See {@link https://bugs.webkit.org/show_bug.cgi?id=161454}. + * @const + * @private + */ +goog.dom.dataset.ALLOWED_ = + !goog.userAgent.product.IE && !goog.labs.userAgent.browser.isSafari(); + + +/** + * The DOM attribute name prefix that must be present for it to be considered + * for a dataset. + * @type {string} + * @const + * @private + */ +goog.dom.dataset.PREFIX_ = 'data-'; + + +/** + * Returns whether a string is a valid dataset property name. + * @param {string} key Property name for the custom data attribute. + * @return {boolean} Whether the string is a valid dataset property name. + * @private + */ +goog.dom.dataset.isValidProperty_ = function(key) { + 'use strict'; + return !/-[a-z]/.test(key); +}; + + +/** + * Sets a custom data attribute on an element. The key should be + * in camelCase format (e.g "keyName" for the "data-key-name" attribute). + * @param {Element} element DOM node to set the custom data attribute on. + * @param {string} key Key for the custom data attribute. + * @param {string} value Value for the custom data attribute. + */ +goog.dom.dataset.set = function(element, key, value) { + 'use strict'; + var htmlElement = /** @type {HTMLElement} */ (element); + if (goog.dom.dataset.ALLOWED_ && htmlElement.dataset) { + htmlElement.dataset[key] = value; + } else if (!goog.dom.dataset.isValidProperty_(key)) { + throw new Error( + goog.DEBUG ? '"' + key + '" is not a valid dataset property name.' : + ''); + } else { + element.setAttribute( + goog.dom.dataset.PREFIX_ + goog.string.toSelectorCase(key), value); + } +}; + + +/** + * Gets a custom data attribute from an element. The key should be + * in camelCase format (e.g "keyName" for the "data-key-name" attribute). + * @param {Element} element DOM node to get the custom data attribute from. + * @param {string} key Key for the custom data attribute. + * @return {?string} The attribute value, if it exists. + */ +goog.dom.dataset.get = function(element, key) { + 'use strict'; + // Edge, unlike other browsers, will do camel-case conversion when retrieving + // "dash-case" properties. + if (!goog.dom.dataset.isValidProperty_(key)) { + return null; + } + var htmlElement = /** @type {HTMLElement} */ (element); + if (goog.dom.dataset.ALLOWED_ && htmlElement.dataset) { + // Android browser (non-chrome) returns the empty string for + // element.dataset['doesNotExist']. + if (goog.labs.userAgent.browser.isAndroidBrowser() && + !(key in htmlElement.dataset)) { + return null; + } + var value = htmlElement.dataset[key]; + return value === undefined ? null : value; + } else { + return htmlElement.getAttribute( + goog.dom.dataset.PREFIX_ + goog.string.toSelectorCase(key)); + } +}; + + +/** + * Removes a custom data attribute from an element. The key should be + * in camelCase format (e.g "keyName" for the "data-key-name" attribute). + * @param {Element} element DOM node to get the custom data attribute from. + * @param {string} key Key for the custom data attribute. + */ +goog.dom.dataset.remove = function(element, key) { + 'use strict'; + // Edge, unlike other browsers, will do camel-case conversion when removing + // "dash-case" properties. + if (!goog.dom.dataset.isValidProperty_(key)) { + return; + } + var htmlElement = /** @type {HTMLElement} */ (element); + if (goog.dom.dataset.ALLOWED_ && htmlElement.dataset) { + // In strict mode Safari will trigger an error when trying to delete a + // property which does not exist. + if (goog.dom.dataset.has(element, key)) { + delete htmlElement.dataset[key]; + } + } else { + element.removeAttribute( + goog.dom.dataset.PREFIX_ + goog.string.toSelectorCase(key)); + } +}; + + +/** + * Checks whether custom data attribute exists on an element. The key should be + * in camelCase format (e.g "keyName" for the "data-key-name" attribute). + * + * @param {Element} element DOM node to get the custom data attribute from. + * @param {string} key Key for the custom data attribute. + * @return {boolean} Whether the attribute exists. + */ +goog.dom.dataset.has = function(element, key) { + 'use strict'; + // Edge, unlike other browsers, will do camel-case conversion when retrieving + // "dash-case" properties. + if (!goog.dom.dataset.isValidProperty_(key)) { + return false; + } + var htmlElement = /** @type {HTMLElement} */ (element); + if (goog.dom.dataset.ALLOWED_ && htmlElement.dataset) { + return key in htmlElement.dataset; + } else if (htmlElement.hasAttribute) { + return htmlElement.hasAttribute( + goog.dom.dataset.PREFIX_ + goog.string.toSelectorCase(key)); + } else { + return !!(htmlElement.getAttribute( + goog.dom.dataset.PREFIX_ + goog.string.toSelectorCase(key))); + } +}; + + +/** + * Gets all custom data attributes as a string map. The attribute names will be + * camel cased (e.g., data-foo-bar -> dataset['fooBar']). This operation is not + * safe for attributes having camel-cased names clashing with already existing + * properties (e.g., data-to-string -> dataset['toString']). + * @param {!Element} element DOM node to get the data attributes from. + * @return {!Object} The string map containing data attributes and their + * respective values. + */ +goog.dom.dataset.getAll = function(element) { + 'use strict'; + var htmlElement = /** @type {HTMLElement} */ (element); + if (goog.dom.dataset.ALLOWED_ && htmlElement.dataset) { + return htmlElement.dataset; + } else { + var dataset = {}; + var attributes = element.attributes; + for (var i = 0; i < attributes.length; ++i) { + var attribute = attributes[i]; + if (goog.string.startsWith(attribute.name, goog.dom.dataset.PREFIX_)) { + // We use slice(5), since it's faster than replacing 'data-' with ''. + var key = goog.string.toCamelCase(attribute.name.slice(5)); + dataset[key] = attribute.value; + } + } + return dataset; + } +}; diff --git a/closure/goog/dom/dataset_test.js b/closure/goog/dom/dataset_test.js new file mode 100644 index 0000000000..9dd3633586 --- /dev/null +++ b/closure/goog/dom/dataset_test.js @@ -0,0 +1,137 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.datasetTest'); +goog.setTestOnly(); + +const dataset = goog.require('goog.dom.dataset'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); + +const $ = dom.getElement; + +testSuite({ + setUp() { + const el = $('el2'); + el.setAttribute('data-dynamic-key', 'dynamic'); + }, + + testHas() { + const el = $('el1'); + + assertTrue( + 'Dataset should have an existing key', dataset.has(el, 'basicKey')); + assertTrue( + 'Dataset should have an existing (unusual) key', + dataset.has(el, 'UnusualKey1')); + assertTrue( + 'Dataset should have an existing (unusual) key', + dataset.has(el, 'unusual-Key2')); + assertTrue( + 'Dataset should have an existing (bizarre) key', + dataset.has(el, '-Bizarre--Key')); + assertTrue( + 'Dataset should have an existing but empty-value attribute key', + dataset.has(el, 'emptyString')); + assertTrue( + 'Dataset should have a boolean attribute key', + dataset.has(el, 'boolean')); + assertFalse( + 'Dataset should not have a non-existent key', + dataset.has(el, 'bogusKey')); + assertFalse( + 'Dataset should not have invalid key', dataset.has(el, 'basic-key')); + }, + + testGet() { + let el = $('el1'); + + assertEquals( + 'Dataset should return the proper value for an existing key', + dataset.get(el, 'basicKey'), 'basic'); + assertEquals( + 'Dataset should have an existing (unusual) key', + dataset.get(el, 'UnusualKey1'), 'unusual1'); + assertEquals( + 'Dataset should have an existing (unusual) key', + dataset.get(el, 'unusual-Key2'), 'unusual2'); + assertEquals( + 'Dataset should have an existing (bizarre) key', + dataset.get(el, '-Bizarre--Key'), 'bizarre'); + assertEquals( + 'Dataset should have an existing but empty-value attribute key', + dataset.get(el, 'emptyString'), ''); + assertEquals( + 'Dataset should have a boolean attribute key', + dataset.get(el, 'boolean'), ''); + assertNull( + 'Dataset should return null for a non-existent key', + dataset.get(el, 'bogusKey')); + assertNull( + 'Dataset should return null for an invalid key', + dataset.get(el, 'basic-key')); + + el = $('el2'); + assertEquals( + 'Dataset should return the proper value for an existing key', + dataset.get(el, 'dynamicKey'), 'dynamic'); + }, + + testSet() { + const el = $('el2'); + + dataset.set(el, 'newKey', 'newValue'); + assertTrue( + 'Dataset should have a newly created key', dataset.has(el, 'newKey')); + assertEquals( + 'Dataset should return the proper value for a newly created key', + dataset.get(el, 'newKey'), 'newValue'); + + dataset.set(el, 'dynamicKey', 'customValue'); + assertTrue( + 'Dataset should have a modified, existing key', + dataset.has(el, 'dynamicKey')); + assertEquals( + 'Dataset should return the proper value for a modified key', + dataset.get(el, 'dynamicKey'), 'customValue'); + + assertThrows('Invalid key should fail noticeably', () => { + dataset.set(el, 'basic-key', ''); + }); + }, + + testRemove() { + const el = $('el2'); + + assertTrue('Dataset starts with key', dataset.has(el, 'dynamicKey')); + dataset.remove(el, 'dynamic-key'); + assertTrue('Dataset should still have key', dataset.has(el, 'dynamicKey')); + + dataset.remove(el, 'dynamicKey'); + assertFalse( + 'Dataset should not have a removed key', dataset.has(el, 'dynamicKey')); + assertNull( + 'Dataset should return null for removed key', + dataset.get(el, 'dynamicKey')); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testGetAll() { + const el = $('el1'); + const expectedDataset = { + 'basicKey': 'basic', + 'UnusualKey1': 'unusual1', + 'unusual-Key2': 'unusual2', + '-Bizarre--Key': 'bizarre', + 'emptyString': '', + 'boolean': '', + }; + assertHashEquals( + 'Dataset should have basicKey, UnusualKey1, ' + + 'unusual-Key2, and -Bizarre--Key', + expectedDataset, dataset.getAll(el)); + }, +}); diff --git a/closure/goog/dom/dataset_test_dom.html b/closure/goog/dom/dataset_test_dom.html new file mode 100644 index 0000000000..d889c6cd78 --- /dev/null +++ b/closure/goog/dom/dataset_test_dom.html @@ -0,0 +1,10 @@ + + + + + diff --git a/closure/goog/dom/dom.js b/closure/goog/dom/dom.js new file mode 100644 index 0000000000..1caa66cbd7 --- /dev/null +++ b/closure/goog/dom/dom.js @@ -0,0 +1,3490 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for manipulating the browser's Document Object Model + * Inspiration taken *heavily* from mochikit (http://mochikit.com/). + * + * You can use {@link goog.dom.DomHelper} to create new dom helpers that refer + * to a different document object. This is useful if you are working with + * frames or multiple windows. + * + * @suppress {strictMissingProperties} + */ + + +// TODO(arv): Rename/refactor getTextContent and getRawTextContent. The problem +// is that getTextContent should mimic the DOM3 textContent. We should add a +// getInnerText (or getText) which tries to return the visible text, innerText. + + +goog.provide('goog.dom'); +goog.provide('goog.dom.Appendable'); +goog.provide('goog.dom.DomHelper'); + +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.asserts.dom'); +goog.require('goog.dom.BrowserFeature'); +goog.require('goog.dom.NodeType'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.safe'); +goog.require('goog.html.SafeHtml'); +goog.require('goog.html.uncheckedconversions'); +goog.require('goog.math.Coordinate'); +goog.require('goog.math.Size'); +goog.require('goog.object'); +goog.require('goog.string'); +goog.require('goog.string.Const'); +goog.require('goog.string.Unicode'); +goog.require('goog.userAgent'); + + +/** + * @define {boolean} Whether we know at compile time that the browser is in + * quirks mode. + */ +goog.dom.ASSUME_QUIRKS_MODE = goog.define('goog.dom.ASSUME_QUIRKS_MODE', false); + + +/** + * @define {boolean} Whether we know at compile time that the browser is in + * standards compliance mode. + */ +goog.dom.ASSUME_STANDARDS_MODE = + goog.define('goog.dom.ASSUME_STANDARDS_MODE', false); + + +/** + * Whether we know the compatibility mode at compile time. + * @type {boolean} + * @private + */ +goog.dom.COMPAT_MODE_KNOWN_ = + goog.dom.ASSUME_QUIRKS_MODE || goog.dom.ASSUME_STANDARDS_MODE; + + +/** + * Gets the DomHelper object for the document where the element resides. + * @param {(Node|Window)=} opt_element If present, gets the DomHelper for this + * element. + * @return {!goog.dom.DomHelper} The DomHelper. + */ +goog.dom.getDomHelper = function(opt_element) { + 'use strict'; + return opt_element ? + new goog.dom.DomHelper(goog.dom.getOwnerDocument(opt_element)) : + (goog.dom.defaultDomHelper_ || + (goog.dom.defaultDomHelper_ = new goog.dom.DomHelper())); +}; + + +/** + * Cached default DOM helper. + * @type {!goog.dom.DomHelper|undefined} + * @private + */ +goog.dom.defaultDomHelper_; + + +/** + * Gets the document object being used by the dom library. + * @return {!Document} Document object. + */ +goog.dom.getDocument = function() { + 'use strict'; + return document; +}; + + +/** + * Gets an element from the current document by element id. + * + * If an Element is passed in, it is returned. + * + * @param {string|Element} element Element ID or a DOM node. + * @return {Element} The element with the given ID, or the node passed in. + */ +goog.dom.getElement = function(element) { + 'use strict'; + return goog.dom.getElementHelper_(document, element); +}; + + +/** + * Gets an HTML element from the current document by element id. + * + * @param {string} id + * @return {?HTMLElement} The element with the given ID or null if no such + * element exists. + */ +goog.dom.getHTMLElement = function(id) { + 'use strict' + const element = goog.dom.getElement(id); + if (!element) { + return null; + } + return goog.asserts.dom.assertIsHtmlElement(element); +}; + + +/** + * Gets an element by id from the given document (if present). + * If an element is given, it is returned. + * @param {!Document} doc + * @param {string|Element} element Element ID or a DOM node. + * @return {Element} The resulting element. + * @private + */ +goog.dom.getElementHelper_ = function(doc, element) { + 'use strict'; + return typeof element === 'string' ? doc.getElementById(element) : element; +}; + + +/** + * Gets an element by id, asserting that the element is found. + * + * This is used when an element is expected to exist, and should fail with + * an assertion error if it does not (if assertions are enabled). + * + * @param {string} id Element ID. + * @return {!Element} The element with the given ID, if it exists. + */ +goog.dom.getRequiredElement = function(id) { + 'use strict'; + return goog.dom.getRequiredElementHelper_(document, id); +}; + + +/** + * Gets an HTML element by id, asserting that the element is found. + * + * This is used when an element is expected to exist, and should fail with + * an assertion error if it does not (if assertions are enabled). + * + * @param {string} id Element ID. + * @return {!HTMLElement} The element with the given ID, if it exists. + */ +goog.dom.getRequiredHTMLElement = function(id) { + 'use strict' + return goog.asserts.dom.assertIsHtmlElement( + goog.dom.getRequiredElementHelper_(document, id)); +}; + + +/** + * Helper function for getRequiredElementHelper functions, both static and + * on DomHelper. Asserts the element with the given id exists. + * @param {!Document} doc + * @param {string} id + * @return {!Element} The element with the given ID, if it exists. + * @private + */ +goog.dom.getRequiredElementHelper_ = function(doc, id) { + 'use strict'; + // To prevent users passing in Elements as is permitted in getElement(). + goog.asserts.assertString(id); + var element = goog.dom.getElementHelper_(doc, id); + return goog.asserts.assert(element, 'No element found with id: ' + id); +}; + + +/** + * Alias for getElement. + * @param {string|Element} element Element ID or a DOM node. + * @return {Element} The element with the given ID, or the node passed in. + * @deprecated Use {@link goog.dom.getElement} instead. + */ +goog.dom.$ = goog.dom.getElement; + + +/** + * Gets elements by tag name. + * @param {!goog.dom.TagName} tagName + * @param {(!Document|!Element)=} opt_parent Parent element or document where to + * look for elements. Defaults to document. + * @return {!NodeList} List of elements. The members of the list are + * {!Element} if tagName is not a member of goog.dom.TagName or more + * specific types if it is (e.g. {!HTMLAnchorElement} for + * goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.getElementsByTagName = function(tagName, opt_parent) { + 'use strict'; + var parent = opt_parent || document; + return parent.getElementsByTagName(String(tagName)); +}; + + +/** + * Looks up elements by both tag and class name, using browser native functions + * (`querySelectorAll`, `getElementsByTagName` or + * `getElementsByClassName`) where possible. This function + * is a useful, if limited, way of collecting a list of DOM elements + * with certain characteristics. `querySelectorAll` offers a + * more powerful and general solution which allows matching on CSS3 + * selector expressions. + * + * Note that tag names are case sensitive in the SVG namespace, and this + * function converts opt_tag to uppercase for comparisons. For queries in the + * SVG namespace you should use querySelector or querySelectorAll instead. + * https://bugzilla.mozilla.org/show_bug.cgi?id=963870 + * https://bugs.webkit.org/show_bug.cgi?id=83438 + * + * @see {https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll} + * + * @param {(string|?goog.dom.TagName)=} opt_tag Element tag name. + * @param {?string=} opt_class Optional class name. + * @param {(Document|Element)=} opt_el Optional element to look in. + * @return {!IArrayLike} Array-like list of elements (only a length property + * and numerical indices are guaranteed to exist). The members of the array + * are {!Element} if opt_tag is not a member of goog.dom.TagName or more + * specific types if it is (e.g. {!HTMLAnchorElement} for + * goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.getElementsByTagNameAndClass = function(opt_tag, opt_class, opt_el) { + 'use strict'; + return goog.dom.getElementsByTagNameAndClass_( + document, opt_tag, opt_class, opt_el); +}; + + +/** + * Gets the first element matching the tag and the class. + * + * @param {(string|?goog.dom.TagName)=} opt_tag Element tag name. + * @param {?string=} opt_class Optional class name. + * @param {(Document|Element)=} opt_el Optional element to look in. + * @return {?R} Reference to a DOM node. The return type is {?Element} if + * tagName is a string or a more specific type if it is a member of + * goog.dom.TagName (e.g. {?HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.getElementByTagNameAndClass = function(opt_tag, opt_class, opt_el) { + 'use strict'; + return goog.dom.getElementByTagNameAndClass_( + document, opt_tag, opt_class, opt_el); +}; + + +/** + * Returns a static, array-like list of the elements with the provided + * className. + * + * @param {string} className the name of the class to look for. + * @param {(Document|Element)=} opt_el Optional element to look in. + * @return {!IArrayLike} The items found with the class name provided. + */ +goog.dom.getElementsByClass = function(className, opt_el) { + 'use strict'; + var parent = opt_el || document; + if (goog.dom.canUseQuerySelector_(parent)) { + return parent.querySelectorAll('.' + className); + } + return goog.dom.getElementsByTagNameAndClass_( + document, '*', className, opt_el); +}; + + +/** + * Returns the first element with the provided className. + * + * @param {string} className the name of the class to look for. + * @param {Element|Document=} opt_el Optional element to look in. + * @return {Element} The first item with the class name provided. + */ +goog.dom.getElementByClass = function(className, opt_el) { + 'use strict'; + var parent = opt_el || document; + var retVal = null; + if (parent.getElementsByClassName) { + retVal = parent.getElementsByClassName(className)[0]; + } else { + retVal = + goog.dom.getElementByTagNameAndClass_(document, '*', className, opt_el); + } + return retVal || null; +}; + + +/** + * Returns the first element with the provided className and asserts that it is + * an HTML element. + * + * @param {string} className the name of the class to look for. + * @param {!Element|!Document=} opt_parent Optional element to look in. + * @return {?HTMLElement} The first item with the class name provided. + */ +goog.dom.getHTMLElementByClass = function(className, opt_parent) { + 'use strict' + const element = goog.dom.getElementByClass(className, opt_parent); + if (!element) { + return null; + } + return goog.asserts.dom.assertIsHtmlElement(element); +}; + + +/** + * Ensures an element with the given className exists, and then returns the + * first element with the provided className. + * + * @param {string} className the name of the class to look for. + * @param {!Element|!Document=} opt_root Optional element or document to look + * in. + * @return {!Element} The first item with the class name provided. + * @throws {goog.asserts.AssertionError} Thrown if no element is found. + */ +goog.dom.getRequiredElementByClass = function(className, opt_root) { + 'use strict'; + var retValue = goog.dom.getElementByClass(className, opt_root); + return goog.asserts.assert( + retValue, 'No element found with className: ' + className); +}; + + +/** + * Ensures an element with the given className exists, and then returns the + * first element with the provided className after asserting that it is an + * HTML element. + * + * @param {string} className the name of the class to look for. + * @param {!Element|!Document=} opt_parent Optional element or document to look + * in. + * @return {!HTMLElement} The first item with the class name provided. + */ +goog.dom.getRequiredHTMLElementByClass = function(className, opt_parent) { + 'use strict' + const retValue = goog.dom.getElementByClass(className, opt_parent); + goog.asserts.assert( + retValue, 'No HTMLElement found with className: ' + className); + return goog.asserts.dom.assertIsHtmlElement(retValue); +}; + + +/** + * Prefer the standardized (http://www.w3.org/TR/selectors-api/), native and + * fast W3C Selectors API. + * @param {!(Element|Document)} parent The parent document object. + * @return {boolean} whether or not we can use parent.querySelector* APIs. + * @private + */ +goog.dom.canUseQuerySelector_ = function(parent) { + 'use strict'; + return !!(parent.querySelectorAll && parent.querySelector); +}; + + +/** + * Helper for `getElementsByTagNameAndClass`. + * @param {!Document} doc The document to get the elements in. + * @param {(string|?goog.dom.TagName)=} opt_tag Element tag name. + * @param {?string=} opt_class Optional class name. + * @param {(Document|Element)=} opt_el Optional element to look in. + * @return {!IArrayLike} Array-like list of elements (only a length property + * and numerical indices are guaranteed to exist). The members of the array + * are {!Element} if opt_tag is not a member of goog.dom.TagName or more + * specific types if it is (e.g. {!HTMLAnchorElement} for + * goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + * @private + */ +goog.dom.getElementsByTagNameAndClass_ = function( + doc, opt_tag, opt_class, opt_el) { + 'use strict'; + var parent = opt_el || doc; + var tagName = + (opt_tag && opt_tag != '*') ? String(opt_tag).toUpperCase() : ''; + + if (goog.dom.canUseQuerySelector_(parent) && (tagName || opt_class)) { + var query = tagName + (opt_class ? '.' + opt_class : ''); + return parent.querySelectorAll(query); + } + + // Use the native getElementsByClassName if available, under the assumption + // that even when the tag name is specified, there will be fewer elements to + // filter through when going by class than by tag name + if (opt_class && parent.getElementsByClassName) { + var els = parent.getElementsByClassName(opt_class); + + if (tagName) { + var arrayLike = {}; + var len = 0; + + // Filter for specific tags if requested. + for (var i = 0, el; el = els[i]; i++) { + if (tagName == el.nodeName) { + arrayLike[len++] = el; + } + } + arrayLike.length = len; + + return /** @type {!IArrayLike} */ (arrayLike); + } else { + return els; + } + } + + var els = parent.getElementsByTagName(tagName || '*'); + + if (opt_class) { + var arrayLike = {}; + var len = 0; + for (var i = 0, el; el = els[i]; i++) { + var className = el.className; + // Check if className has a split function since SVG className does not. + if (typeof className.split == 'function' && + goog.array.contains(className.split(/\s+/), opt_class)) { + arrayLike[len++] = el; + } + } + arrayLike.length = len; + return /** @type {!IArrayLike} */ (arrayLike); + } else { + return els; + } +}; + + +/** + * Helper for goog.dom.getElementByTagNameAndClass. + * + * @param {!Document} doc The document to get the elements in. + * @param {(string|?goog.dom.TagName)=} opt_tag Element tag name. + * @param {?string=} opt_class Optional class name. + * @param {(Document|Element)=} opt_el Optional element to look in. + * @return {?R} Reference to a DOM node. The return type is {?Element} if + * tagName is a string or a more specific type if it is a member of + * goog.dom.TagName (e.g. {?HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + * @private + */ +goog.dom.getElementByTagNameAndClass_ = function( + doc, opt_tag, opt_class, opt_el) { + 'use strict'; + var parent = opt_el || doc; + var tag = (opt_tag && opt_tag != '*') ? String(opt_tag).toUpperCase() : ''; + if (goog.dom.canUseQuerySelector_(parent) && (tag || opt_class)) { + return parent.querySelector(tag + (opt_class ? '.' + opt_class : '')); + } + var elements = + goog.dom.getElementsByTagNameAndClass_(doc, opt_tag, opt_class, opt_el); + return elements[0] || null; +}; + + + +/** + * Alias for `getElementsByTagNameAndClass`. + * @param {(string|?goog.dom.TagName)=} opt_tag Element tag name. + * @param {?string=} opt_class Optional class name. + * @param {Element=} opt_el Optional element to look in. + * @return {!IArrayLike} Array-like list of elements (only a length property + * and numerical indices are guaranteed to exist). The members of the array + * are {!Element} if opt_tag is not a member of goog.dom.TagName or more + * specific types if it is (e.g. {!HTMLAnchorElement} for + * goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + * @deprecated Use {@link goog.dom.getElementsByTagNameAndClass} instead. + */ +goog.dom.$$ = goog.dom.getElementsByTagNameAndClass; + + +/** + * Sets multiple properties, and sometimes attributes, on an element. Note that + * properties are simply object properties on the element instance, while + * attributes are visible in the DOM. Many properties map to attributes with the + * same names, some with different names, and there are also unmappable cases. + * + * This method sets properties by default (which means that custom attributes + * are not supported). These are the exeptions (some of which is legacy): + * - "style": Even though this is an attribute name, it is translated to a + * property, "style.cssText". Note that this property sanitizes and formats + * its value, unlike the attribute. + * - "class": This is an attribute name, it is translated to the "className" + * property. + * - "for": This is an attribute name, it is translated to the "htmlFor" + * property. + * - Entries in {@see goog.dom.DIRECT_ATTRIBUTE_MAP_} are set as attributes, + * this is probably due to browser quirks. + * - "aria-*", "data-*": Always set as attributes, they have no property + * counterparts. + * + * @param {Element} element DOM node to set properties on. + * @param {Object} properties Hash of property:value pairs. + * Property values can be strings or goog.string.TypedString values (such as + * goog.html.SafeUrl). + */ +goog.dom.setProperties = function(element, properties) { + 'use strict'; + goog.object.forEach(properties, function(val, key) { + 'use strict'; + if (val && typeof val == 'object' && val.implementsGoogStringTypedString) { + val = val.getTypedStringValue(); + } + if (key == 'style') { + element.style.cssText = val; + } else if (key == 'class') { + element.className = val; + } else if (key == 'for') { + element.htmlFor = val; + } else if (goog.dom.DIRECT_ATTRIBUTE_MAP_.hasOwnProperty(key)) { + element.setAttribute(goog.dom.DIRECT_ATTRIBUTE_MAP_[key], val); + } else if ( + goog.string.startsWith(key, 'aria-') || + goog.string.startsWith(key, 'data-')) { + element.setAttribute(key, val); + } else { + element[key] = val; + } + }); +}; + + +/** + * Map of attributes that should be set using + * element.setAttribute(key, val) instead of element[key] = val. Used + * by goog.dom.setProperties. + * + * @private {!Object} + * @const + */ +goog.dom.DIRECT_ATTRIBUTE_MAP_ = { + 'cellpadding': 'cellPadding', + 'cellspacing': 'cellSpacing', + 'colspan': 'colSpan', + 'frameborder': 'frameBorder', + 'height': 'height', + 'maxlength': 'maxLength', + 'nonce': 'nonce', + 'role': 'role', + 'rowspan': 'rowSpan', + 'type': 'type', + 'usemap': 'useMap', + 'valign': 'vAlign', + 'width': 'width' +}; + + +/** + * Gets the dimensions of the viewport. + * + * Gecko Standards mode: + * docEl.clientWidth Width of viewport excluding scrollbar. + * win.innerWidth Width of viewport including scrollbar. + * body.clientWidth Width of body element. + * + * docEl.clientHeight Height of viewport excluding scrollbar. + * win.innerHeight Height of viewport including scrollbar. + * body.clientHeight Height of document. + * + * Gecko Backwards compatible mode: + * docEl.clientWidth Width of viewport excluding scrollbar. + * win.innerWidth Width of viewport including scrollbar. + * body.clientWidth Width of viewport excluding scrollbar. + * + * docEl.clientHeight Height of document. + * win.innerHeight Height of viewport including scrollbar. + * body.clientHeight Height of viewport excluding scrollbar. + * + * IE6/7 Standards mode: + * docEl.clientWidth Width of viewport excluding scrollbar. + * win.innerWidth Undefined. + * body.clientWidth Width of body element. + * + * docEl.clientHeight Height of viewport excluding scrollbar. + * win.innerHeight Undefined. + * body.clientHeight Height of document element. + * + * IE5 + IE6/7 Backwards compatible mode: + * docEl.clientWidth 0. + * win.innerWidth Undefined. + * body.clientWidth Width of viewport excluding scrollbar. + * + * docEl.clientHeight 0. + * win.innerHeight Undefined. + * body.clientHeight Height of viewport excluding scrollbar. + * + * Opera 9 Standards and backwards compatible mode: + * docEl.clientWidth Width of viewport excluding scrollbar. + * win.innerWidth Width of viewport including scrollbar. + * body.clientWidth Width of viewport excluding scrollbar. + * + * docEl.clientHeight Height of document. + * win.innerHeight Height of viewport including scrollbar. + * body.clientHeight Height of viewport excluding scrollbar. + * + * WebKit: + * Safari 2 + * docEl.clientHeight Same as scrollHeight. + * docEl.clientWidth Same as innerWidth. + * win.innerWidth Width of viewport excluding scrollbar. + * win.innerHeight Height of the viewport including scrollbar. + * frame.innerHeight Height of the viewport excluding scrollbar. + * + * Safari 3 (tested in 522) + * + * docEl.clientWidth Width of viewport excluding scrollbar. + * docEl.clientHeight Height of viewport excluding scrollbar in strict mode. + * body.clientHeight Height of viewport excluding scrollbar in quirks mode. + * + * @param {Window=} opt_window Optional window element to test. + * @return {!goog.math.Size} Object with values 'width' and 'height'. + */ +goog.dom.getViewportSize = function(opt_window) { + 'use strict'; + // TODO(arv): This should not take an argument + return goog.dom.getViewportSize_(opt_window || window); +}; + + +/** + * Helper for `getViewportSize`. + * @param {Window} win The window to get the view port size for. + * @return {!goog.math.Size} Object with values 'width' and 'height'. + * @private + */ +goog.dom.getViewportSize_ = function(win) { + 'use strict'; + var doc = win.document; + var el = goog.dom.isCss1CompatMode_(doc) ? doc.documentElement : doc.body; + return new goog.math.Size(el.clientWidth, el.clientHeight); +}; + + +/** + * Calculates the height of the document. + * + * @return {number} The height of the current document. + */ +goog.dom.getDocumentHeight = function() { + 'use strict'; + return goog.dom.getDocumentHeight_(window); +}; + +/** + * Calculates the height of the document of the given window. + * + * @param {!Window} win The window whose document height to retrieve. + * @return {number} The height of the document of the given window. + */ +goog.dom.getDocumentHeightForWindow = function(win) { + 'use strict'; + return goog.dom.getDocumentHeight_(win); +}; + +/** + * Calculates the height of the document of the given window. + * + * Function code copied from the opensocial gadget api: + * gadgets.window.adjustHeight(opt_height) + * + * @private + * @param {!Window} win The window whose document height to retrieve. + * @return {number} The height of the document of the given window. + */ +goog.dom.getDocumentHeight_ = function(win) { + 'use strict'; + // NOTE(eae): This method will return the window size rather than the document + // size in webkit quirks mode. + var doc = win.document; + var height = 0; + + if (doc) { + // Calculating inner content height is hard and different between + // browsers rendering in Strict vs. Quirks mode. We use a combination of + // three properties within document.body and document.documentElement: + // - scrollHeight + // - offsetHeight + // - clientHeight + // These values differ significantly between browsers and rendering modes. + // But there are patterns. It just takes a lot of time and persistence + // to figure out. + + var body = doc.body; + var docEl = /** @type {!HTMLElement} */ (doc.documentElement); + if (!(docEl && body)) { + return 0; + } + + // Get the height of the viewport + var vh = goog.dom.getViewportSize_(win).height; + if (goog.dom.isCss1CompatMode_(doc) && docEl.scrollHeight) { + // In Strict mode: + // The inner content height is contained in either: + // document.documentElement.scrollHeight + // document.documentElement.offsetHeight + // Based on studying the values output by different browsers, + // use the value that's NOT equal to the viewport height found above. + height = + docEl.scrollHeight != vh ? docEl.scrollHeight : docEl.offsetHeight; + } else { + // In Quirks mode: + // documentElement.clientHeight is equal to documentElement.offsetHeight + // except in IE. In most browsers, document.documentElement can be used + // to calculate the inner content height. + // However, in other browsers (e.g. IE), document.body must be used + // instead. How do we know which one to use? + // If document.documentElement.clientHeight does NOT equal + // document.documentElement.offsetHeight, then use document.body. + var sh = docEl.scrollHeight; + var oh = docEl.offsetHeight; + if (docEl.clientHeight != oh) { + sh = body.scrollHeight; + oh = body.offsetHeight; + } + + // Detect whether the inner content height is bigger or smaller + // than the bounding box (viewport). If bigger, take the larger + // value. If smaller, take the smaller value. + if (sh > vh) { + // Content is larger + height = sh > oh ? sh : oh; + } else { + // Content is smaller + height = sh < oh ? sh : oh; + } + } + } + + return height; +}; + + +/** + * Gets the page scroll distance as a coordinate object. + * + * @param {Window=} opt_window Optional window element to test. + * @return {!goog.math.Coordinate} Object with values 'x' and 'y'. + * @deprecated Use {@link goog.dom.getDocumentScroll} instead. + */ +goog.dom.getPageScroll = function(opt_window) { + 'use strict'; + var win = opt_window || goog.global || window; + return goog.dom.getDomHelper(win.document).getDocumentScroll(); +}; + + +/** + * Gets the document scroll distance as a coordinate object. + * + * @return {!goog.math.Coordinate} Object with values 'x' and 'y'. + */ +goog.dom.getDocumentScroll = function() { + 'use strict'; + return goog.dom.getDocumentScroll_(document); +}; + + +/** + * Helper for `getDocumentScroll`. + * + * @param {!Document} doc The document to get the scroll for. + * @return {!goog.math.Coordinate} Object with values 'x' and 'y'. + * @private + */ +goog.dom.getDocumentScroll_ = function(doc) { + 'use strict'; + var el = goog.dom.getDocumentScrollElement_(doc); + var win = goog.dom.getWindow_(doc); + if (goog.userAgent.IE && win.pageYOffset != el.scrollTop) { + // The keyboard on IE10 touch devices shifts the page using the pageYOffset + // without modifying scrollTop. For this case, we want the body scroll + // offsets. + return new goog.math.Coordinate(el.scrollLeft, el.scrollTop); + } + return new goog.math.Coordinate( + win.pageXOffset || el.scrollLeft, win.pageYOffset || el.scrollTop); +}; + + +/** + * Gets the document scroll element. + * @return {!Element} Scrolling element. + */ +goog.dom.getDocumentScrollElement = function() { + 'use strict'; + return goog.dom.getDocumentScrollElement_(document); +}; + + +/** + * Helper for `getDocumentScrollElement`. + * @param {!Document} doc The document to get the scroll element for. + * @return {!Element} Scrolling element. + * @private + */ +goog.dom.getDocumentScrollElement_ = function(doc) { + 'use strict'; + // Old WebKit needs body.scrollLeft in both quirks mode and strict mode. We + // also default to the documentElement if the document does not have a body + // (e.g. a SVG document). + // Uses http://dev.w3.org/csswg/cssom-view/#dom-document-scrollingelement to + // avoid trying to guess about browser behavior from the UA string. + if (doc.scrollingElement) { + return doc.scrollingElement; + } + if (!goog.userAgent.WEBKIT && goog.dom.isCss1CompatMode_(doc)) { + return doc.documentElement; + } + return doc.body || doc.documentElement; +}; + + +/** + * Gets the window object associated with the given document. + * + * @param {Document=} opt_doc Document object to get window for. + * @return {!Window} The window associated with the given document. + */ +goog.dom.getWindow = function(opt_doc) { + 'use strict'; + // TODO(arv): This should not take an argument. + return opt_doc ? goog.dom.getWindow_(opt_doc) : window; +}; + + +/** + * Helper for `getWindow`. + * + * @param {!Document} doc Document object to get window for. + * @return {!Window} The window associated with the given document. + * @private + */ +goog.dom.getWindow_ = function(doc) { + 'use strict'; + return /** @type {!Window} */ (doc.parentWindow || doc.defaultView); +}; + + +/** + * Returns a dom node with a set of attributes. This function accepts varargs + * for subsequent nodes to be added. Subsequent nodes will be added to the + * first node as childNodes. + * + * So: + * createDom(goog.dom.TagName.DIV, null, createDom(goog.dom.TagName.P), + * createDom(goog.dom.TagName.P)); would return a div with two child + * paragraphs + * + * This function uses {@link goog.dom.setProperties} to set attributes: the + * `opt_attributes` parameter follows the same rules. + * + * @param {string|!goog.dom.TagName} tagName Tag to create. + * @param {?Object|?Array|string=} opt_attributes If object, then a map + * of name-value pairs for attributes. If a string, then this is the + * className of the new element. If an array, the elements will be joined + * together as the className of the new element. + * @param {...(Object|string|Array|NodeList|null|undefined)} var_args Further + * DOM nodes or strings for text nodes. If one of the var_args is an array + * or NodeList, its elements will be added as childNodes instead. + * @return {R} Reference to a DOM node. The return type is {!Element} if tagName + * is a string or a more specific type if it is a member of + * goog.dom.TagName (e.g. {!HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.createDom = function(tagName, opt_attributes, var_args) { + 'use strict'; + return goog.dom.createDom_(document, arguments); +}; + + +/** + * Helper for `createDom`. + * @param {!Document} doc The document to create the DOM in. + * @param {!Arguments} args Argument object passed from the callers. See + * `goog.dom.createDom` for details. + * @return {!Element} Reference to a DOM node. + * @private + */ +goog.dom.createDom_ = function(doc, args) { + 'use strict'; + var tagName = String(args[0]); + var attributes = args[1]; + + var element = goog.dom.createElement_(doc, tagName); + + if (attributes) { + if (typeof attributes === 'string') { + element.className = attributes; + } else if (Array.isArray(attributes)) { + element.className = attributes.join(' '); + } else { + goog.dom.setProperties(element, attributes); + } + } + + if (args.length > 2) { + goog.dom.append_(doc, element, args, 2); + } + + return element; +}; + + +/** + * Appends a node with text or other nodes. + * @param {!Document} doc The document to create new nodes in. + * @param {!Node} parent The node to append nodes to. + * @param {!Arguments} args The values to add. See `goog.dom.append`. + * @param {number} startIndex The index of the array to start from. + * @private + */ +goog.dom.append_ = function(doc, parent, args, startIndex) { + 'use strict'; + function childHandler(child) { + // TODO(user): More coercion, ala MochiKit? + if (child) { + parent.appendChild( + typeof child === 'string' ? doc.createTextNode(child) : child); + } + } + + for (var i = startIndex; i < args.length; i++) { + var arg = args[i]; + // TODO(attila): Fix isArrayLike to return false for a text node. + if (goog.isArrayLike(arg) && !goog.dom.isNodeLike(arg)) { + // If the argument is a node list, not a real array, use a clone, + // because forEach can't be used to mutate a NodeList. + goog.array.forEach( + goog.dom.isNodeList(arg) ? goog.array.toArray(arg) : arg, + childHandler); + } else { + childHandler(arg); + } + } +}; + + +/** + * Alias for `createDom`. + * @param {string|!goog.dom.TagName} tagName Tag to create. + * @param {?Object|?Array|string=} opt_attributes If object, then a map + * of name-value pairs for attributes. If a string, then this is the + * className of the new element. If an array, the elements will be joined + * together as the className of the new element. + * @param {...(Object|string|Array|NodeList|null|undefined)} var_args Further + * DOM nodes or strings for text nodes. If one of the var_args is an array, + * its children will be added as childNodes instead. + * @return {R} Reference to a DOM node. The return type is {!Element} if tagName + * is a string or a more specific type if it is a member of + * goog.dom.TagName (e.g. {!HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + * @deprecated Use {@link goog.dom.createDom} instead. + */ +goog.dom.$dom = goog.dom.createDom; + + +/** + * Creates a new element. + * @param {string|!goog.dom.TagName} name Tag to create. + * @return {R} The new element. The return type is {!Element} if name is + * a string or a more specific type if it is a member of goog.dom.TagName + * (e.g. {!HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.createElement = function(name) { + 'use strict'; + return goog.dom.createElement_(document, name); +}; + + +/** + * Creates a new element. + * @param {!Document} doc The document to create the element in. + * @param {string|!goog.dom.TagName} name Tag to create. + * @return {R} The new element. The return type is {!Element} if name is + * a string or a more specific type if it is a member of goog.dom.TagName + * (e.g. {!HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + * @private + */ +goog.dom.createElement_ = function(doc, name) { + 'use strict'; + name = String(name); + if (doc.contentType === 'application/xhtml+xml') name = name.toLowerCase(); + return doc.createElement(name); +}; + + +/** + * Creates a new text node. + * @param {number|string} content Content. + * @return {!Text} The new text node. + */ +goog.dom.createTextNode = function(content) { + 'use strict'; + return document.createTextNode(String(content)); +}; + + +/** + * Create a table. + * @param {number} rows The number of rows in the table. Must be >= 1. + * @param {number} columns The number of columns in the table. Must be >= 1. + * @param {boolean=} opt_fillWithNbsp If true, fills table entries with + * `goog.string.Unicode.NBSP` characters. + * @return {!Element} The created table. + */ +goog.dom.createTable = function(rows, columns, opt_fillWithNbsp) { + 'use strict'; + // TODO(mlourenco): Return HTMLTableElement, also in prototype function. + // Callers need to be updated to e.g. not assign numbers to table.cellSpacing. + return goog.dom.createTable_(document, rows, columns, !!opt_fillWithNbsp); +}; + + +/** + * Create a table. + * @param {!Document} doc Document object to use to create the table. + * @param {number} rows The number of rows in the table. Must be >= 1. + * @param {number} columns The number of columns in the table. Must be >= 1. + * @param {boolean} fillWithNbsp If true, fills table entries with + * `goog.string.Unicode.NBSP` characters. + * @return {!HTMLTableElement} The created table. + * @private + */ +goog.dom.createTable_ = function(doc, rows, columns, fillWithNbsp) { + 'use strict'; + var table = goog.dom.createElement_(doc, goog.dom.TagName.TABLE); + var tbody = + table.appendChild(goog.dom.createElement_(doc, goog.dom.TagName.TBODY)); + for (var i = 0; i < rows; i++) { + var tr = goog.dom.createElement_(doc, goog.dom.TagName.TR); + for (var j = 0; j < columns; j++) { + var td = goog.dom.createElement_(doc, goog.dom.TagName.TD); + // IE <= 9 will create a text node if we set text content to the empty + // string, so we avoid doing it unless necessary. This ensures that the + // same DOM tree is returned on all browsers. + if (fillWithNbsp) { + goog.dom.setTextContent(td, goog.string.Unicode.NBSP); + } + tr.appendChild(td); + } + tbody.appendChild(tr); + } + return table; +}; + + + +/** + * Creates a new Node from constant strings of HTML markup. + * @param {...!goog.string.Const} var_args The HTML strings to concatenate then + * convert into a node. + * @return {!Node} + */ +goog.dom.constHtmlToNode = function(var_args) { + 'use strict'; + var stringArray = + Array.prototype.map.call(arguments, goog.string.Const.unwrap); + var safeHtml = + goog.html.uncheckedconversions + .safeHtmlFromStringKnownToSatisfyTypeContract( + goog.string.Const.from( + 'Constant HTML string, that gets turned into a ' + + 'Node later, so it will be automatically balanced.'), + stringArray.join('')); + return goog.dom.safeHtmlToNode(safeHtml); +}; + + +/** + * Converts HTML markup into a node. This is a safe version of + * `goog.dom.htmlToDocumentFragment` which is now deleted. + * @param {!goog.html.SafeHtml} html The HTML markup to convert. + * @return {!Node} The resulting node. + */ +goog.dom.safeHtmlToNode = function(html) { + 'use strict'; + return goog.dom.safeHtmlToNode_(document, html); +}; + + +/** + * Helper for `safeHtmlToNode`. + * @param {!Document} doc The document. + * @param {!goog.html.SafeHtml} html The HTML markup to convert. + * @return {!Node} The resulting node. + * @private + */ +goog.dom.safeHtmlToNode_ = function(doc, html) { + 'use strict'; + var tempDiv = goog.dom.createElement_(doc, goog.dom.TagName.DIV); + if (goog.dom.BrowserFeature.INNER_HTML_NEEDS_SCOPED_ELEMENT) { + goog.dom.safe.setInnerHtml( + tempDiv, goog.html.SafeHtml.concat(goog.html.SafeHtml.BR, html)); + tempDiv.removeChild(goog.asserts.assert(tempDiv.firstChild)); + } else { + goog.dom.safe.setInnerHtml(tempDiv, html); + } + return goog.dom.childrenToNode_(doc, tempDiv); +}; + + +/** + * Helper for `safeHtmlToNode_`. + * @param {!Document} doc The document. + * @param {!Node} tempDiv The input node. + * @return {!Node} The resulting node. + * @private + */ +goog.dom.childrenToNode_ = function(doc, tempDiv) { + 'use strict'; + if (tempDiv.childNodes.length == 1) { + return tempDiv.removeChild(goog.asserts.assert(tempDiv.firstChild)); + } else { + var fragment = doc.createDocumentFragment(); + while (tempDiv.firstChild) { + fragment.appendChild(tempDiv.firstChild); + } + return fragment; + } +}; + + +/** + * Returns true if the browser is in "CSS1-compatible" (standards-compliant) + * mode, false otherwise. + * @return {boolean} True if in CSS1-compatible mode. + */ +goog.dom.isCss1CompatMode = function() { + 'use strict'; + return goog.dom.isCss1CompatMode_(document); +}; + + +/** + * Returns true if the browser is in "CSS1-compatible" (standards-compliant) + * mode, false otherwise. + * @param {!Document} doc The document to check. + * @return {boolean} True if in CSS1-compatible mode. + * @private + */ +goog.dom.isCss1CompatMode_ = function(doc) { + 'use strict'; + if (goog.dom.COMPAT_MODE_KNOWN_) { + return goog.dom.ASSUME_STANDARDS_MODE; + } + + return doc.compatMode == 'CSS1Compat'; +}; + + +/** + * Determines if the given node can contain children, intended to be used for + * HTML generation. + * + * IE natively supports node.canHaveChildren but has inconsistent behavior. + * Prior to IE8 the base tag allows children and in IE9 all nodes return true + * for canHaveChildren. + * + * In practice all non-IE browsers allow you to add children to any node, but + * the behavior is inconsistent: + * + *
    + *   var a = goog.dom.createElement(goog.dom.TagName.BR);
    + *   a.appendChild(document.createTextNode('foo'));
    + *   a.appendChild(document.createTextNode('bar'));
    + *   console.log(a.childNodes.length);  // 2
    + *   console.log(a.innerHTML);  // Chrome: "", IE9: "foobar", FF3.5: "foobar"
    + * 
    + * + * For more information, see: + * http://dev.w3.org/html5/markup/syntax.html#syntax-elements + * + * TODO(user): Rename shouldAllowChildren() ? + * + * @param {Node} node The node to check. + * @return {boolean} Whether the node can contain children. + */ +goog.dom.canHaveChildren = function(node) { + 'use strict'; + if (node.nodeType != goog.dom.NodeType.ELEMENT) { + return false; + } + switch (/** @type {!Element} */ (node).tagName) { + case String(goog.dom.TagName.APPLET): + case String(goog.dom.TagName.AREA): + case String(goog.dom.TagName.BASE): + case String(goog.dom.TagName.BR): + case String(goog.dom.TagName.COL): + case String(goog.dom.TagName.COMMAND): + case String(goog.dom.TagName.EMBED): + case String(goog.dom.TagName.FRAME): + case String(goog.dom.TagName.HR): + case String(goog.dom.TagName.IMG): + case String(goog.dom.TagName.INPUT): + case String(goog.dom.TagName.IFRAME): + case String(goog.dom.TagName.ISINDEX): + case String(goog.dom.TagName.KEYGEN): + case String(goog.dom.TagName.LINK): + case String(goog.dom.TagName.NOFRAMES): + case String(goog.dom.TagName.NOSCRIPT): + case String(goog.dom.TagName.META): + case String(goog.dom.TagName.OBJECT): + case String(goog.dom.TagName.PARAM): + case String(goog.dom.TagName.SCRIPT): + case String(goog.dom.TagName.SOURCE): + case String(goog.dom.TagName.STYLE): + case String(goog.dom.TagName.TRACK): + case String(goog.dom.TagName.WBR): + return false; + } + return true; +}; + + +/** + * Appends a child to a node. + * @param {Node} parent Parent. + * @param {Node} child Child. + */ +goog.dom.appendChild = function(parent, child) { + 'use strict'; + goog.asserts.assert( + parent != null && child != null, + 'goog.dom.appendChild expects non-null arguments'); + parent.appendChild(child); +}; + + +/** + * Appends a node with text or other nodes. + * @param {!Node} parent The node to append nodes to. + * @param {...goog.dom.Appendable} var_args The things to append to the node. + * If this is a Node it is appended as is. + * If this is a string then a text node is appended. + * If this is an array like object then fields 0 to length - 1 are appended. + */ +goog.dom.append = function(parent, var_args) { + 'use strict'; + goog.dom.append_(goog.dom.getOwnerDocument(parent), parent, arguments, 1); +}; + + +/** + * Removes all the child nodes on a DOM node. + * @param {Node} node Node to remove children from. + * @return {void} + */ +goog.dom.removeChildren = function(node) { + 'use strict'; + // Note: Iterations over live collections can be slow, this is the fastest + // we could find. The double parenthesis are used to prevent JsCompiler and + // strict warnings. + var child; + while ((child = node.firstChild)) { + node.removeChild(child); + } +}; + + +/** + * Inserts a new node before an existing reference node (i.e. as the previous + * sibling). If the reference node has no parent, then does nothing. + * @param {Node} newNode Node to insert. + * @param {Node} refNode Reference node to insert before. + */ +goog.dom.insertSiblingBefore = function(newNode, refNode) { + 'use strict'; + goog.asserts.assert( + newNode != null && refNode != null, + 'goog.dom.insertSiblingBefore expects non-null arguments'); + if (refNode.parentNode) { + refNode.parentNode.insertBefore(newNode, refNode); + } +}; + + +/** + * Inserts a new node after an existing reference node (i.e. as the next + * sibling). If the reference node has no parent, then does nothing. + * @param {Node} newNode Node to insert. + * @param {Node} refNode Reference node to insert after. + * @return {void} + */ +goog.dom.insertSiblingAfter = function(newNode, refNode) { + 'use strict'; + goog.asserts.assert( + newNode != null && refNode != null, + 'goog.dom.insertSiblingAfter expects non-null arguments'); + if (refNode.parentNode) { + refNode.parentNode.insertBefore(newNode, refNode.nextSibling); + } +}; + + +/** + * Insert a child at a given index. If index is larger than the number of child + * nodes that the parent currently has, the node is inserted as the last child + * node. + * @param {Element} parent The element into which to insert the child. + * @param {Node} child The element to insert. + * @param {number} index The index at which to insert the new child node. Must + * not be negative. + * @return {void} + */ +goog.dom.insertChildAt = function(parent, child, index) { + 'use strict'; + // Note that if the second argument is null, insertBefore + // will append the child at the end of the list of children. + goog.asserts.assert( + parent != null, 'goog.dom.insertChildAt expects a non-null parent'); + parent.insertBefore( + /** @type {!Node} */ (child), parent.childNodes[index] || null); +}; + + +/** + * Removes a node from its parent. + * @param {Node} node The node to remove. + * @return {Node} The node removed if removed; else, null. + */ +goog.dom.removeNode = function(node) { + 'use strict'; + return node && node.parentNode ? node.parentNode.removeChild(node) : null; +}; + + +/** + * Replaces a node in the DOM tree. Will do nothing if `oldNode` has no + * parent. + * @param {Node} newNode Node to insert. + * @param {Node} oldNode Node to replace. + */ +goog.dom.replaceNode = function(newNode, oldNode) { + 'use strict'; + goog.asserts.assert( + newNode != null && oldNode != null, + 'goog.dom.replaceNode expects non-null arguments'); + var parent = oldNode.parentNode; + if (parent) { + parent.replaceChild(newNode, oldNode); + } +}; + + +/** + * Replaces child nodes of `target` with child nodes of `source`. This is + * roughly equivalent to `target.innerHTML = source.innerHTML` which is not + * compatible with Trusted Types. + * @param {?Node} target Node to clean and replace its children. + * @param {?Node} source Node to get the children from. The nodes will be cloned + * so they will stay in source. + */ +goog.dom.copyContents = function(target, source) { + 'use strict'; + goog.asserts.assert( + target != null && source != null, + 'goog.dom.copyContents expects non-null arguments'); + var childNodes = source.cloneNode(/* deep= */ true).childNodes; + goog.dom.removeChildren(target); + while (childNodes.length) { + target.appendChild(childNodes[0]); + } +}; + + +/** + * Flattens an element. That is, removes it and replace it with its children. + * Does nothing if the element is not in the document. + * @param {Element} element The element to flatten. + * @return {Element|undefined} The original element, detached from the document + * tree, sans children; or undefined, if the element was not in the document + * to begin with. + */ +goog.dom.flattenElement = function(element) { + 'use strict'; + var child, parent = element.parentNode; + if (parent && parent.nodeType != goog.dom.NodeType.DOCUMENT_FRAGMENT) { + // Use IE DOM method (supported by Opera too) if available + if (element.removeNode) { + return /** @type {Element} */ (element.removeNode(false)); + } else { + // Move all children of the original node up one level. + while ((child = element.firstChild)) { + parent.insertBefore(child, element); + } + + // Detach the original element. + return /** @type {Element} */ (goog.dom.removeNode(element)); + } + } +}; + + +/** + * Returns an array containing just the element children of the given element. + * @param {Element} element The element whose element children we want. + * @return {!(Array|NodeList)} An array or array-like list + * of just the element children of the given element. + */ +goog.dom.getChildren = function(element) { + 'use strict'; + // We check if the children attribute is supported for child elements + // since IE8 misuses the attribute by also including comments. + if (element.children != undefined) { + return element.children; + } + // Fall back to manually filtering the element's child nodes. + return Array.prototype.filter.call(element.childNodes, function(node) { + return node.nodeType == goog.dom.NodeType.ELEMENT; + }); +}; + + +/** + * Returns the first child node that is an element. + * @param {Node} node The node to get the first child element of. + * @return {Element} The first child node of `node` that is an element. + */ +goog.dom.getFirstElementChild = function(node) { + 'use strict'; + if (node.firstElementChild !== undefined) { + return /** @type {!Element} */ (node).firstElementChild; + } + return goog.dom.getNextElementNode_(node.firstChild, true); +}; + + +/** + * Returns the last child node that is an element. + * @param {Node} node The node to get the last child element of. + * @return {Element} The last child node of `node` that is an element. + */ +goog.dom.getLastElementChild = function(node) { + 'use strict'; + if (node.lastElementChild !== undefined) { + return /** @type {!Element} */ (node).lastElementChild; + } + return goog.dom.getNextElementNode_(node.lastChild, false); +}; + + +/** + * Returns the first next sibling that is an element. + * @param {Node} node The node to get the next sibling element of. + * @return {Element} The next sibling of `node` that is an element. + */ +goog.dom.getNextElementSibling = function(node) { + 'use strict'; + if (node.nextElementSibling !== undefined) { + return /** @type {!Element} */ (node).nextElementSibling; + } + return goog.dom.getNextElementNode_(node.nextSibling, true); +}; + + +/** + * Returns the first previous sibling that is an element. + * @param {Node} node The node to get the previous sibling element of. + * @return {Element} The first previous sibling of `node` that is + * an element. + */ +goog.dom.getPreviousElementSibling = function(node) { + 'use strict'; + if (node.previousElementSibling !== undefined) { + return /** @type {!Element} */ (node).previousElementSibling; + } + return goog.dom.getNextElementNode_(node.previousSibling, false); +}; + + +/** + * Returns the first node that is an element in the specified direction, + * starting with `node`. + * @param {Node} node The node to get the next element from. + * @param {boolean} forward Whether to look forwards or backwards. + * @return {Element} The first element. + * @private + */ +goog.dom.getNextElementNode_ = function(node, forward) { + 'use strict'; + while (node && node.nodeType != goog.dom.NodeType.ELEMENT) { + node = forward ? node.nextSibling : node.previousSibling; + } + + return /** @type {Element} */ (node); +}; + + +/** + * Returns the next node in source order from the given node. + * @param {Node} node The node. + * @return {Node} The next node in the DOM tree, or null if this was the last + * node. + */ +goog.dom.getNextNode = function(node) { + 'use strict'; + if (!node) { + return null; + } + + if (node.firstChild) { + return node.firstChild; + } + + while (node && !node.nextSibling) { + node = node.parentNode; + } + + return node ? node.nextSibling : null; +}; + + +/** + * Returns the previous node in source order from the given node. + * @param {Node} node The node. + * @return {Node} The previous node in the DOM tree, or null if this was the + * first node. + */ +goog.dom.getPreviousNode = function(node) { + 'use strict'; + if (!node) { + return null; + } + + if (!node.previousSibling) { + return node.parentNode; + } + + node = node.previousSibling; + while (node && node.lastChild) { + node = node.lastChild; + } + + return node; +}; + + +/** + * Whether the object looks like a DOM node. + * @param {?} obj The object being tested for node likeness. + * @return {boolean} Whether the object looks like a DOM node. + */ +goog.dom.isNodeLike = function(obj) { + 'use strict'; + return goog.isObject(obj) && obj.nodeType > 0; +}; + + +/** + * Whether the object looks like an Element. + * @param {?} obj The object being tested for Element likeness. + * @return {boolean} Whether the object looks like an Element. + */ +goog.dom.isElement = function(obj) { + 'use strict'; + return goog.isObject(obj) && obj.nodeType == goog.dom.NodeType.ELEMENT; +}; + + +/** + * Returns true if the specified value is a Window object. This includes the + * global window for HTML pages, and iframe windows. + * @param {?} obj Variable to test. + * @return {boolean} Whether the variable is a window. + */ +goog.dom.isWindow = function(obj) { + 'use strict'; + return goog.isObject(obj) && obj['window'] == obj; +}; + + +/** + * Returns an element's parent, if it's an Element. + * @param {Element} element The DOM element. + * @return {Element} The parent, or null if not an Element. + */ +goog.dom.getParentElement = function(element) { + 'use strict'; + var parent; + if (goog.dom.BrowserFeature.CAN_USE_PARENT_ELEMENT_PROPERTY) { + parent = element.parentElement; + if (parent) { + return parent; + } + } + parent = element.parentNode; + return goog.dom.isElement(parent) ? /** @type {!Element} */ (parent) : null; +}; + + +/** + * Whether a node contains another node. + * @param {?Node|undefined} parent The node that should contain the other node. + * @param {?Node|undefined} descendant The node to test presence of. + * @return {boolean} Whether the parent node contains the descendant node. + */ +goog.dom.contains = function(parent, descendant) { + 'use strict'; + if (!parent || !descendant) { + return false; + } + // We use browser specific methods for this if available since it is faster + // that way. + + // IE DOM + if (parent.contains && descendant.nodeType == goog.dom.NodeType.ELEMENT) { + return parent == descendant || parent.contains(descendant); + } + + // W3C DOM Level 3 + if (typeof parent.compareDocumentPosition != 'undefined') { + return parent == descendant || + Boolean(parent.compareDocumentPosition(descendant) & 16); + } + + // W3C DOM Level 1 + while (descendant && parent != descendant) { + descendant = descendant.parentNode; + } + return descendant == parent; +}; + + +/** + * Compares the document order of two nodes, returning 0 if they are the same + * node, a negative number if node1 is before node2, and a positive number if + * node2 is before node1. Note that we compare the order the tags appear in the + * document so in the tree text the B node is considered to be + * before the I node. + * + * @param {Node} node1 The first node to compare. + * @param {Node} node2 The second node to compare. + * @return {number} 0 if the nodes are the same node, a negative number if node1 + * is before node2, and a positive number if node2 is before node1. + */ +goog.dom.compareNodeOrder = function(node1, node2) { + 'use strict'; + // Fall out quickly for equality. + if (node1 == node2) { + return 0; + } + + // Use compareDocumentPosition where available + if (node1.compareDocumentPosition) { + // 4 is the bitmask for FOLLOWS. + return node1.compareDocumentPosition(node2) & 2 ? 1 : -1; + } + + // Special case for document nodes on IE 7 and 8. + if (goog.userAgent.IE && !goog.userAgent.isDocumentModeOrHigher(9)) { + if (node1.nodeType == goog.dom.NodeType.DOCUMENT) { + return -1; + } + if (node2.nodeType == goog.dom.NodeType.DOCUMENT) { + return 1; + } + } + + // Process in IE using sourceIndex - we check to see if the first node has + // a source index or if its parent has one. + if ('sourceIndex' in node1 || + (node1.parentNode && 'sourceIndex' in node1.parentNode)) { + var isElement1 = node1.nodeType == goog.dom.NodeType.ELEMENT; + var isElement2 = node2.nodeType == goog.dom.NodeType.ELEMENT; + + if (isElement1 && isElement2) { + return node1.sourceIndex - node2.sourceIndex; + } else { + var parent1 = node1.parentNode; + var parent2 = node2.parentNode; + + if (parent1 == parent2) { + return goog.dom.compareSiblingOrder_(node1, node2); + } + + if (!isElement1 && goog.dom.contains(parent1, node2)) { + return -1 * goog.dom.compareParentsDescendantNodeIe_(node1, node2); + } + + + if (!isElement2 && goog.dom.contains(parent2, node1)) { + return goog.dom.compareParentsDescendantNodeIe_(node2, node1); + } + + return (isElement1 ? node1.sourceIndex : parent1.sourceIndex) - + (isElement2 ? node2.sourceIndex : parent2.sourceIndex); + } + } + + // For Safari, we compare ranges. + var doc = goog.dom.getOwnerDocument(node1); + + var range1, range2; + range1 = doc.createRange(); + range1.selectNode(node1); + range1.collapse(true); + + range2 = doc.createRange(); + range2.selectNode(node2); + range2.collapse(true); + + return range1.compareBoundaryPoints( + goog.global['Range'].START_TO_END, range2); +}; + + +/** + * Utility function to compare the position of two nodes, when + * `textNode`'s parent is an ancestor of `node`. If this entry + * condition is not met, this function will attempt to reference a null object. + * @param {!Node} textNode The textNode to compare. + * @param {Node} node The node to compare. + * @return {number} -1 if node is before textNode, +1 otherwise. + * @private + */ +goog.dom.compareParentsDescendantNodeIe_ = function(textNode, node) { + 'use strict'; + var parent = textNode.parentNode; + if (parent == node) { + // If textNode is a child of node, then node comes first. + return -1; + } + var sibling = node; + while (sibling.parentNode != parent) { + sibling = sibling.parentNode; + } + return goog.dom.compareSiblingOrder_(sibling, textNode); +}; + + +/** + * Utility function to compare the position of two nodes known to be non-equal + * siblings. + * @param {Node} node1 The first node to compare. + * @param {!Node} node2 The second node to compare. + * @return {number} -1 if node1 is before node2, +1 otherwise. + * @private + */ +goog.dom.compareSiblingOrder_ = function(node1, node2) { + 'use strict'; + var s = node2; + while ((s = s.previousSibling)) { + if (s == node1) { + // We just found node1 before node2. + return -1; + } + } + + // Since we didn't find it, node1 must be after node2. + return 1; +}; + + +/** + * Find the deepest common ancestor of the given nodes. + * @param {...Node} var_args The nodes to find a common ancestor of. + * @return {Node} The common ancestor of the nodes, or null if there is none. + * null will only be returned if two or more of the nodes are from different + * documents. + */ +goog.dom.findCommonAncestor = function(var_args) { + 'use strict'; + var i, count = arguments.length; + if (!count) { + return null; + } else if (count == 1) { + return arguments[0]; + } + + var paths = []; + var minLength = Infinity; + for (i = 0; i < count; i++) { + // Compute the list of ancestors. + var ancestors = []; + var node = arguments[i]; + while (node) { + ancestors.unshift(node); + node = node.parentNode; + } + + // Save the list for comparison. + paths.push(ancestors); + minLength = Math.min(minLength, ancestors.length); + } + var output = null; + for (i = 0; i < minLength; i++) { + var first = paths[0][i]; + for (var j = 1; j < count; j++) { + if (first != paths[j][i]) { + return output; + } + } + output = first; + } + return output; +}; + + +/** + * Returns whether node is in a document or detached. Throws an error if node + * itself is a document. This specifically handles two cases beyond naive use of + * builtins: (1) it works correctly in IE, and (2) it works for elements from + * different documents/iframes. If neither of these considerations are relevant + * then a simple `document.contains(node)` may be used instead. + * @param {!Node} node + * @return {boolean} + */ +goog.dom.isInDocument = function(node) { + 'use strict'; + return (node.ownerDocument.compareDocumentPosition(node) & 16) == 16; +}; + + +/** + * Returns the owner document for a node. + * @param {Node|Window} node The node to get the document for. + * @return {!Document} The document owning the node. + */ +goog.dom.getOwnerDocument = function(node) { + 'use strict'; + // TODO(nnaze): Update param signature to be non-nullable. + goog.asserts.assert(node, 'Node cannot be null or undefined.'); + return /** @type {!Document} */ ( + node.nodeType == goog.dom.NodeType.DOCUMENT ? + node : + node.ownerDocument || node.document); +}; + + +/** + * Cross-browser function for getting the document element of a frame or iframe. + * @param {Element} frame Frame element. + * @return {!Document} The frame content document. + */ +goog.dom.getFrameContentDocument = function(frame) { + 'use strict'; + return frame.contentDocument || + /** @type {!HTMLFrameElement} */ (frame).contentWindow.document; +}; + + +/** + * Cross-browser function for getting the window of a frame or iframe. + * @param {Element} frame Frame element. + * @return {Window} The window associated with the given frame, or null if none + * exists. + */ +goog.dom.getFrameContentWindow = function(frame) { + 'use strict'; + try { + return frame.contentWindow || + (frame.contentDocument ? goog.dom.getWindow(frame.contentDocument) : + null); + } catch (e) { + // NOTE(user): In IE8, checking the contentWindow or contentDocument + // properties will throw a "Unspecified Error" exception if the iframe is + // not inserted in the DOM. If we get this we can be sure that no window + // exists, so return null. + } + return null; +}; + + +/** + * Sets the text content of a node, with cross-browser support. + * @param {Node} node The node to change the text content of. + * @param {string|number} text The value that should replace the node's content. + * @return {void} + */ +goog.dom.setTextContent = function(node, text) { + 'use strict'; + goog.asserts.assert( + node != null, + 'goog.dom.setTextContent expects a non-null value for node'); + + if ('textContent' in node) { + node.textContent = text; + } else if (node.nodeType == goog.dom.NodeType.TEXT) { + /** @type {!Text} */ (node).data = String(text); + } else if ( + node.firstChild && node.firstChild.nodeType == goog.dom.NodeType.TEXT) { + // If the first child is a text node we just change its data and remove the + // rest of the children. + while (node.lastChild != node.firstChild) { + node.removeChild(goog.asserts.assert(node.lastChild)); + } + /** @type {!Text} */ (node.firstChild).data = String(text); + } else { + goog.dom.removeChildren(node); + var doc = goog.dom.getOwnerDocument(node); + node.appendChild(doc.createTextNode(String(text))); + } +}; + + +/** + * Gets the outerHTML of a node, which is like innerHTML, except that it + * actually contains the HTML of the node itself. + * @param {Element} element The element to get the HTML of. + * @return {string} The outerHTML of the given element. + */ +goog.dom.getOuterHtml = function(element) { + 'use strict'; + goog.asserts.assert( + element !== null, + 'goog.dom.getOuterHtml expects a non-null value for element'); + // IE, Opera and WebKit all have outerHTML. + if ('outerHTML' in element) { + return element.outerHTML; + } else { + var doc = goog.dom.getOwnerDocument(element); + var div = goog.dom.createElement_(doc, goog.dom.TagName.DIV); + div.appendChild(element.cloneNode(true)); + return div.innerHTML; + } +}; + + +/** + * Finds the first descendant node that matches the filter function, using depth + * first search. This function offers the most general purpose way of finding a + * matching element. + * + * Prefer using `querySelector` if the matching criteria can be expressed as a + * CSS selector, or `goog.dom.findElement` if you would filter for `nodeType == + * Node.ELEMENT_NODE`. + * + * @param {Node} root The root of the tree to search. + * @param {function(Node) : boolean} p The filter function. + * @return {Node|undefined} The found node or undefined if none is found. + */ +goog.dom.findNode = function(root, p) { + 'use strict'; + var rv = []; + var found = goog.dom.findNodes_(root, p, rv, true); + return found ? rv[0] : undefined; +}; + + +/** + * Finds all the descendant nodes that match the filter function, using depth + * first search. This function offers the most general-purpose way + * of finding a set of matching elements. + * + * Prefer using `querySelectorAll` if the matching criteria can be expressed as + * a CSS selector, or `goog.dom.findElements` if you would filter for + * `nodeType == Node.ELEMENT_NODE`. + * + * @param {Node} root The root of the tree to search. + * @param {function(Node) : boolean} p The filter function. + * @return {!Array} The found nodes or an empty array if none are found. + */ +goog.dom.findNodes = function(root, p) { + 'use strict'; + var rv = []; + goog.dom.findNodes_(root, p, rv, false); + return rv; +}; + + +/** + * Finds the first or all the descendant nodes that match the filter function, + * using a depth first search. + * @param {Node} root The root of the tree to search. + * @param {function(Node) : boolean} p The filter function. + * @param {!Array} rv The found nodes are added to this array. + * @param {boolean} findOne If true we exit after the first found node. + * @return {boolean} Whether the search is complete or not. True in case findOne + * is true and the node is found. False otherwise. + * @private + */ +goog.dom.findNodes_ = function(root, p, rv, findOne) { + 'use strict'; + if (root != null) { + var child = root.firstChild; + while (child) { + if (p(child)) { + rv.push(child); + if (findOne) { + return true; + } + } + if (goog.dom.findNodes_(child, p, rv, findOne)) { + return true; + } + child = child.nextSibling; + } + } + return false; +}; + + +/** + * Finds the first descendant element (excluding `root`) that matches the filter + * function, using depth first search. Prefer using `querySelector` if the + * matching criteria can be expressed as a CSS selector. + * + * @param {!Element | !Document} root + * @param {function(!Element): boolean} pred Filter function. + * @return {?Element} First matching element or null if there is none. + */ +goog.dom.findElement = function(root, pred) { + 'use strict'; + var stack = goog.dom.getChildrenReverse_(root); + while (stack.length > 0) { + var next = stack.pop(); + if (pred(next)) return next; + for (var c = next.lastElementChild; c; c = c.previousElementSibling) { + stack.push(c); + } + } + return null; +}; + + +/** + * Finds all the descendant elements (excluding `root`) that match the filter + * function, using depth first search. Prefer using `querySelectorAll` if the + * matching criteria can be expressed as a CSS selector. + * + * @param {!Element | !Document} root + * @param {function(!Element): boolean} pred Filter function. + * @return {!Array} + */ +goog.dom.findElements = function(root, pred) { + 'use strict'; + var result = [], stack = goog.dom.getChildrenReverse_(root); + while (stack.length > 0) { + var next = stack.pop(); + if (pred(next)) result.push(next); + for (var c = next.lastElementChild; c; c = c.previousElementSibling) { + stack.push(c); + } + } + return result; +}; + + +/** + * @param {!Element | !Document} node + * @return {!Array} node's child elements in reverse order. + * @private + */ +goog.dom.getChildrenReverse_ = function(node) { + 'use strict'; + // document.lastElementChild doesn't exist in IE9; fall back to + // documentElement. + if (node.nodeType == goog.dom.NodeType.DOCUMENT) { + return [node.documentElement]; + } else { + var children = []; + for (var c = node.lastElementChild; c; c = c.previousElementSibling) { + children.push(c); + } + return children; + } +}; + + +/** + * Map of tags whose content to ignore when calculating text length. + * @private {!Object} + * @const + */ +goog.dom.TAGS_TO_IGNORE_ = { + 'SCRIPT': 1, + 'STYLE': 1, + 'HEAD': 1, + 'IFRAME': 1, + 'OBJECT': 1 +}; + + +/** + * Map of tags which have predefined values with regard to whitespace. + * @private {!Object} + * @const + */ +goog.dom.PREDEFINED_TAG_VALUES_ = { + 'IMG': ' ', + 'BR': '\n' +}; + + +/** + * Returns true if the element has a tab index that allows it to receive + * keyboard focus (tabIndex >= 0), false otherwise. Note that some elements + * natively support keyboard focus, even if they have no tab index. + * @param {!Element} element Element to check. + * @return {boolean} Whether the element has a tab index that allows keyboard + * focus. + */ +goog.dom.isFocusableTabIndex = function(element) { + 'use strict'; + return goog.dom.hasSpecifiedTabIndex_(element) && + goog.dom.isTabIndexFocusable_(element); +}; + + +/** + * Enables or disables keyboard focus support on the element via its tab index. + * Only elements for which {@link goog.dom.isFocusableTabIndex} returns true + * (or elements that natively support keyboard focus, like form elements) can + * receive keyboard focus. See http://go/tabindex for more info. + * @param {Element} element Element whose tab index is to be changed. + * @param {boolean} enable Whether to set or remove a tab index on the element + * that supports keyboard focus. + * @return {void} + */ +goog.dom.setFocusableTabIndex = function(element, enable) { + 'use strict'; + if (enable) { + element.tabIndex = 0; + } else { + // Set tabIndex to -1 first, then remove it. This is a workaround for + // Safari (confirmed in version 4 on Windows). When removing the attribute + // without setting it to -1 first, the element remains keyboard focusable + // despite not having a tabIndex attribute anymore. + element.tabIndex = -1; + element.removeAttribute('tabIndex'); // Must be camelCase! + } +}; + + +/** + * Returns true if the element can be focused, i.e. it has a tab index that + * allows it to receive keyboard focus (tabIndex >= 0), or it is an element + * that natively supports keyboard focus. + * @param {!Element} element Element to check. + * @return {boolean} Whether the element allows keyboard focus. + */ +goog.dom.isFocusable = function(element) { + 'use strict'; + var focusable; + // Some elements can have unspecified tab index and still receive focus. + if (goog.dom.nativelySupportsFocus_(element)) { + // Make sure the element is not disabled ... + focusable = !element.disabled && + // ... and if a tab index is specified, it allows focus. + (!goog.dom.hasSpecifiedTabIndex_(element) || + goog.dom.isTabIndexFocusable_(element)); + } else { + focusable = goog.dom.isFocusableTabIndex(element); + } + + // IE requires elements to be visible in order to focus them. + return focusable && goog.userAgent.IE ? + goog.dom.hasNonZeroBoundingRect_(/** @type {!HTMLElement} */ (element)) : + focusable; +}; + + +/** + * Returns true if the element has a specified tab index. + * @param {!Element} element Element to check. + * @return {boolean} Whether the element has a specified tab index. + * @private + */ +goog.dom.hasSpecifiedTabIndex_ = function(element) { + 'use strict'; + return element.hasAttribute('tabindex'); +}; + + +/** + * Returns true if the element's tab index allows the element to be focused. + * @param {!Element} element Element to check. + * @return {boolean} Whether the element's tab index allows focus. + * @private + */ +goog.dom.isTabIndexFocusable_ = function(element) { + 'use strict'; + var index = /** @type {!HTMLElement} */ (element).tabIndex; + // NOTE: IE9 puts tabIndex in 16-bit int, e.g. -2 is 65534. + return typeof (index) === 'number' && index >= 0 && index < 32768; +}; + + +/** + * Returns true if the element is focusable even when tabIndex is not set. + * @param {!Element} element Element to check. + * @return {boolean} Whether the element natively supports focus. + * @private + */ +goog.dom.nativelySupportsFocus_ = function(element) { + 'use strict'; + return ( + element.tagName == goog.dom.TagName.A && element.hasAttribute('href') || + element.tagName == goog.dom.TagName.INPUT || + element.tagName == goog.dom.TagName.TEXTAREA || + element.tagName == goog.dom.TagName.SELECT || + element.tagName == goog.dom.TagName.BUTTON); +}; + + +/** + * Returns true if the element has a bounding rectangle that would be visible + * (i.e. its width and height are greater than zero). + * @param {!HTMLElement} element Element to check. + * @return {boolean} Whether the element has a non-zero bounding rectangle. + * @private + */ +goog.dom.hasNonZeroBoundingRect_ = function(element) { + 'use strict'; + var rect; + if (typeof element['getBoundingClientRect'] !== 'function' || + // In IE, getBoundingClientRect throws on detached nodes. + (goog.userAgent.IE && element.parentElement == null)) { + rect = {'height': element.offsetHeight, 'width': element.offsetWidth}; + } else { + rect = element.getBoundingClientRect(); + } + return rect != null && rect.height > 0 && rect.width > 0; +}; + + +/** + * Returns the text content of the current node, without markup and invisible + * symbols. New lines are stripped and whitespace is collapsed, + * such that each character would be visible. + * + * In browsers that support it, innerText is used. Other browsers attempt to + * simulate it via node traversal. Line breaks are canonicalized in IE. + * + * @param {Node} node The node from which we are getting content. + * @return {string} The text content. + */ +goog.dom.getTextContent = function(node) { + 'use strict'; + var textContent; + var buf = []; + goog.dom.getTextContent_(node, buf, true); + textContent = buf.join(''); + + // Strip ­ entities. goog.format.insertWordBreaks inserts them in Opera. + textContent = textContent.replace(/ \xAD /g, ' ').replace(/\xAD/g, ''); + // Strip ​ entities. goog.format.insertWordBreaks inserts them in IE8. + textContent = textContent.replace(/\u200B/g, ''); + + textContent = textContent.replace(/ +/g, ' '); + if (textContent != ' ') { + textContent = textContent.replace(/^\s*/, ''); + } + + return textContent; +}; + + +/** + * Returns the text content of the current node, without markup. + * + * Unlike `getTextContent` this method does not collapse whitespaces + * or normalize lines breaks. + * + * @param {Node} node The node from which we are getting content. + * @return {string} The raw text content. + */ +goog.dom.getRawTextContent = function(node) { + 'use strict'; + var buf = []; + goog.dom.getTextContent_(node, buf, false); + + return buf.join(''); +}; + + +/** + * Recursive support function for text content retrieval. + * + * @param {Node} node The node from which we are getting content. + * @param {Array} buf string buffer. + * @param {boolean} normalizeWhitespace Whether to normalize whitespace. + * @private + */ +goog.dom.getTextContent_ = function(node, buf, normalizeWhitespace) { + 'use strict'; + if (node.nodeName in goog.dom.TAGS_TO_IGNORE_) { + // ignore certain tags + } else if (node.nodeType == goog.dom.NodeType.TEXT) { + if (normalizeWhitespace) { + buf.push(String(node.nodeValue).replace(/(\r\n|\r|\n)/g, '')); + } else { + buf.push(node.nodeValue); + } + } else if (node.nodeName in goog.dom.PREDEFINED_TAG_VALUES_) { + buf.push(goog.dom.PREDEFINED_TAG_VALUES_[node.nodeName]); + } else { + var child = node.firstChild; + while (child) { + goog.dom.getTextContent_(child, buf, normalizeWhitespace); + child = child.nextSibling; + } + } +}; + + +/** + * Returns the text length of the text contained in a node, without markup. This + * is equivalent to the selection length if the node was selected, or the number + * of cursor movements to traverse the node. Images & BRs take one space. New + * lines are ignored. + * + * @param {Node} node The node whose text content length is being calculated. + * @return {number} The length of `node`'s text content. + */ +goog.dom.getNodeTextLength = function(node) { + 'use strict'; + return goog.dom.getTextContent(node).length; +}; + + +/** + * Returns the text offset of a node relative to one of its ancestors. The text + * length is the same as the length calculated by goog.dom.getNodeTextLength. + * + * @param {Node} node The node whose offset is being calculated. + * @param {Node=} opt_offsetParent The node relative to which the offset will + * be calculated. Defaults to the node's owner document's body. + * @return {number} The text offset. + */ +goog.dom.getNodeTextOffset = function(node, opt_offsetParent) { + 'use strict'; + var root = opt_offsetParent || goog.dom.getOwnerDocument(node).body; + var buf = []; + while (node && node != root) { + var cur = node; + while ((cur = cur.previousSibling)) { + buf.unshift(goog.dom.getTextContent(cur)); + } + node = node.parentNode; + } + // Trim left to deal with FF cases when there might be line breaks and empty + // nodes at the front of the text + return goog.string.trimLeft(buf.join('')).replace(/ +/g, ' ').length; +}; + + +/** + * Returns the node at a given offset in a parent node. If an object is + * provided for the optional third parameter, the node and the remainder of the + * offset will stored as properties of this object. + * @param {Node} parent The parent node. + * @param {number} offset The offset into the parent node. + * @param {Object=} opt_result Object to be used to store the return value. The + * return value will be stored in the form {node: Node, remainder: number} + * if this object is provided. + * @return {Node} The node at the given offset. + */ +goog.dom.getNodeAtOffset = function(parent, offset, opt_result) { + 'use strict'; + var stack = [parent], pos = 0, cur = null; + while (stack.length > 0 && pos < offset) { + cur = stack.pop(); + if (cur.nodeName in goog.dom.TAGS_TO_IGNORE_) { + // ignore certain tags + } else if (cur.nodeType == goog.dom.NodeType.TEXT) { + var text = cur.nodeValue.replace(/(\r\n|\r|\n)/g, '').replace(/ +/g, ' '); + pos += text.length; + } else if (cur.nodeName in goog.dom.PREDEFINED_TAG_VALUES_) { + pos += goog.dom.PREDEFINED_TAG_VALUES_[cur.nodeName].length; + } else { + for (var i = cur.childNodes.length - 1; i >= 0; i--) { + stack.push(cur.childNodes[i]); + } + } + } + if (goog.isObject(opt_result)) { + opt_result.remainder = cur ? cur.nodeValue.length + offset - pos - 1 : 0; + opt_result.node = cur; + } + + return cur; +}; + + +/** + * Returns true if the object is a `NodeList`. To qualify as a NodeList, + * the object must have a numeric length property and an item function (which + * has type 'string' on IE for some reason). + * @param {Object} val Object to test. + * @return {boolean} Whether the object is a NodeList. + */ +goog.dom.isNodeList = function(val) { + 'use strict'; + // TODO(attila): Now the isNodeList is part of goog.dom we can use + // goog.userAgent to make this simpler. + // A NodeList must have a length property of type 'number' on all platforms. + if (val && typeof val.length == 'number') { + // A NodeList is an object everywhere except Safari, where it's a function. + if (goog.isObject(val)) { + // A NodeList must have an item function (on non-IE platforms) or an item + // property of type 'string' (on IE). + return typeof val.item == 'function' || typeof val.item == 'string'; + } else if (typeof val === 'function') { + // On Safari, a NodeList is a function with an item property that is also + // a function. + return typeof /** @type {?} */ (val.item) == 'function'; + } + } + + // Not a NodeList. + return false; +}; + + +/** + * Walks up the DOM hierarchy returning the first ancestor that has the passed + * tag name and/or class name. If the passed element matches the specified + * criteria, the element itself is returned. + * @param {Node} element The DOM node to start with. + * @param {?(goog.dom.TagName|string)=} opt_tag The tag name to match (or + * null/undefined to match only based on class name). + * @param {?string=} opt_class The class name to match (or null/undefined to + * match only based on tag name). + * @param {number=} opt_maxSearchSteps Maximum number of levels to search up the + * dom. + * @return {?R} The first ancestor that matches the passed criteria, or + * null if no match is found. The return type is {?Element} if opt_tag is + * not a member of goog.dom.TagName or a more specific type if it is (e.g. + * {?HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.getAncestorByTagNameAndClass = function( + element, opt_tag, opt_class, opt_maxSearchSteps) { + 'use strict'; + if (!opt_tag && !opt_class) { + return null; + } + var tagName = opt_tag ? String(opt_tag).toUpperCase() : null; + return /** @type {Element} */ (goog.dom.getAncestor(element, function(node) { + 'use strict'; + return (!tagName || node.nodeName == tagName) && + (!opt_class || + typeof node.className === 'string' && + goog.array.contains(node.className.split(/\s+/), opt_class)); + }, true, opt_maxSearchSteps)); +}; + + +/** + * Walks up the DOM hierarchy returning the first ancestor that has the passed + * class name. If the passed element matches the specified criteria, the + * element itself is returned. + * @param {Node} element The DOM node to start with. + * @param {string} className The class name to match. + * @param {number=} opt_maxSearchSteps Maximum number of levels to search up the + * dom. + * @return {Element} The first ancestor that matches the passed criteria, or + * null if none match. + */ +goog.dom.getAncestorByClass = function(element, className, opt_maxSearchSteps) { + 'use strict'; + return goog.dom.getAncestorByTagNameAndClass( + element, null, className, opt_maxSearchSteps); +}; + + +/** + * Walks up the DOM hierarchy returning the first ancestor that passes the + * matcher function. + * @param {Node} element The DOM node to start with. + * @param {function(!Node) : boolean} matcher A function that returns true if + * the passed node matches the desired criteria. + * @param {boolean=} opt_includeNode If true, the node itself is included in + * the search (the first call to the matcher will pass startElement as + * the node to test). + * @param {number=} opt_maxSearchSteps Maximum number of levels to search up the + * dom. + * @return {Node} DOM node that matched the matcher, or null if there was + * no match. + */ +goog.dom.getAncestor = function( + element, matcher, opt_includeNode, opt_maxSearchSteps) { + 'use strict'; + if (element && !opt_includeNode) { + element = element.parentNode; + } + var steps = 0; + while (element && + (opt_maxSearchSteps == null || steps <= opt_maxSearchSteps)) { + goog.asserts.assert(element.name != 'parentNode'); + if (matcher(element)) { + return element; + } + element = element.parentNode; + steps++; + } + // Reached the root of the DOM without a match + return null; +}; + + +/** + * Determines the active element in the given document. + * @param {Document} doc The document to look in. + * @return {Element} The active element. + */ +goog.dom.getActiveElement = function(doc) { + 'use strict'; + // While in an iframe, IE9 will throw "Unspecified error" when accessing + // activeElement. + try { + var activeElement = doc && doc.activeElement; + // While not in an iframe, IE9-11 sometimes gives null. + // While in an iframe, IE11 sometimes returns an empty object. + return activeElement && activeElement.nodeName ? activeElement : null; + } catch (e) { + return null; + } +}; + + +/** + * Gives the current devicePixelRatio. + * + * By default, this is the value of window.devicePixelRatio (which should be + * preferred if present). + * + * If window.devicePixelRatio is not present, the ratio is calculated with + * window.matchMedia, if present. Otherwise, gives 1.0. + * + * Some browsers (including Chrome) consider the browser zoom level in the pixel + * ratio, so the value may change across multiple calls. + * + * @return {number} The number of actual pixels per virtual pixel. + */ +goog.dom.getPixelRatio = function() { + 'use strict'; + var win = goog.dom.getWindow(); + if (win.devicePixelRatio !== undefined) { + return win.devicePixelRatio; + } else if (win.matchMedia) { + // Should be for IE10 and FF6-17 (this basically clamps to lower) + // Note that the order of these statements is important + return goog.dom.matchesPixelRatio_(3) || goog.dom.matchesPixelRatio_(2) || + goog.dom.matchesPixelRatio_(1.5) || goog.dom.matchesPixelRatio_(1) || + .75; + } + return 1; +}; + + +/** + * Calculates a mediaQuery to check if the current device supports the + * given actual to virtual pixel ratio. + * @param {number} pixelRatio The ratio of actual pixels to virtual pixels. + * @return {number} pixelRatio if applicable, otherwise 0. + * @private + */ +goog.dom.matchesPixelRatio_ = function(pixelRatio) { + 'use strict'; + var win = goog.dom.getWindow(); + /** + * Due to the 1:96 fixed ratio of CSS in to CSS px, 1dppx is equivalent to + * 96dpi. + * @const {number} + */ + var dpiPerDppx = 96; + var query = + // FF16-17 + '(min-resolution: ' + pixelRatio + 'dppx),' + + // FF6-15 + '(min--moz-device-pixel-ratio: ' + pixelRatio + '),' + + // IE10 (this works for the two browsers above too but I don't want to + // trust the 1:96 fixed ratio magic) + '(min-resolution: ' + (pixelRatio * dpiPerDppx) + 'dpi)'; + return win.matchMedia(query).matches ? pixelRatio : 0; +}; + + +/** + * Gets '2d' context of a canvas. Shortcut for canvas.getContext('2d') with a + * type information. + * @param {!HTMLCanvasElement|!OffscreenCanvas} canvas + * @return {!CanvasRenderingContext2D} + */ +goog.dom.getCanvasContext2D = function(canvas) { + 'use strict'; + return /** @type {!CanvasRenderingContext2D} */ (canvas.getContext('2d')); +}; + + + +/** + * Create an instance of a DOM helper with a new document object. + * @param {Document=} opt_document Document object to associate with this + * DOM helper. + * @constructor + */ +goog.dom.DomHelper = function(opt_document) { + 'use strict'; + /** + * Reference to the document object to use + * @type {!Document} + * @private + */ + this.document_ = opt_document || goog.global.document || document; +}; + + +/** + * Gets the dom helper object for the document where the element resides. + * @param {Node=} opt_node If present, gets the DomHelper for this node. + * @return {!goog.dom.DomHelper} The DomHelper. + */ +goog.dom.DomHelper.prototype.getDomHelper = goog.dom.getDomHelper; + + +/** + * Sets the document object. + * @param {!Document} document Document object. + */ +goog.dom.DomHelper.prototype.setDocument = function(document) { + 'use strict'; + this.document_ = document; +}; + + +/** + * Gets the document object being used by the dom library. + * @return {!Document} Document object. + */ +goog.dom.DomHelper.prototype.getDocument = function() { + 'use strict'; + return this.document_; +}; + + +/** + * Alias for `getElementById`. If a DOM node is passed in then we just + * return that. + * @param {string|Element} element Element ID or a DOM node. + * @return {Element} The element with the given ID, or the node passed in. + */ +goog.dom.DomHelper.prototype.getElement = function(element) { + 'use strict'; + return goog.dom.getElementHelper_(this.document_, element); +}; + + +/** + * Gets an element by id, asserting that the element is found. + * + * This is used when an element is expected to exist, and should fail with + * an assertion error if it does not (if assertions are enabled). + * + * @param {string} id Element ID. + * @return {!Element} The element with the given ID, if it exists. + */ +goog.dom.DomHelper.prototype.getRequiredElement = function(id) { + 'use strict'; + return goog.dom.getRequiredElementHelper_(this.document_, id); +}; + + +/** + * Alias for `getElement`. + * @param {string|Element} element Element ID or a DOM node. + * @return {Element} The element with the given ID, or the node passed in. + * @deprecated Use {@link goog.dom.DomHelper.prototype.getElement} instead. + */ +goog.dom.DomHelper.prototype.$ = goog.dom.DomHelper.prototype.getElement; + + +/** + * Gets elements by tag name. + * @param {!goog.dom.TagName} tagName + * @param {(!Document|!Element)=} opt_parent Parent element or document where to + * look for elements. Defaults to document of this DomHelper. + * @return {!NodeList} List of elements. The members of the list are + * {!Element} if tagName is not a member of goog.dom.TagName or more + * specific types if it is (e.g. {!HTMLAnchorElement} for + * goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.DomHelper.prototype.getElementsByTagName = function( + tagName, opt_parent) { + 'use strict'; + var parent = opt_parent || this.document_; + return parent.getElementsByTagName(String(tagName)); +}; + + +/** + * Looks up elements by both tag and class name, using browser native functions + * (`querySelectorAll`, `getElementsByTagName` or + * `getElementsByClassName`) where possible. The returned array is a live + * NodeList or a static list depending on the code path taken. + * + * @param {(string|?goog.dom.TagName)=} opt_tag Element tag name or * for all + * tags. + * @param {?string=} opt_class Optional class name. + * @param {(Document|Element)=} opt_el Optional element to look in. + * @return {!IArrayLike} Array-like list of elements (only a length property + * and numerical indices are guaranteed to exist). The members of the array + * are {!Element} if opt_tag is not a member of goog.dom.TagName or more + * specific types if it is (e.g. {!HTMLAnchorElement} for + * goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.DomHelper.prototype.getElementsByTagNameAndClass = function( + opt_tag, opt_class, opt_el) { + 'use strict'; + return goog.dom.getElementsByTagNameAndClass_( + this.document_, opt_tag, opt_class, opt_el); +}; + + +/** + * Gets the first element matching the tag and the class. + * + * @param {(string|?goog.dom.TagName)=} opt_tag Element tag name. + * @param {?string=} opt_class Optional class name. + * @param {(Document|Element)=} opt_el Optional element to look in. + * @return {?R} Reference to a DOM node. The return type is {?Element} if + * tagName is a string or a more specific type if it is a member of + * goog.dom.TagName (e.g. {?HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.DomHelper.prototype.getElementByTagNameAndClass = function( + opt_tag, opt_class, opt_el) { + 'use strict'; + return goog.dom.getElementByTagNameAndClass_( + this.document_, opt_tag, opt_class, opt_el); +}; + + +/** + * Returns an array of all the elements with the provided className. + * @param {string} className the name of the class to look for. + * @param {Element|Document=} opt_el Optional element to look in. + * @return {!IArrayLike} The items found with the class name provided. + */ +goog.dom.DomHelper.prototype.getElementsByClass = function(className, opt_el) { + 'use strict'; + var doc = opt_el || this.document_; + return goog.dom.getElementsByClass(className, doc); +}; + + +/** + * Returns the first element we find matching the provided class name. + * @param {string} className the name of the class to look for. + * @param {(Element|Document)=} opt_el Optional element to look in. + * @return {Element} The first item found with the class name provided. + */ +goog.dom.DomHelper.prototype.getElementByClass = function(className, opt_el) { + 'use strict'; + var doc = opt_el || this.document_; + return goog.dom.getElementByClass(className, doc); +}; + + +/** + * Ensures an element with the given className exists, and then returns the + * first element with the provided className. + * @param {string} className the name of the class to look for. + * @param {(!Element|!Document)=} opt_root Optional element or document to look + * in. + * @return {!Element} The first item found with the class name provided. + * @throws {goog.asserts.AssertionError} Thrown if no element is found. + */ +goog.dom.DomHelper.prototype.getRequiredElementByClass = function( + className, opt_root) { + 'use strict'; + var root = opt_root || this.document_; + return goog.dom.getRequiredElementByClass(className, root); +}; + + +/** + * Alias for `getElementsByTagNameAndClass`. + * @deprecated Use DomHelper getElementsByTagNameAndClass. + * + * @param {(string|?goog.dom.TagName)=} opt_tag Element tag name. + * @param {?string=} opt_class Optional class name. + * @param {Element=} opt_el Optional element to look in. + * @return {!IArrayLike} Array-like list of elements (only a length property + * and numerical indices are guaranteed to exist). The members of the array + * are {!Element} if opt_tag is a string or more specific types if it is + * a member of goog.dom.TagName (e.g. {!HTMLAnchorElement} for + * goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.DomHelper.prototype.$$ = + goog.dom.DomHelper.prototype.getElementsByTagNameAndClass; + + +/** + * Sets a number of properties on a node. + * @param {Element} element DOM node to set properties on. + * @param {Object} properties Hash of property:value pairs. + */ +goog.dom.DomHelper.prototype.setProperties = goog.dom.setProperties; + + +/** + * Gets the dimensions of the viewport. + * @param {Window=} opt_window Optional window element to test. Defaults to + * the window of the Dom Helper. + * @return {!goog.math.Size} Object with values 'width' and 'height'. + */ +goog.dom.DomHelper.prototype.getViewportSize = function(opt_window) { + 'use strict'; + // TODO(arv): This should not take an argument. That breaks the rule of a + // a DomHelper representing a single frame/window/document. + return goog.dom.getViewportSize(opt_window || this.getWindow()); +}; + + +/** + * Calculates the height of the document. + * + * @return {number} The height of the document. + */ +goog.dom.DomHelper.prototype.getDocumentHeight = function() { + 'use strict'; + return goog.dom.getDocumentHeight_(this.getWindow()); +}; + + +/** + * Typedef for use with goog.dom.createDom and goog.dom.append. + * @typedef {Object|string|Array|NodeList} + */ +goog.dom.Appendable; + + +/** + * Returns a dom node with a set of attributes. This function accepts varargs + * for subsequent nodes to be added. Subsequent nodes will be added to the + * first node as childNodes. + * + * So: + * createDom(goog.dom.TagName.DIV, null, createDom(goog.dom.TagName.P), + * createDom(goog.dom.TagName.P)); would return a div with two child + * paragraphs + * + * An easy way to move all child nodes of an existing element to a new parent + * element is: + * createDom(goog.dom.TagName.DIV, null, oldElement.childNodes); + * which will remove all child nodes from the old element and add them as + * child nodes of the new DIV. + * + * @param {string|!goog.dom.TagName} tagName Tag to create. + * @param {?Object|?Array|string=} opt_attributes If object, then a map + * of name-value pairs for attributes. If a string, then this is the + * className of the new element. If an array, the elements will be joined + * together as the className of the new element. + * @param {...(goog.dom.Appendable|undefined)} var_args Further DOM nodes or + * strings for text nodes. If one of the var_args is an array or + * NodeList, its elements will be added as childNodes instead. + * @return {R} Reference to a DOM node. The return type is {!Element} if tagName + * is a string or a more specific type if it is a member of + * goog.dom.TagName (e.g. {!HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.DomHelper.prototype.createDom = function( + tagName, opt_attributes, var_args) { + 'use strict'; + return goog.dom.createDom_(this.document_, arguments); +}; + + +/** + * Alias for `createDom`. + * @param {string|!goog.dom.TagName} tagName Tag to create. + * @param {?Object|?Array|string=} opt_attributes If object, then a map + * of name-value pairs for attributes. If a string, then this is the + * className of the new element. If an array, the elements will be joined + * together as the className of the new element. + * @param {...(goog.dom.Appendable|undefined)} var_args Further DOM nodes or + * strings for text nodes. If one of the var_args is an array, its children + * will be added as childNodes instead. + * @return {R} Reference to a DOM node. The return type is {!Element} if tagName + * is a string or a more specific type if it is a member of + * goog.dom.TagName (e.g. {!HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + * @deprecated Use {@link goog.dom.DomHelper.prototype.createDom} instead. + */ +goog.dom.DomHelper.prototype.$dom = goog.dom.DomHelper.prototype.createDom; + + +/** + * Creates a new element. + * @param {string|!goog.dom.TagName} name Tag to create. + * @return {R} The new element. The return type is {!Element} if name is + * a string or a more specific type if it is a member of goog.dom.TagName + * (e.g. {!HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.DomHelper.prototype.createElement = function(name) { + 'use strict'; + return goog.dom.createElement_(this.document_, name); +}; + + +/** + * Creates a new text node. + * @param {number|string} content Content. + * @return {!Text} The new text node. + */ +goog.dom.DomHelper.prototype.createTextNode = function(content) { + 'use strict'; + return this.document_.createTextNode(String(content)); +}; + + +/** + * Create a table. + * @param {number} rows The number of rows in the table. Must be >= 1. + * @param {number} columns The number of columns in the table. Must be >= 1. + * @param {boolean=} opt_fillWithNbsp If true, fills table entries with + * `goog.string.Unicode.NBSP` characters. + * @return {!HTMLElement} The created table. + */ +goog.dom.DomHelper.prototype.createTable = function( + rows, columns, opt_fillWithNbsp) { + 'use strict'; + return goog.dom.createTable_( + this.document_, rows, columns, !!opt_fillWithNbsp); +}; + + +/** + * Converts an HTML into a node or a document fragment. A single Node is used if + * `html` only generates a single node. If `html` generates multiple + * nodes then these are put inside a `DocumentFragment`. This is a safe + * version of `goog.dom.DomHelper#htmlToDocumentFragment` which is now + * deleted. + * @param {!goog.html.SafeHtml} html The HTML markup to convert. + * @return {!Node} The resulting node. + */ +goog.dom.DomHelper.prototype.safeHtmlToNode = function(html) { + 'use strict'; + return goog.dom.safeHtmlToNode_(this.document_, html); +}; + + +/** + * Returns true if the browser is in "CSS1-compatible" (standards-compliant) + * mode, false otherwise. + * @return {boolean} True if in CSS1-compatible mode. + */ +goog.dom.DomHelper.prototype.isCss1CompatMode = function() { + 'use strict'; + return goog.dom.isCss1CompatMode_(this.document_); +}; + + +/** + * Gets the window object associated with the document. + * @return {!Window} The window associated with the given document. + */ +goog.dom.DomHelper.prototype.getWindow = function() { + 'use strict'; + return goog.dom.getWindow_(this.document_); +}; + + +/** + * Gets the document scroll element. + * @return {!Element} Scrolling element. + */ +goog.dom.DomHelper.prototype.getDocumentScrollElement = function() { + 'use strict'; + return goog.dom.getDocumentScrollElement_(this.document_); +}; + + +/** + * Gets the document scroll distance as a coordinate object. + * @return {!goog.math.Coordinate} Object with properties 'x' and 'y'. + */ +goog.dom.DomHelper.prototype.getDocumentScroll = function() { + 'use strict'; + return goog.dom.getDocumentScroll_(this.document_); +}; + + +/** + * Determines the active element in the given document. + * @param {Document=} opt_doc The document to look in. + * @return {Element} The active element. + */ +goog.dom.DomHelper.prototype.getActiveElement = function(opt_doc) { + 'use strict'; + return goog.dom.getActiveElement(opt_doc || this.document_); +}; + + +/** + * Appends a child to a node. + * @param {Node} parent Parent. + * @param {Node} child Child. + */ +goog.dom.DomHelper.prototype.appendChild = goog.dom.appendChild; + + +/** + * Appends a node with text or other nodes. + * @param {!Node} parent The node to append nodes to. + * @param {...goog.dom.Appendable} var_args The things to append to the node. + * If this is a Node it is appended as is. + * If this is a string then a text node is appended. + * If this is an array like object then fields 0 to length - 1 are appended. + */ +goog.dom.DomHelper.prototype.append = goog.dom.append; + + +/** + * Determines if the given node can contain children, intended to be used for + * HTML generation. + * + * @param {Node} node The node to check. + * @return {boolean} Whether the node can contain children. + */ +goog.dom.DomHelper.prototype.canHaveChildren = goog.dom.canHaveChildren; + + +/** + * Removes all the child nodes on a DOM node. + * @param {Node} node Node to remove children from. + */ +goog.dom.DomHelper.prototype.removeChildren = goog.dom.removeChildren; + + +/** + * Inserts a new node before an existing reference node (i.e., as the previous + * sibling). If the reference node has no parent, then does nothing. + * @param {Node} newNode Node to insert. + * @param {Node} refNode Reference node to insert before. + */ +goog.dom.DomHelper.prototype.insertSiblingBefore = goog.dom.insertSiblingBefore; + + +/** + * Inserts a new node after an existing reference node (i.e., as the next + * sibling). If the reference node has no parent, then does nothing. + * @param {Node} newNode Node to insert. + * @param {Node} refNode Reference node to insert after. + */ +goog.dom.DomHelper.prototype.insertSiblingAfter = goog.dom.insertSiblingAfter; + + +/** + * Insert a child at a given index. If index is larger than the number of child + * nodes that the parent currently has, the node is inserted as the last child + * node. + * @param {Element} parent The element into which to insert the child. + * @param {Node} child The element to insert. + * @param {number} index The index at which to insert the new child node. Must + * not be negative. + */ +goog.dom.DomHelper.prototype.insertChildAt = goog.dom.insertChildAt; + + +/** + * Removes a node from its parent. + * @param {Node} node The node to remove. + * @return {Node} The node removed if removed; else, null. + */ +goog.dom.DomHelper.prototype.removeNode = goog.dom.removeNode; + + +/** + * Replaces a node in the DOM tree. Will do nothing if `oldNode` has no + * parent. + * @param {Node} newNode Node to insert. + * @param {Node} oldNode Node to replace. + */ +goog.dom.DomHelper.prototype.replaceNode = goog.dom.replaceNode; + + +/** + * Replaces child nodes of `target` with child nodes of `source`. This is + * roughly equivalent to `target.innerHTML = source.innerHTML` which is not + * compatible with Trusted Types. + * @param {?Node} target Node to clean and replace its children. + * @param {?Node} source Node to get the children from. The nodes will be cloned + * so they will stay in source. + */ +goog.dom.DomHelper.prototype.copyContents = goog.dom.copyContents; + + +/** + * Flattens an element. That is, removes it and replace it with its children. + * @param {Element} element The element to flatten. + * @return {Element|undefined} The original element, detached from the document + * tree, sans children, or undefined if the element was already not in the + * document. + */ +goog.dom.DomHelper.prototype.flattenElement = goog.dom.flattenElement; + + +/** + * Returns an array containing just the element children of the given element. + * @param {Element} element The element whose element children we want. + * @return {!(Array|NodeList)} An array or array-like list + * of just the element children of the given element. + */ +goog.dom.DomHelper.prototype.getChildren = goog.dom.getChildren; + + +/** + * Returns the first child node that is an element. + * @param {Node} node The node to get the first child element of. + * @return {Element} The first child node of `node` that is an element. + */ +goog.dom.DomHelper.prototype.getFirstElementChild = + goog.dom.getFirstElementChild; + + +/** + * Returns the last child node that is an element. + * @param {Node} node The node to get the last child element of. + * @return {Element} The last child node of `node` that is an element. + */ +goog.dom.DomHelper.prototype.getLastElementChild = goog.dom.getLastElementChild; + + +/** + * Returns the first next sibling that is an element. + * @param {Node} node The node to get the next sibling element of. + * @return {Element} The next sibling of `node` that is an element. + */ +goog.dom.DomHelper.prototype.getNextElementSibling = + goog.dom.getNextElementSibling; + + +/** + * Returns the first previous sibling that is an element. + * @param {Node} node The node to get the previous sibling element of. + * @return {Element} The first previous sibling of `node` that is + * an element. + */ +goog.dom.DomHelper.prototype.getPreviousElementSibling = + goog.dom.getPreviousElementSibling; + + +/** + * Returns the next node in source order from the given node. + * @param {Node} node The node. + * @return {Node} The next node in the DOM tree, or null if this was the last + * node. + */ +goog.dom.DomHelper.prototype.getNextNode = goog.dom.getNextNode; + + +/** + * Returns the previous node in source order from the given node. + * @param {Node} node The node. + * @return {Node} The previous node in the DOM tree, or null if this was the + * first node. + */ +goog.dom.DomHelper.prototype.getPreviousNode = goog.dom.getPreviousNode; + + +/** + * Whether the object looks like a DOM node. + * @param {?} obj The object being tested for node likeness. + * @return {boolean} Whether the object looks like a DOM node. + */ +goog.dom.DomHelper.prototype.isNodeLike = goog.dom.isNodeLike; + + +/** + * Whether the object looks like an Element. + * @param {?} obj The object being tested for Element likeness. + * @return {boolean} Whether the object looks like an Element. + */ +goog.dom.DomHelper.prototype.isElement = goog.dom.isElement; + + +/** + * Returns true if the specified value is a Window object. This includes the + * global window for HTML pages, and iframe windows. + * @param {?} obj Variable to test. + * @return {boolean} Whether the variable is a window. + */ +goog.dom.DomHelper.prototype.isWindow = goog.dom.isWindow; + + +/** + * Returns an element's parent, if it's an Element. + * @param {Element} element The DOM element. + * @return {Element} The parent, or null if not an Element. + */ +goog.dom.DomHelper.prototype.getParentElement = goog.dom.getParentElement; + + +/** + * Whether a node contains another node. + * @param {Node} parent The node that should contain the other node. + * @param {Node} descendant The node to test presence of. + * @return {boolean} Whether the parent node contains the descendant node. + */ +goog.dom.DomHelper.prototype.contains = goog.dom.contains; + + +/** + * Compares the document order of two nodes, returning 0 if they are the same + * node, a negative number if node1 is before node2, and a positive number if + * node2 is before node1. Note that we compare the order the tags appear in the + * document so in the tree text the B node is considered to be + * before the I node. + * + * @param {Node} node1 The first node to compare. + * @param {Node} node2 The second node to compare. + * @return {number} 0 if the nodes are the same node, a negative number if node1 + * is before node2, and a positive number if node2 is before node1. + */ +goog.dom.DomHelper.prototype.compareNodeOrder = goog.dom.compareNodeOrder; + + +/** + * Find the deepest common ancestor of the given nodes. + * @param {...Node} var_args The nodes to find a common ancestor of. + * @return {Node} The common ancestor of the nodes, or null if there is none. + * null will only be returned if two or more of the nodes are from different + * documents. + */ +goog.dom.DomHelper.prototype.findCommonAncestor = goog.dom.findCommonAncestor; + + +/** + * Returns the owner document for a node. + * @param {Node} node The node to get the document for. + * @return {!Document} The document owning the node. + */ +goog.dom.DomHelper.prototype.getOwnerDocument = goog.dom.getOwnerDocument; + + +/** + * Cross browser function for getting the document element of an iframe. + * @param {Element} iframe Iframe element. + * @return {!Document} The frame content document. + */ +goog.dom.DomHelper.prototype.getFrameContentDocument = + goog.dom.getFrameContentDocument; + + +/** + * Cross browser function for getting the window of a frame or iframe. + * @param {Element} frame Frame element. + * @return {Window} The window associated with the given frame. + */ +goog.dom.DomHelper.prototype.getFrameContentWindow = + goog.dom.getFrameContentWindow; + + +/** + * Sets the text content of a node, with cross-browser support. + * @param {Node} node The node to change the text content of. + * @param {string|number} text The value that should replace the node's content. + */ +goog.dom.DomHelper.prototype.setTextContent = goog.dom.setTextContent; + + +/** + * Gets the outerHTML of a node, which islike innerHTML, except that it + * actually contains the HTML of the node itself. + * @param {Element} element The element to get the HTML of. + * @return {string} The outerHTML of the given element. + */ +goog.dom.DomHelper.prototype.getOuterHtml = goog.dom.getOuterHtml; + + +/** + * Finds the first descendant node that matches the filter function. This does + * a depth first search. + * @param {Node} root The root of the tree to search. + * @param {function(Node) : boolean} p The filter function. + * @return {Node|undefined} The found node or undefined if none is found. + */ +goog.dom.DomHelper.prototype.findNode = goog.dom.findNode; + + +/** + * Finds all the descendant nodes that matches the filter function. This does a + * depth first search. + * @param {Node} root The root of the tree to search. + * @param {function(Node) : boolean} p The filter function. + * @return {Array} The found nodes or an empty array if none are found. + */ +goog.dom.DomHelper.prototype.findNodes = goog.dom.findNodes; + + +/** + * Returns true if the element has a tab index that allows it to receive + * keyboard focus (tabIndex >= 0), false otherwise. Note that some elements + * natively support keyboard focus, even if they have no tab index. + * @param {!Element} element Element to check. + * @return {boolean} Whether the element has a tab index that allows keyboard + * focus. + */ +goog.dom.DomHelper.prototype.isFocusableTabIndex = goog.dom.isFocusableTabIndex; + + +/** + * Enables or disables keyboard focus support on the element via its tab index. + * Only elements for which {@link goog.dom.isFocusableTabIndex} returns true + * (or elements that natively support keyboard focus, like form elements) can + * receive keyboard focus. See http://go/tabindex for more info. + * @param {Element} element Element whose tab index is to be changed. + * @param {boolean} enable Whether to set or remove a tab index on the element + * that supports keyboard focus. + */ +goog.dom.DomHelper.prototype.setFocusableTabIndex = + goog.dom.setFocusableTabIndex; + + +/** + * Returns true if the element can be focused, i.e. it has a tab index that + * allows it to receive keyboard focus (tabIndex >= 0), or it is an element + * that natively supports keyboard focus. + * @param {!Element} element Element to check. + * @return {boolean} Whether the element allows keyboard focus. + */ +goog.dom.DomHelper.prototype.isFocusable = goog.dom.isFocusable; + + +/** + * Returns the text contents of the current node, without markup. New lines are + * stripped and whitespace is collapsed, such that each character would be + * visible. + * + * In browsers that support it, innerText is used. Other browsers attempt to + * simulate it via node traversal. Line breaks are canonicalized in IE. + * + * @param {Node} node The node from which we are getting content. + * @return {string} The text content. + */ +goog.dom.DomHelper.prototype.getTextContent = goog.dom.getTextContent; + + +/** + * Returns the text length of the text contained in a node, without markup. This + * is equivalent to the selection length if the node was selected, or the number + * of cursor movements to traverse the node. Images & BRs take one space. New + * lines are ignored. + * + * @param {Node} node The node whose text content length is being calculated. + * @return {number} The length of `node`'s text content. + */ +goog.dom.DomHelper.prototype.getNodeTextLength = goog.dom.getNodeTextLength; + + +/** + * Returns the text offset of a node relative to one of its ancestors. The text + * length is the same as the length calculated by + * `goog.dom.getNodeTextLength`. + * + * @param {Node} node The node whose offset is being calculated. + * @param {Node=} opt_offsetParent Defaults to the node's owner document's body. + * @return {number} The text offset. + */ +goog.dom.DomHelper.prototype.getNodeTextOffset = goog.dom.getNodeTextOffset; + + +/** + * Returns the node at a given offset in a parent node. If an object is + * provided for the optional third parameter, the node and the remainder of the + * offset will stored as properties of this object. + * @param {Node} parent The parent node. + * @param {number} offset The offset into the parent node. + * @param {Object=} opt_result Object to be used to store the return value. The + * return value will be stored in the form {node: Node, remainder: number} + * if this object is provided. + * @return {Node} The node at the given offset. + */ +goog.dom.DomHelper.prototype.getNodeAtOffset = goog.dom.getNodeAtOffset; + + +/** + * Returns true if the object is a `NodeList`. To qualify as a NodeList, + * the object must have a numeric length property and an item function (which + * has type 'string' on IE for some reason). + * @param {Object} val Object to test. + * @return {boolean} Whether the object is a NodeList. + */ +goog.dom.DomHelper.prototype.isNodeList = goog.dom.isNodeList; + + +/** + * Walks up the DOM hierarchy returning the first ancestor that has the passed + * tag name and/or class name. If the passed element matches the specified + * criteria, the element itself is returned. + * @param {Node} element The DOM node to start with. + * @param {?(goog.dom.TagName|string)=} opt_tag The tag name to match (or + * null/undefined to match only based on class name). + * @param {?string=} opt_class The class name to match (or null/undefined to + * match only based on tag name). + * @param {number=} opt_maxSearchSteps Maximum number of levels to search up the + * dom. + * @return {?R} The first ancestor that matches the passed criteria, or + * null if no match is found. The return type is {?Element} if opt_tag is + * not a member of goog.dom.TagName or a more specific type if it is (e.g. + * {?HTMLAnchorElement} for goog.dom.TagName.A). + * @template T + * @template R := cond(isUnknown(T), 'Element', T) =: + */ +goog.dom.DomHelper.prototype.getAncestorByTagNameAndClass = + goog.dom.getAncestorByTagNameAndClass; + + +/** + * Walks up the DOM hierarchy returning the first ancestor that has the passed + * class name. If the passed element matches the specified criteria, the + * element itself is returned. + * @param {Node} element The DOM node to start with. + * @param {string} class The class name to match. + * @param {number=} opt_maxSearchSteps Maximum number of levels to search up the + * dom. + * @return {Element} The first ancestor that matches the passed criteria, or + * null if none match. + */ +goog.dom.DomHelper.prototype.getAncestorByClass = goog.dom.getAncestorByClass; + + +/** + * Walks up the DOM hierarchy returning the first ancestor that passes the + * matcher function. + * @param {Node} element The DOM node to start with. + * @param {function(Node) : boolean} matcher A function that returns true if the + * passed node matches the desired criteria. + * @param {boolean=} opt_includeNode If true, the node itself is included in + * the search (the first call to the matcher will pass startElement as + * the node to test). + * @param {number=} opt_maxSearchSteps Maximum number of levels to search up the + * dom. + * @return {Node} DOM node that matched the matcher, or null if there was + * no match. + */ +goog.dom.DomHelper.prototype.getAncestor = goog.dom.getAncestor; + + +/** + * Gets '2d' context of a canvas. Shortcut for canvas.getContext('2d') with a + * type information. + * @param {!HTMLCanvasElement} canvas + * @return {!CanvasRenderingContext2D} + */ +goog.dom.DomHelper.prototype.getCanvasContext2D = goog.dom.getCanvasContext2D; diff --git a/closure/goog/dom/dom_benchmark.html b/closure/goog/dom/dom_benchmark.html new file mode 100644 index 0000000000..ddffc543d4 --- /dev/null +++ b/closure/goog/dom/dom_benchmark.html @@ -0,0 +1,68 @@ + + + + + +Closure Benchmarks - goog.dom + + + + + + + + diff --git a/closure/goog/dom/dom_compile_test.js b/closure/goog/dom/dom_compile_test.js new file mode 100644 index 0000000000..4293ed2f37 --- /dev/null +++ b/closure/goog/dom/dom_compile_test.js @@ -0,0 +1,50 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.DomCompileTest'); +goog.setTestOnly(); + +const TagName = goog.require('goog.dom.TagName'); +const googDom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); + +testSuite({ + /** Checks types with TagName. */ + testDomTagNameTypes() { + /** @type {!HTMLAnchorElement} */ + const a = googDom.createDom(TagName.A); + + /** @type {!HTMLAnchorElement} */ + const el = googDom.createElement(TagName.A); + + /** @type {!IArrayLike} */ + const anchors = googDom.getElementsByTagNameAndClass(TagName.A); + + // Check that goog.dom.HtmlElement is assignable to HTMLElement. + /** @type {!HTMLElement} */ + const b = googDom.createElement(TagName.B); + + /** @type {?HTMLAnchorElement} */ + const anchor = googDom.getElementByTagNameAndClass(TagName.A); + }, + + /** Checks types with TagName. */ + testDomHelperTagNameTypes() { + const dom = googDom.getDomHelper(); + + /** @type {!HTMLAnchorElement} */ + const a = dom.createDom(TagName.A); + + /** @type {!HTMLAnchorElement} */ + const el = dom.createElement(TagName.A); + + /** @type {!IArrayLike} */ + const anchors = dom.getElementsByTagNameAndClass(TagName.A); + + /** @type {?HTMLAnchorElement} */ + const anchor = dom.getElementByTagNameAndClass(TagName.A); + }, +}); diff --git a/closure/goog/dom/dom_test.js b/closure/goog/dom/dom_test.js new file mode 100644 index 0000000000..ba1e15a9a1 --- /dev/null +++ b/closure/goog/dom/dom_test.js @@ -0,0 +1,2080 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Shared code for dom_test.html and dom_quirks_test.html. + */ + +/** @suppress {extraProvide} */ +goog.module('goog.dom.dom_test'); +goog.setTestOnly(); + +const Const = goog.require('goog.string.Const'); +const DomHelper = goog.require('goog.dom.DomHelper'); +const InputType = goog.require('goog.dom.InputType'); +const NodeType = goog.require('goog.dom.NodeType'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const SafeUrl = goog.require('goog.html.SafeUrl'); +const TagName = goog.require('goog.dom.TagName'); +const Unicode = goog.require('goog.string.Unicode'); +const asserts = goog.require('goog.asserts'); +const functions = goog.require('goog.functions'); +const googArray = goog.require('goog.array'); +const googDom = goog.require('goog.dom'); +const googObject = goog.require('goog.object'); +const testSuite = goog.require('goog.testing.testSuite'); +const testing = goog.require('goog.html.testing'); +/** @suppress {extraRequire} */ +const testingAsserts = goog.require('goog.testing.asserts'); +const userAgent = goog.require('goog.userAgent'); + +const $ = googDom.getElement; + +let divForTestingScrolling; +let myIframe; +let myIframeDoc; +let crossDocumentElement; +let stubs; + +function createTestDom(txt) { + const dom = googDom.createDom(TagName.DIV); + dom.innerHTML = txt; + return dom; +} + +/** + * Simple alternative implementation of googDom.isFocusable. Serves as a sanity + * check whether the tests are correct. Unfortunately it can't replace the real + * implementation because of the side effects. + * @param {!Element} element + * @return {boolean} + * @suppress {strictMissingProperties} suppression added to enable type checking + */ +function isFocusableAlternativeImpl(element) { + element.focus(); + return document.activeElement == element && // programmatically focusable + element.tabIndex >= 0; // keyboard focusing is not disabled +} + +/** @param {!Element} element */ +function assertFocusable(element) { + const message = 'element with id=' + element.id + ' should be focusable'; + assertTrue(message, isFocusableAlternativeImpl(element)); + assertTrue(message, googDom.isFocusable(element)); +} + +/** @param {!Element} element */ +function assertNotFocusable(element) { + const message = 'element with id=' + element.id + ' should not be focusable'; + assertFalse(message, isFocusableAlternativeImpl(element)); + assertFalse(message, googDom.isFocusable(element)); +} + +// IE inserts line breaks and capitalizes nodenames. +function assertEqualsCaseAndLeadingWhitespaceInsensitive(value1, value2) { + value1 = value1.replace(/^\s+|\s+$/g, '').toLowerCase(); + value2 = value2.replace(/^\s+|\s+$/g, '').toLowerCase(); + assertEquals(value1, value2); +} + +/** + * Assert that the given Const, when converted to a Node, + * stringifies in one of the specified ways. + * @param {!Array} potentialStringifications + * @param {...!Const} var_args The constants to use. + */ +function assertConstHtmlToNodeStringifiesToOneOf( + potentialStringifications, var_args) { + const node = googDom.constHtmlToNode.apply( + undefined, Array.prototype.slice.call(arguments, 1)); + /** @suppress {checkTypes} suppression added to enable type checking */ + const stringified = googDom.getOuterHtml(node); + if (potentialStringifications.find(element => element == stringified) === + null) { + fail( + 'Unexpected stringification for a node built from "' + + Array.prototype.slice.call(arguments, 1).map(Const.unwrap).join('') + + '": "' + stringified + '"'); + } +} + +/** @return {boolean} Returns true if the userAgent is IE8 or higher. */ +function isIE() { + return userAgent.IE; +} + +/** + * Stub out googDom.getWindow with passed object. + * @param {!Object} win Fake window object. + */ +function setWindow(win) { + stubs.set(googDom, 'getWindow', functions.constant(win)); +} + +testSuite({ + setUpPage() { + stubs = new PropertyReplacer(); + divForTestingScrolling = googDom.createElement(TagName.DIV); + divForTestingScrolling.style.width = '5000px'; + divForTestingScrolling.style.height = '5000px'; + document.body.appendChild(divForTestingScrolling); + + // Setup for the iframe + myIframe = $('myIframe'); + myIframeDoc = googDom.getFrameContentDocument( + /** @type {HTMLIFrameElement} */ (myIframe)); + + // Set up document for iframe: total height of elements in document is 65 + // If the elements are not create like below, IE will get a wrong height for + // the document. + myIframeDoc.open(); + // Make sure we progate the compat mode + myIframeDoc.write( + (googDom.isCss1CompatMode() ? '' : '') + + '' + + '
    ' + + 'hello world
    ' + + '
    ' + + 'hello world
    ' + + '
    '); + myIframeDoc.close(); + + crossDocumentElement = myIframeDoc.getElementById('xdoc').cloneNode(true); + document.body.appendChild(crossDocumentElement); + }, + + tearDownPage() { + document.body.removeChild(divForTestingScrolling); + document.body.removeChild(crossDocumentElement); + }, + + tearDown() { + window.scrollTo(0, 0); + stubs.reset(); + }, + + testDom() { + assert('Dom library exists', typeof googDom != 'undefined'); + }, + + testGetElement() { + const el = $('testEl'); + assertEquals('Should be able to get id', el.id, 'testEl'); + + assertEquals($, googDom.getElement); + assertEquals(googDom.$, googDom.getElement); + }, + + testGetElementDomHelper() { + const domHelper = new DomHelper(); + const el = domHelper.getElement('testEl'); + assertEquals('Should be able to get id', el.id, 'testEl'); + }, + + testGetHTMLElement() { + const el = googDom.getHTMLElement('testEl'); + assertEquals('Should be able to get id', el.id, 'testEl'); + assertNull(googDom.getHTMLElement('nonexistent')); + assertThrows(() => googDom.getHTMLElement('testSvg')); + assertNotNull(googDom.getHTMLElement('xdoc')); + }, + + testGetRequiredHTMLElement() { + const el = googDom.getRequiredHTMLElement('testEl'); + assertTrue(el != null); + assertEquals('testEl', el.id); + assertThrows(() => googDom.getRequiredHTMLElement('does_not_exist')); + assertNotNull(googDom.getElement('testSvg')); + assertThrows(() => googDom.getRequiredHTMLElement('testSvg')); + assertNotNull(googDom.getRequiredHTMLElement('xdoc')); + }, + + testGetRequiredElement() { + const el = googDom.getRequiredElement('testEl'); + assertTrue(el != null); + assertEquals('testEl', el.id); + assertThrows(() => { + googDom.getRequiredElement('does_not_exist'); + }); + }, + + testGetRequiredElementDomHelper() { + const domHelper = new DomHelper(); + const el = domHelper.getRequiredElement('testEl'); + assertTrue(el != null); + assertEquals('testEl', el.id); + assertThrows(/** + @suppress {undefinedVars} suppression added to enable type + checking + */ + () => { + googDom.getRequiredElementByClass( + 'does_not_exist', container); + }); + }, + + testGetRequiredElementByClassDomHelper() { + const domHelper = new DomHelper(); + assertNotNull(domHelper.getRequiredElementByClass('test1')); + assertNotNull(domHelper.getRequiredElementByClass('test2')); + + const container = domHelper.getElement('span-container'); + assertNotNull(domHelper.getElementByClass('test1', container)); + assertThrows(/** + @suppress {checkTypes} suppression added to enable type + checking + */ + () => { + domHelper.getRequiredElementByClass( + 'does_not_exist', container); + }); + }, + + testGetElementsByTagName() { + const divs = googDom.getElementsByTagName(TagName.DIV); + assertTrue(divs.length > 0); + const el = googDom.getRequiredElement('testEl'); + const spans = googDom.getElementsByTagName(TagName.SPAN, el); + assertTrue(spans.length > 0); + }, + + testGetElementsByTagNameDomHelper() { + const domHelper = new DomHelper(); + const divs = domHelper.getElementsByTagName(TagName.DIV); + assertTrue(divs.length > 0); + const el = domHelper.getRequiredElement('testEl'); + const spans = domHelper.getElementsByTagName(TagName.SPAN, el); + assertTrue(spans.length > 0); + }, + + testGetElementsByTagNameAndClass() { + assertEquals( + 'Should get 6 spans', + googDom.getElementsByTagNameAndClass(TagName.SPAN).length, 6); + assertEquals( + 'Should get 6 spans', + googDom.getElementsByTagNameAndClass(TagName.SPAN).length, 6); + assertEquals( + 'Should get 3 spans', + googDom.getElementsByTagNameAndClass(TagName.SPAN, 'test1').length, 3); + assertEquals( + 'Should get 1 span', + googDom.getElementsByTagNameAndClass(TagName.SPAN, 'test2').length, 1); + assertEquals( + 'Should get 1 span', + googDom.getElementsByTagNameAndClass(TagName.SPAN, 'test2').length, 1); + assertEquals( + 'Should get lots of elements', + googDom.getElementsByTagNameAndClass().length, + document.getElementsByTagName('*').length); + + assertEquals( + 'Should get 1 span', + googDom.getElementsByTagNameAndClass(TagName.SPAN, null, $('testEl')) + .length, + 1); + + // '*' as the tag name should be equivalent to all tags + const container = googDom.getElement('span-container'); + assertEquals( + 5, + googDom.getElementsByTagNameAndClass('*', undefined, container).length); + assertEquals( + 3, + googDom.getElementsByTagNameAndClass('*', 'test1', container).length); + assertEquals( + 1, + googDom.getElementsByTagNameAndClass('*', 'test2', container).length); + + // Some version of WebKit have problems with mixed-case class names + assertEquals( + 1, + googDom.getElementsByTagNameAndClass(undefined, 'mixedCaseClass') + .length); + + // Make sure that out of bounds indices are OK + assertUndefined( + googDom.getElementsByTagNameAndClass(undefined, 'noSuchClass')[0]); + + assertEquals( + googDom.getElementsByTagNameAndClass, + googDom.getElementsByTagNameAndClass); + }, + + testGetElementsByClass() { + assertEquals(3, googDom.getElementsByClass('test1').length); + assertEquals(1, googDom.getElementsByClass('test2').length); + assertEquals(0, googDom.getElementsByClass('nonexistant').length); + + const container = googDom.getElement('span-container'); + assertEquals(3, googDom.getElementsByClass('test1', container).length); + }, + + testGetElementByClass() { + assertNotNull(googDom.getElementByClass('test1')); + assertNotNull(googDom.getElementByClass('test2')); + // assertNull(goog.dom.getElementByClass('nonexistant')); + + const container = googDom.getElement('span-container'); + assertNotNull(googDom.getElementByClass('test1', container)); + }, + + testGetHTMLElementByClass() { + assertNotNull(googDom.getHTMLElementByClass('test1')); + assertNotNull(googDom.getHTMLElementByClass('test2')); + + const container = googDom.getRequiredElement('span-container'); + assertNotNull(googDom.getHTMLElementByClass('test1', container)); + + assertNotNull(googDom.getElementByClass('svg-test')); + assertThrows(() => googDom.getHTMLElementByClass('svg-test')); + assertNull(googDom.getHTMLElementByClass('nonexistent')); + assertNotNull(googDom.getElementByClass('xdoc')); + }, + + testGetRequiredHTMLElementByClass() { + assertNotNull(googDom.getHTMLElementByClass('test1')); + assertNotNull(googDom.getHTMLElementByClass('test2')); + + const container = googDom.getRequiredElement('span-container'); + assertNotNull(googDom.getHTMLElementByClass('test1', container)); + + assertNotNull(googDom.getElementByClass('svg-test')); + assertThrows(() => googDom.getRequiredHTMLElementByClass('svg-test')); + assertThrows(() => googDom.getRequiredHTMLElementByClass('nonexistent')); + assertNotNull(googDom.getRequiredHTMLElementByClass('xdoc')); + }, + + testGetElementByTagNameAndClass() { + assertNotNull(googDom.getElementByTagNameAndClass('', 'test1')); + assertNotNull(googDom.getElementByTagNameAndClass('*', 'test1')); + assertNotNull(googDom.getElementByTagNameAndClass('span', 'test1')); + assertNull(googDom.getElementByTagNameAndClass('div', 'test1')); + assertNull(googDom.getElementByTagNameAndClass('*', 'nonexistant')); + + const container = googDom.getElement('span-container'); + assertNotNull(googDom.getElementByTagNameAndClass('*', 'test1', container)); + }, + + /** + @suppress {strictMissingProperties,missingProperties} suppression added to + enable type checking + */ + testSetProperties() { + const attrs = { + 'name': 'test3', + 'title': 'A title', + 'random': 'woop', + 'other-random': null, + 'href': SafeUrl.sanitize('https://google.com'), + 'stringWithTypedStringProp': 'http://example.com/', + 'numberWithTypedStringProp': 123, + 'booleanWithTypedStringProp': true, + }; + + // TODO(johnlenz): Attempting to set an property on a primitive throws in + // strict mode + /* + // Primitives with properties that wrongly indicate that the text is of a + // type that implements `goog.string.TypedString`. This simulates a property + // renaming collision with a String, Number or Boolean property set + // externally. renaming collision with a String property set externally + // (b/80124112). + attrs['stringWithTypedStringProp'].implementsGoogStringTypedString = true; + attrs['numberWithTypedStringProp'].implementsGoogStringTypedString = true; + attrs['booleanWithTypedStringProp'].implementsGoogStringTypedString = true; + */ + + const el = $('testEl'); + googDom.setProperties(el, attrs); + assertEquals('test3', el.name); + assertEquals('A title', el.title); + assertEquals('woop', el.random); + assertEquals('https://google.com', el.href); + assertEquals('http://example.com/', el.stringWithTypedStringProp); + assertEquals(123, el.numberWithTypedStringProp); + assertEquals(true, el.booleanWithTypedStringProp); + }, + + testSetPropertiesDirectAttributeMap() { + const attrs = {'usemap': '#myMap'}; + const el = googDom.createDom(TagName.IMG); + + const res = googDom.setProperties(el, attrs); + assertEquals('Should be equal', '#myMap', el.getAttribute('usemap')); + }, + + testSetPropertiesDirectAttributeMapChecksForOwnProperties() { + stubs.set(Object.prototype, 'customProp', 'sdflasdf.,m.,<>fsdflas213!@#'); + const attrs = {'usemap': '#myMap'}; + const el = googDom.createDom(TagName.IMG); + + const res = googDom.setProperties(el, attrs); + assertEquals('Should be equal', '#myMap', el.getAttribute('usemap')); + }, + + testSetPropertiesAria() { + const attrs = { + 'aria-hidden': 'true', + 'aria-label': 'This is a label', + 'role': 'presentation', + }; + const el = googDom.createDom(TagName.DIV); + + googDom.setProperties(el, attrs); + assertEquals('Should be equal', 'true', el.getAttribute('aria-hidden')); + assertEquals( + 'Should be equal', 'This is a label', el.getAttribute('aria-label')); + assertEquals('Should be equal', 'presentation', el.getAttribute('role')); + }, + + testSetPropertiesData() { + const attrs = { + 'data-tooltip': 'This is a tooltip', + 'data-tooltip-delay': '100', + }; + const el = googDom.createDom(TagName.DIV); + + googDom.setProperties(el, attrs); + assertEquals( + 'Should be equal', 'This is a tooltip', + el.getAttribute('data-tooltip')); + assertEquals( + 'Should be equal', '100', el.getAttribute('data-tooltip-delay')); + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testSetTableProperties() { + const attrs = { + 'style': 'padding-left: 10px;', + 'class': 'mytestclass', + 'height': '101', + 'cellpadding': '15', + }; + const el = $('testTable1'); + + const res = googDom.setProperties(el, attrs); + assertEquals('Should be equal', el.style.paddingLeft, '10px'); + assertEquals('Should be equal', el.className, 'mytestclass'); + assertEquals('Should be equal', el.getAttribute('height'), '101'); + assertEquals('Should be equal', el.cellPadding, '15'); + }, + + testGetViewportSize() { + // TODO: This is failing in the test runner now, fix later. + // var dims = getViewportSize(); + // assertNotUndefined('Should be defined at least', dims.width); + // assertNotUndefined('Should be defined at least', dims.height); + }, + + testGetViewportSizeInIframe() { + const iframe = + /** @type {HTMLIFrameElement} */ (googDom.getElement('iframe')); + const contentDoc = googDom.getFrameContentDocument(iframe); + const outerSize = googDom.getViewportSize(); + const innerSize = (new DomHelper(contentDoc)).getViewportSize(); + assert('Viewport sizes must not match', innerSize.width != outerSize.width); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testGetDocumentHeightInIframe() { + const doc = googDom.getDomHelper(myIframeDoc).getDocument(); + const height = googDom.getDomHelper(myIframeDoc).getDocumentHeight(); + + // Broken in webkit/edge quirks mode and in IE8+ + if ((googDom.isCss1CompatMode_(doc) || + !userAgent.WEBKIT && !userAgent.EDGE) && + !isIE()) { + assertEquals('height should be 65', 42 + 23, height); + } + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testCreateDom() { + const el = googDom.createDom( + TagName.DIV, { + style: 'border: 1px solid black; width: 50%; background-color: #EEE;', + onclick: 'alert(\'woo\')', + }, + googDom.createDom( + TagName.P, {style: 'font: normal 12px arial; color: red; '}, + 'Para 1'), + googDom.createDom( + TagName.P, {style: 'font: bold 18px garamond; color: blue; '}, + 'Para 2'), + googDom.createDom( + TagName.P, {style: 'font: normal 24px monospace; color: green'}, + 'Para 3 ', + googDom.createDom( + TagName.A, { + name: 'link', + href: SafeUrl.sanitize('http://bbc.co.uk/'), + }, + 'has a link'), + ', how cool is this?')); + + assertEquals('Tagname should be a DIV', String(TagName.DIV), el.tagName); + assertEquals('Style width should be 50%', '50%', el.style.width); + assertEquals( + 'first child is a P tag', String(TagName.P), el.childNodes[0].tagName); + assertEquals( + 'second child .innerHTML', 'Para 2', el.childNodes[1].innerHTML); + assertEquals( + 'Link href as SafeUrl', 'http://bbc.co.uk/', + el.childNodes[2].childNodes[1].href); + }, + + testCreateDomNoChildren() { + let el; + + // Test unspecified children. + el = googDom.createDom(TagName.DIV); + assertNull('firstChild should be null', el.firstChild); + + // Test null children. + el = googDom.createDom(TagName.DIV, null, null); + assertNull('firstChild should be null', el.firstChild); + + // Test empty array of children. + el = googDom.createDom(TagName.DIV, null, []); + assertNull('firstChild should be null', el.firstChild); + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testCreateDomAcceptsArray() { + const items = [ + googDom.createDom(TagName.LI, {}, 'Item 1'), + googDom.createDom(TagName.LI, {}, 'Item 2'), + ]; + const ul = googDom.createDom(TagName.UL, {}, items); + assertEquals('List should have two children', 2, ul.childNodes.length); + assertEquals( + 'First child should be an LI tag', String(TagName.LI), + ul.firstChild.tagName); + assertEquals('Item 1', ul.childNodes[0].innerHTML); + assertEquals('Item 2', ul.childNodes[1].innerHTML); + }, + + testCreateDomStringArg() { + let el; + + // Test string arg. + el = googDom.createDom(TagName.DIV, null, 'Hello'); + assertEquals( + 'firstChild should be a text node', NodeType.TEXT, + el.firstChild.nodeType); + assertEquals( + 'firstChild should have node value "Hello"', 'Hello', + el.firstChild.nodeValue); + + // Test text node arg. + el = googDom.createDom(TagName.DIV, null, googDom.createTextNode('World')); + assertEquals( + 'firstChild should be a text node', NodeType.TEXT, + el.firstChild.nodeType); + assertEquals( + 'firstChild should have node value "World"', 'World', + el.firstChild.nodeValue); + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testCreateDomNodeListArg() { + let el; + const emptyElem = googDom.createDom(TagName.DIV); + const simpleElem = googDom.createDom(TagName.DIV, null, 'Hello, world!'); + const complexElem = googDom.createDom( + TagName.DIV, null, 'Hello, ', + googDom.createDom(TagName.B, null, 'world'), + googDom.createTextNode('!')); + + // Test empty node list. + el = googDom.createDom(TagName.DIV, null, emptyElem.childNodes); + assertNull('emptyElem.firstChild should be null', emptyElem.firstChild); + assertNull('firstChild should be null', el.firstChild); + + // Test simple node list. + el = googDom.createDom(TagName.DIV, null, simpleElem.childNodes); + assertNull('simpleElem.firstChild should be null', simpleElem.firstChild); + assertEquals( + 'firstChild should be a text node with value "Hello, world!"', + 'Hello, world!', el.firstChild.nodeValue); + + // Test complex node list. + el = googDom.createDom(TagName.DIV, null, complexElem.childNodes); + assertNull('complexElem.firstChild should be null', complexElem.firstChild); + assertEquals('Element should have 3 child nodes', 3, el.childNodes.length); + assertEquals( + 'childNodes[0] should be a text node with value "Hello, "', 'Hello, ', + el.childNodes[0].nodeValue); + assertEquals( + 'childNodes[1] should be an element node with tagName "B"', + String(TagName.B), el.childNodes[1].tagName); + assertEquals( + 'childNodes[2] should be a text node with value "!"', '!', + el.childNodes[2].nodeValue); + }, + + testCreateDomWithTypeAttribute() { + const el = googDom.createDom( + TagName.BUTTON, {'type': InputType.RESET, 'id': 'cool-button'}, + 'Cool button'); + assertNotNull('Button with type attribute was created successfully', el); + assertEquals('Button has correct type attribute', InputType.RESET, el.type); + assertEquals('Button has correct id', 'cool-button', el.id); + }, + + testCreateDomWithClassList() { + const el = googDom.createDom(TagName.DIV, ['foo', 'bar']); + assertEquals('foo bar', el.className); + }, + + testContains() { + assertTrue( + 'HTML should contain BODY', + googDom.contains(document.documentElement, document.body)); + assertTrue( + 'Document should contain BODY', + googDom.contains(document, document.body)); + + const d = googDom.createDom(TagName.P, null, 'A paragraph'); + const t = d.firstChild; + assertTrue('Same element', googDom.contains(d, d)); + assertTrue('Same text', googDom.contains(t, t)); + assertTrue('Nested text', googDom.contains(d, t)); + assertFalse('Nested text, reversed', googDom.contains(t, d)); + assertFalse('Disconnected element', googDom.contains(document, d)); + googDom.appendChild(document.body, d); + assertTrue('Connected element', googDom.contains(document, d)); + googDom.removeNode(d); + }, + + testCreateDomWithClassName() { + let el = googDom.createDom(TagName.DIV, 'cls'); + assertNull('firstChild should be null', el.firstChild); + assertEquals('Tagname should be a DIV', String(TagName.DIV), el.tagName); + assertEquals('ClassName should be cls', 'cls', el.className); + + el = googDom.createDom(TagName.DIV, ''); + assertEquals('ClassName should be empty', '', el.className); + }, + + testCompareNodeOrder() { + const b1 = $('b1'); + const b2 = $('b2'); + const p2 = $('p2'); + + assertEquals( + 'equal nodes should compare to 0', 0, googDom.compareNodeOrder(b1, b1)); + + assertTrue( + 'parent should come before child', + googDom.compareNodeOrder(p2, b1) < 0); + assertTrue( + 'child should come after parent', googDom.compareNodeOrder(b1, p2) > 0); + + assertTrue( + 'parent should come before text child', + googDom.compareNodeOrder(b1, b1.firstChild) < 0); + assertTrue( + 'text child should come after parent', + googDom.compareNodeOrder(b1.firstChild, b1) > 0); + + assertTrue( + 'first sibling should come before second', + googDom.compareNodeOrder(b1, b2) < 0); + assertTrue( + 'second sibling should come after first', + googDom.compareNodeOrder(b2, b1) > 0); + + assertTrue( + 'text node after cousin element returns correct value', + googDom.compareNodeOrder(b1.nextSibling, b1) > 0); + assertTrue( + 'text node before cousin element returns correct value', + googDom.compareNodeOrder(b1, b1.nextSibling) < 0); + + assertTrue( + 'text node is before once removed cousin element', + googDom.compareNodeOrder(b1.firstChild, b2) < 0); + assertTrue( + 'once removed cousin element is before text node', + googDom.compareNodeOrder(b2, b1.firstChild) > 0); + + assertTrue( + 'text node is after once removed cousin text node', + googDom.compareNodeOrder(b1.nextSibling, b1.firstChild) > 0); + assertTrue( + 'once removed cousin text node is before text node', + googDom.compareNodeOrder(b1.firstChild, b1.nextSibling) < 0); + + assertTrue( + 'first text node is before second text node', + googDom.compareNodeOrder(b1.previousSibling, b1.nextSibling) < 0); + assertTrue( + 'second text node is after first text node', + googDom.compareNodeOrder(b1.nextSibling, b1.previousSibling) > 0); + + assertTrue( + 'grandchild is after grandparent', + googDom.compareNodeOrder(b1.firstChild, b1.parentNode) > 0); + assertTrue( + 'grandparent is after grandchild', + googDom.compareNodeOrder(b1.parentNode, b1.firstChild) < 0); + + assertTrue( + 'grandchild is after grandparent', + googDom.compareNodeOrder(b1.firstChild, b1.parentNode) > 0); + assertTrue( + 'grandparent is after grandchild', + googDom.compareNodeOrder(b1.parentNode, b1.firstChild) < 0); + + assertTrue( + 'second cousins compare correctly', + googDom.compareNodeOrder(b1.firstChild, b2.firstChild) < 0); + assertTrue( + 'second cousins compare correctly in reverse', + googDom.compareNodeOrder(b2.firstChild, b1.firstChild) > 0); + + assertTrue( + 'testEl2 is after testEl', + googDom.compareNodeOrder($('testEl2'), $('testEl')) > 0); + assertTrue( + 'testEl is before testEl2', + googDom.compareNodeOrder($('testEl'), $('testEl2')) < 0); + + const p = $('order-test'); + const text1 = document.createTextNode('1'); + p.appendChild(text1); + const text2 = document.createTextNode('1'); + p.appendChild(text2); + + assertEquals( + 'Equal text nodes should compare to 0', 0, + googDom.compareNodeOrder(text1, text1)); + assertTrue( + 'First text node is before second', + googDom.compareNodeOrder(text1, text2) < 0); + assertTrue( + 'Second text node is after first', + googDom.compareNodeOrder(text2, text1) > 0); + assertTrue( + 'Late text node is after b1', + googDom.compareNodeOrder(text1, $('b1')) > 0); + + assertTrue( + 'Document node is before non-document node', + googDom.compareNodeOrder(document, b1) < 0); + assertTrue( + 'Non-document node is after document node', + googDom.compareNodeOrder(b1, document) > 0); + }, + + testFindCommonAncestor() { + const b1 = $('b1'); + const b2 = $('b2'); + const p1 = $('p1'); + const p2 = $('p2'); + const testEl2 = $('testEl2'); + + assertNull('findCommonAncestor() = null', googDom.findCommonAncestor()); + assertEquals( + 'findCommonAncestor(b1) = b1', b1, googDom.findCommonAncestor(b1)); + assertEquals( + 'findCommonAncestor(b1, b1) = b1', b1, + googDom.findCommonAncestor(b1, b1)); + assertEquals( + 'findCommonAncestor(b1, b2) = p2', p2, + googDom.findCommonAncestor(b1, b2)); + assertEquals( + 'findCommonAncestor(p1, b2) = body', document.body, + googDom.findCommonAncestor(p1, b2)); + assertEquals( + 'findCommonAncestor(testEl2, b1, b2, p1, p2) = body', document.body, + googDom.findCommonAncestor(testEl2, b1, b2, p1, p2)); + + const outOfDoc = googDom.createElement(TagName.DIV); + assertNull( + 'findCommonAncestor(outOfDoc, b1) = null', + googDom.findCommonAncestor(outOfDoc, b1)); + }, + + testRemoveNode() { + const b = googDom.createElement(TagName.B); + const el = $('p1'); + el.appendChild(b); + googDom.removeNode(b); + assertTrue('b should have been removed', el.lastChild != b); + }, + + testReplaceNode() { + const n = $('toReplace'); + const previousSibling = n.previousSibling; + const goodNode = googDom.createDom(TagName.DIV, {'id': 'goodReplaceNode'}); + googDom.replaceNode(goodNode, n); + + assertEquals( + 'n should have been replaced', previousSibling.nextSibling, goodNode); + assertNull('n should no longer be in the DOM tree', $('toReplace')); + + const badNode = googDom.createDom(TagName.DIV, {'id': 'badReplaceNode'}); + googDom.replaceNode(badNode, n); + assertNull('badNode should not be in the DOM tree', $('badReplaceNode')); + }, + + testCopyContents() { + const target = + googDom.createDom('div', {}, 'a', googDom.createDom('span', {}, 'b')); + const source = + googDom.createDom('div', {}, googDom.createDom('span', {}, 'c'), 'd'); + googDom.copyContents(target, source); + assertEquals('cd', target.textContent); + assertEquals('cd', source.textContent); + assertEquals('c', target.firstChild.textContent); + googDom.copyContents(source, source); + assertEquals('cd', source.textContent); + assertEquals('c', source.firstChild.textContent); + }, + + testInsertChildAt() { + const parent = $('p2'); + const origNumChildren = parent.childNodes.length; + + // Append, with last index. + const child1 = googDom.createElement(TagName.DIV); + googDom.insertChildAt(parent, child1, origNumChildren); + assertEquals(origNumChildren + 1, parent.childNodes.length); + assertEquals(child1, parent.childNodes[parent.childNodes.length - 1]); + + // Append, with value larger than last index. + const child2 = googDom.createElement(TagName.DIV); + googDom.insertChildAt(parent, child2, origNumChildren + 42); + assertEquals(origNumChildren + 2, parent.childNodes.length); + assertEquals(child2, parent.childNodes[parent.childNodes.length - 1]); + + // Prepend. + const child3 = googDom.createElement(TagName.DIV); + googDom.insertChildAt(parent, child3, 0); + assertEquals(origNumChildren + 3, parent.childNodes.length); + assertEquals(child3, parent.childNodes[0]); + + // Self move (no-op). + googDom.insertChildAt(parent, child3, 0); + assertEquals(origNumChildren + 3, parent.childNodes.length); + assertEquals(child3, parent.childNodes[0]); + + // Move. + googDom.insertChildAt(parent, child3, 2); + assertEquals(origNumChildren + 3, parent.childNodes.length); + assertEquals(child3, parent.childNodes[1]); + + parent.removeChild(child1); + parent.removeChild(child2); + parent.removeChild(child3); + + const emptyParentNotInDocument = googDom.createElement(TagName.DIV); + googDom.insertChildAt(emptyParentNotInDocument, child1, 0); + assertEquals(1, emptyParentNotInDocument.childNodes.length); + }, + + testFlattenElement() { + const text = document.createTextNode('Text'); + const br = googDom.createElement(TagName.BR); + const span = googDom.createDom(TagName.SPAN, null, text, br); + assertEquals('span should have 2 children', 2, span.childNodes.length); + + const el = $('p1'); + el.appendChild(span); + + const ret = googDom.flattenElement(span); + + assertTrue('span should have been removed', el.lastChild != span); + assertFalse( + 'span should have no parent', + !!span.parentNode && + span.parentNode.nodeType != NodeType.DOCUMENT_FRAGMENT); + assertEquals('span should have no children', 0, span.childNodes.length); + assertEquals('Last child of p should be br', br, el.lastChild); + assertEquals( + 'Previous sibling of br should be text', text, br.previousSibling); + + const outOfDoc = googDom.createDom(TagName.SPAN, null, '1 child'); + // Should do nothing. + googDom.flattenElement(outOfDoc); + assertEquals( + 'outOfDoc should still have 1 child', 1, outOfDoc.childNodes.length); + }, + + testIsNodeLike() { + assertTrue('document should be node like', googDom.isNodeLike(document)); + assertTrue( + 'document.body should be node like', googDom.isNodeLike(document.body)); + assertTrue( + 'a text node should be node like', + googDom.isNodeLike(document.createTextNode(''))); + + assertFalse('null should not be node like', googDom.isNodeLike(null)); + assertFalse('a string should not be node like', googDom.isNodeLike('abcd')); + + assertTrue( + 'custom object should be node like', googDom.isNodeLike({nodeType: 1})); + }, + + testIsElement() { + assertFalse('document is not an element', googDom.isElement(document)); + assertTrue('document.body is an element', googDom.isElement(document.body)); + assertFalse( + 'a text node is not an element', + googDom.isElement(document.createTextNode(''))); + assertTrue( + 'an element created with createElement() is an element', + googDom.isElement(googDom.createElement(TagName.A))); + + assertFalse('null is not an element', googDom.isElement(null)); + assertFalse('a string is not an element', googDom.isElement('abcd')); + + assertTrue('custom object is an element', googDom.isElement({nodeType: 1})); + assertFalse( + 'custom non-element object is a not an element', + googDom.isElement({someProperty: 'somevalue'})); + }, + + testIsWindow() { + const global = globalThis; + const frame = window.frames['frame']; + const otherWindow = window.open('', 'blank'); + const object = {window: globalThis}; + const nullVar = null; + let notDefined; + + try { + // Use try/finally to ensure that we clean up the window we open, even if + // an assertion fails or something else goes wrong. + assertTrue( + 'global object in HTML context should be a window', + googDom.isWindow(globalThis)); + assertTrue('iframe window should be a window', googDom.isWindow(frame)); + if (otherWindow) { + assertTrue( + 'other window should be a window', googDom.isWindow(otherWindow)); + } + assertFalse('object should not be a window', googDom.isWindow(object)); + assertFalse('null should not be a window', googDom.isWindow(nullVar)); + assertFalse( + 'undefined should not be a window', googDom.isWindow(notDefined)); + } finally { + if (otherWindow) { + otherWindow.close(); + } + } + }, + + testIsInDocument() { + assertThrows(() => { + googDom.isInDocument(document); + }); + + assertTrue(googDom.isInDocument(document.documentElement)); + + const div = document.createElement('div'); + assertFalse(googDom.isInDocument(div)); + document.body.appendChild(div); + assertTrue(googDom.isInDocument(div)); + + const textNode = document.createTextNode(''); + assertFalse(googDom.isInDocument(textNode)); + div.appendChild(textNode); + assertTrue(googDom.isInDocument(textNode)); + + const attribute = document.createAttribute('a'); + assertFalse(googDom.isInDocument(attribute)); + div.setAttributeNode(attribute); + assertTrue(googDom.isInDocument(attribute)); + }, + + testGetOwnerDocument() { + assertEquals(googDom.getOwnerDocument($('p1')), document); + assertEquals(googDom.getOwnerDocument(document.body), document); + assertEquals(googDom.getOwnerDocument(document.documentElement), document); + }, + + // Tests the breakages resulting in rollback cl/64715474 + testGetOwnerDocumentNonNodeInput() { + // We should fail on null. + assertThrows(() => { + googDom.getOwnerDocument(null); + }); + assertEquals(document, googDom.getOwnerDocument(window)); + }, + + testDomHelper() { + const x = new DomHelper(window.frames['frame'].document); + assertTrue( + 'Should have some HTML', x.getDocument().body.innerHTML.length > 0); + }, + + testGetFirstElementChild() { + const p2 = $('p2'); + let b1 = googDom.getFirstElementChild(p2); + assertNotNull('First element child of p2 should not be null', b1); + assertEquals('First element child is b1', 'b1', b1.id); + + const c = googDom.getFirstElementChild(b1); + assertNull('First element child of b1 should be null', c); + + // Test with an undefined firstElementChild attribute. + const b2 = $('b2'); + const mockP2 = { + childNodes: [b1, b2], + firstChild: b1, + firstElementChild: undefined, + }; + + /** @suppress {checkTypes} suppression added to enable type checking */ + b1 = googDom.getFirstElementChild(mockP2); + assertNotNull('First element child of mockP2 should not be null', b1); + assertEquals('First element child is b1', 'b1', b1.id); + }, + + testGetLastElementChild() { + const p2 = $('p2'); + let b2 = googDom.getLastElementChild(p2); + assertNotNull('Last element child of p2 should not be null', b2); + assertEquals('Last element child is b2', 'b2', b2.id); + + const c = googDom.getLastElementChild(b2); + assertNull('Last element child of b2 should be null', c); + + // Test with an undefined lastElementChild attribute. + const b1 = $('b1'); + const mockP2 = { + childNodes: [b1, b2], + lastChild: b2, + lastElementChild: undefined, + }; + + /** @suppress {checkTypes} suppression added to enable type checking */ + b2 = googDom.getLastElementChild(mockP2); + assertNotNull('Last element child of mockP2 should not be null', b2); + assertEquals('Last element child is b2', 'b2', b2.id); + }, + + testGetNextElementSibling() { + const b1 = $('b1'); + let b2 = googDom.getNextElementSibling(b1); + assertNotNull('Next element sibling of b1 should not be null', b1); + assertEquals('Next element sibling is b2', 'b2', b2.id); + + const c = googDom.getNextElementSibling(b2); + assertNull('Next element sibling of b2 should be null', c); + + // Test with an undefined nextElementSibling attribute. + const mockB1 = {nextSibling: b2, nextElementSibling: undefined}; + + /** @suppress {checkTypes} suppression added to enable type checking */ + b2 = googDom.getNextElementSibling(mockB1); + assertNotNull('Next element sibling of mockB1 should not be null', b1); + assertEquals('Next element sibling is b2', 'b2', b2.id); + }, + + testGetPreviousElementSibling() { + const b2 = $('b2'); + let b1 = googDom.getPreviousElementSibling(b2); + assertNotNull('Previous element sibling of b2 should not be null', b1); + assertEquals('Previous element sibling is b1', 'b1', b1.id); + + const c = googDom.getPreviousElementSibling(b1); + assertNull('Previous element sibling of b1 should be null', c); + + // Test with an undefined previousElementSibling attribute. + const mockB2 = {previousSibling: b1, previousElementSibling: undefined}; + + /** @suppress {checkTypes} suppression added to enable type checking */ + b1 = googDom.getPreviousElementSibling(mockB2); + assertNotNull('Previous element sibling of mockB2 should not be null', b1); + assertEquals('Previous element sibling is b1', 'b1', b1.id); + }, + + testGetChildren() { + const p2 = $('p2'); + let children = googDom.getChildren(p2); + assertNotNull('Elements array should not be null', children); + assertEquals( + 'List of element children should be length two.', 2, children.length); + + const b1 = $('b1'); + const b2 = $('b2'); + assertObjectEquals('First element child should be b1.', b1, children[0]); + assertObjectEquals('Second element child should be b2.', b2, children[1]); + + const noChildren = googDom.getChildren(b1); + assertNotNull('Element children array should not be null', noChildren); + assertEquals( + 'List of element children should be length zero.', 0, + noChildren.length); + + // Test with an undefined children attribute. + const mockP2 = {childNodes: [b1, b2], children: undefined}; + + /** @suppress {checkTypes} suppression added to enable type checking */ + children = googDom.getChildren(mockP2); + assertNotNull('Elements array should not be null', children); + assertEquals( + 'List of element children should be length two.', 2, children.length); + + assertObjectEquals('First element child should be b1.', b1, children[0]); + assertObjectEquals('Second element child should be b2.', b2, children[1]); + }, + + testGetNextNode() { + const tree = googDom.safeHtmlToNode(testing.newSafeHtmlForTest( + '
    ' + + '

    Some text

    ' + + '
    Some special text
    ' + + '
    Foo
    ' + + '
    ')); + + assertNull(googDom.getNextNode(null)); + + let node = tree; + const next = () => node = googDom.getNextNode(node); + + assertEquals(String(TagName.P), next().tagName); + assertEquals('Some text', next().nodeValue); + assertEquals(String(TagName.BLOCKQUOTE), next().tagName); + assertEquals('Some ', next().nodeValue); + assertEquals(String(TagName.I), next().tagName); + assertEquals('special', next().nodeValue); + assertEquals(' ', next().nodeValue); + assertEquals(String(TagName.B), next().tagName); + assertEquals('text', next().nodeValue); + assertEquals(String(TagName.ADDRESS), next().tagName); + assertEquals(NodeType.COMMENT, next().nodeType); + assertEquals('Foo', next().nodeValue); + + assertNull(next()); + }, + + testGetPreviousNode() { + const tree = googDom.safeHtmlToNode(testing.newSafeHtmlForTest( + '
    ' + + '

    Some text

    ' + + '
    Some special text
    ' + + '
    Foo
    ' + + '
    ')); + + assertNull(googDom.getPreviousNode(null)); + + let node = tree.lastChild.lastChild; + const previous = () => node = googDom.getPreviousNode(node); + + assertEquals(NodeType.COMMENT, previous().nodeType); + assertEquals(String(TagName.ADDRESS), previous().tagName); + assertEquals('text', previous().nodeValue); + assertEquals(String(TagName.B), previous().tagName); + assertEquals(' ', previous().nodeValue); + assertEquals('special', previous().nodeValue); + assertEquals(String(TagName.I), previous().tagName); + assertEquals('Some ', previous().nodeValue); + assertEquals(String(TagName.BLOCKQUOTE), previous().tagName); + assertEquals('Some text', previous().nodeValue); + assertEquals(String(TagName.P), previous().tagName); + assertEquals(String(TagName.DIV), previous().tagName); + + if (!userAgent.IE) { + // Internet Explorer maintains a parentNode for Elements after they are + // removed from the hierarchy. Everyone else agrees on a null parentNode. + assertNull(previous()); + } + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testSetTextContent() { + const p1 = $('p1'); + let s = 'hello world'; + googDom.setTextContent(p1, s); + assertEquals( + 'We should have one childNode after setTextContent', 1, + p1.childNodes.length); + assertEquals(s, p1.firstChild.data); + assertEquals(s, p1.innerHTML); + + s = 'four elefants < five ants'; + const sHtml = 'four elefants < five ants'; + googDom.setTextContent(p1, s); + assertEquals( + 'We should have one childNode after setTextContent', 1, + p1.childNodes.length); + assertEquals(s, p1.firstChild.data); + assertEquals(sHtml, p1.innerHTML); + + // ensure that we remove existing children + p1.innerHTML = 'abc'; + s = 'hello world'; + googDom.setTextContent(p1, s); + assertEquals( + 'We should have one childNode after setTextContent', 1, + p1.childNodes.length); + assertEquals(s, p1.firstChild.data); + + // same but start with an element + p1.innerHTML = 'abc'; + s = 'hello world'; + googDom.setTextContent(p1, s); + assertEquals( + 'We should have one childNode after setTextContent', 1, + p1.childNodes.length); + assertEquals(s, p1.firstChild.data); + + // Text/CharacterData + googDom.setTextContent(p1, 'before'); + s = 'after'; + googDom.setTextContent(p1.firstChild, s); + assertEquals( + 'We should have one childNode after setTextContent', 1, + p1.childNodes.length); + assertEquals(s, p1.firstChild.data); + + // DocumentFragment + const df = document.createDocumentFragment(); + s = 'hello world'; + googDom.setTextContent(df, s); + assertEquals( + 'We should have one childNode after setTextContent', 1, + df.childNodes.length); + assertEquals(s, df.firstChild.data); + + // clean up + googDom.removeChildren(p1); + }, + + testFindNode() { + let expected = document.body; + let result = googDom.findNode( + document, + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + (n) => n.nodeType == NodeType.ELEMENT && n.tagName == TagName.BODY); + assertEquals(expected, result); + + expected = googDom.getElementsByTagName(TagName.P)[0]; + result = googDom.findNode( + document, + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + (n) => n.nodeType == NodeType.ELEMENT && n.tagName == TagName.P); + assertEquals(expected, result); + + result = googDom.findNode(document, (n) => false); + assertUndefined(result); + }, + + testFindElement_works() { + const isBody = (element) => element.tagName == 'BODY'; + const isP = (element) => element.tagName == 'P'; + const firstP = document.querySelector('p'); + const htmlElement = document.documentElement; + + // root is an element + assertNull(googDom.findElement(document.body, functions.FALSE)); + assertEquals(firstP, googDom.findElement(document.body, isP)); + + // root is the document + assertEquals(htmlElement, googDom.findElement(document, functions.TRUE)); + assertNull(googDom.findElement(document, functions.FALSE)); + assertEquals(document.body, googDom.findElement(document, isBody)); + assertEquals(firstP, googDom.findElement(document, isP)); + }, + + testFindElement_excludesRootElement() { + assertNull(googDom.findElement( + document.body, (element) => element.tagName == 'BODY')); + }, + + testFindElement_onlyCallsFilterFunctionWithElements() { + googDom.findElement(document, (param) => { + asserts.assertElement(param); + return false; // to visit all nodes + }); + }, + + testFindNodes() { + const expected = googDom.getElementsByTagName(TagName.P); + let result = googDom.findNodes( + document, + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + (n) => n.nodeType == NodeType.ELEMENT && n.tagName == TagName.P); + assertEquals(expected.length, result.length); + assertEquals(expected[0], result[0]); + assertEquals(expected[1], result[1]); + + result = googDom.findNodes(document, (n) => false).length; + assertEquals(0, result); + }, + + testFindElements_works() { + const isP = (element) => element.tagName == 'P'; + + assertArrayEquals([], googDom.findElements(document, functions.FALSE)); + + // Should return the elements in the same order as getElementsByTagName. + assertArrayEquals( + googArray.toArray(document.getElementsByTagName('p')), + googDom.findElements(document, isP)); + assertArrayEquals( + googArray.toArray(document.getElementsByTagName('*')), + googDom.findElements(document, functions.TRUE)); + assertArrayEquals( + googArray.toArray(document.body.getElementsByTagName('*')), + googDom.findElements(document.body, functions.TRUE)); + }, + + testFindElements_excludesRootElement() { + const isBody = (element) => element.tagName == 'BODY'; + + assertArrayEquals( + [document.body], + googDom.findElements(document.documentElement, isBody)); + assertArrayEquals([], googDom.findElements(document.body, isBody)); + }, + + testFindElements_onlyCallsFilterFunctionWithElements() { + googDom.findElements(document, (param) => { + asserts.assertElement(param); + return false; // to visit all nodes + }); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testIsFocusableTabIndex() { + assertFalse( + 'isFocusableTabIndex() must be false for no tab index', + googDom.isFocusableTabIndex(googDom.getElement('noTabIndex'))); + assertFalse( + 'isFocusableTabIndex() must be false for tab index -2', + googDom.isFocusableTabIndex(googDom.getElement('tabIndexNegative2'))); + assertFalse( + 'isFocusableTabIndex() must be false for tab index -1', + googDom.isFocusableTabIndex(googDom.getElement('tabIndexNegative1'))); + + assertTrue( + 'isFocusableTabIndex() must be true for tab index 0', + googDom.isFocusableTabIndex(googDom.getElement('tabIndex0'))); + assertTrue( + 'isFocusableTabIndex() must be true for tab index 1', + googDom.isFocusableTabIndex(googDom.getElement('tabIndex1'))); + assertTrue( + 'isFocusableTabIndex() must be true for tab index 2', + googDom.isFocusableTabIndex(googDom.getElement('tabIndex2'))); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testSetFocusableTabIndex() { + // Test enabling focusable tab index. + googDom.setFocusableTabIndex(googDom.getElement('noTabIndex'), true); + assertTrue( + 'isFocusableTabIndex() must be true after enabling tab index', + googDom.isFocusableTabIndex(googDom.getElement('noTabIndex'))); + + // Test disabling focusable tab index that was added programmatically. + googDom.setFocusableTabIndex(googDom.getElement('noTabIndex'), false); + assertFalse( + 'isFocusableTabIndex() must be false after disabling tab ' + + 'index that was programmatically added', + googDom.isFocusableTabIndex(googDom.getElement('noTabIndex'))); + + // Test disabling focusable tab index that was specified in markup. + googDom.setFocusableTabIndex(googDom.getElement('tabIndex0'), false); + assertFalse( + 'isFocusableTabIndex() must be false after disabling tab ' + + 'index that was specified in markup', + googDom.isFocusableTabIndex(googDom.getElement('tabIndex0'))); + + // Test re-enabling focusable tab index. + googDom.setFocusableTabIndex(googDom.getElement('tabIndex0'), true); + assertTrue( + 'isFocusableTabIndex() must be true after reenabling tabindex', + googDom.isFocusableTabIndex(googDom.getElement('tabIndex0'))); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testIsFocusable() { + // Form elements without explicit tab index + assertFocusable(googDom.getElement('noTabIndexAnchor')); // + assertNotFocusable(googDom.getElement('noTabIndexNoHrefAnchor')); // + assertFocusable(googDom.getElement('noTabIndexInput')); // + assertFocusable(googDom.getElement('noTabIndexTextArea')); // + + + + + + + + + + + +
    Replace Test
    + + + +
    hello world
    +
    hello world
    + + + +
    Foo + + + + + + diff --git a/closure/goog/dom/element.js b/closure/goog/dom/element.js new file mode 100644 index 0000000000..80821ab454 --- /dev/null +++ b/closure/goog/dom/element.js @@ -0,0 +1,208 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.element'); +goog.module.declareLegacyNamespace(); + +const NodeType = goog.require('goog.dom.NodeType'); +const TagName = goog.require('goog.dom.TagName'); + +/** @const {string} */ +const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; + +/** + * Determines if a value is a DOM Element. + * @param {*} value + * @return {boolean} + */ +const isElement = (value) => { + return goog.isObject(value) && + /** @type {!Node} */ (value).nodeType === NodeType.ELEMENT; +}; + +/** + * Determines if a value is a DOM HTML Element. + * @param {*} value + * @return {boolean} + */ +const isHtmlElement = (value) => { + return goog.isObject(value) && isElement(value) && + // namespaceURI of old browsers (FF < 3.6, IE < 9) will be null. + (!/** @type {!Element} */ (value).namespaceURI || + /** @type {!Element} */ (value).namespaceURI === HTML_NAMESPACE); +}; + +/** + * Determines if a value is a DOM HTML Element of a specified tag name. For + * modern browsers, tags that provide access to special DOM APIs are implemented + * as special subclasses of HTMLElement. + * @param {*} value + * @param {!TagName} tagName + * @return {boolean} + */ +const isHtmlElementOfType = (value, tagName) => { + return goog.isObject(value) && isHtmlElement(value) && + // Some uncommon JS environments (e.g. Cobalt 9) have issues with tag + // capitalization. + (/** @type {!HTMLElement} */ (value).tagName.toUpperCase() === + tagName.toString()); +}; + +/** + * Determines if a value is an Element. + * @param {*} value + * @return {boolean} + */ +const isHtmlAnchorElement = (value) => { + return isHtmlElementOfType(value, TagName.A); +}; + +/** + * Determines if a value is a + + + + + + + + + + + + + + +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + +
    \ No newline at end of file diff --git a/closure/goog/dom/fullscreen.js b/closure/goog/dom/fullscreen.js new file mode 100644 index 0000000000..b74da42fd6 --- /dev/null +++ b/closure/goog/dom/fullscreen.js @@ -0,0 +1,193 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Functions for managing full screen status of the DOM. + */ + +goog.provide('goog.dom.fullscreen'); +goog.provide('goog.dom.fullscreen.EventType'); + +goog.require('goog.dom'); + +/** + * Event types for full screen. + * @enum {string} + */ +goog.dom.fullscreen.EventType = { + /** Dispatched by the Document when the fullscreen status changes. */ + CHANGE: (function() { + 'use strict'; + var el = goog.dom.getDomHelper().getDocument().documentElement; + if (el.requestFullscreen) { + return 'fullscreenchange'; + } + if (el.webkitRequestFullscreen) { + return 'webkitfullscreenchange'; + } + if (el.mozRequestFullScreen) { + return 'mozfullscreenchange'; + } + if (el.msRequestFullscreen) { + return 'MSFullscreenChange'; + } + // Opera 12-14, and W3C standard (Draft): + // https://dvcs.w3.org/hg/fullscreen/raw-file/tip/Overview.html + return 'fullscreenchange'; + })() +}; + + +/** + * Options for fullscreen navigation UI: + * https://fullscreen.spec.whatwg.org/#dictdef-fullscreenoptions + * @enum {string} + */ +goog.dom.fullscreen.FullscreenNavigationUI = { + AUTO: 'auto', + HIDE: 'hide', + SHOW: 'show' +}; + +/** + * @record + * @extends {FullscreenOptions} + */ +goog.dom.fullscreen.FullscreenOptions = function() {}; + +/** @type {!goog.dom.fullscreen.FullscreenNavigationUI} */ +goog.dom.fullscreen.FullscreenOptions.prototype.navigationUI; + + +/** + * Determines if full screen is supported. + * @param {!goog.dom.DomHelper=} opt_domHelper The DomHelper for the DOM being + * queried. If not provided, use the current DOM. + * @return {boolean} True iff full screen is supported. + */ +goog.dom.fullscreen.isSupported = function(opt_domHelper) { + 'use strict'; + var doc = goog.dom.fullscreen.getDocument_(opt_domHelper); + var body = doc.body; + return !!( + (body.webkitRequestFullscreen && doc.webkitFullscreenEnabled) || + (body.mozRequestFullScreen && doc.mozFullScreenEnabled) || + (body.msRequestFullscreen && doc.msFullscreenEnabled) || + (body.requestFullscreen && doc.fullscreenEnabled)); +}; + + +/** + * Requests putting the element in full screen. + * @param {!Element} element The element to put full screen. + * @param {!goog.dom.fullscreen.FullscreenOptions=} opt_options Options for full + * screen. This field will be ignored on older browsers. + @return {!Promise|undefined} A promise in later versions of Chrome + and undefined otherwise. + */ +goog.dom.fullscreen.requestFullScreen = function(element, opt_options) { + 'use strict'; + if (element.requestFullscreen) { + return element.requestFullscreen(opt_options); + } else if (element.webkitRequestFullscreen) { + return element.webkitRequestFullscreen(); + } else if (element.mozRequestFullScreen) { + return element.mozRequestFullScreen(); + } else if (element.msRequestFullscreen) { + return element.msRequestFullscreen(); + } +}; + + +/** + * Requests putting the element in full screen with full keyboard access. + * @param {!Element} element The element to put full screen. + * @param {!goog.dom.fullscreen.FullscreenOptions=} opt_options Options for full + * screen. This field will be ignored on older browsers. + @return {!Promise|undefined} A promise in later versions of Chrome + and undefined otherwise. + */ +goog.dom.fullscreen.requestFullScreenWithKeys = function(element, opt_options) { + 'use strict'; + if (element.mozRequestFullScreenWithKeys) { + return element.mozRequestFullScreenWithKeys(); + } else { + return goog.dom.fullscreen.requestFullScreen(element, opt_options); + } +}; + + +/** + * Exits full screen. + * @param {!goog.dom.DomHelper=} opt_domHelper The DomHelper for the DOM being + * queried. If not provided, use the current DOM. + */ +goog.dom.fullscreen.exitFullScreen = function(opt_domHelper) { + 'use strict'; + var doc = goog.dom.fullscreen.getDocument_(opt_domHelper); + if (doc.exitFullscreen) { + doc.exitFullscreen(); + } else if (doc.webkitCancelFullScreen) { + doc.webkitCancelFullScreen(); + } else if (doc.mozCancelFullScreen) { + doc.mozCancelFullScreen(); + } else if (doc.msExitFullscreen) { + doc.msExitFullscreen(); + } +}; + + +/** + * Determines if the document is full screen. + * @param {!goog.dom.DomHelper=} opt_domHelper The DomHelper for the DOM being + * queried. If not provided, use the current DOM. + * @return {boolean} Whether the document is full screen. + */ +goog.dom.fullscreen.isFullScreen = function(opt_domHelper) { + 'use strict'; + var doc = goog.dom.fullscreen.getDocument_(opt_domHelper); + // IE 11 doesn't have similar boolean property, so check whether + // document.msFullscreenElement is null instead. + return !!( + doc.webkitIsFullScreen || doc.mozFullScreen || doc.msFullscreenElement || + doc.fullscreenElement); +}; + + +/** + * Get the root element in full screen mode. + * @param {!goog.dom.DomHelper=} opt_domHelper The DomHelper for the DOM being + * queried. If not provided, use the current DOM. + * @return {?Element} The root element in full screen mode. + */ +goog.dom.fullscreen.getFullScreenElement = function(opt_domHelper) { + 'use strict'; + var doc = goog.dom.fullscreen.getDocument_(opt_domHelper); + var element_list = [ + doc.fullscreenElement, doc.webkitFullscreenElement, + doc.mozFullScreenElement, doc.msFullscreenElement + ]; + for (var i = 0; i < element_list.length; i++) { + if (element_list[i] != null) { + return element_list[i]; + } + } + return null; +}; + + +/** + * Gets the document object of the dom. + * @param {!goog.dom.DomHelper=} opt_domHelper The DomHelper for the DOM being + * queried. If not provided, use the current DOM. + * @return {!Document} The dom document. + * @private + */ +goog.dom.fullscreen.getDocument_ = function(opt_domHelper) { + 'use strict'; + return opt_domHelper ? opt_domHelper.getDocument() : + goog.dom.getDomHelper().getDocument(); +}; diff --git a/closure/goog/dom/fullscreen_demo.html b/closure/goog/dom/fullscreen_demo.html new file mode 100644 index 0000000000..36972b911d --- /dev/null +++ b/closure/goog/dom/fullscreen_demo.html @@ -0,0 +1,38 @@ + + + + +goog.dom.fullscreen Demo + + + + +
    +Click anywhere to make this element fullscreen, or to switch back to non-fullscreen. +
    + + + diff --git a/closure/goog/dom/fullscreen_test.js b/closure/goog/dom/fullscreen_test.js new file mode 100644 index 0000000000..d00f0f4a26 --- /dev/null +++ b/closure/goog/dom/fullscreen_test.js @@ -0,0 +1,41 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.fullscreen_test'); +goog.setTestOnly(); + +const DomHelper = goog.require('goog.dom.DomHelper'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const asserts = goog.require('goog.testing.asserts'); +const fullscreen = goog.require('goog.dom.fullscreen'); +const testSuite = goog.require('goog.testing.testSuite'); + +let domHelper; +let mockDoc; +let stubs; + +testSuite({ + setUp() { + mockDoc = {}; + domHelper = new DomHelper(); + stubs = new PropertyReplacer(); + stubs.replace(domHelper, 'getDocument', () => mockDoc); + }, + + testGetFullScreenElement() { + const element = document.createElement('div'); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + mockDoc.fullscreenElement = element; + assertEquals(element, fullscreen.getFullScreenElement(domHelper)); + }, + + testGetFullScreenElementNotFullScreen() { + assertNull(fullscreen.getFullScreenElement(domHelper)); + }, +}); diff --git a/closure/goog/dom/htmlelement.js b/closure/goog/dom/htmlelement.js new file mode 100644 index 0000000000..35b40bcc8e --- /dev/null +++ b/closure/goog/dom/htmlelement.js @@ -0,0 +1,21 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('goog.dom.HtmlElement'); + + + +/** + * This subclass of HTMLElement is used when only a HTMLElement is possible and + * not any of its subclasses. Normally, a type can refer to an instance of + * itself or an instance of any subtype. More concretely, if HTMLElement is used + * then the compiler must assume that it might still be e.g. HTMLScriptElement. + * With this, the type check knows that it couldn't be any special element. + * + * @constructor + * @extends {HTMLElement} + */ +goog.dom.HtmlElement = function() {}; diff --git a/closure/goog/dom/iframe.js b/closure/goog/dom/iframe.js new file mode 100644 index 0000000000..92e8adb13c --- /dev/null +++ b/closure/goog/dom/iframe.js @@ -0,0 +1,200 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for creating and working with iframes + * cross-browser. + */ + + +goog.provide('goog.dom.iframe'); + +goog.require('goog.dom'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.safe'); +goog.require('goog.html.SafeHtml'); +goog.require('goog.html.SafeStyle'); +goog.require('goog.html.TrustedResourceUrl'); +goog.require('goog.string.Const'); +goog.require('goog.userAgent'); + + +/** + * Safe source for a blank iframe. + * + * Intentionally not about:blank for IE, which gives mixed content warnings in + * IE6 over HTTPS. Using 'about:blank' for all other browsers to support Content + * Security Policy (CSP). According to http://www.w3.org/TR/CSP/ CSP does not + * allow inline javascript by default. + * + * @const {!goog.html.TrustedResourceUrl} + */ +goog.dom.iframe.BLANK_SOURCE_URL = goog.userAgent.IE ? + goog.html.TrustedResourceUrl.fromConstant( + goog.string.Const.from('javascript:""')) : + goog.html.TrustedResourceUrl.fromConstant( + goog.string.Const.from('about:blank')); + + +/** + * Legacy version of goog.dom.iframe.BLANK_SOURCE_URL. + * @const {string} + */ +goog.dom.iframe.BLANK_SOURCE = + goog.html.TrustedResourceUrl.unwrap(goog.dom.iframe.BLANK_SOURCE_URL); + + +/** + * Safe source for a new blank iframe that may not cause a new load of the + * iframe. This is different from `goog.dom.iframe.BLANK_SOURCE` in that + * it will allow an iframe to be loaded synchronously in more browsers, notably + * Gecko, following the javascript protocol spec. + * + * NOTE: This should not be used to replace the source of an existing iframe. + * The new src value will be ignored, per the spec. + * + * Due to cross-browser differences, the load is not guaranteed to be + * synchronous. If code depends on the load of the iframe, + * then `goog.net.IframeLoadMonitor` or a similar technique should be + * used. + * + * According to + * http://www.whatwg.org/specs/web-apps/current-work/multipage/webappapis.html#javascript-protocol + * the 'javascript:""' URL should trigger a new load of the iframe, which may be + * asynchronous. A void src, such as 'javascript:undefined', does not change + * the browsing context document's, and thus should not trigger another load. + * + * Intentionally not about:blank, which also triggers a load. + * + * NOTE: 'javascript:' URL handling spec compliance varies per browser. IE + * throws an error with 'javascript:undefined'. Webkit browsers will reload the + * iframe when setting this source on an existing iframe. + * + * @const {!goog.html.TrustedResourceUrl} + */ +goog.dom.iframe.BLANK_SOURCE_NEW_FRAME_URL = goog.userAgent.IE ? + goog.html.TrustedResourceUrl.fromConstant( + goog.string.Const.from('javascript:""')) : + goog.html.TrustedResourceUrl.fromConstant( + goog.string.Const.from('javascript:undefined')); + + +/** + * Legacy version of goog.dom.iframe.BLANK_SOURCE_NEW_FRAME_URL. + * @const {string} + */ +goog.dom.iframe.BLANK_SOURCE_NEW_FRAME = goog.html.TrustedResourceUrl.unwrap( + goog.dom.iframe.BLANK_SOURCE_NEW_FRAME_URL); + + +/** + * Styles to help ensure an undecorated iframe. + * @const {string} + * @private + */ +goog.dom.iframe.STYLES_ = 'border:0;vertical-align:bottom;'; + + +/** + * Creates a completely blank iframe element. + * + * The iframe will not caused mixed-content warnings for IE6 under HTTPS. + * The iframe will also have no borders or padding, so that the styled width + * and height will be the actual width and height of the iframe. + * + * This function currently only attempts to create a blank iframe. There + * are no guarantees to the contents of the iframe or whether it is rendered + * in quirks mode. + * + * @param {goog.dom.DomHelper} domHelper The dom helper to use. + * @param {!goog.html.SafeStyle=} opt_styles CSS styles for the iframe. + * @return {!HTMLIFrameElement} A completely blank iframe. + */ +goog.dom.iframe.createBlank = function(domHelper, opt_styles) { + 'use strict'; + var styles; + if (opt_styles) { + // SafeStyle has to be converted back to a string for now, since there's + // no safe alternative to createDom(). + styles = goog.html.SafeStyle.unwrap(opt_styles); + } else { // undefined. + styles = ''; + } + var iframe = domHelper.createDom(goog.dom.TagName.IFRAME, { + 'frameborder': 0, + // Since iframes are inline elements, we must align to bottom to + // compensate for the line descent. + 'style': goog.dom.iframe.STYLES_ + styles + }); + goog.dom.safe.setIframeSrc(iframe, goog.dom.iframe.BLANK_SOURCE_URL); + return iframe; +}; + + +/** + * Writes the contents of a blank iframe that has already been inserted + * into the document. + * @param {!HTMLIFrameElement} iframe An iframe with no contents, such as + * one created by {@link #createBlank}, but already appended to + * a parent document. + * @param {!goog.html.SafeHtml} content Content to write to the iframe, + * from doctype to the HTML close tag. + */ +goog.dom.iframe.writeSafeContent = function(iframe, content) { + 'use strict'; + var doc = goog.dom.getFrameContentDocument(iframe); + doc.open(); + goog.dom.safe.documentWrite(doc, content); + doc.close(); +}; + + +// TODO(gboyer): Provide a higher-level API for the most common use case, so +// that you can just provide a list of stylesheets and some content HTML. +/** + * Creates a same-domain iframe containing preloaded content. + * + * This is primarily useful for DOM sandboxing. One use case is to embed + * a trusted JavaScript app with potentially conflicting CSS styles. The + * second case is to reduce the cost of layout passes by the browser -- for + * example, you can perform sandbox sizing of characters in an iframe while + * manipulating a heavy DOM in the main window. The iframe and parent frame + * can access each others' properties and functions without restriction. + * + * @param {!Element} parentElement The parent element in which to append the + * iframe. + * @param {!goog.html.SafeHtml=} opt_headContents Contents to go into the + * iframe's head. + * @param {!goog.html.SafeHtml=} opt_bodyContents Contents to go into the + * iframe's body. + * @param {!goog.html.SafeStyle=} opt_styles CSS styles for the iframe itself, + * before adding to the parent element. + * @param {boolean=} opt_quirks Whether to use quirks mode (false by default). + * @return {!HTMLIFrameElement} An iframe that has the specified contents. + */ +goog.dom.iframe.createWithContent = function( + parentElement, opt_headContents, opt_bodyContents, opt_styles, opt_quirks) { + 'use strict'; + var domHelper = goog.dom.getDomHelper(parentElement); + + var content = goog.html.SafeHtml.create( + 'html', {}, + goog.html.SafeHtml.concat( + goog.html.SafeHtml.create('head', {}, opt_headContents), + goog.html.SafeHtml.create('body', {}, opt_bodyContents))); + if (!opt_quirks) { + content = + goog.html.SafeHtml.concat(goog.html.SafeHtml.DOCTYPE_HTML, content); + } + + var iframe = goog.dom.iframe.createBlank(domHelper, opt_styles); + + // Cannot manipulate iframe content until it is in a document. + parentElement.appendChild(iframe); + goog.dom.iframe.writeSafeContent(iframe, content); + + return iframe; +}; diff --git a/closure/goog/dom/iframe_test.js b/closure/goog/dom/iframe_test.js new file mode 100644 index 0000000000..818d9033f3 --- /dev/null +++ b/closure/goog/dom/iframe_test.js @@ -0,0 +1,64 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.iframeTest'); +goog.setTestOnly(); + +const Const = goog.require('goog.string.Const'); +const SafeHtml = goog.require('goog.html.SafeHtml'); +const SafeStyle = goog.require('goog.html.SafeStyle'); +const dom = goog.require('goog.dom'); +const domIframe = goog.require('goog.dom.iframe'); +const testSuite = goog.require('goog.testing.testSuite'); + +let domHelper; +let sandbox; + +testSuite({ + setUpPage() { + domHelper = dom.getDomHelper(); + sandbox = domHelper.getElement('sandbox'); + }, + + setUp() { + dom.removeChildren(sandbox); + }, + + testCreateWithContent_safeTypes() { + const head = SafeHtml.create('title', {}, 'Foo Title'); + const body = SafeHtml.create('div', {'id': 'blah'}, 'Test'); + const style = SafeStyle.fromConstant(Const.from('position: absolute;')); + /** @suppress {checkTypes} suppression added to enable type checking */ + const iframe = domIframe.createWithContent( + sandbox, head, body, style, false /* opt_quirks */); + + const doc = dom.getFrameContentDocument(iframe); + assertNotNull(doc.getElementById('blah')); + assertEquals('Foo Title', doc.title); + assertEquals('absolute', iframe.style.position); + }, + + testCreateBlankYieldsIframeWithNoBorderOrPadding() { + const iframe = domIframe.createBlank(domHelper); + iframe.style.width = '350px'; + iframe.style.height = '250px'; + const blankElement = domHelper.getElement('blank'); + blankElement.appendChild(iframe); + assertEquals( + 'Width should be as styled: no extra borders, padding, etc.', 350, + blankElement.offsetWidth); + assertEquals( + 'Height should be as styled: no extra borders, padding, etc.', 250, + blankElement.offsetHeight); + }, + + testCreateBlankWithSafeStyles() { + const iframe = domIframe.createBlank( + domHelper, SafeStyle.fromConstant(Const.from('position:absolute;'))); + assertEquals('absolute', iframe.style.position); + assertEquals('bottom', iframe.style.verticalAlign); + }, +}); diff --git a/closure/goog/dom/iframe_test_dom.html b/closure/goog/dom/iframe_test_dom.html new file mode 100644 index 0000000000..ea343ff25c --- /dev/null +++ b/closure/goog/dom/iframe_test_dom.html @@ -0,0 +1,26 @@ + +
    +
    + Blank Iframe - The below area should be completely white. +
    + + + + + +
    +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/closure/goog/dom/inputtype.js b/closure/goog/dom/inputtype.js new file mode 100644 index 0000000000..33f061fb28 --- /dev/null +++ b/closure/goog/dom/inputtype.js @@ -0,0 +1,56 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Defines the goog.dom.InputType enum. This enumerates all + * input element types (for INPUT, BUTTON, SELECT and TEXTAREA elements) in + * either the W3C HTML 4.01 index of elements or the HTML5 draft specification. + * + * References: + * http://www.w3.org/TR/html401/sgml/dtd.html#InputType + * http://www.w3.org/TR/html-markup/input.html#input + * https://html.spec.whatwg.org/multipage/forms.html#dom-input-type + * https://html.spec.whatwg.org/multipage/forms.html#dom-button-type + * https://html.spec.whatwg.org/multipage/forms.html#dom-select-type + * https://html.spec.whatwg.org/multipage/forms.html#dom-textarea-type + */ +goog.provide('goog.dom.InputType'); + + +/** + * Enum of all input types (for INPUT, BUTTON, SELECT and TEXTAREA elements) + * specified by the W3C HTML4.01 and HTML5 specifications. + * @enum {string} + */ +goog.dom.InputType = { + BUTTON: 'button', + CHECKBOX: 'checkbox', + COLOR: 'color', + DATE: 'date', + DATETIME: 'datetime', + DATETIME_LOCAL: 'datetime-local', + EMAIL: 'email', + FILE: 'file', + HIDDEN: 'hidden', + IMAGE: 'image', + MENU: 'menu', + MONTH: 'month', + NUMBER: 'number', + PASSWORD: 'password', + RADIO: 'radio', + RANGE: 'range', + RESET: 'reset', + SEARCH: 'search', + SELECT_MULTIPLE: 'select-multiple', + SELECT_ONE: 'select-one', + SUBMIT: 'submit', + TEL: 'tel', + TEXT: 'text', + TEXTAREA: 'textarea', + TIME: 'time', + URL: 'url', + WEEK: 'week' +}; diff --git a/closure/goog/dom/inputtype_test.js b/closure/goog/dom/inputtype_test.js new file mode 100644 index 0000000000..dc55807d7f --- /dev/null +++ b/closure/goog/dom/inputtype_test.js @@ -0,0 +1,45 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.InputTypeTest'); +goog.setTestOnly(); + +const InputType = goog.require('goog.dom.InputType'); +const googObject = goog.require('goog.object'); +const testSuite = goog.require('goog.testing.testSuite'); +const userAgent = goog.require('goog.userAgent'); + +testSuite({ + testCorrectNumberOfInputTypes() { + assertEquals(27, googObject.getCount(InputType)); + }, + + testPropertyNamesEqualValues() { + for (let propertyName in InputType) { + assertEquals( + propertyName.toLowerCase().replace('_', '-'), + InputType[propertyName]); + } + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testTypes() { + assertEquals(InputType.TEXT, document.getElementById('textinput').type); + // Not all browsers support the time input type. + if (userAgent.CHROME || userAgent.EDGE) { + assertEquals(InputType.TIME, document.getElementById('timeinput').type); + } + assertEquals(InputType.TEXTAREA, document.getElementById('textarea').type); + assertEquals( + InputType.SELECT_ONE, document.getElementById('selectone').type); + assertEquals( + InputType.SELECT_MULTIPLE, + document.getElementById('selectmultiple').type); + }, +}); diff --git a/closure/goog/dom/inputtype_test_dom.html b/closure/goog/dom/inputtype_test_dom.html new file mode 100644 index 0000000000..631a448e0b --- /dev/null +++ b/closure/goog/dom/inputtype_test_dom.html @@ -0,0 +1,19 @@ + +
    + + + + + + + +
    diff --git a/closure/goog/dom/iter.js b/closure/goog/dom/iter.js new file mode 100644 index 0000000000..1041f2375c --- /dev/null +++ b/closure/goog/dom/iter.js @@ -0,0 +1,130 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Iterators over DOM nodes. + */ + +goog.provide('goog.dom.iter'); +goog.provide('goog.dom.iter.AncestorIterator'); +goog.provide('goog.dom.iter.ChildIterator'); +goog.provide('goog.dom.iter.SiblingIterator'); + +goog.require('goog.iter'); +goog.require('goog.iter.Iterator'); + + + +/** + * Iterator over a Node's siblings. + * @param {Node} node The node to start with. + * @param {boolean=} opt_includeNode Whether to return the given node as the + * first return value from next. + * @param {boolean=} opt_reverse Whether to traverse siblings in reverse + * document order. + * @constructor + * @extends {goog.iter.Iterator} + */ +goog.dom.iter.SiblingIterator = function(node, opt_includeNode, opt_reverse) { + 'use strict'; + /** + * The current node, or null if iteration is finished. + * @type {Node} + * @private + */ + this.node_ = node; + + /** + * Whether to iterate in reverse. + * @type {boolean} + * @private + */ + this.reverse_ = !!opt_reverse; + + if (node && !opt_includeNode) { + this.next(); + } +}; +goog.inherits(goog.dom.iter.SiblingIterator, goog.iter.Iterator); + + +/** + * @return {!IIterableResult} + * @override + */ +goog.dom.iter.SiblingIterator.prototype.next = function() { + 'use strict'; + var node = this.node_; + if (!node) { + return goog.iter.ES6_ITERATOR_DONE; + } + this.node_ = this.reverse_ ? node.previousSibling : node.nextSibling; + return goog.iter.createEs6IteratorYield(node); +}; + + +/** + * Iterator over an Element's children. + * @param {Element} element The element to iterate over. + * @param {boolean=} opt_reverse Optionally traverse children from last to + * first. + * @param {number=} opt_startIndex Optional starting index. + * @constructor + * @extends {goog.dom.iter.SiblingIterator} + * @final + */ +goog.dom.iter.ChildIterator = function(element, opt_reverse, opt_startIndex) { + 'use strict'; + if (opt_startIndex === undefined) { + opt_startIndex = opt_reverse && element.childNodes.length ? + element.childNodes.length - 1 : + 0; + } + goog.dom.iter.SiblingIterator.call( + this, element.childNodes[opt_startIndex], true, opt_reverse); +}; +goog.inherits(goog.dom.iter.ChildIterator, goog.dom.iter.SiblingIterator); + + + +/** + * Iterator over a Node's ancestors, stopping after the document body. + * @param {Node} node The node to start with. + * @param {boolean=} opt_includeNode Whether to return the given node as the + * first return value from next. + * @constructor + * @extends {goog.iter.Iterator} + * @final + */ +goog.dom.iter.AncestorIterator = function(node, opt_includeNode) { + 'use strict'; + /** + * The current node, or null if iteration is finished. + * @type {Node} + * @private + */ + this.node_ = node; + + if (node && !opt_includeNode) { + this.next(); + } +}; +goog.inherits(goog.dom.iter.AncestorIterator, goog.iter.Iterator); + + +/** + * @return {!IIterableResult} + * @override + */ +goog.dom.iter.AncestorIterator.prototype.next = function() { + 'use strict'; + var node = this.node_; + if (!node) { + return goog.iter.ES6_ITERATOR_DONE; + } + this.node_ = node.parentNode; + return goog.iter.createEs6IteratorYield(node); +}; \ No newline at end of file diff --git a/closure/goog/dom/iter_test.js b/closure/goog/dom/iter_test.js new file mode 100644 index 0000000000..a302de8ad9 --- /dev/null +++ b/closure/goog/dom/iter_test.js @@ -0,0 +1,89 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.iterTest'); +goog.setTestOnly(); + +const AncestorIterator = goog.require('goog.dom.iter.AncestorIterator'); +const ChildIterator = goog.require('goog.dom.iter.ChildIterator'); +const NodeType = goog.require('goog.dom.NodeType'); +const SiblingIterator = goog.require('goog.dom.iter.SiblingIterator'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); +const testingDom = goog.require('goog.testing.dom'); + +let test; +let br; + +testSuite({ + setUpPage() { + test = dom.getElement('test'); + br = dom.getElement('br'); + }, + + testNextSibling() { + const expectedContent = ['#br', 'def']; + testingDom.assertNodesMatch( + new SiblingIterator(test.firstChild), expectedContent); + }, + + testNextSiblingInclusive() { + const expectedContent = ['abc', '#br', 'def']; + testingDom.assertNodesMatch( + new SiblingIterator(test.firstChild, true), expectedContent); + }, + + testPreviousSibling() { + const expectedContent = ['#br', 'abc']; + testingDom.assertNodesMatch( + new SiblingIterator(test.lastChild, false, true), expectedContent); + }, + + testPreviousSiblingInclusive() { + const expectedContent = ['def', '#br', 'abc']; + testingDom.assertNodesMatch( + new SiblingIterator(test.lastChild, true, true), expectedContent); + }, + + testChildIterator() { + const expectedContent = ['abc', '#br', 'def']; + testingDom.assertNodesMatch(new ChildIterator(test), expectedContent); + }, + + testChildIteratorIndex() { + const expectedContent = ['#br', 'def']; + testingDom.assertNodesMatch( + new ChildIterator(test, false, 1), expectedContent); + }, + + testChildIteratorReverse() { + const expectedContent = ['def', '#br', 'abc']; + testingDom.assertNodesMatch(new ChildIterator(test, true), expectedContent); + }, + + testEmptyChildIteratorReverse() { + const expectedContent = []; + testingDom.assertNodesMatch(new ChildIterator(br, true), expectedContent); + }, + + testChildIteratorIndexReverse() { + const expectedContent = ['#br', 'abc']; + testingDom.assertNodesMatch( + new ChildIterator(test, true, 1), expectedContent); + }, + + testAncestorIterator() { + const expectedContent = ['#test', '#body', '#html', NodeType.DOCUMENT]; + testingDom.assertNodesMatch(new AncestorIterator(br), expectedContent); + }, + + testAncestorIteratorInclusive() { + const expectedContent = + ['#br', '#test', '#body', '#html', NodeType.DOCUMENT]; + testingDom.assertNodesMatch( + new AncestorIterator(br, true), expectedContent); + }, +}); diff --git a/closure/goog/dom/iter_test_dom.html b/closure/goog/dom/iter_test_dom.html new file mode 100644 index 0000000000..cbc3e6d16d --- /dev/null +++ b/closure/goog/dom/iter_test_dom.html @@ -0,0 +1,11 @@ + + + +
    abc
    def
    + + diff --git a/closure/goog/dom/multirange.js b/closure/goog/dom/multirange.js new file mode 100644 index 0000000000..792eb1ec9b --- /dev/null +++ b/closure/goog/dom/multirange.js @@ -0,0 +1,579 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for working with W3C multi-part ranges. + */ + + + +// TODO(user): We're trying to migrate all ES5 subclasses of Closure +// Library to ES6. In ES6 this cannot be referenced before super is called. This +// file has at least one this before a super call (in ES5) and cannot be +// automatically upgraded to ES6 as a result. Please fix this if you have a +// chance. Note: This can sometimes be caused by not calling the super +// constructor at all. You can run the conversion tool yourself to see what it +// does on this file: blaze run //javascript/refactoring/es6_classes:convert. + +goog.provide('goog.dom.MultiRange'); +goog.provide('goog.dom.MultiRangeIterator'); + +goog.require('goog.array'); +goog.require('goog.dom'); +goog.require('goog.dom.AbstractMultiRange'); +goog.require('goog.dom.AbstractRange'); +goog.require('goog.dom.RangeIterator'); +goog.require('goog.dom.RangeType'); +goog.require('goog.dom.SavedCaretRange'); +goog.require('goog.dom.SavedRange'); +goog.require('goog.dom.TextRange'); +goog.require('goog.iter'); +goog.require('goog.log'); + + + +/** + * Creates a new multi part range with no properties. Do not use this + * constructor: use one of the goog.dom.Range.createFrom* methods instead. + * @constructor + * @extends {goog.dom.AbstractMultiRange} + * @final + */ +goog.dom.MultiRange = function() { + 'use strict'; + /** + * Logging object. + * @private {goog.log.Logger} + */ + this.logger_ = goog.log.getLogger('goog.dom.MultiRange'); + + /** + * Array of browser sub-ranges comprising this multi-range. + * @private {Array} + */ + this.browserRanges_ = []; + + /** + * Lazily initialized array of range objects comprising this multi-range. + * @private {Array} + */ + this.ranges_ = []; + + /** + * Lazily computed sorted version of ranges_, sorted by start point. + * @private {Array?} + */ + this.sortedRanges_ = null; + + /** + * Lazily computed container node. + * @private {?Node} + */ + this.container_ = null; +}; +goog.inherits(goog.dom.MultiRange, goog.dom.AbstractMultiRange); + + +/** + * Creates a new range wrapper from the given browser selection object. Do not + * use this method directly - please use goog.dom.Range.createFrom* instead. + * @param {Selection} selection The browser selection object. + * @return {!goog.dom.MultiRange} A range wrapper object. + */ +goog.dom.MultiRange.createFromBrowserSelection = function(selection) { + 'use strict'; + var range = new goog.dom.MultiRange(); + for (var i = 0, len = selection.rangeCount; i < len; i++) { + range.browserRanges_.push(selection.getRangeAt(i)); + } + return range; +}; + + +/** + * Creates a new range wrapper from the given browser ranges. Do not + * use this method directly - please use goog.dom.Range.createFrom* instead. + * @param {Array} browserRanges The browser ranges. + * @return {!goog.dom.MultiRange} A range wrapper object. + */ +goog.dom.MultiRange.createFromBrowserRanges = function(browserRanges) { + 'use strict'; + var range = new goog.dom.MultiRange(); + range.browserRanges_ = goog.array.clone(browserRanges); + return range; +}; + + +/** + * Creates a new range wrapper from the given goog.dom.TextRange objects. Do + * not use this method directly - please use goog.dom.Range.createFrom* instead. + * @param {Array} textRanges The text range objects. + * @return {!goog.dom.MultiRange} A range wrapper object. + */ +goog.dom.MultiRange.createFromTextRanges = function(textRanges) { + 'use strict'; + var range = new goog.dom.MultiRange(); + range.ranges_ = textRanges; + range.browserRanges_ = textRanges.map(function(range) { + 'use strict'; + return range.getBrowserRangeObject(); + }); + return range; +}; + + +// Method implementations + + +/** + * Clears cached values. Should be called whenever this.browserRanges_ is + * modified. + * @private + */ +goog.dom.MultiRange.prototype.clearCachedValues_ = function() { + 'use strict'; + this.ranges_ = []; + this.sortedRanges_ = null; + this.container_ = null; +}; + + +/** + * @return {!goog.dom.MultiRange} A clone of this range. + * @override + */ +goog.dom.MultiRange.prototype.clone = function() { + 'use strict'; + return goog.dom.MultiRange.createFromBrowserRanges(this.browserRanges_); +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getType = function() { + 'use strict'; + return goog.dom.RangeType.MULTI; +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getBrowserRangeObject = function() { + 'use strict'; + // NOTE(robbyw): This method does not make sense for multi-ranges. + if (this.browserRanges_.length > 1) { + goog.log.warning( + this.logger_, + 'getBrowserRangeObject called on MultiRange with more than 1 range'); + } + return this.browserRanges_[0]; +}; + + +/** @override */ +goog.dom.MultiRange.prototype.setBrowserRangeObject = function(nativeRange) { + 'use strict'; + // TODO(robbyw): Look in to adding setBrowserSelectionObject. + return false; +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getTextRangeCount = function() { + 'use strict'; + return this.browserRanges_.length; +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getTextRange = function(i) { + 'use strict'; + if (!this.ranges_[i]) { + this.ranges_[i] = + goog.dom.TextRange.createFromBrowserRange(this.browserRanges_[i]); + } + return this.ranges_[i]; +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getContainer = function() { + 'use strict'; + if (!this.container_) { + var nodes = []; + for (var i = 0, len = this.getTextRangeCount(); i < len; i++) { + nodes.push(this.getTextRange(i).getContainer()); + } + this.container_ = goog.dom.findCommonAncestor.apply(null, nodes); + } + return this.container_; +}; + + +/** + * @return {!Array} An array of sub-ranges, sorted by start + * point. + */ +goog.dom.MultiRange.prototype.getSortedRanges = function() { + 'use strict'; + if (!this.sortedRanges_) { + this.sortedRanges_ = this.getTextRanges(); + this.sortedRanges_.sort(function(a, b) { + 'use strict'; + var aStartNode = a.getStartNode(); + var aStartOffset = a.getStartOffset(); + var bStartNode = b.getStartNode(); + var bStartOffset = b.getStartOffset(); + + if (aStartNode == bStartNode && aStartOffset == bStartOffset) { + return 0; + } + + /** + * @suppress {missingRequire} Cannot depend on goog.dom.Range because + * it creates a circular dependency. + */ + const isReversed = goog.dom.Range.isReversed( + aStartNode, aStartOffset, bStartNode, bStartOffset); + return isReversed ? 1 : -1; + }); + } + return this.sortedRanges_; +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getStartNode = function() { + 'use strict'; + return this.getSortedRanges()[0].getStartNode(); +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getStartOffset = function() { + 'use strict'; + return this.getSortedRanges()[0].getStartOffset(); +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getEndNode = function() { + 'use strict'; + // NOTE(robbyw): This may return the wrong node if any subranges overlap. + return goog.array.peek(this.getSortedRanges()).getEndNode(); +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getEndOffset = function() { + 'use strict'; + // NOTE(robbyw): This may return the wrong value if any subranges overlap. + return goog.array.peek(this.getSortedRanges()).getEndOffset(); +}; + + +/** @override */ +goog.dom.MultiRange.prototype.isRangeInDocument = function() { + 'use strict'; + return this.getTextRanges().every(function(range) { + 'use strict'; + return range.isRangeInDocument(); + }); +}; + + +/** @override */ +goog.dom.MultiRange.prototype.isCollapsed = function() { + 'use strict'; + return this.browserRanges_.length == 0 || + this.browserRanges_.length == 1 && this.getTextRange(0).isCollapsed(); +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getText = function() { + 'use strict'; + return this.getTextRanges() + .map(function(range) { + 'use strict'; + return range.getText(); + }) + .join(''); +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getHtmlFragment = function() { + 'use strict'; + return this.getValidHtml(); +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getValidHtml = function() { + 'use strict'; + // NOTE(robbyw): This does not behave well if the sub-ranges overlap. + return this.getTextRanges() + .map(function(range) { + 'use strict'; + return range.getValidHtml(); + }) + .join(''); +}; + + +/** @override */ +goog.dom.MultiRange.prototype.getPastableHtml = function() { + 'use strict'; + // TODO(robbyw): This should probably do something smart like group TR and TD + // selections in to the same table. + return this.getValidHtml(); +}; + + +/** @override */ +goog.dom.MultiRange.prototype.__iterator__ = function(opt_keys) { + 'use strict'; + return new goog.dom.MultiRangeIterator(this); +}; + + +// RANGE ACTIONS + + +/** + * @override + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.dom.MultiRange.prototype.select = function() { + 'use strict'; + var selection = + goog.dom.AbstractRange.getBrowserSelectionForWindow(this.getWindow()); + selection.removeAllRanges(); + for (var i = 0, len = this.getTextRangeCount(); i < len; i++) { + selection.addRange(this.getTextRange(i).getBrowserRangeObject()); + } +}; + + +/** @override */ +goog.dom.MultiRange.prototype.removeContents = function() { + 'use strict'; + this.getTextRanges().forEach(function(range) { + 'use strict'; + range.removeContents(); + }); +}; + + +// SAVE/RESTORE + + +/** @override */ +goog.dom.MultiRange.prototype.saveUsingDom = function() { + 'use strict'; + return new goog.dom.DomSavedMultiRange_(this); +}; + +/** @override */ +goog.dom.MultiRange.prototype.saveUsingCarets = function() { + 'use strict'; + return (this.getStartNode() && this.getEndNode()) ? + new goog.dom.SavedCaretRange(this) : + null; +}; + +// RANGE MODIFICATION + + +/** + * Collapses this range to a single point, either the first or last point + * depending on the parameter. This will result in the number of ranges in this + * multi range becoming 1. + * @param {boolean} toAnchor Whether to collapse to the anchor. + * @override + */ +goog.dom.MultiRange.prototype.collapse = function(toAnchor) { + 'use strict'; + if (!this.isCollapsed()) { + var range = toAnchor ? this.getTextRange(0) : + this.getTextRange(this.getTextRangeCount() - 1); + + this.clearCachedValues_(); + range.collapse(toAnchor); + this.ranges_ = [range]; + this.sortedRanges_ = [range]; + this.browserRanges_ = [range.getBrowserRangeObject()]; + } +}; + + +// SAVED RANGE OBJECTS + + + +/** + * A SavedRange implementation using DOM endpoints. + * @param {goog.dom.MultiRange} range The range to save. + * @constructor + * @extends {goog.dom.SavedRange} + * @private + */ +goog.dom.DomSavedMultiRange_ = function(range) { + 'use strict'; + /** + * Array of saved ranges. + * @type {Array} + * @private + */ + this.savedRanges_ = range.getTextRanges().map(function(range) { + 'use strict'; + return range.saveUsingDom(); + }); +}; +goog.inherits(goog.dom.DomSavedMultiRange_, goog.dom.SavedRange); + + +/** + * @return {!goog.dom.MultiRange} The restored range. + * @override + */ +goog.dom.DomSavedMultiRange_.prototype.restoreInternal = function() { + 'use strict'; + var ranges = this.savedRanges_.map(function(savedRange) { + 'use strict'; + return savedRange.restore(); + }); + return goog.dom.MultiRange.createFromTextRanges(ranges); +}; + + +/** @override */ +goog.dom.DomSavedMultiRange_.prototype.disposeInternal = function() { + 'use strict'; + goog.dom.DomSavedMultiRange_.superClass_.disposeInternal.call(this); + + this.savedRanges_.forEach(function(savedRange) { + 'use strict'; + savedRange.dispose(); + }); + delete this.savedRanges_; +}; + + +// RANGE ITERATION + + + +/** + * Subclass of goog.dom.TagIterator that iterates over a DOM range. It + * adds functions to determine the portion of each text node that is selected. + * + * @param {goog.dom.MultiRange} range The range to traverse. + * @constructor + * @extends {goog.dom.RangeIterator} + * @final + */ +goog.dom.MultiRangeIterator = function(range) { + 'use strict'; + /** + * The list of range iterators left to traverse. + * @private {?Array} + */ + this.iterators_ = null; + + /** + * The index of the current sub-iterator being traversed. + * @private {number} + */ + this.currentIdx_ = 0; + + if (range) { + this.iterators_ = range.getSortedRanges().map(function(r) { + 'use strict'; + return goog.iter.toIterator(r); + }); + } + + goog.dom.MultiRangeIterator.base( + this, 'constructor', range ? this.getStartNode() : null, false); +}; +goog.inherits(goog.dom.MultiRangeIterator, goog.dom.RangeIterator); + + +/** @override */ +goog.dom.MultiRangeIterator.prototype.getStartTextOffset = function() { + 'use strict'; + return this.iterators_[this.currentIdx_].getStartTextOffset(); +}; + + +/** @override */ +goog.dom.MultiRangeIterator.prototype.getEndTextOffset = function() { + 'use strict'; + return this.iterators_[this.currentIdx_].getEndTextOffset(); +}; + + +/** @override */ +goog.dom.MultiRangeIterator.prototype.getStartNode = function() { + 'use strict'; + return this.iterators_[0].getStartNode(); +}; + + +/** @override */ +goog.dom.MultiRangeIterator.prototype.getEndNode = function() { + 'use strict'; + return goog.array.peek(this.iterators_).getEndNode(); +}; + + +/** @override */ +goog.dom.MultiRangeIterator.prototype.isLast = function() { + 'use strict'; + return this.iterators_[this.currentIdx_].isLast(); +}; + + +/** + * @return {!IIterableResult} + * @override + */ +goog.dom.MultiRangeIterator.prototype.next = function() { + 'use strict'; + while (this.currentIdx_ < this.iterators_.length) { + const iterator = this.iterators_[this.currentIdx_]; + const it = iterator.next(); + if (it.done) { + this.currentIdx_++; + // Try again from the top, will move to return 'done' if no more iterators + continue; + } + this.setPosition(iterator.node, iterator.tagType, iterator.depth); + return it; + } + return goog.iter.ES6_ITERATOR_DONE; +}; + + +/** @override */ +goog.dom.MultiRangeIterator.prototype.copyFrom = function(other) { + 'use strict'; + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + this.iterators_ = goog.array.clone(other.iterators_); + goog.dom.MultiRangeIterator.superClass_.copyFrom.call(this, other); +}; + + +/** + * @return {!goog.dom.MultiRangeIterator} An identical iterator. + * @override + */ +goog.dom.MultiRangeIterator.prototype.clone = function() { + 'use strict'; + var copy = new goog.dom.MultiRangeIterator(null); + copy.copyFrom(this); + return copy; +}; diff --git a/closure/goog/dom/multirange_test.js b/closure/goog/dom/multirange_test.js new file mode 100644 index 0000000000..f4f1300dba --- /dev/null +++ b/closure/goog/dom/multirange_test.js @@ -0,0 +1,73 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.MultiRangeTest'); +goog.setTestOnly(); + +const MultiRange = goog.require('goog.dom.MultiRange'); +const Range = goog.require('goog.dom.Range'); +const dom = goog.require('goog.dom'); +const iter = goog.require('goog.iter'); +const testSuite = goog.require('goog.testing.testSuite'); + +let range; + +testSuite({ + setUp() { + /** @suppress {checkTypes} suppression added to enable type checking */ + range = new MultiRange.createFromTextRanges([ + Range.createFromNodeContents(dom.getElement('test2')), + Range.createFromNodeContents(dom.getElement('test1')), + ]); + }, + + testStartAndEnd() { + assertEquals(dom.getElement('test1').firstChild, range.getStartNode()); + assertEquals(0, range.getStartOffset()); + assertEquals(dom.getElement('test2').firstChild, range.getEndNode()); + assertEquals(6, range.getEndOffset()); + }, + + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + testStartAndEndIterator() { + const it = iter.toIterator(range); + assertEquals(dom.getElement('test1').firstChild, it.getStartNode()); + assertEquals(0, it.getStartTextOffset()); + assertEquals(dom.getElement('test2').firstChild, it.getEndNode()); + assertEquals(3, it.getEndTextOffset()); + + it.next(); + it.next(); + assertEquals(6, it.getEndTextOffset()); + }, + + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + testStartAndEndIteratorEs6() { + const it = iter.toIterator(range); + assertEquals(dom.getElement('test1').firstChild, it.getStartNode()); + assertEquals(0, it.getStartTextOffset()); + assertEquals(dom.getElement('test2').firstChild, it.getEndNode()); + assertEquals(3, it.getEndTextOffset()); + + it.next(); + it.next(); + assertEquals(6, it.getEndTextOffset()); + }, + + testIteration() { + const tags = iter.toArray(range); + assertEquals(2, tags.length); + + assertEquals(dom.getElement('test1').firstChild, tags[0]); + assertEquals(dom.getElement('test2').firstChild, tags[1]); + }, +}); diff --git a/closure/goog/dom/multirange_test_dom.html b/closure/goog/dom/multirange_test_dom.html new file mode 100644 index 0000000000..9bdc2f20c8 --- /dev/null +++ b/closure/goog/dom/multirange_test_dom.html @@ -0,0 +1,10 @@ + +
    +
    abc
    +
    defghi
    +
    diff --git a/closure/goog/dom/nodeiterator.js b/closure/goog/dom/nodeiterator.js new file mode 100644 index 0000000000..d097699cc1 --- /dev/null +++ b/closure/goog/dom/nodeiterator.js @@ -0,0 +1,81 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Iterator subclass for DOM tree traversal. + */ + +goog.provide('goog.dom.NodeIterator'); + +goog.require('goog.dom.TagIterator'); +goog.require('goog.iter'); + + + +/** + * A DOM tree traversal iterator. + * + * Starting with the given node, the iterator walks the DOM in order, reporting + * events for each node. The iterator acts as a prefix iterator: + * + *
    + * <div>1<span>2</span>3</div>
    + * 
    + * + * Will return the following nodes: + * + * [div, 1, span, 2, 3] + * + * With the following depths + * + * [1, 1, 2, 2, 1] + * + * Imagining | represents iterator position, the traversal stops at + * each of the following locations: + * + *
    <div>|1|<span>|2|</span>3|</div>
    + * + * The iterator can also be used in reverse mode, which will return the nodes + * and states in the opposite order. The depths will be slightly different + * since, like in normal mode, the depth is computed *after* the last move. + * + * Lastly, it is possible to create an iterator that is unconstrained, meaning + * that it will continue iterating until the end of the document instead of + * until exiting the start node. + * + * @param {Node=} opt_node The start node. Defaults to an empty iterator. + * @param {boolean=} opt_reversed Whether to traverse the tree in reverse. + * @param {boolean=} opt_unconstrained Whether the iterator is not constrained + * to the starting node and its children. + * @param {number=} opt_depth The starting tree depth. + * @constructor + * @extends {goog.dom.TagIterator} + * @final + */ +goog.dom.NodeIterator = function( + opt_node, opt_reversed, opt_unconstrained, opt_depth) { + 'use strict'; + goog.dom.TagIterator.call( + this, opt_node, opt_reversed, opt_unconstrained, null, opt_depth); +}; +goog.inherits(goog.dom.NodeIterator, goog.dom.TagIterator); + + +/** + * Moves to the next position in the DOM tree. + * @return {!IIterableResult} + * @override + */ +goog.dom.NodeIterator.prototype.next = function() { + 'use strict'; + do { + // also updates `this.node` reference on iteration. + const it = goog.dom.NodeIterator.superClass_.next.call(this); + if (it.done) return it; + } while (this.isEndTag()); + + return goog.iter.createEs6IteratorYield(/** @type {!Node} */ (this.node)); +}; diff --git a/closure/goog/dom/nodeiterator_test.js b/closure/goog/dom/nodeiterator_test.js new file mode 100644 index 0000000000..54c610da0a --- /dev/null +++ b/closure/goog/dom/nodeiterator_test.js @@ -0,0 +1,35 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.NodeIteratorTest'); +goog.setTestOnly(); + +const DomNodeIterator = goog.require('goog.dom.NodeIterator'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); +const testingDom = goog.require('goog.testing.dom'); + +testSuite({ + testBasic() { + const expectedContent = + ['#test', '#a1', 'T', '#b1', 'e', 'xt', '#span1', '#p1', 'Text']; + testingDom.assertNodesMatch( + new DomNodeIterator(dom.getElement('test')), expectedContent); + }, + + testUnclosed() { + const expectedContent = ['#test2', '#li1', 'Not', '#li2', 'Closed']; + testingDom.assertNodesMatch( + new DomNodeIterator(dom.getElement('test2')), expectedContent); + }, + + testReverse() { + const expectedContent = + ['Text', '#p1', '#span1', 'xt', 'e', '#b1', 'T', '#a1', '#test']; + testingDom.assertNodesMatch( + new DomNodeIterator(dom.getElement('test'), true), expectedContent); + }, +}); diff --git a/closure/goog/dom/nodeiterator_test_dom.html b/closure/goog/dom/nodeiterator_test_dom.html new file mode 100644 index 0000000000..c93a134c09 --- /dev/null +++ b/closure/goog/dom/nodeiterator_test_dom.html @@ -0,0 +1,18 @@ + + + + + +
    Text

    Text

    +
    • Not
    • Closed
    + + + diff --git a/closure/goog/dom/nodeoffset.js b/closure/goog/dom/nodeoffset.js new file mode 100644 index 0000000000..1602e1c67d --- /dev/null +++ b/closure/goog/dom/nodeoffset.js @@ -0,0 +1,108 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Object to store the offset from one node to another in a way + * that works on any similar DOM structure regardless of whether it is the same + * actual nodes. + */ + +goog.provide('goog.dom.NodeOffset'); + +goog.require('goog.Disposable'); +goog.require('goog.dom.TagName'); + + + +/** + * Object to store the offset from one node to another in a way that works on + * any similar DOM structure regardless of whether it is the same actual nodes. + * @param {Node} node The node to get the offset for. + * @param {Node} baseNode The node to calculate the offset from. + * @extends {goog.Disposable} + * @constructor + * @final + */ +goog.dom.NodeOffset = function(node, baseNode) { + 'use strict'; + goog.Disposable.call(this); + + /** + * A stack of childNode offsets. + * @type {Array} + * @private + */ + this.offsetStack_ = []; + + /** + * A stack of childNode names. + * @type {Array} + * @private + */ + this.nameStack_ = []; + + while (node && node.nodeName != goog.dom.TagName.BODY && node != baseNode) { + // Compute the sibling offset. + var siblingOffset = 0; + var sib = node.previousSibling; + while (sib) { + sib = sib.previousSibling; + ++siblingOffset; + } + this.offsetStack_.unshift(siblingOffset); + this.nameStack_.unshift(node.nodeName); + + node = node.parentNode; + } +}; +goog.inherits(goog.dom.NodeOffset, goog.Disposable); + + +/** + * @return {string} A string representation of this object. + * @override + */ +goog.dom.NodeOffset.prototype.toString = function() { + 'use strict'; + var strs = []; + var name; + for (var i = 0; name = this.nameStack_[i]; i++) { + strs.push(this.offsetStack_[i] + ',' + name); + } + return strs.join('\n'); +}; + + +/** + * Walk the dom and find the node relative to baseNode. Returns null on + * failure. + * @param {Node} baseNode The node to start walking from. Should be equivalent + * to the node passed in to the constructor, in that it should have the + * same contents. + * @return {Node} The node relative to baseNode, or null on failure. + */ +goog.dom.NodeOffset.prototype.findTargetNode = function(baseNode) { + 'use strict'; + var name; + var curNode = baseNode; + for (var i = 0; name = this.nameStack_[i]; ++i) { + curNode = curNode.childNodes[this.offsetStack_[i]]; + + // Sanity check and make sure the element names match. + if (!curNode || curNode.nodeName != name) { + return null; + } + } + return curNode; +}; + + +/** @override */ +goog.dom.NodeOffset.prototype.disposeInternal = function() { + 'use strict'; + delete this.offsetStack_; + delete this.nameStack_; +}; diff --git a/closure/goog/dom/nodeoffset_test.js b/closure/goog/dom/nodeoffset_test.js new file mode 100644 index 0000000000..3d4ad8a410 --- /dev/null +++ b/closure/goog/dom/nodeoffset_test.js @@ -0,0 +1,84 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.NodeOffsetTest'); +goog.setTestOnly(); + +const NodeOffset = goog.require('goog.dom.NodeOffset'); +const NodeType = goog.require('goog.dom.NodeType'); +const TagName = goog.require('goog.dom.TagName'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); + +let test1; +let test2; +let i; +let empty; + +testSuite({ + setUpPage() { + test1 = dom.getElement('test1'); + i = dom.getElement('i'); + test2 = dom.getElement('test2'); + test2.innerHTML = test1.innerHTML; + empty = dom.getElement('empty'); + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testElementOffset() { + const nodeOffset = new NodeOffset(i, test1); + + const recovered = nodeOffset.findTargetNode(test2); + assertNotNull('Should recover a node.', recovered); + assertEquals( + 'Should recover an I node.', String(TagName.I), recovered.tagName); + assertTrue( + 'Should recover a child of test2', dom.contains(test2, recovered)); + assertFalse( + 'Should not recover a child of test1', dom.contains(test1, recovered)); + + nodeOffset.dispose(); + }, + + testNodeOffset() { + const nodeOffset = new NodeOffset(i.firstChild, test1); + + const recovered = nodeOffset.findTargetNode(test2); + assertNotNull('Should recover a node.', recovered); + assertEquals( + 'Should recover a text node.', NodeType.TEXT, recovered.nodeType); + assertEquals( + 'Should have correct contents.', 'text.', recovered.nodeValue); + assertTrue( + 'Should recover a child of test2', dom.contains(test2, recovered)); + assertFalse( + 'Should not recover a child of test1', dom.contains(test1, recovered)); + + nodeOffset.dispose(); + }, + + testToString() { + const nodeOffset = new NodeOffset(i.firstChild, test1); + + assertEquals( + 'Should have correct string representation', '3,B\n1,I\n0,#text', + nodeOffset.toString()); + + nodeOffset.dispose(); + }, + + testBadRecovery() { + const nodeOffset = new NodeOffset(i.firstChild, test1); + + const recovered = nodeOffset.findTargetNode(empty); + assertNull('Should recover nothing.', recovered); + + nodeOffset.dispose(); + }, +}); diff --git a/closure/goog/dom/nodeoffset_test_dom.html b/closure/goog/dom/nodeoffset_test_dom.html new file mode 100644 index 0000000000..d924ea0c00 --- /dev/null +++ b/closure/goog/dom/nodeoffset_test_dom.html @@ -0,0 +1,9 @@ + +
    Text
    and more text.
    +
    +
    diff --git a/closure/goog/dom/nodetype.js b/closure/goog/dom/nodetype.js new file mode 100644 index 0000000000..9aa0b600a1 --- /dev/null +++ b/closure/goog/dom/nodetype.js @@ -0,0 +1,40 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Definition of goog.dom.NodeType. + */ + +goog.provide('goog.dom.NodeType'); + + +/** + * Constants for the nodeType attribute in the Node interface. + * + * These constants match those specified in the Node interface. These are + * usually present on the Node object in recent browsers, but not in older + * browsers (specifically, early IEs) and thus are given here. + * + * In some browsers (early IEs), these are not defined on the Node object, + * so they are provided here. + * + * See http://www.w3.org/TR/DOM-Level-2-Core/core.html#ID-1950641247 + * @enum {number} + */ +goog.dom.NodeType = { + ELEMENT: 1, + ATTRIBUTE: 2, + TEXT: 3, + CDATA_SECTION: 4, + ENTITY_REFERENCE: 5, + ENTITY: 6, + PROCESSING_INSTRUCTION: 7, + COMMENT: 8, + DOCUMENT: 9, + DOCUMENT_TYPE: 10, + DOCUMENT_FRAGMENT: 11, + NOTATION: 12 +}; diff --git a/closure/goog/dom/range.js b/closure/goog/dom/range.js new file mode 100644 index 0000000000..bf38b1df93 --- /dev/null +++ b/closure/goog/dom/range.js @@ -0,0 +1,225 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for working with ranges in HTML documents. + * + * @suppress {strictMissingProperties} + */ + +goog.provide('goog.dom.Range'); + +goog.require('goog.dom'); +goog.require('goog.dom.AbstractRange'); +goog.require('goog.dom.ControlRange'); +goog.require('goog.dom.MultiRange'); +goog.require('goog.dom.NodeType'); +goog.require('goog.dom.TextRange'); + + +/** + * Create a new selection from the given browser window's current selection. + * Note that this object does not auto-update if the user changes their + * selection and should be used as a snapshot. + * @param {Window=} opt_win The window to get the selection of. Defaults to the + * window this class was defined in. + * @return {goog.dom.AbstractRange?} A range wrapper object, or null if there + * was an error. + */ +goog.dom.Range.createFromWindow = function(opt_win) { + 'use strict'; + var sel = + goog.dom.AbstractRange.getBrowserSelectionForWindow(opt_win || window); + return sel && goog.dom.Range.createFromBrowserSelection(sel); +}; + + +/** + * Create a new range wrapper from the given browser selection object. Note + * that this object does not auto-update if the user changes their selection and + * should be used as a snapshot. + * @param {!Object} selection The browser selection object. + * @return {goog.dom.AbstractRange?} A range wrapper object or null if there + * was an error. + */ +goog.dom.Range.createFromBrowserSelection = function(selection) { + 'use strict'; + var range; + var isReversed = false; + if (selection.createRange) { + + try { + range = selection.createRange(); + } catch (e) { + // Access denied errors can be thrown here in IE if the selection was + // a flash obj or if there are cross domain issues + return null; + } + } else if (selection.rangeCount) { + if (selection.rangeCount > 1) { + return goog.dom.MultiRange.createFromBrowserSelection( + /** @type {!Selection} */ (selection)); + } else { + range = selection.getRangeAt(0); + isReversed = goog.dom.Range.isReversed( + selection.anchorNode, selection.anchorOffset, selection.focusNode, + selection.focusOffset); + } + } else { + return null; + } + + return goog.dom.Range.createFromBrowserRange(range, isReversed); +}; + + +/** + * Create a new range wrapper from the given browser range object. + * @param {Range|TextRange} range The browser range object. + * @param {boolean=} opt_isReversed Whether the focus node is before the anchor + * node. + * @return {!goog.dom.AbstractRange} A range wrapper object. + */ +goog.dom.Range.createFromBrowserRange = function(range, opt_isReversed) { + 'use strict'; + // Create an IE control range when appropriate. + return goog.dom.AbstractRange.isNativeControlRange(range) ? + goog.dom.ControlRange.createFromBrowserRange(range) : + goog.dom.TextRange.createFromBrowserRange(range, opt_isReversed); +}; + + +/** + * Create a new range wrapper that selects the given node's text. + * @param {Node} node The node to select. + * @param {boolean=} opt_isReversed Whether the focus node is before the anchor + * node. + * @return {!goog.dom.AbstractRange} A range wrapper object. + */ +goog.dom.Range.createFromNodeContents = function(node, opt_isReversed) { + 'use strict'; + return goog.dom.TextRange.createFromNodeContents(node, opt_isReversed); +}; + + +/** + * Create a new range wrapper that represents a caret at the given node, + * accounting for the given offset. This always creates a TextRange, regardless + * of whether node is an image node or other control range type node. + * @param {Node} node The node to place a caret at. + * @param {number} offset The offset within the node to place the caret at. + * @return {!goog.dom.AbstractRange} A range wrapper object. + */ +goog.dom.Range.createCaret = function(node, offset) { + 'use strict'; + return goog.dom.TextRange.createFromNodes(node, offset, node, offset); +}; + + +/** + * Create a new range wrapper that selects the area between the given nodes, + * accounting for the given offsets. + * @param {Node} anchorNode The node to anchor on. + * @param {number} anchorOffset The offset within the node to anchor on. + * @param {Node} focusNode The node to focus on. + * @param {number} focusOffset The offset within the node to focus on. + * @return {!goog.dom.AbstractRange} A range wrapper object. + */ +goog.dom.Range.createFromNodes = function( + anchorNode, anchorOffset, focusNode, focusOffset) { + 'use strict'; + return goog.dom.TextRange.createFromNodes( + anchorNode, anchorOffset, focusNode, focusOffset); +}; + + +/** + * Clears the window's selection. + * @param {Window=} opt_win The window to get the selection of. Defaults to the + * window this class was defined in. + */ +goog.dom.Range.clearSelection = function(opt_win) { + 'use strict'; + var sel = + goog.dom.AbstractRange.getBrowserSelectionForWindow(opt_win || window); + if (!sel) { + return; + } + if (sel.empty) { + // We can't just check that the selection is empty, because IE + // sometimes gets confused. + try { + sel.empty(); + } catch (e) { + // Emptying an already empty selection throws an exception in IE + } + } else { + try { + sel.removeAllRanges(); + } catch (e) { + // This throws in IE9 if the range has been invalidated; for example, if + // the user clicked on an element which disappeared during the event + // handler. + } + } +}; + + +/** + * Tests if the window has a selection. + * @param {Window=} opt_win The window to check the selection of. Defaults to + * the window this class was defined in. + * @return {boolean} Whether the window has a selection. + */ +goog.dom.Range.hasSelection = function(opt_win) { + 'use strict'; + var sel = + goog.dom.AbstractRange.getBrowserSelectionForWindow(opt_win || window); + return !!(sel && sel.rangeCount); +}; + + +/** + * Returns whether the focus position occurs before the anchor position. + * @param {Node} anchorNode The node to anchor on. + * @param {number} anchorOffset The offset within the node to anchor on. + * @param {Node} focusNode The node to focus on. + * @param {number} focusOffset The offset within the node to focus on. + * @return {boolean} Whether the focus position occurs before the anchor + * position. + */ +goog.dom.Range.isReversed = function( + anchorNode, anchorOffset, focusNode, focusOffset) { + 'use strict'; + if (anchorNode == focusNode) { + return focusOffset < anchorOffset; + } + var child; + if (anchorNode.nodeType == goog.dom.NodeType.ELEMENT && anchorOffset) { + child = anchorNode.childNodes[anchorOffset]; + if (child) { + anchorNode = child; + anchorOffset = 0; + } else if (goog.dom.contains(anchorNode, focusNode)) { + // If focus node is contained in anchorNode, it must be before the + // end of the node. Hence we are reversed. + return true; + } + } + if (focusNode.nodeType == goog.dom.NodeType.ELEMENT && focusOffset) { + child = focusNode.childNodes[focusOffset]; + if (child) { + focusNode = child; + focusOffset = 0; + } else if (goog.dom.contains(focusNode, anchorNode)) { + // If anchor node is contained in focusNode, it must be before the + // end of the node. Hence we are not reversed. + return false; + } + } + return (goog.dom.compareNodeOrder(anchorNode, focusNode) || + anchorOffset - focusOffset) > 0; +}; diff --git a/closure/goog/dom/range_test.js b/closure/goog/dom/range_test.js new file mode 100644 index 0000000000..e39d75ef4c --- /dev/null +++ b/closure/goog/dom/range_test.js @@ -0,0 +1,725 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.dom.RangeTest'); +goog.setTestOnly(); + +const DomTextRange = goog.require('goog.dom.TextRange'); +const NodeType = goog.require('goog.dom.NodeType'); +const Range = goog.require('goog.dom.Range'); +const RangeType = goog.require('goog.dom.RangeType'); +const TagName = goog.require('goog.dom.TagName'); +const browserrange = goog.require('goog.dom.browserrange'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); +const testingDom = goog.require('goog.testing.dom'); +const userAgent = goog.require('goog.userAgent'); + +const assertRangeEquals = testingDom.assertRangeEquals; + +function normalizeHtml(str) { + return str.toLowerCase() + .replace(/[\n\r\f"]/g, '') + .replace(/<\/li>/g, ''); // " for emacs +} + +// TODO(robbyw): Test iteration over a strange document fragment. + +function removeHelper( + testNumber, range, outer, expectedChildCount, expectedContent) { + range.removeContents(); + assertTrue( + `${testNumber}: Removed range should now be collapsed`, + range.isCollapsed()); + assertEquals( + `${testNumber}: Removed range content should be ""`, '', range.getText()); + assertEquals( + `${testNumber}: Outer div should contain correct text`, expectedContent, + outer.innerHTML.toLowerCase()); + assertEquals( + `${testNumber}: Outer div should have ${expectedChildCount}` + + ' children now', + expectedChildCount, outer.childNodes.length); + assertNotNull( + `${testNumber}: Empty node should still exist`, dom.getElement('empty')); +} + +/** + * Given two offsets into the 'foobar' node, make sure that inserting + * nodes at those offsets doesn't change a selection of 'oba'. + * @bug 1480638 + */ +function assertSurroundDoesntChangeSelectionWithOffsets( + offset1, offset2, expectedHtml) { + const div = dom.getElement('bug1480638'); + dom.setTextContent(div, 'foobar'); + const rangeToSelect = + Range.createFromNodes(div.firstChild, 2, div.firstChild, 5); + rangeToSelect.select(); + + const rangeToSurround = + Range.createFromNodes(div.firstChild, offset1, div.firstChild, offset2); + rangeToSurround.surroundWithNodes( + dom.createDom(TagName.SPAN), dom.createDom(TagName.SPAN)); + + // Make sure that the selection didn't change. + assertHTMLEquals( + 'Selection must not change when contents are surrounded.', expectedHtml, + Range.createFromWindow().getHtmlFragment()); +} + +function assertForward(string, startNode, startOffset, endNode, endOffset) { + const root = dom.getElement('test2'); + const originalInnerHtml = root.innerHTML; + + assertFalse( + string, Range.isReversed(startNode, startOffset, endNode, endOffset)); + assertTrue( + string, Range.isReversed(endNode, endOffset, startNode, startOffset)); + assertEquals( + `Contents should be unaffected after: ${string}`, root.innerHTML, + originalInnerHtml); +} + +function assertNodeEquals(expected, actual) { + assertEquals( + 'Expected: ' + testingDom.exposeNode(expected) + + '\nActual: ' + testingDom.exposeNode(actual), + expected, actual); +} +testSuite({ + setUp() { + // Reset the focus; some tests may invalidate the focus to exercise various + // browser bugs. + const focusableElement = dom.getElement('focusableElement'); + focusableElement.focus(); + focusableElement.blur(); + }, + + testCreate() { + assertNotNull( + 'Browser range object can be created for node', + Range.createFromNodeContents(dom.getElement('test1'))); + }, + + testTableRange() { + const tr = dom.getElement('cell').parentNode; + const range = Range.createFromNodeContents(tr); + assertEquals('Selection should have correct text', '12', range.getText()); + assertEquals( + 'Selection should have correct html fragment', '12', + normalizeHtml(range.getHtmlFragment())); + + // TODO(robbyw): On IE the TR is included, on FF it is not. + // assertEquals('Selection should have correct valid html', + // '12', + // normalizeHtml(range.getValidHtml())); + + assertEquals( + 'Selection should have correct pastable html', + '
    12
    ', + normalizeHtml(range.getPastableHtml())); + }, + + testUnorderedListRange() { + const ul = dom.getElement('ulTest').firstChild; + const range = Range.createFromNodeContents(ul); + assertEquals( + 'Selection should have correct html fragment', '1
  • 2', + normalizeHtml(range.getHtmlFragment())); + + // TODO(robbyw): On IE the UL is included, on FF it is not. + // assertEquals('Selection should have correct valid html', + // '
  • 1
  • 2
  • ', normalizeHtml(range.getValidHtml())); + + assertEquals( + 'Selection should have correct pastable html', '
    • 1
    • 2
    ', + normalizeHtml(range.getPastableHtml())); + }, + + testOrderedListRange() { + const ol = dom.getElement('olTest').firstChild; + const range = Range.createFromNodeContents(ol); + assertEquals( + 'Selection should have correct html fragment', '1
  • 2', + normalizeHtml(range.getHtmlFragment())); + + // TODO(robbyw): On IE the OL is included, on FF it is not. + // assertEquals('Selection should have correct valid html', + // '
  • 1
  • 2
  • ', normalizeHtml(range.getValidHtml())); + + assertEquals( + 'Selection should have correct pastable html', '
    1. 1
    2. 2
    ', + normalizeHtml(range.getPastableHtml())); + }, + + testCreateFromNodes() { + const start = dom.getElement('test1').firstChild; + const end = dom.getElement('br'); + const range = Range.createFromNodes(start, 2, end, 0); + assertNotNull( + 'Browser range object can be created for W3C node range', range); + + assertEquals( + 'Start node should be selected at start endpoint', start, + range.getStartNode()); + assertEquals( + 'Selection should start at offset 2', 2, range.getStartOffset()); + assertEquals( + 'Start node should be selected at anchor endpoint', start, + range.getAnchorNode()); + assertEquals( + 'Selection should be anchored at offset 2', 2, range.getAnchorOffset()); + + const div = dom.getElement('test2'); + assertEquals( + 'DIV node should be selected at end endpoint', div, range.getEndNode()); + assertEquals('Selection should end at offset 1', 1, range.getEndOffset()); + assertEquals( + 'DIV node should be selected at focus endpoint', div, + range.getFocusNode()); + assertEquals( + 'Selection should be focused at offset 1', 1, range.getFocusOffset()); + + assertTrue( + 'Text content should be "xt\\s*abc"', /xt\s*abc/.test(range.getText())); + assertFalse('Nodes range is not collapsed', range.isCollapsed()); + }, + + testCreateControlRange() { + if (!userAgent.IE) { + return; + } + const cr = document.body.createControlRange(); + cr.addElement(dom.getElement('logo')); + + const range = Range.createFromBrowserRange(cr); + assertNotNull( + 'Control range object can be created from browser range', range); + assertEquals( + 'Created range is a control range', RangeType.CONTROL, range.getType()); + }, + + testTextNode() { + const range = + Range.createFromNodeContents(dom.getElement('test1').firstChild); + + assertEquals( + 'Created range is a text range', RangeType.TEXT, range.getType()); + assertEquals( + 'Text node should be selected at start endpoint', 'Text', + range.getStartNode().nodeValue); + assertEquals( + 'Selection should start at offset 0', 0, range.getStartOffset()); + + assertEquals( + 'Text node should be selected at end endpoint', 'Text', + range.getEndNode().nodeValue); + assertEquals( + 'Selection should end at offset 4', 'Text'.length, + range.getEndOffset()); + + assertEquals( + 'Container should be text node', NodeType.TEXT, + range.getContainer().nodeType); + + assertEquals('Text content should be "Text"', 'Text', range.getText()); + assertFalse('Text range is not collapsed', range.isCollapsed()); + }, + + testDiv() { + const range = Range.createFromNodeContents(dom.getElement('test2')); + + assertEquals( + 'Text node "abc" should be selected at start endpoint', 'abc', + range.getStartNode().nodeValue); + assertEquals( + 'Selection should start at offset 0', 0, range.getStartOffset()); + + assertEquals( + 'Text node "def" should be selected at end endpoint', 'def', + range.getEndNode().nodeValue); + assertEquals( + 'Selection should end at offset 3', 'def'.length, range.getEndOffset()); + + assertEquals( + 'Container should be DIV', dom.getElement('test2'), + range.getContainer()); + + assertTrue( + 'Div text content should be "abc\\s*def"', + /abc\s*def/.test(range.getText())); + assertFalse('Div range is not collapsed', range.isCollapsed()); + }, + + testEmptyNode() { + const range = Range.createFromNodeContents(dom.getElement('empty')); + + assertEquals( + 'DIV be selected at start endpoint', dom.getElement('empty'), + range.getStartNode()); + assertEquals( + 'Selection should start at offset 0', 0, range.getStartOffset()); + + assertEquals( + 'DIV should be selected at end endpoint', dom.getElement('empty'), + range.getEndNode()); + assertEquals('Selection should end at offset 0', 0, range.getEndOffset()); + + assertEquals( + 'Container should be DIV', dom.getElement('empty'), + range.getContainer()); + + assertEquals('Empty text content should be ""', '', range.getText()); + assertTrue('Empty range is collapsed', range.isCollapsed()); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testCollapse() { + let range = Range.createFromNodeContents(dom.getElement('test2')); + assertFalse('Div range is not collapsed', range.isCollapsed()); + range.collapse(); + assertTrue( + 'Div range is collapsed after call to empty()', range.isCollapsed()); + + range = Range.createFromNodeContents(dom.getElement('empty')); + assertTrue('Empty range is collapsed', range.isCollapsed()); + range.collapse(); + assertTrue('Empty range is still collapsed', range.isCollapsed()); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testIterator() { + const expectedContent = ['abc', '#br', '#br', 'def']; + testingDom.assertNodesMatch( + Range.createFromNodeContents(dom.getElement('test2')), expectedContent); + testingDom.assertNodesMatch( + Range.createFromNodeContents(dom.getElement('test2')), expectedContent, + false); + }, + + testReversedNodes() { + let node = dom.getElement('test1').firstChild; + let range = Range.createFromNodes(node, 4, node, 0); + assertTrue('Range is reversed', range.isReversed()); + node = dom.getElement('test3'); + range = Range.createFromNodes(node, 0, node, 1); + assertFalse('Range is not reversed', range.isReversed()); + }, + + testReversedContents() { + const range = Range.createFromNodeContents(dom.getElement('test1'), true); + assertTrue('Range is reversed', range.isReversed()); + assertEquals('Range should select "Text"', 'Text', range.getText()); + assertEquals('Range start offset should be 0', 0, range.getStartOffset()); + assertEquals('Range end offset should be 4', 4, range.getEndOffset()); + assertEquals('Range anchor offset should be 4', 4, range.getAnchorOffset()); + assertEquals('Range focus offset should be 0', 0, range.getFocusOffset()); + + const range2 = range.clone(); + + range.collapse(true); + assertTrue('Range is collapsed', range.isCollapsed()); + assertFalse('Collapsed range is not reversed', range.isReversed()); + assertEquals( + 'Post collapse start offset should be 4', 4, range.getStartOffset()); + + range2.collapse(false); + assertTrue('Range 2 is collapsed', range2.isCollapsed()); + assertFalse('Collapsed range 2 is not reversed', range2.isReversed()); + assertEquals( + 'Post collapse start offset 2 should be 0', 0, range2.getStartOffset()); + }, + + testRemoveContents() { + const outer = dom.getElement('removeTest'); + const range = Range.createFromNodeContents(outer.firstChild); + + range.removeContents(); + + assertEquals('Removed range content should be ""', '', range.getText()); + assertTrue('Removed range should be collapsed', range.isCollapsed()); + assertEquals( + 'Outer div should have 1 child now', 1, outer.childNodes.length); + assertEquals( + 'Inner div should be empty', 0, outer.firstChild.childNodes.length); + }, + + testRemovePartialContents() { + const outer = dom.getElement('removePartialTest'); + const originalText = dom.getTextContent(outer); + + try { + let range = + Range.createFromNodes(outer.firstChild, 2, outer.firstChild, 4); + removeHelper(1, range, outer, 1, '0145'); + + range = Range.createFromNodes(outer.firstChild, 0, outer.firstChild, 1); + removeHelper(2, range, outer, 1, '145'); + + range = Range.createFromNodes(outer.firstChild, 2, outer.firstChild, 3); + removeHelper(3, range, outer, 1, '14'); + + const br = dom.createDom(TagName.BR); + outer.appendChild(br); + range = Range.createFromNodes(outer.firstChild, 1, outer, 1); + removeHelper(4, range, outer, 2, '1
    '); + + outer.innerHTML = '
    123'; + range = Range.createFromNodes(outer, 0, outer.lastChild, 2); + removeHelper(5, range, outer, 1, '3'); + + outer.innerHTML = '123
    456'; + range = Range.createFromNodes(outer.firstChild, 1, outer.lastChild, 2); + removeHelper(6, range, outer, 2, '16'); + + outer.innerHTML = '123
    456'; + range = Range.createFromNodes(outer.firstChild, 0, outer.lastChild, 2); + removeHelper(7, range, outer, 1, '6'); + + outer.innerHTML = '
    '; + range = Range.createFromNodeContents(outer.firstChild); + removeHelper(8, range, outer, 1, '
    '); + } finally { + // Restore the original text state for repeated runs. + dom.setTextContent(outer, originalText); + } + + // TODO(robbyw): Fix the following edge cases: + // * Selecting contents of a node containing multiply empty divs + // * Selecting via createFromNodes(x, 0, x, x.childNodes.length) + // * Consistent handling of nodeContents(
    ).remove + }, + + testSurroundContents() { + const outer = dom.getElement('surroundTest'); + outer.innerHTML = '---Text that
    will be surrounded---'; + const range = Range.createFromNodes( + outer.firstChild, 3, outer.lastChild, + outer.lastChild.nodeValue.length - 3); + + const div = dom.createDom(TagName.DIV, {'style': 'color: red'}); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + const output = range.surroundContents(div); + + assertEquals( + 'Outer element should contain new element', outer, output.parentNode); + assertFalse('New element should have no id', !!output.id); + assertEquals('New element should be red', 'red', output.style.color); + assertEquals( + 'Outer element should have three children', 3, outer.childNodes.length); + assertEquals( + 'New element should have three children', 3, output.childNodes.length); + + // TODO(robbyw): Ensure the range stays in a reasonable state. + }, + + testSurroundWithNodesDoesntChangeSelection1() { + assertSurroundDoesntChangeSelectionWithOffsets( + 3, 4, 'oba'); + }, + + testSurroundWithNodesDoesntChangeSelection2() { + assertSurroundDoesntChangeSelectionWithOffsets(3, 6, 'oba'); + }, + + testSurroundWithNodesDoesntChangeSelection3() { + assertSurroundDoesntChangeSelectionWithOffsets(1, 3, 'oba'); + }, + + testSurroundWithNodesDoesntChangeSelection4() { + assertSurroundDoesntChangeSelectionWithOffsets(1, 6, 'oba'); + }, + + testInsertNode() { + const outer = dom.getElement('insertTest'); + dom.setTextContent(outer, 'ACD'); + + let range = Range.createFromNodes(outer.firstChild, 1, outer.firstChild, 2); + range.insertNode(dom.createTextNode('B'), true); + assertEquals( + 'Element should have correct innerHTML', 'ABCD', outer.innerHTML); + + dom.setTextContent(outer, '12'); + range = Range.createFromNodes(outer.firstChild, 0, outer.firstChild, 1); + const br = range.insertNode(dom.createDom(TagName.BR), false); + assertEquals( + 'New element should have correct innerHTML', '1
    2', + outer.innerHTML.toLowerCase()); + assertEquals('BR should be in outer', outer, br.parentNode); + }, + + testReplaceContentsWithNode() { + const outer = dom.getElement('insertTest'); + dom.setTextContent(outer, 'AXC'); + + let range = Range.createFromNodes(outer.firstChild, 1, outer.firstChild, 2); + range.replaceContentsWithNode(dom.createTextNode('B')); + assertEquals( + 'Element should have correct innerHTML', 'ABC', outer.innerHTML); + + dom.setTextContent(outer, 'ABC'); + range = Range.createFromNodes(outer.firstChild, 3, outer.firstChild, 3); + range.replaceContentsWithNode(dom.createTextNode('D')); + assertEquals( + 'Element should have correct innerHTML after collapsed replace', 'ABCD', + outer.innerHTML); + + outer.innerHTML = 'AXXXC'; + range = Range.createFromNodes(outer.firstChild, 1, outer.lastChild, 1); + range.replaceContentsWithNode(dom.createTextNode('B')); + testingDom.assertHtmlContentsMatch('ABC', outer); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testSurroundWithNodes() { + const outer = dom.getElement('insertTest'); + dom.setTextContent(outer, 'ACE'); + const range = + Range.createFromNodes(outer.firstChild, 1, outer.firstChild, 2); + + range.surroundWithNodes(dom.createTextNode('B'), dom.createTextNode('D')); + + assertEquals( + 'New element should have correct innerHTML', 'ABCDE', outer.innerHTML); + }, + + testIsRangeInDocument() { + const outer = dom.getElement('insertTest'); + outer.innerHTML = '
    ABC'; + const range = Range.createCaret(outer.lastChild, 1); + + assertEquals( + 'Should get correct start element', 'ABC', + range.getStartNode().nodeValue); + assertTrue('Should be considered in document', range.isRangeInDocument()); + + dom.setTextContent(outer, 'DEF'); + + assertFalse( + 'Should be marked as out of document', range.isRangeInDocument()); + }, + + testRemovedNode() { + const node = dom.getElement('removeNodeTest'); + const range = browserrange.createRangeFromNodeContents(node); + range.select(); + dom.removeNode(node); + + const newRange = Range.createFromWindow(window); + + assertTrue( + 'The other browsers will just have an empty range.', + newRange.isCollapsed()); + }, + + testReversedRange() { + if (userAgent.EDGE_OR_IE) return; // IE doesn't make this distinction. + + Range + .createFromNodes(dom.getElement('test2'), 0, dom.getElement('test1'), 0) + .select(); + + const range = Range.createFromWindow(window); + assertTrue('Range should be reversed', range.isReversed()); + }, + + testUnreversedRange() { + Range + .createFromNodes(dom.getElement('test1'), 0, dom.getElement('test2'), 0) + .select(); + + const range = Range.createFromWindow(window); + assertFalse('Range should not be reversed', range.isReversed()); + }, + + testReversedThenUnreversedRange() { + // This tests a workaround for a webkit bug where webkit caches selections + // incorrectly. + Range + .createFromNodes(dom.getElement('test2'), 0, dom.getElement('test1'), 0) + .select(); + Range + .createFromNodes(dom.getElement('test1'), 0, dom.getElement('test2'), 0) + .select(); + + const range = Range.createFromWindow(window); + assertFalse('Range should not be reversed', range.isReversed()); + }, + + testHasAndClearSelection() { + Range.createFromNodeContents(dom.getElement('test1')).select(); + + assertTrue('Selection should exist', Range.hasSelection()); + + Range.clearSelection(); + + assertFalse('Selection should not exist', Range.hasSelection()); + }, + + testIsReversed() { + const root = dom.getElement('test2'); + const text1 = root.firstChild; // Text content: 'abc'. + const br = root.childNodes[1]; + const text2 = root.lastChild; // Text content: 'def'. + + assertFalse( + 'Same element position gives false', + Range.isReversed(root, 0, root, 0)); + assertFalse( + 'Same text position gives false', Range.isReversed(text1, 0, text2, 0)); + assertForward( + 'Element offsets should compare against each other', root, 0, root, 2); + assertForward( + 'Text node offsets should compare against each other', text1, 0, text2, + 2); + assertForward('Text nodes should compare correctly', text1, 0, text2, 0); + assertForward( + 'Text nodes should compare to later elements', text1, 0, br, 0); + assertForward( + 'Text nodes should compare to earlier elements', br, 0, text2, 0); + assertForward('Parent is before element child', root, 0, br, 0); + assertForward('Parent is before text child', root, 0, text1, 0); + assertFalse( + 'Equivalent position gives false', Range.isReversed(root, 0, text1, 0)); + assertFalse( + 'Equivalent position gives false', Range.isReversed(root, 1, br, 0)); + assertForward('End of element is after children', text1, 0, root, 3); + assertForward('End of element is after children', br, 0, root, 3); + assertForward('End of element is after children', text2, 0, root, 3); + assertForward( + 'End of element is after end of last child', text2, 3, root, 3); + }, + + testSelectAroundSpaces() { + // set the selection + const textNode = dom.getElement('textWithSpaces').firstChild; + DomTextRange.createFromNodes(textNode, 5, textNode, 12).select(); + + // get the selection and check that it matches what we set it to + const range = Range.createFromWindow(); + assertEquals(' world ', range.getText()); + assertEquals(5, range.getStartOffset()); + assertEquals(12, range.getEndOffset()); + assertEquals(textNode, range.getContainer()); + + // Check the contents again, because there used to be a bug where + // it changed after calling getContainer(). + assertEquals(' world ', range.getText()); + }, + + testSelectInsideSpaces() { + // set the selection + const textNode = dom.getElement('textWithSpaces').firstChild; + DomTextRange.createFromNodes(textNode, 6, textNode, 11).select(); + + // get the selection and check that it matches what we set it to + const range = Range.createFromWindow(); + assertEquals('world', range.getText()); + assertEquals(6, range.getStartOffset()); + assertEquals(11, range.getEndOffset()); + assertEquals(textNode, range.getContainer()); + + // Check the contents again, because there used to be a bug where + // it changed after calling getContainer(). + assertEquals('world', range.getText()); + }, + + testRangeBeforeBreak() { + const container = dom.getElement('rangeAroundBreaks'); + const text = container.firstChild; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + const offset = text.length; + assertEquals(4, offset); + + const br = container.childNodes[1]; + const caret = Range.createCaret(text, offset); + caret.select(); + assertEquals(offset, caret.getStartOffset()); + + const range = Range.createFromWindow(); + assertFalse('Should not contain whole
    ', range.containsNode(br, false)); + if (userAgent.IE && !userAgent.isDocumentModeOrHigher(9)) { + assertTrue( + 'Range over
    is adjacent to the immediate range before it', + range.containsNode(br, true)); + } else { + assertFalse( + 'Should not contain partial
    ', range.containsNode(br, true)); + } + + assertEquals(offset, range.getStartOffset()); + assertEquals(text, range.getStartNode()); + }, + + testRangeAfterBreak() { + const container = dom.getElement('rangeAroundBreaks'); + const br = container.childNodes[1]; + const caret = Range.createCaret(container.lastChild, 0); + caret.select(); + assertEquals(0, caret.getStartOffset()); + + const range = Range.createFromWindow(); + assertFalse('Should not contain whole
    ', range.containsNode(br, false)); + const isSafari3 = false; + + if (userAgent.IE && !userAgent.isDocumentModeOrHigher(9) || isSafari3) { + assertTrue( + 'Range over
    is adjacent to the immediate range after it', + range.containsNode(br, true)); + } else { + assertFalse( + 'Should not contain partial
    ', range.containsNode(br, true)); + } + + if (isSafari3) { + assertEquals(2, range.getStartOffset()); + assertEquals(container, range.getStartNode()); + } else { + assertEquals(0, range.getStartOffset()); + assertEquals(container.lastChild, range.getStartNode()); + } + }, + + testRangeAtBreakAtStart() { + const container = dom.getElement('breaksAroundNode'); + const br = container.firstChild; + const caret = Range.createCaret(container.firstChild, 0); + caret.select(); + assertEquals(0, caret.getStartOffset()); + + const range = Range.createFromWindow(); + assertTrue( + 'Range over
    is adjacent to the immediate range before it', + range.containsNode(br, true)); + assertFalse('Should not contain whole
    ', range.containsNode(br, false)); + + assertRangeEquals(container, 0, container, 0, range); + }, + + testFocusedElementDisappears() { + // This reproduces a failure case specific to Gecko, where an element is + // created, contentEditable is set, is focused, and removed. After that + // happens, calling selection.collapse fails. + // https://bugzilla.mozilla.org/show_bug.cgi?id=773137 + const disappearingElement = dom.createDom(TagName.DIV); + document.body.appendChild(disappearingElement); + disappearingElement.contentEditable = true; + disappearingElement.focus(); + document.body.removeChild(disappearingElement); + const container = dom.getElement('empty'); + const caret = Range.createCaret(container, 0); + // This should not throw. + caret.select(); + assertEquals(0, caret.getStartOffset()); + }, +}); diff --git a/closure/goog/dom/range_test_dom.html b/closure/goog/dom/range_test_dom.html new file mode 100644 index 0000000000..e7c6bb7138 --- /dev/null +++ b/closure/goog/dom/range_test_dom.html @@ -0,0 +1,30 @@ + +
    Text
    +
    abc
    def
    +
    +
    +
    Text that
    will be deleted
    +
    +
    +
    +
    012345
    +
    12
    +
    • 1
    • 2
    +
    1. 1
    2. 2
    + +
    Will be removed
    + +
    +
    hello world !
    +
    +
    abcd
    e
    +

    abcde
    +
    + + + diff --git a/closure/goog/dom/rangeendpoint.js b/closure/goog/dom/rangeendpoint.js new file mode 100644 index 0000000000..ff9b33dbb6 --- /dev/null +++ b/closure/goog/dom/rangeendpoint.js @@ -0,0 +1,22 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Simple struct for endpoints of a range. + */ + + +goog.provide('goog.dom.RangeEndpoint'); + + +/** + * Constants for selection endpoints. + * @enum {number} + */ +goog.dom.RangeEndpoint = { + START: 1, + END: 0 +}; diff --git a/closure/goog/dom/safe.js b/closure/goog/dom/safe.js new file mode 100644 index 0000000000..6ea3bdb6c4 --- /dev/null +++ b/closure/goog/dom/safe.js @@ -0,0 +1,942 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Type-safe wrappers for unsafe DOM APIs. + * + * This file provides type-safe wrappers for DOM APIs that can result in + * cross-site scripting (XSS) vulnerabilities, if the API is supplied with + * untrusted (attacker-controlled) input. Instead of plain strings, the type + * safe wrappers consume values of types from the goog.html package whose + * contract promises that values are safe to use in the corresponding context. + * + * Hence, a program that exclusively uses the wrappers in this file (i.e., whose + * only reference to security-sensitive raw DOM APIs are in this file) is + * guaranteed to be free of XSS due to incorrect use of such DOM APIs (modulo + * correctness of code that produces values of the respective goog.html types, + * and absent code that violates type safety). + * + * For example, assigning to an element's .innerHTML property a string that is + * derived (even partially) from untrusted input typically results in an XSS + * vulnerability. The type-safe wrapper goog.dom.safe.setInnerHtml consumes a + * value of type goog.html.SafeHtml, whose contract states that using its values + * in a HTML context will not result in XSS. Hence a program that is free of + * direct assignments to any element's innerHTML property (with the exception of + * the assignment to .innerHTML in this file) is guaranteed to be free of XSS + * due to assignment of untrusted strings to the innerHTML property. + */ + +goog.provide('goog.dom.safe'); +goog.provide('goog.dom.safe.InsertAdjacentHtmlPosition'); + +goog.require('goog.asserts'); +goog.require('goog.asserts.dom'); +goog.require('goog.dom.asserts'); +goog.require('goog.functions'); +goog.require('goog.html.SafeHtml'); +goog.require('goog.html.SafeScript'); +goog.require('goog.html.SafeStyle'); +goog.require('goog.html.SafeUrl'); +goog.require('goog.html.TrustedResourceUrl'); +goog.require('goog.html.uncheckedconversions'); +goog.require('goog.string.Const'); +goog.require('goog.string.internal'); + + +/** + * @enum {string} + * @deprecated Use a plain string value instead. + */ +goog.dom.safe.InsertAdjacentHtmlPosition = { + AFTERBEGIN: 'afterbegin', + AFTEREND: 'afterend', + BEFOREBEGIN: 'beforebegin', + BEFOREEND: 'beforeend' +}; + + +/** + * Inserts known-safe HTML into a Node, at the specified position. + * @param {!Node} node The node on which to call insertAdjacentHTML. + * @param {!goog.dom.safe.InsertAdjacentHtmlPosition} position Position where + * to insert the HTML. + * @param {!goog.html.SafeHtml} html The known-safe HTML to insert. + * @deprecated Use a `safevalues.dom.safeElement.insertAdjacentHtml` instead. + */ +goog.dom.safe.insertAdjacentHtml = function(node, position, html) { + 'use strict'; + node.insertAdjacentHTML(position, goog.html.SafeHtml.unwrapTrustedHTML(html)); +}; + + +/** + * Tags not allowed in goog.dom.safe.setInnerHtml. + * @private @const {!Object} + */ +goog.dom.safe.SET_INNER_HTML_DISALLOWED_TAGS_ = { + 'MATH': true, + 'SCRIPT': true, + 'STYLE': true, + 'SVG': true, + 'TEMPLATE': true +}; + + +/** + * Whether assigning to innerHTML results in a non-spec-compliant clean-up. Used + * to define goog.dom.safe.unsafeSetInnerHtmlDoNotUseOrElse. + * + *

    As mentioned in https://stackoverflow.com/questions/28741528, re-rendering + * an element in IE by setting innerHTML causes IE to recursively disconnect all + * parent/children connections that were in the previous contents of the + * element. Unfortunately, this can unexpectedly result in confusing cases where + * a function is run (typically asynchronously) on element that has since + * disconnected from the DOM but assumes the presence of its children. A simple + * workaround is to remove all children first. Testing on IE11 via + * https://jsperf.com/innerhtml-vs-removechild/239, removeChild seems to be + * ~10x faster than innerHTML='' for a large number of children (perhaps due + * to the latter's recursive behavior), implying that this workaround would + * not hurt performance and might actually improve it. + * @return {boolean} + * @private + */ +goog.dom.safe.isInnerHtmlCleanupRecursive_ = + goog.functions.cacheReturnValue(function() { + 'use strict'; + // `document` missing in some test frameworks. + if (goog.DEBUG && typeof document === 'undefined') { + return false; + } + // Create 3 nested

    s without using innerHTML. + // We're not chaining the appendChilds in one call, as this breaks + // in a DocumentFragment. + var div = document.createElement('div'); + var childDiv = document.createElement('div'); + childDiv.appendChild(document.createElement('div')); + div.appendChild(childDiv); + // `firstChild` is null in Google Js Test. + if (goog.DEBUG && !div.firstChild) { + return false; + } + var innerChild = div.firstChild.firstChild; + div.innerHTML = + goog.html.SafeHtml.unwrapTrustedHTML(goog.html.SafeHtml.EMPTY); + return !innerChild.parentElement; + }); + + +/** + * Assigns HTML to an element's innerHTML property. Helper to use only here and + * in soy.js. + * @param {?Element|?ShadowRoot} elem The element whose innerHTML is to be + * assigned to. + * @param {!goog.html.SafeHtml} html + */ +goog.dom.safe.unsafeSetInnerHtmlDoNotUseOrElse = function(elem, html) { + 'use strict'; + // See comment above goog.dom.safe.isInnerHtmlCleanupRecursive_. + if (goog.dom.safe.isInnerHtmlCleanupRecursive_()) { + while (elem.lastChild) { + elem.removeChild(elem.lastChild); + } + } + elem.innerHTML = goog.html.SafeHtml.unwrapTrustedHTML(html); +}; + + +/** + * Assigns known-safe HTML to an element's innerHTML property. + * @param {!Element|!ShadowRoot} elem The element whose innerHTML is to be + * assigned to. + * @param {!goog.html.SafeHtml} html The known-safe HTML to assign. + * @throws {Error} If called with one of these tags: math, script, style, svg, + * template. + * @deprecated Use `safevalues.dom.safeElement.setInnerHtml` instead. + */ +goog.dom.safe.setInnerHtml = function(elem, html) { + 'use strict'; + if (goog.asserts.ENABLE_ASSERTS && /** @type {?} */ (elem).tagName) { + var tagName = /** @type {!Element} */ (elem).tagName.toUpperCase(); + if (goog.dom.safe.SET_INNER_HTML_DISALLOWED_TAGS_[tagName]) { + throw new Error( + 'goog.dom.safe.setInnerHtml cannot be used to set content of ' + + /** @type {!Element} */ (elem).tagName + '.'); + } + } + + goog.dom.safe.unsafeSetInnerHtmlDoNotUseOrElse(elem, html); +}; + + +/** + * Assigns constant HTML to an element's innerHTML property. + * @param {!Element} element The element whose innerHTML is to be assigned to. + * @param {!goog.string.Const} constHtml The known-safe HTML to assign. + * @throws {!Error} If called with one of these tags: math, script, style, svg, + * template. + */ +goog.dom.safe.setInnerHtmlFromConstant = function(element, constHtml) { + 'use strict'; + goog.dom.safe.setInnerHtml( + element, + goog.html.uncheckedconversions + .safeHtmlFromStringKnownToSatisfyTypeContract( + goog.string.Const.from('Constant HTML to be immediatelly used.'), + goog.string.Const.unwrap(constHtml))); +}; + + +/** + * Assigns known-safe HTML to an element's outerHTML property. + * @param {!Element} elem The element whose outerHTML is to be assigned to. + * @param {!goog.html.SafeHtml} html The known-safe HTML to assign. + * @deprecated Use `safevalues.dom.safeElement.setOuterHtml` instead. + */ +goog.dom.safe.setOuterHtml = function(elem, html) { + 'use strict'; + elem.outerHTML = goog.html.SafeHtml.unwrapTrustedHTML(html); +}; + + +/** + * Safely assigns a URL a form element's action property. + * + * If url is of type goog.html.SafeUrl, its value is unwrapped and assigned to + * form's action property. If url is of type string however, it is first + * sanitized using goog.html.SafeUrl.sanitize. + * + * Example usage: + * goog.dom.safe.setFormElementAction(formEl, url); + * which is a safe alternative to + * formEl.action = url; + * The latter can result in XSS vulnerabilities if url is a + * user-/attacker-controlled value. + * + * @param {!Element} form The form element whose action property + * is to be assigned to. + * @param {string|!goog.html.SafeUrl} url The URL to assign. + * @return {void} + * @see goog.html.SafeUrl#sanitize + * @deprecated Use `safevalues.dom.safeFormEl.setAction` instead. + */ +goog.dom.safe.setFormElementAction = function(form, url) { + 'use strict'; + /** @type {!goog.html.SafeUrl} */ + var safeUrl; + if (url instanceof goog.html.SafeUrl) { + safeUrl = url; + } else { + safeUrl = goog.html.SafeUrl.sanitizeJavascriptUrlAssertUnchanged(url); + } + goog.asserts.dom.assertIsHtmlFormElement(form).action = + goog.html.SafeUrl.unwrap(safeUrl); +}; + +/** + * Safely assigns a URL to a button element's formaction property. + * + * If url is of type goog.html.SafeUrl, its value is unwrapped and assigned to + * button's formaction property. If url is of type string however, it is first + * sanitized using goog.html.SafeUrl.sanitize. + * + * Example usage: + * goog.dom.safe.setButtonFormAction(buttonEl, url); + * which is a safe alternative to + * buttonEl.action = url; + * The latter can result in XSS vulnerabilities if url is a + * user-/attacker-controlled value. + * + * @param {!Element} button The button element whose action property + * is to be assigned to. + * @param {string|!goog.html.SafeUrl} url The URL to assign. + * @return {void} + * @see goog.html.SafeUrl#sanitize + * @deprecated Use `safevalues.dom.safeButtonEl.setFormaction` instead. + */ +goog.dom.safe.setButtonFormAction = function(button, url) { + 'use strict'; + /** @type {!goog.html.SafeUrl} */ + var safeUrl; + if (url instanceof goog.html.SafeUrl) { + safeUrl = url; + } else { + safeUrl = goog.html.SafeUrl.sanitizeJavascriptUrlAssertUnchanged(url); + } + goog.asserts.dom.assertIsHtmlButtonElement(button).formAction = + goog.html.SafeUrl.unwrap(safeUrl); +}; +/** + * Safely assigns a URL to an input element's formaction property. + * + * If url is of type goog.html.SafeUrl, its value is unwrapped and assigned to + * input's formaction property. If url is of type string however, it is first + * sanitized using goog.html.SafeUrl.sanitize. + * + * Example usage: + * goog.dom.safe.setInputFormAction(inputEl, url); + * which is a safe alternative to + * inputEl.action = url; + * The latter can result in XSS vulnerabilities if url is a + * user-/attacker-controlled value. + * + * @param {!Element} input The input element whose action property + * is to be assigned to. + * @param {string|!goog.html.SafeUrl} url The URL to assign. + * @return {void} + * @see goog.html.SafeUrl#sanitize + * @deprecated Use `safevalues.dom.safeInputEl.setFormaction` instead. + */ +goog.dom.safe.setInputFormAction = function(input, url) { + 'use strict'; + /** @type {!goog.html.SafeUrl} */ + var safeUrl; + if (url instanceof goog.html.SafeUrl) { + safeUrl = url; + } else { + safeUrl = goog.html.SafeUrl.sanitizeJavascriptUrlAssertUnchanged(url); + } + goog.asserts.dom.assertIsHtmlInputElement(input).formAction = + goog.html.SafeUrl.unwrap(safeUrl); +}; + +/** + * Sets the given element's style property to the contents of the provided + * SafeStyle object. + * @param {!Element} elem + * @param {!goog.html.SafeStyle} style + * @return {void} + */ +goog.dom.safe.setStyle = function(elem, style) { + 'use strict'; + elem.style.cssText = goog.html.SafeStyle.unwrap(style); +}; + + +/** + * Writes known-safe HTML to a document. + * @param {!Document} doc The document to be written to. + * @param {!goog.html.SafeHtml} html The known-safe HTML to assign. + * @return {void} + * @deprecated Use `safevalues.dom.safeDocument.write` instead. + */ +goog.dom.safe.documentWrite = function(doc, html) { + 'use strict'; + doc.write(goog.html.SafeHtml.unwrapTrustedHTML(html)); +}; + + +/** + * Safely assigns a URL to an anchor element's href property. + * + * If url is of type goog.html.SafeUrl, its value is unwrapped and assigned to + * anchor's href property. If url is of type string however, it is first + * sanitized using goog.html.SafeUrl.sanitize. + * + * Example usage: + * goog.dom.safe.setAnchorHref(anchorEl, url); + * which is a safe alternative to + * anchorEl.href = url; + * The latter can result in XSS vulnerabilities if url is a + * user-/attacker-controlled value. + * + * @param {!HTMLAnchorElement} anchor The anchor element whose href property + * is to be assigned to. + * @param {string|!goog.html.SafeUrl} url The URL to assign. + * @return {void} + * @see goog.html.SafeUrl#sanitize + * @deprecated Use `safevalues.dom.safeAnchorEl.setHref` instead. + */ +goog.dom.safe.setAnchorHref = function(anchor, url) { + 'use strict'; + goog.asserts.dom.assertIsHtmlAnchorElement(anchor); + /** @type {!goog.html.SafeUrl} */ + var safeUrl; + if (url instanceof goog.html.SafeUrl) { + safeUrl = url; + } else { + safeUrl = goog.html.SafeUrl.sanitizeJavascriptUrlAssertUnchanged(url); + } + anchor.href = goog.html.SafeUrl.unwrap(safeUrl); +}; + + +/** + * Safely assigns a URL to a audio element's src property. + * + * If url is of type goog.html.SafeUrl, its value is unwrapped and assigned to + * audio's src property. If url is of type string however, it is first + * sanitized using goog.html.SafeUrl.sanitize. + * + * @param {!HTMLAudioElement} audioElement The audio element whose src property + * is to be assigned to. + * @param {string|!goog.html.SafeUrl} url The URL to assign. + * @return {void} + * @see goog.html.SafeUrl#sanitize + * @deprecated Use a plain property assignement `myAudioEl.src = x` instead. + */ +goog.dom.safe.setAudioSrc = function(audioElement, url) { + 'use strict'; + goog.asserts.dom.assertIsHtmlAudioElement(audioElement); + /** @type {!goog.html.SafeUrl} */ + var safeUrl; + if (url instanceof goog.html.SafeUrl) { + safeUrl = url; + } else { + safeUrl = goog.html.SafeUrl.sanitizeJavascriptUrlAssertUnchanged(url); + } + audioElement.src = goog.html.SafeUrl.unwrap(safeUrl); +}; + +/** + * Safely assigns a URL to a video element's src property. + * + * If url is of type goog.html.SafeUrl, its value is unwrapped and assigned to + * video's src property. If url is of type string however, it is first + * sanitized using goog.html.SafeUrl.sanitize. + * + * @param {!HTMLVideoElement} videoElement The video element whose src property + * is to be assigned to. + * @param {string|!goog.html.SafeUrl} url The URL to assign. + * @return {void} + * @see goog.html.SafeUrl#sanitize + * @deprecated Use a plain property assignement `myAudioEl.src = x` instead. + */ +goog.dom.safe.setVideoSrc = function(videoElement, url) { + 'use strict'; + goog.asserts.dom.assertIsHtmlVideoElement(videoElement); + /** @type {!goog.html.SafeUrl} */ + var safeUrl; + if (url instanceof goog.html.SafeUrl) { + safeUrl = url; + } else { + safeUrl = goog.html.SafeUrl.sanitizeJavascriptUrlAssertUnchanged(url); + } + videoElement.src = goog.html.SafeUrl.unwrap(safeUrl); +}; + +/** + * Safely assigns a URL to an embed element's src property. + * + * Example usage: + * goog.dom.safe.setEmbedSrc(embedEl, url); + * which is a safe alternative to + * embedEl.src = url; + * The latter can result in loading untrusted code unless it is ensured that + * the URL refers to a trustworthy resource. + * + * @param {!HTMLEmbedElement} embed The embed element whose src property + * is to be assigned to. + * @param {!goog.html.TrustedResourceUrl} url The URL to assign. + * @deprecated Use `safevalues.dom.safeEmbedEl.setSrc` instead. + */ +goog.dom.safe.setEmbedSrc = function(embed, url) { + 'use strict'; + goog.asserts.dom.assertIsHtmlEmbedElement(embed); + embed.src = goog.html.TrustedResourceUrl.unwrapTrustedScriptURL(url); +}; + + +/** + * Safely assigns a URL to a frame element's src property. + * + * Example usage: + * goog.dom.safe.setFrameSrc(frameEl, url); + * which is a safe alternative to + * frameEl.src = url; + * The latter can result in loading untrusted code unless it is ensured that + * the URL refers to a trustworthy resource. + * @deprecated Use safevalues.dom.safeIframeEl.setSrc instead. + * @param {!HTMLFrameElement} frame The frame element whose src property + * is to be assigned to. + * @param {!goog.html.TrustedResourceUrl} url The URL to assign. + * @return {void} + */ +goog.dom.safe.setFrameSrc = function(frame, url) { + 'use strict'; + goog.asserts.dom.assertIsHtmlFrameElement(frame); + frame.src = goog.html.TrustedResourceUrl.unwrap(url); +}; + + +/** + * Safely assigns a URL to an iframe element's src property. + * + * Example usage: + * goog.dom.safe.setIframeSrc(iframeEl, url); + * which is a safe alternative to + * iframeEl.src = url; + * The latter can result in loading untrusted code unless it is ensured that + * the URL refers to a trustworthy resource. + * + * @param {!HTMLIFrameElement} iframe The iframe element whose src property + * is to be assigned to. + * @param {!goog.html.TrustedResourceUrl} url The URL to assign. + * @return {void} + * @deprecated Use `safevalues.dom.safeIframeEl.setSrc` instead. + */ +goog.dom.safe.setIframeSrc = function(iframe, url) { + 'use strict'; + goog.asserts.dom.assertIsHtmlIFrameElement(iframe); + iframe.src = goog.html.TrustedResourceUrl.unwrap(url); +}; + + +/** + * Safely assigns HTML to an iframe element's srcdoc property. + * + * Example usage: + * goog.dom.safe.setIframeSrcdoc(iframeEl, safeHtml); + * which is a safe alternative to + * iframeEl.srcdoc = html; + * The latter can result in loading untrusted code. + * + * @param {!HTMLIFrameElement} iframe The iframe element whose srcdoc property + * is to be assigned to. + * @param {!goog.html.SafeHtml} html The HTML to assign. + * @return {void} + * @deprecated Use `safevalues.dom.safeIframeEl.setSrcdoc` instead. + */ +goog.dom.safe.setIframeSrcdoc = function(iframe, html) { + 'use strict'; + goog.asserts.dom.assertIsHtmlIFrameElement(iframe); + iframe.srcdoc = goog.html.SafeHtml.unwrapTrustedHTML(html); +}; + + +/** + * Safely sets a link element's href and rel properties. Whether or not + * the URL assigned to href has to be a goog.html.TrustedResourceUrl + * depends on the value of the rel property. If rel contains "stylesheet" + * then a TrustedResourceUrl is required. + * + * Example usage: + * goog.dom.safe.setLinkHrefAndRel(linkEl, url, 'stylesheet'); + * which is a safe alternative to + * linkEl.rel = 'stylesheet'; + * linkEl.href = url; + * The latter can result in loading untrusted code unless it is ensured that + * the URL refers to a trustworthy resource. + * + * @param {!HTMLLinkElement} link The link element whose href property + * is to be assigned to. + * @param {string|!goog.html.SafeUrl|!goog.html.TrustedResourceUrl} url The URL + * to assign to the href property. Must be a TrustedResourceUrl if the + * value assigned to rel contains "stylesheet". A string value is + * sanitized with goog.html.SafeUrl.sanitize. + * @param {string} rel The value to assign to the rel property. + * @return {void} + * @throws {Error} if rel contains "stylesheet" and url is not a + * TrustedResourceUrl + * @see goog.html.SafeUrl#sanitize + * @deprecated Use `safevalues.dom.safeLinkEl.setHrefAndRel` instead. + */ +goog.dom.safe.setLinkHrefAndRel = function(link, url, rel) { + 'use strict'; + goog.asserts.dom.assertIsHtmlLinkElement(link); + link.rel = rel; + if (goog.string.internal.caseInsensitiveContains(rel, 'stylesheet')) { + goog.asserts.assert( + url instanceof goog.html.TrustedResourceUrl, + 'URL must be TrustedResourceUrl because "rel" contains "stylesheet"'); + link.href = goog.html.TrustedResourceUrl.unwrap(url); + const win = link.ownerDocument && link.ownerDocument.defaultView; + const nonce = goog.dom.safe.getStyleNonce(win); + if (nonce) { + link.setAttribute('nonce', nonce); + } + } else if (url instanceof goog.html.TrustedResourceUrl) { + link.href = goog.html.TrustedResourceUrl.unwrap(url); + } else if (url instanceof goog.html.SafeUrl) { + link.href = goog.html.SafeUrl.unwrap(url); + } else { // string + // SafeUrl.sanitize must return legitimate SafeUrl when passed a string. + link.href = goog.html.SafeUrl.unwrap( + goog.html.SafeUrl.sanitizeJavascriptUrlAssertUnchanged(url)); + } +}; + + +/** + * Safely assigns a URL to an object element's data property. + * + * Example usage: + * goog.dom.safe.setObjectData(objectEl, url); + * which is a safe alternative to + * objectEl.data = url; + * The latter can result in loading untrusted code unless setit is ensured that + * the URL refers to a trustworthy resource. + * @deprecated + * + * @param {!HTMLObjectElement} object The object element whose data property + * is to be assigned to. + * @param {!goog.html.TrustedResourceUrl} url The URL to assign. + * @return {void} + */ +goog.dom.safe.setObjectData = function(object, url) { + 'use strict'; + goog.asserts.dom.assertIsHtmlObjectElement(object); + object.data = goog.html.TrustedResourceUrl.unwrapTrustedScriptURL(url); +}; + + +/** + * Safely assigns a URL to a script element's src property. + * + * Example usage: + * goog.dom.safe.setScriptSrc(scriptEl, url); + * which is a safe alternative to + * scriptEl.src = url; + * The latter can result in loading untrusted code unless it is ensured that + * the URL refers to a trustworthy resource. + * + * @param {!HTMLScriptElement} script The script element whose src property + * is to be assigned to. + * @param {!goog.html.TrustedResourceUrl} url The URL to assign. + * @return {void} + * @deprecated Use `safevalues.dom.safeScriptEl.setSrc` instead. + */ +goog.dom.safe.setScriptSrc = function(script, url) { + 'use strict'; + goog.asserts.dom.assertIsHtmlScriptElement(script); + goog.dom.safe.setNonceForScriptElement_(script); + script.src = goog.html.TrustedResourceUrl.unwrapTrustedScriptURL(url); +}; + + +/** + * Safely assigns a value to a script element's content. + * + * Example usage: + * goog.dom.safe.setScriptContent(scriptEl, content); + * which is a safe alternative to + * scriptEl.text = content; + * The latter can result in executing untrusted code unless it is ensured that + * the code is loaded from a trustworthy resource. + * + * @param {!HTMLScriptElement} script The script element whose content is being + * set. + * @param {!goog.html.SafeScript} content The content to assign. + * @return {void} + * @deprecated Use `safevalues.dom.safeScriptEl.setTextContent` instead. + */ +goog.dom.safe.setScriptContent = function(script, content) { + 'use strict'; + goog.asserts.dom.assertIsHtmlScriptElement(script); + goog.dom.safe.setNonceForScriptElement_(script); + script.textContent = goog.html.SafeScript.unwrapTrustedScript(content); +}; + + +/** + * Set nonce-based CSPs to dynamically created scripts. + * @param {!HTMLScriptElement} script The script element whose nonce value + * is to be calculated + * @private + */ +goog.dom.safe.setNonceForScriptElement_ = function(script) { + 'use strict'; + var win = script.ownerDocument && script.ownerDocument.defaultView; + const nonce = goog.dom.safe.getScriptNonce(win); + if (nonce) { + script.setAttribute('nonce', nonce); + } +}; + + +/** + * Safely assigns a URL to a Location object's href property. + * + * If url is of type goog.html.SafeUrl, its value is unwrapped and assigned to + * loc's href property. If url is of type string however, it is first sanitized + * using goog.html.SafeUrl.sanitize. + * + * Example usage: + * goog.dom.safe.setLocationHref(document.location, redirectUrl); + * which is a safe alternative to + * document.location.href = redirectUrl; + * The latter can result in XSS vulnerabilities if redirectUrl is a + * user-/attacker-controlled value. + * + * @param {!Location} loc The Location object whose href property is to be + * assigned to. + * @param {string|!goog.html.SafeUrl} url The URL to assign. + * @return {void} + * @see goog.html.SafeUrl#sanitize + * @deprecated Use `safevalues.dom.safeLocation.setHref` instead. + + */ +goog.dom.safe.setLocationHref = function(loc, url) { + 'use strict'; + goog.dom.asserts.assertIsLocation(loc); + /** @type {!goog.html.SafeUrl} */ + var safeUrl; + if (url instanceof goog.html.SafeUrl) { + safeUrl = url; + } else { + safeUrl = goog.html.SafeUrl.sanitizeJavascriptUrlAssertUnchanged(url); + } + loc.href = goog.html.SafeUrl.unwrap(safeUrl); +}; + +/** + * Safely assigns the URL of a Location object. + * + * If url is of type goog.html.SafeUrl, its value is unwrapped and + * passed to Location#assign. If url is of type string however, it is + * first sanitized using goog.html.SafeUrl.sanitize. + * + * Example usage: + * goog.dom.safe.assignLocation(document.location, newUrl); + * which is a safe alternative to + * document.location.assign(newUrl); + * The latter can result in XSS vulnerabilities if newUrl is a + * user-/attacker-controlled value. + * + * This has the same behaviour as setLocationHref, however some test + * mock Location.assign instead of a property assignment. + * + * @param {!Location} loc The Location object which is to be assigned. + * @param {string|!goog.html.SafeUrl} url The URL to assign. + * @return {void} + * @see goog.html.SafeUrl#sanitize + * @deprecated Use `safevalues.dom.safeLocation.assign` instead. + */ +goog.dom.safe.assignLocation = function(loc, url) { + 'use strict'; + goog.dom.asserts.assertIsLocation(loc); + /** @type {!goog.html.SafeUrl} */ + var safeUrl; + if (url instanceof goog.html.SafeUrl) { + safeUrl = url; + } else { + safeUrl = goog.html.SafeUrl.sanitizeJavascriptUrlAssertUnchanged(url); + } + loc.assign(goog.html.SafeUrl.unwrap(safeUrl)); +}; + + +/** + * Safely replaces the URL of a Location object. + * + * If url is of type goog.html.SafeUrl, its value is unwrapped and + * passed to Location#replace. If url is of type string however, it is + * first sanitized using goog.html.SafeUrl.sanitize. + * + * Example usage: + * goog.dom.safe.replaceLocation(document.location, newUrl); + * which is a safe alternative to + * document.location.replace(newUrl); + * The latter can result in XSS vulnerabilities if newUrl is a + * user-/attacker-controlled value. + * + * @param {!Location} loc The Location object which is to be replaced. + * @param {string|!goog.html.SafeUrl} url The URL to assign. + * @return {void} + * @see goog.html.SafeUrl#sanitize + * @deprecated Use `safevalues.dom.safeLocation.replace` instead. + */ +goog.dom.safe.replaceLocation = function(loc, url) { + 'use strict'; + /** @type {!goog.html.SafeUrl} */ + var safeUrl; + if (url instanceof goog.html.SafeUrl) { + safeUrl = url; + } else { + safeUrl = goog.html.SafeUrl.sanitizeJavascriptUrlAssertUnchanged(url); + } + loc.replace(goog.html.SafeUrl.unwrap(safeUrl)); +}; + + +/** + * Safely opens a URL in a new window (via window.open). + * + * If url is of type goog.html.SafeUrl, its value is unwrapped and passed in to + * window.open. If url is of type string however, it is first sanitized + * using goog.html.SafeUrl.sanitize. + * + * Note that this function does not prevent leakages via the referer that is + * sent by window.open. It is advised to only use this to open 1st party URLs. + * + * Example usage: + * goog.dom.safe.openInWindow(url); + * which is a safe alternative to + * window.open(url); + * The latter can result in XSS vulnerabilities if url is a + * user-/attacker-controlled value. + * + * @param {string|!goog.html.SafeUrl} url The URL to open. + * @param {Window=} opt_openerWin Window of which to call the .open() method. + * Defaults to the global window. + * @param {!goog.string.Const|string=} opt_name Name of the window to open in. + * Can be _top, etc as allowed by window.open(). This accepts string for + * legacy reasons. Pass goog.string.Const if possible. + * @param {string=} opt_specs Comma-separated list of specifications, same as + * in window.open(). + * @return {Window} Window the url was opened in. + * @deprecated Use `safevalues.dom.safeWindow.open` instead. + */ +goog.dom.safe.openInWindow = function(url, opt_openerWin, opt_name, opt_specs) { + 'use strict'; + /** @type {!goog.html.SafeUrl} */ + var safeUrl; + if (url instanceof goog.html.SafeUrl) { + safeUrl = url; + } else { + safeUrl = goog.html.SafeUrl.sanitizeJavascriptUrlAssertUnchanged(url); + } + var win = opt_openerWin || goog.global; + // If opt_name is undefined, simply passing that in to open() causes IE to + // reuse the current window instead of opening a new one. Thus we pass '' in + // instead, which according to spec opens a new window. See + // https://html.spec.whatwg.org/multipage/browsers.html#dom-open . + var name = opt_name instanceof goog.string.Const ? + goog.string.Const.unwrap(opt_name) : + opt_name || ''; + // Do not pass opt_specs to window.open unless it was provided by the caller. + // IE11 will use it as a signal to open a new window rather than a new tab + // (even if it is undefined). + if (opt_specs !== undefined) { + return win.open(goog.html.SafeUrl.unwrap(safeUrl), name, opt_specs); + } else { + return win.open(goog.html.SafeUrl.unwrap(safeUrl), name); + } +}; + + +/** + * Parses the HTML as 'text/html'. + * @param {!DOMParser} parser + * @param {!goog.html.SafeHtml} html The HTML to be parsed. + * @return {!Document} + * @deprecated Use `safevalues.dom.safeDomParser.parseHtml` instead. + */ +goog.dom.safe.parseFromStringHtml = function(parser, html) { + 'use strict'; + return goog.dom.safe.parseFromString(parser, html, 'text/html'); +}; + + +/** + * Parses the string. + * @param {!DOMParser} parser + * @param {!goog.html.SafeHtml} content Note: We don't have a special type for + * XML or SVG supported by this function so we use SafeHtml. + * @param {string} type + * @return {!Document} + * @deprecated Use `safevalues.dom.safeDomParser.parseFromString` instead. + */ +goog.dom.safe.parseFromString = function(parser, content, type) { + 'use strict'; + return parser.parseFromString( + goog.html.SafeHtml.unwrapTrustedHTML(content), type); +}; + + +/** + * Safely creates an HTMLImageElement from a Blob. + * + * Example usage: + * goog.dom.safe.createImageFromBlob(blob); + * which is a safe alternative to + * image.src = createObjectUrl(blob) + * The latter can result in executing malicious same-origin scripts from a bad + * Blob. + * @param {!Blob} blob The blob to create the image from. + * @return {!HTMLImageElement} The image element created from the blob. + * @throws {!Error} If called with a Blob with a MIME type other than image/.*. + * @deprecated Use `safevalues.objectUrlFromSafeSource` and assign it to the + * img.src. + */ +goog.dom.safe.createImageFromBlob = function(blob) { + 'use strict'; + // Any image/* MIME type is accepted as safe. + if (!/^image\/.*/g.test(blob.type)) { + throw new Error( + 'goog.dom.safe.createImageFromBlob only accepts MIME type image/.*.'); + } + var objectUrl = goog.global.URL.createObjectURL(blob); + var image = new goog.global.Image(); + image.onload = function() { + 'use strict'; + goog.global.URL.revokeObjectURL(objectUrl); + }; + image.src = objectUrl; + return image; +}; + +/** + * Creates a DocumentFragment by parsing html in the context of a Range. + * @param {!Range} range The Range object starting from the context node to + * create a fragment in. + * @param {!goog.html.SafeHtml} html HTML to create a fragment from. + * @return {?DocumentFragment} + * @deprecated Use `safevalues.dom.safeRange.createContextualFragment` instead. + */ +goog.dom.safe.createContextualFragment = function(range, html) { + 'use strict'; + return range.createContextualFragment( + goog.html.SafeHtml.unwrapTrustedHTML(html)); +}; + +/** + * Returns CSP script nonce, if set for any diff --git a/closure/goog/editor/defines.js b/closure/goog/editor/defines.js new file mode 100644 index 0000000000..28d49d8b83 --- /dev/null +++ b/closure/goog/editor/defines.js @@ -0,0 +1,25 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Text editor constants for compile time feature selection. + */ + +goog.provide('goog.editor.defines'); + + +/** + * @define {boolean} Use contentEditable in FF. + * There are a number of known bugs when the only content in your field is + * inline (e.g. just text, no block elements): + * -indent is a noop + * -inserting lists inserts just a NBSP, no list! + * Once those two are fixed, we should have one client guinea pig it and put + * it through a QA run. If we can file the bugs with Mozilla, there's a chance + * they'll fix them for a dot release of Firefox 3. + */ +goog.editor.defines.USE_CONTENTEDITABLE_IN_FIREFOX_3 = + goog.define('goog.editor.defines.USE_CONTENTEDITABLE_IN_FIREFOX_3', false); diff --git a/closure/goog/editor/field.js b/closure/goog/editor/field.js new file mode 100644 index 0000000000..fe793ca0ce --- /dev/null +++ b/closure/goog/editor/field.js @@ -0,0 +1,2726 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Class to encapsulate an editable field. Always uses an + * iframe to contain the editable area, never inherits the style of the + * surrounding page, and is always a fixed height. + * + * @see ../demos/editor/editor.html + * @see ../demos/editor/field_basic.html + */ + +goog.provide('goog.editor.Field'); +goog.provide('goog.editor.Field.EventType'); + +goog.require('goog.a11y.aria'); +goog.require('goog.a11y.aria.Role'); +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.async.Delay'); +goog.require('goog.dom'); +goog.require('goog.dom.Range'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.classlist'); +goog.require('goog.dom.safe'); +goog.require('goog.editor.BrowserFeature'); +goog.require('goog.editor.Command'); +goog.require('goog.editor.PluginImpl'); +goog.require('goog.editor.icontent'); +goog.require('goog.editor.icontent.FieldFormatInfo'); +goog.require('goog.editor.icontent.FieldStyleInfo'); +goog.require('goog.editor.node'); +goog.require('goog.editor.range'); +goog.require('goog.events'); +goog.require('goog.events.EventHandler'); +goog.require('goog.events.EventTarget'); +goog.require('goog.events.EventType'); +goog.require('goog.events.KeyCodes'); +goog.require('goog.functions'); +goog.require('goog.html.SafeHtml'); +goog.require('goog.html.SafeStyleSheet'); +goog.require('goog.html.legacyconversions'); +goog.require('goog.labs.userAgent.platform'); +goog.require('goog.log'); +goog.require('goog.log.Level'); +goog.require('goog.string'); +goog.require('goog.string.Unicode'); +goog.require('goog.style'); +goog.require('goog.userAgent'); +goog.requireType('goog.Disposable'); +goog.requireType('goog.dom.AbstractRange'); +goog.requireType('goog.dom.SavedRange'); +goog.requireType('goog.events.BrowserEvent'); +goog.requireType('goog.html.TrustedResourceUrl'); + + + +/** + * This class encapsulates an editable field. + * + * event: load Fires when the field is loaded + * event: unload Fires when the field is unloaded (made not editable) + * + * event: beforechange Fires before the content of the field might change + * + * event: delayedchange Fires a short time after field has changed. If multiple + * change events happen really close to each other only + * the last one will trigger the delayedchange event. + * + * event: beforefocus Fires before the field becomes active + * event: focus Fires when the field becomes active. Fires after the blur event + * event: blur Fires when the field becomes inactive + * + * TODO: figure out if blur or beforefocus fires first in IE and make FF match + * + * @param {string} id An identifer for the field. This is used to find the + * field and the element associated with this field. + * @param {Document=} opt_doc The document that the element with the given + * id can be found in. If not provided, the default document is used. + * @constructor + * @extends {goog.events.EventTarget} + */ +goog.editor.Field = function(id, opt_doc) { + 'use strict'; + goog.events.EventTarget.call(this); + + /** + * The id for this editable field, which must match the id of the element + * associated with this field. + * @type {string} + */ + this.id = id; + + /** + * The hash code for this field. Should be equal to the id. + * @type {string} + * @private + */ + this.hashCode_ = id; + + /** + * Dom helper for the editable node. + * @type {?goog.dom.DomHelper} + * @protected + */ + this.editableDomHelper = null; + + /** + * Map of class id to registered plugin. + * @type {Object} + * @private + */ + this.plugins_ = {}; + + + /** + * Plugins registered on this field, indexed by the goog.editor.PluginImpl.Op + * that they support. + * @type {!Object>} + * @private + */ + this.indexedPlugins_ = {}; + + for (var op in goog.editor.PluginImpl.OPCODE) { + this.indexedPlugins_[op] = []; + } + + + /** + * Additional styles to install for the editable field. + * @type {!goog.html.SafeStyleSheet} + * @protected + */ + this.cssStyles = goog.html.SafeStyleSheet.EMPTY; + + // The field will not listen to change events until it has finished loading + /** @private */ + this.stoppedEvents_ = {}; + this.stopEvent(goog.editor.Field.EventType.CHANGE); + this.stopEvent(goog.editor.Field.EventType.DELAYEDCHANGE); + /** @private */ + this.isModified_ = false; + /** @private */ + this.isEverModified_ = false; + /** @private */ + this.delayedChangeTimer_ = new goog.async.Delay( + this.dispatchDelayedChange_, goog.editor.Field.DELAYED_CHANGE_FREQUENCY, + this); + this.registerDisposable(this.delayedChangeTimer_); + + /** @private */ + this.debouncedEvents_ = {}; + for (var key in goog.editor.Field.EventType) { + this.debouncedEvents_[goog.editor.Field.EventType[key]] = 0; + } + + /** + * @type {goog.events.EventHandler} + * @protected + */ + this.eventRegister = new goog.events.EventHandler(this); + + // Wrappers around this field, to be disposed when the field is disposed. + /** @private */ + this.wrappers_ = []; + + /** @private */ + this.loadState_ = goog.editor.Field.LoadState_.UNEDITABLE; + + var doc = opt_doc || document; + + /** + * The dom helper for the node to be made editable. + * @type {goog.dom.DomHelper} + * @protected + */ + this.originalDomHelper = goog.dom.getDomHelper(doc); + + /** + * The original node that is being made editable, or null if it has + * not yet been found. + * @type {Element} + * @protected + */ + this.originalElement = this.originalDomHelper.getElement(this.id); + + /** + * @private {boolean} + */ + this.followLinkInNewWindow_ = + goog.editor.BrowserFeature.FOLLOWS_EDITABLE_LINKS; + + // Default to the same window as the field is in. + /** @private */ + this.appWindow_ = this.originalDomHelper.getWindow(); +}; +goog.inherits(goog.editor.Field, goog.events.EventTarget); + + +/** + * The editable dom node. + * @type {?Element} + * TODO(user): Make this private! + */ +goog.editor.Field.prototype.field = null; + + +/** + * Logging object. + * @type {goog.log.Logger} + * @protected + */ +goog.editor.Field.prototype.logger = goog.log.getLogger('goog.editor.Field'); + + +/** + * Event types that can be stopped/started. + * @enum {string} + */ +goog.editor.Field.EventType = { + /** + * Dispatched when the command state of the selection may have changed. This + * event should be listened to for updating toolbar state. + */ + COMMAND_VALUE_CHANGE: 'cvc', + /** + * Dispatched when the field is loaded and ready to use. + */ + LOAD: 'load', + /** + * Dispatched when the field is fully unloaded and uneditable. + */ + UNLOAD: 'unload', + /** + * Dispatched before the field contents are changed. + */ + BEFORECHANGE: 'beforechange', + /** + * Dispatched when the field contents change, in FF only. + * Used for internal resizing, please do not use. + */ + CHANGE: 'change', + /** + * Dispatched on a slight delay after changes are made. + * Use for autosave, or other times your app needs to know + * that the field contents changed. + */ + DELAYEDCHANGE: 'delayedchange', + /** + * Dispatched before focus in moved into the field. + */ + BEFOREFOCUS: 'beforefocus', + /** + * Dispatched when focus is moved into the field. + */ + FOCUS: 'focus', + /** + * Dispatched when the field is blurred. + */ + BLUR: 'blur', + /** + * Dispatched before tab is handled by the field. This is a legacy way + * of controlling tab behavior. Use trog.plugins.AbstractTabHandler now. + */ + BEFORETAB: 'beforetab', + /** + * Dispatched after the iframe containing the field is resized, so that UI + * components which contain it can respond. + */ + IFRAME_RESIZED: 'ifrsz', + /** + * Dispatched after a user action that will eventually fire a SELECTIONCHANGE + * event. For mouseups, this is fired immediately before SELECTIONCHANGE, + * since {@link #handleMouseUp_} fires SELECTIONCHANGE immediately. May be + * fired up to {@link #SELECTION_CHANGE_FREQUENCY_} ms before SELECTIONCHANGE + * is fired in the case of keyup events, since they use + * {@link #selectionChangeTimer_}. + */ + BEFORESELECTIONCHANGE: 'beforeselectionchange', + /** + * Dispatched when the selection changes. + * Use handleSelectionChange from plugin API instead of listening + * directly to this event. + */ + SELECTIONCHANGE: 'selectionchange' +}; + + +/** + * The load state of the field. + * @enum {number} + * @private + */ +goog.editor.Field.LoadState_ = { + UNEDITABLE: 0, + LOADING: 1, + EDITABLE: 2 +}; + + +/** + * The amount of time that a debounce blocks an event. + * TODO(nicksantos): As of 9/30/07, this is only used for blocking + * a keyup event after a keydown. We might need to tweak this for other + * types of events. Maybe have a per-event debounce time? + * @type {number} + * @private + */ +goog.editor.Field.DEBOUNCE_TIME_MS_ = 500; + + +/** + * There is at most one "active" field at a time. By "active" field, we mean + * a field that has focus and is being used. + * @type {?string} + * @private + */ +goog.editor.Field.activeFieldId_ = null; + + +/** + * Whether this field is in "modal interaction" mode. This usually + * means that it's being edited by a dialog. + * @type {boolean} + * @private + */ +goog.editor.Field.prototype.inModalMode_ = false; + + +/** + * The window where dialogs and bubbles should be rendered. + * @type {!Window} + * @private + */ +goog.editor.Field.prototype.appWindow_; + + +/** @private {?goog.async.Delay} */ +goog.editor.Field.prototype.selectionChangeTimer_ = null; + +/** @private {boolean} */ +goog.editor.Field.prototype.isSelectionEditable_ = false; + + +/** + * Target node to be used when dispatching SELECTIONCHANGE asynchronously on + * mouseup (to avoid IE quirk). Should be set just before starting the timer and + * nulled right after consuming. + * @type {Node} + * @private + */ +goog.editor.Field.prototype.selectionChangeTarget_; + + +/** + * Flag controlling whether to capture mouse up events on the window or not. + * @type {boolean} + * @private + */ +goog.editor.Field.prototype.useWindowMouseUp_ = false; + + +/** + * FLag indicating the handling of a mouse event sequence. + * @type {boolean} + * @private + */ +goog.editor.Field.prototype.waitingForMouseUp_ = false; + + +/** + * Sets the active field id. + * @param {?string} fieldId The active field id. + */ +goog.editor.Field.setActiveFieldId = function(fieldId) { + 'use strict'; + goog.editor.Field.activeFieldId_ = fieldId; +}; + + +/** + * @return {?string} The id of the active field. + */ +goog.editor.Field.getActiveFieldId = function() { + 'use strict'; + return goog.editor.Field.activeFieldId_; +}; + + +/** + * Sets flag to control whether to use window mouse up after seeing + * a mouse down operation on the field. + * @param {boolean} flag True to track window mouse up. + */ +goog.editor.Field.prototype.setUseWindowMouseUp = function(flag) { + 'use strict'; + goog.asserts.assert( + !flag || !this.usesIframe(), + 'procssing window mouse up should only be enabled when not using iframe'); + this.useWindowMouseUp_ = flag; +}; + + +/** + * @return {boolean} Whether we're in modal interaction mode. When this + * returns true, another plugin is interacting with the field contents + * in a synchronous way, and expects you not to make changes to + * the field's DOM structure or selection. + */ +goog.editor.Field.prototype.inModalMode = function() { + 'use strict'; + return this.inModalMode_; +}; + + +/** + * @param {boolean} inModalMode Sets whether we're in modal interaction mode. + */ +goog.editor.Field.prototype.setModalMode = function(inModalMode) { + 'use strict'; + this.inModalMode_ = inModalMode; +}; + + +/** + * Returns a string usable as a hash code for this field. For field's + * that were created with an id, the hash code is guaranteed to be the id. + * TODO(user): I think we can get rid of this. Seems only used from editor. + * @return {string} The hash code for this editable field. + */ +goog.editor.Field.prototype.getHashCode = function() { + 'use strict'; + return this.hashCode_; +}; + + +/** + * Returns the editable DOM element or null if this field + * is not editable. + *

    On IE or Safari this is the element with contentEditable=true + * (in whitebox mode, the iFrame body). + *

    On Gecko this is the iFrame body + * TODO(user): How do we word this for subclass version? + * @return {Element} The editable DOM element, defined as above. + */ +goog.editor.Field.prototype.getElement = function() { + 'use strict'; + return this.field; +}; + + +/** + * Returns original DOM element that is being made editable by Trogedit or + * null if that element has not yet been found in the appropriate document. + * @return {Element} The original element. + */ +goog.editor.Field.prototype.getOriginalElement = function() { + 'use strict'; + return this.originalElement; +}; + + +/** + * Registers a keyboard event listener on the field. This is necessary for + * Gecko since the fields are contained in an iFrame and there is no way to + * auto-propagate key events up to the main window. + * @param {string|Array} type Event type to listen for or array of + * event types, for example goog.events.EventType.KEYDOWN. + * @param {Function} listener Function to be used as the listener. + * @param {boolean=} opt_capture Whether to use capture phase (optional, + * defaults to false). + * @param {Object=} opt_handler Object in whose scope to call the listener. + */ +goog.editor.Field.prototype.addListener = function( + type, listener, opt_capture, opt_handler) { + 'use strict'; + var elem = this.getElement(); + // On Gecko, keyboard events only reliably fire on the document element when + // using an iframe. + if (goog.editor.BrowserFeature.USE_DOCUMENT_FOR_KEY_EVENTS && elem && + this.usesIframe()) { + elem = elem.ownerDocument; + } + if (opt_handler) { + this.eventRegister.listenWithScope( + elem, type, listener, opt_capture, opt_handler); + } else { + this.eventRegister.listen(elem, type, listener, opt_capture); + } +}; + + +/** + * Returns the registered plugin with the given classId. + * @param {string} classId classId of the plugin. + * @return {?goog.editor.PluginImpl} Registered plugin with the given classId. + */ +goog.editor.Field.prototype.getPluginByClassId = function(classId) { + 'use strict'; + return this.plugins_[classId] || null; +}; + + +/** + * Registers the plugin with the editable field. + * @param {!goog.editor.PluginImpl} plugin The plugin to register. + */ +goog.editor.Field.prototype.registerPlugin = function(plugin) { + 'use strict'; + var classId = plugin.getTrogClassId(); + if (this.plugins_[classId]) { + goog.log.error( + this.logger, 'Cannot register the same class of plugin twice.'); + } + this.plugins_[classId] = plugin; + + // Only key events and execute should have these has* functions with a custom + // handler array since they need to be very careful about performance. + // The rest of the plugin hooks should be event-based. + for (var op in goog.editor.PluginImpl.OPCODE) { + var opcode = goog.editor.PluginImpl.OPCODE[op]; + if (plugin[opcode]) { + this.indexedPlugins_[op].push(plugin); + } + } + plugin.registerFieldObject(this); + + // By default we enable all plugins for fields that are currently loaded. + if (this.isLoaded()) { + plugin.enable(this); + } +}; + + +/** + * Unregisters the plugin with this field. + * @param {?goog.editor.PluginImpl} plugin The plugin to unregister. + */ +goog.editor.Field.prototype.unregisterPlugin = function(plugin) { + 'use strict'; + if (!plugin) { + return; + } + + var classId = plugin.getTrogClassId(); + if (!this.plugins_[classId]) { + goog.log.error( + this.logger, 'Cannot unregister a plugin that isn\'t registered.'); + } + delete this.plugins_[classId]; + + for (var op in goog.editor.PluginImpl.OPCODE) { + var opcode = goog.editor.PluginImpl.OPCODE[op]; + if (plugin[opcode]) { + goog.array.remove(this.indexedPlugins_[op], plugin); + } + } + + plugin.unregisterFieldObject(this); +}; + + +/** + * Sets the value that will replace the style attribute of this field's + * element when the field is made non-editable. This method is called with the + * current value of the style attribute when the field is made editable. + * @param {string} cssText The value of the style attribute. + */ +goog.editor.Field.prototype.setInitialStyle = function(cssText) { + 'use strict'; + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + this.cssText = cssText; +}; + + +/** + * Reset the properties on the original field element to how it was before + * it was made editable. + */ +goog.editor.Field.prototype.resetOriginalElemProperties = function() { + 'use strict'; + var field = this.getOriginalElement(); + field.removeAttribute('contentEditable'); + field.removeAttribute('g_editable'); + field.removeAttribute('role'); + + if (!this.id) { + field.removeAttribute('id'); + } else { + field.id = this.id; + } + + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + field.className = this.savedClassName_ || ''; + + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + var cssText = this.cssText; + if (!cssText) { + field.removeAttribute('style'); + } else { + goog.dom.setProperties(field, {'style': cssText}); + } + + if (typeof (this.originalFieldLineHeight_) === 'string') { + goog.style.setStyle(field, 'lineHeight', this.originalFieldLineHeight_); + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + this.originalFieldLineHeight_ = null; + } +}; + + +/** + * Checks the modified state of the field. + * Note: Changes that take place while the goog.editor.Field.EventType.CHANGE + * event is stopped do not effect the modified state. + * @param {boolean=} opt_useIsEverModified Set to true to check if the field + * has ever been modified since it was created, otherwise checks if the field + * has been modified since the last goog.editor.Field.EventType.DELAYEDCHANGE + * event was dispatched. + * @return {boolean} Whether the field has been modified. + */ +goog.editor.Field.prototype.isModified = function(opt_useIsEverModified) { + 'use strict'; + return opt_useIsEverModified ? this.isEverModified_ : this.isModified_; +}; + + +/** + * Number of milliseconds after a change when the change event should be fired. + * @type {number} + */ +goog.editor.Field.CHANGE_FREQUENCY = 15; + + +/** + * Number of milliseconds between delayed change events. + * @type {number} + */ +goog.editor.Field.DELAYED_CHANGE_FREQUENCY = 250; + + +/** + * @return {boolean} Whether the field is implemented as an iframe. + */ +goog.editor.Field.prototype.usesIframe = goog.functions.TRUE; + + +/** + * @return {boolean} Whether the field should be rendered with a fixed + * height, or should expand to fit its contents. + */ +goog.editor.Field.prototype.isFixedHeight = goog.functions.TRUE; + + +/** + * Map of keyCodes (not charCodes) that cause changes in the field contents. + * @type {Object} + * @private + */ +goog.editor.Field.KEYS_CAUSING_CHANGES_ = { + 46: true, // DEL + 8: true // BACKSPACE +}; + +if (!goog.userAgent.IE) { + // Only IE doesn't change the field by default upon tab. + // TODO(user): This really isn't right now that we have tab plugins. + goog.editor.Field.KEYS_CAUSING_CHANGES_[9] = true; // TAB +} + + +/** + * Map of keyCodes (not charCodes) that when used in conjunction with the + * Ctrl key cause changes in the field contents. These are the keys that are + * not handled by basic formatting trogedit plugins. + * @type {Object} + * @private + */ +goog.editor.Field.CTRL_KEYS_CAUSING_CHANGES_ = { + 86: true, // V + 88: true // X +}; + +if ((goog.userAgent.WINDOWS || goog.labs.userAgent.platform.isAndroid()) && + !goog.userAgent.GECKO) { + // In IE and Webkit, input from IME (Input Method Editor) does not generate a + // keypress event so we have to rely on the keydown event. This way we have + // false positives while the user is using keyboard to select the + // character to input, but it is still better than the false negatives + // that ignores user's final input at all. + // The same phenomina happen on android devices - no KeyPress events are + // emitted, and all KeyDown events have no useful charCode or other + // identifying information (see + // https://bugs.chromium.org/p/chromium/issues/detail?id=118639 for + // background, but it's considered WAI by various Input Method experts). + goog.editor.Field.KEYS_CAUSING_CHANGES_[229] = true; // from IME; +} + + +/** + * Returns true if the keypress generates a change in contents. + * @param {goog.events.BrowserEvent} e The event. + * @param {boolean} testAllKeys True to test for all types of generating keys. + * False to test for only the keys found in + * goog.editor.Field.KEYS_CAUSING_CHANGES_. + * @return {boolean} Whether the keypress generates a change in contents. + * @private + */ +goog.editor.Field.isGeneratingKey_ = function(e, testAllKeys) { + 'use strict'; + if (goog.editor.Field.isSpecialGeneratingKey_(e)) { + return true; + } + + return !!( + testAllKeys && !(e.ctrlKey || e.metaKey) && + (!goog.userAgent.GECKO || e.charCode)); +}; + + +/** + * Returns true if the keypress generates a change in the contents. + * due to a special key listed in goog.editor.Field.KEYS_CAUSING_CHANGES_ + * @param {goog.events.BrowserEvent} e The event. + * @return {boolean} Whether the keypress generated a change in the contents. + * @private + */ +goog.editor.Field.isSpecialGeneratingKey_ = function(e) { + 'use strict'; + var testCtrlKeys = (e.ctrlKey || e.metaKey) && + e.keyCode in goog.editor.Field.CTRL_KEYS_CAUSING_CHANGES_; + var testRegularKeys = !(e.ctrlKey || e.metaKey) && + e.keyCode in goog.editor.Field.KEYS_CAUSING_CHANGES_; + + return testCtrlKeys || testRegularKeys; +}; + + +/** + * Sets the application window. + * @param {!Window} appWindow The window where dialogs and bubbles should be + * rendered. + */ +goog.editor.Field.prototype.setAppWindow = function(appWindow) { + 'use strict'; + this.appWindow_ = appWindow; +}; + + +/** + * Returns the "application" window, where dialogs and bubbles + * should be rendered. + * @return {!Window} The window. + */ +goog.editor.Field.prototype.getAppWindow = function() { + 'use strict'; + return this.appWindow_; +}; + + +/** + * Sets the zIndex that the field should be based off of. + * TODO(user): Get rid of this completely. Here for Sites. + * Should this be set directly on UI plugins? + * + * @param {number} zindex The base zIndex of the editor. + */ +goog.editor.Field.prototype.setBaseZindex = function(zindex) { + 'use strict'; + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + this.baseZindex_ = zindex; +}; + + +/** + * Returns the zindex of the base level of the field. + * + * @return {number} The base zindex of the editor. + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.Field.prototype.getBaseZindex = function() { + 'use strict'; + return this.baseZindex_ || 0; +}; + + +/** + * Sets up the field object and window util of this field, and enables this + * editable field with all registered plugins. + * This is essential to the initialization of the field. + * It must be called when the field becomes fully loaded and editable. + * @param {Element} field The field property. + * @protected + */ +goog.editor.Field.prototype.setupFieldObject = function(field) { + 'use strict'; + this.loadState_ = goog.editor.Field.LoadState_.EDITABLE; + this.field = field; + this.editableDomHelper = goog.dom.getDomHelper(field); + this.isModified_ = false; + this.isEverModified_ = false; + field.setAttribute('g_editable', 'true'); + goog.a11y.aria.setRole(field, goog.a11y.aria.Role.TEXTBOX); +}; + + +/** + * Help make the field not editable by setting internal data structures to null, + * and disabling this field with all registered plugins. + * @private + */ +goog.editor.Field.prototype.tearDownFieldObject_ = function() { + 'use strict'; + this.loadState_ = goog.editor.Field.LoadState_.UNEDITABLE; + + for (var classId in this.plugins_) { + var plugin = this.plugins_[classId]; + if (!plugin.activeOnUneditableFields()) { + plugin.disable(this); + } + } + + this.field = null; + this.editableDomHelper = null; +}; + + +/** + * Initialize listeners on the field. + * @private + */ +goog.editor.Field.prototype.setupChangeListeners_ = function() { + 'use strict'; + + + if (goog.editor.BrowserFeature.SUPPORTS_FOCUSIN) { + this.addListener(goog.events.EventType.FOCUS, this.dispatchFocus_); + this.addListener(goog.events.EventType.FOCUSIN, this.dispatchBeforeFocus_); + } else { + this.addListener( + goog.events.EventType.FOCUS, this.dispatchFocusAndBeforeFocus_); + } + this.addListener(goog.events.EventType.BLUR, this.dispatchBlur); + + // Ways to detect that a change is about to happen in other browsers. (IE and + // Safari have these events. Opera appears to work, but we haven't researched + // it.) + // + // onbeforepaste + // onbeforecut + // ondrop - happens when the user drops something on the editable text field + // the value at this time does not contain the dropped text + // ondragleave - when the user drags something from the current document. This + // might not cause a change if the action was copy instead of + // move + // onkeypress - IE only fires keypress events if the key will generate output. + // It will not trigger for delete and backspace + // onkeydown - For delete and backspace + // + // known issues: IE triggers beforepaste just by opening the edit menu delete + // at the end should not cause beforechange backspace at the + // beginning should not cause beforechange see above in + // ondragleave + // TODO(user): Why don't we dispatchBeforeChange from the handleDrop event + // for all browsers? + this.addListener( + ['beforecut', 'beforepaste', 'drop', 'dragend'], + this.dispatchBeforeChange); + this.addListener(['cut', 'paste'], goog.functions.lock(this.dispatchChange)); + this.addListener('drop', this.handleDrop_); + + // TODO(user): Figure out why we use dragend vs dragdrop and + // document this better. + var dropEventName = goog.userAgent.WEBKIT ? 'dragend' : 'dragdrop'; + this.addListener(dropEventName, this.handleDrop_); + + this.addListener(goog.events.EventType.KEYDOWN, this.handleKeyDown_); + this.addListener(goog.events.EventType.KEYPRESS, this.handleKeyPress_); + this.addListener(goog.events.EventType.KEYUP, this.handleKeyUp_); + + // Handles changes from non-keyboard forms of input. Such as choosing a + // spellcheck suggestion. + this.addListener(goog.events.EventType.INPUT, this.handleChange); + + this.selectionChangeTimer_ = new goog.async.Delay( + this.handleSelectionChangeTimer_, + goog.editor.Field.SELECTION_CHANGE_FREQUENCY_, this); + this.registerDisposable(this.selectionChangeTimer_); + + if (this.followLinkInNewWindow_) { + this.addListener( + goog.events.EventType.CLICK, goog.editor.Field.cancelLinkClick_); + } + + this.addListener(goog.events.EventType.MOUSEDOWN, this.handleMouseDown_); + if (this.useWindowMouseUp_) { + this.eventRegister.listen( + this.editableDomHelper.getDocument(), goog.events.EventType.MOUSEUP, + this.handleMouseUp_); + this.addListener(goog.events.EventType.DRAGSTART, this.handleDragStart_); + } else { + this.addListener(goog.events.EventType.MOUSEUP, this.handleMouseUp_); + } +}; + + +/** + * Frequency to check for selection changes. + * @type {number} + * @private + */ +goog.editor.Field.SELECTION_CHANGE_FREQUENCY_ = 250; + + +/** + * Stops all listeners and timers. + * @protected + */ +goog.editor.Field.prototype.clearListeners = function() { + 'use strict'; + if (this.eventRegister) { + this.eventRegister.removeAll(); + } + + this.delayedChangeTimer_.stop(); +}; + + +/** @override */ +goog.editor.Field.prototype.disposeInternal = function() { + 'use strict'; + if (this.isLoading() || this.isLoaded()) { + goog.log.warning(this.logger, 'Disposing a field that is in use.'); + } + + if (this.getOriginalElement()) { + this.execCommand(goog.editor.Command.CLEAR_LOREM); + } + + this.tearDownFieldObject_(); + this.clearListeners(); + this.clearFieldLoadListener_(); + this.originalDomHelper = null; + + if (this.eventRegister) { + this.eventRegister.dispose(); + this.eventRegister = null; + } + + this.removeAllWrappers(); + + if (goog.editor.Field.getActiveFieldId() == this.id) { + goog.editor.Field.setActiveFieldId(null); + } + + for (var classId in this.plugins_) { + var plugin = this.plugins_[classId]; + if (plugin.isAutoDispose()) { + plugin.dispose(); + } + } + delete (this.plugins_); + + goog.editor.Field.superClass_.disposeInternal.call(this); +}; + + +/** + * Attach an wrapper to this field, to be thrown out when the field + * is disposed. + * @param {goog.Disposable} wrapper The wrapper to attach. + */ +goog.editor.Field.prototype.attachWrapper = function(wrapper) { + 'use strict'; + this.wrappers_.push(wrapper); +}; + + +/** + * Removes all wrappers and destroys them. + */ +goog.editor.Field.prototype.removeAllWrappers = function() { + 'use strict'; + var wrapper; + while (wrapper = this.wrappers_.pop()) { + wrapper.dispose(); + } +}; + + +/** + * Sets whether activating a hyperlink in this editable field will open a new + * window or not. + * @param {boolean} followLinkInNewWindow + */ +goog.editor.Field.prototype.setFollowLinkInNewWindow = function( + followLinkInNewWindow) { + 'use strict'; + this.followLinkInNewWindow_ = followLinkInNewWindow; +}; + + +/** + * Handle before change key events and fire the beforetab event if appropriate. + * This needs to happen on keydown in IE and keypress in FF. + * @param {goog.events.BrowserEvent} e The browser event. + * @return {boolean} Whether to still perform the default key action. Only set + * to true if the actual event has already been canceled. + * @private + */ +goog.editor.Field.prototype.handleBeforeChangeKeyEvent_ = function(e) { + 'use strict'; + // There are two reasons to block a key: + var block = + // #1: to intercept a tab + // TODO: possibly don't allow clients to intercept tabs outside of LIs and + // maybe tables as well? + (e.keyCode == goog.events.KeyCodes.TAB && !this.dispatchBeforeTab_(e)) || + // #2: to block a Firefox-specific bug where Macs try to navigate + // back a page when you hit command+left arrow or comamnd-right arrow. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=341886 + // This was fixed in Firefox 29, but still exists in older versions. + (goog.userAgent.GECKO && e.metaKey && + !goog.userAgent.isVersionOrHigher(29) && + (e.keyCode == goog.events.KeyCodes.LEFT || + e.keyCode == goog.events.KeyCodes.RIGHT)); + + if (block) { + e.preventDefault(); + return false; + } else { + // In Gecko we have both keyCode and charCode. charCode is for human + // readable characters like a, b and c. However pressing ctrl+c and so on + // also causes charCode to be set. + + // TODO(arv): Del at end of field or backspace at beginning should be + // ignored. + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + this.gotGeneratingKey_ = !!e.charCode || + goog.editor.Field.isGeneratingKey_(e, goog.userAgent.GECKO); + if (this.gotGeneratingKey_) { + this.dispatchBeforeChange(); + // TODO(robbyw): Should we return the value of the above? + } + } + + return true; +}; + + +/** + * Keycodes that result in a selectionchange event (e.g. the cursor moving). + * @type {!Object} + */ +goog.editor.Field.SELECTION_CHANGE_KEYCODES = { + 8: 1, // backspace + 9: 1, // tab + 13: 1, // enter + 33: 1, // page up + 34: 1, // page down + 35: 1, // end + 36: 1, // home + 37: 1, // left + 38: 1, // up + 39: 1, // right + 40: 1, // down + 46: 1 // delete +}; + + +/** + * Map of keyCodes (not charCodes) that when used in conjunction with the + * Ctrl key cause selection changes in the field contents. These are the keys + * that are not handled by the basic formatting trogedit plugins. Note that + * combinations like Ctrl-left etc are already handled in + * SELECTION_CHANGE_KEYCODES + * @type {Object} + * @private + */ +goog.editor.Field.CTRL_KEYS_CAUSING_SELECTION_CHANGES_ = { + 65: true, // A + 86: true, // V + 88: true // X +}; + + +/** + * Map of keyCodes (not charCodes) that might need to be handled as a keyboard + * shortcut (even when ctrl/meta key is not pressed) by some plugin. Currently + * it is a small list. If it grows too big we can optimize it by using ranges + * or extending it from SELECTION_CHANGE_KEYCODES + * @type {Object} + * @private + */ +goog.editor.Field.POTENTIAL_SHORTCUT_KEYCODES_ = { + 8: 1, // backspace + 9: 1, // tab + 13: 1, // enter + 27: 1, // esc + 33: 1, // page up + 34: 1, // page down + 37: 1, // left + 38: 1, // up + 39: 1, // right + 40: 1 // down +}; + + +/** + * Calls all the plugins of the given operation, in sequence, with the + * given arguments. This is short-circuiting: once one plugin cancels + * the event, no more plugins will be invoked. + * @param {goog.editor.PluginImpl.Op} op A plugin op. + * @param {...*} var_args The arguments to the plugin. + * @return {boolean} True if one of the plugins cancel the event, false + * otherwise. + * @private + */ +goog.editor.Field.prototype.invokeShortCircuitingOp_ = function(op, var_args) { + 'use strict'; + var plugins = this.indexedPlugins_[op]; + var argList = Array.prototype.slice.call(arguments, 1); + for (var i = 0; i < plugins.length; ++i) { + // If the plugin returns true, that means it handled the event and + // we shouldn't propagate to the other plugins. + var plugin = plugins[i]; + if ((plugin.isEnabled(this) || + goog.editor.PluginImpl.IRREPRESSIBLE_OPS[op]) && + plugin[goog.editor.PluginImpl.OPCODE[op]].apply(plugin, argList)) { + // Only one plugin is allowed to handle the event. If for some reason + // a plugin wants to handle it and still allow other plugins to handle + // it, it shouldn't return true. + return true; + } + } + + return false; +}; + + +/** + * Invoke this operation on all plugins with the given arguments. + * @param {!goog.editor.PluginImpl.Op} op A plugin op. + * @param {...*} var_args The arguments to the plugin. + * @private + */ +goog.editor.Field.prototype.invokeOp_ = function(op, var_args) { + 'use strict'; + var plugins = this.indexedPlugins_[op]; + var argList = Array.prototype.slice.call(arguments, 1); + for (var i = 0; i < plugins.length; ++i) { + var plugin = plugins[i]; + if (plugin.isEnabled(this) || + goog.editor.PluginImpl.IRREPRESSIBLE_OPS[op]) { + plugin[goog.editor.PluginImpl.OPCODE[op]].apply(plugin, argList); + } + } +}; + + +/** + * Reduce this argument over all plugins. The result of each plugin invocation + * will be passed to the next plugin invocation. See goog.array.reduce. + * @param {goog.editor.PluginImpl.Op} op A plugin op. + * @param {string} arg The argument to reduce. For now, we assume it's a + * string, but we should widen this later if there are reducing + * plugins that don't operate on strings. + * @param {...*} var_args Any extra arguments to pass to the plugin. These args + * will not be reduced. + * @return {string} The reduced argument. + * @private + */ +goog.editor.Field.prototype.reduceOp_ = function(op, arg, var_args) { + 'use strict'; + var plugins = this.indexedPlugins_[op]; + var argList = Array.prototype.slice.call(arguments, 1); + for (var i = 0; i < plugins.length; ++i) { + var plugin = plugins[i]; + if (plugin.isEnabled(this) || + goog.editor.PluginImpl.IRREPRESSIBLE_OPS[op]) { + argList[0] = + plugin[goog.editor.PluginImpl.OPCODE[op]].apply(plugin, argList); + } + } + return argList[0]; +}; + + +/** + * Prepare the given contents, then inject them into the editable field. + * @param {?string} contents The contents to prepare. + * @param {Element} field The field element. + * @protected + */ +goog.editor.Field.prototype.injectContents = function(contents, field) { + 'use strict'; + var styles = {}; + var newHtml = this.getInjectableContents(contents, styles); + goog.style.setStyle(field, styles); + goog.editor.node.replaceInnerHtml(field, newHtml); +}; + + +/** + * Returns prepared contents that can be injected into the editable field. + * @param {?string} contents The contents to prepare. + * @param {Object} styles A map that will be populated with styles that should + * be applied to the field element together with the contents. + * @return {string} The prepared contents. + */ +goog.editor.Field.prototype.getInjectableContents = function(contents, styles) { + 'use strict'; + return this.reduceOp_( + goog.editor.PluginImpl.Op.PREPARE_CONTENTS_HTML, contents || '', styles); +}; + + +/** + * Handles keydown on the field. + * @param {goog.events.BrowserEvent} e The browser event. + * @private + */ +goog.editor.Field.prototype.handleKeyDown_ = function(e) { + 'use strict'; + // Mac only fires Cmd+A for keydown, not keyup: b/22407515. + if (goog.userAgent.MAC && e.keyCode == goog.events.KeyCodes.A) { + this.maybeStartSelectionChangeTimer_(e); + } + + if (!this.handleBeforeChangeKeyEvent_(e)) { + return; + } + + if (!this.invokeShortCircuitingOp_(goog.editor.PluginImpl.Op.KEYDOWN, e) && + goog.editor.BrowserFeature.USES_KEYDOWN) { + this.handleKeyboardShortcut_(e); + } +}; + + +/** + * Handles keypress on the field. + * @param {goog.events.BrowserEvent} e The browser event. + * @private + */ +goog.editor.Field.prototype.handleKeyPress_ = function(e) { + 'use strict'; + // In IE only keys that generate output trigger keypress + // In Mozilla charCode is set for keys generating content. + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + this.gotGeneratingKey_ = true; + this.dispatchBeforeChange(); + + if (!this.invokeShortCircuitingOp_(goog.editor.PluginImpl.Op.KEYPRESS, e) && + !goog.editor.BrowserFeature.USES_KEYDOWN) { + this.handleKeyboardShortcut_(e); + } +}; + + +/** + * Handles keyup on the field. + * @param {!goog.events.BrowserEvent} e The browser event. + * @private + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.Field.prototype.handleKeyUp_ = function(e) { + 'use strict'; + if (this.gotGeneratingKey_ || goog.editor.Field.isSpecialGeneratingKey_(e)) { + // The special keys won't have set the gotGeneratingKey flag, so we check + // for them explicitly + this.handleChange(); + } + + this.invokeShortCircuitingOp_(goog.editor.PluginImpl.Op.KEYUP, e); + this.maybeStartSelectionChangeTimer_(e); +}; + + +/** + * Fires `BEFORESELECTIONCHANGE` and starts the selection change timer + * (which will fire `SELECTIONCHANGE`) if the given event is a key event + * that causes a selection change. + * @param {!goog.events.BrowserEvent} e The browser event. + * @private + */ +goog.editor.Field.prototype.maybeStartSelectionChangeTimer_ = function(e) { + 'use strict'; + if (this.isEventStopped(goog.editor.Field.EventType.SELECTIONCHANGE)) { + return; + } + + if (goog.editor.Field.SELECTION_CHANGE_KEYCODES[e.keyCode] || + ((e.ctrlKey || e.metaKey) && + goog.editor.Field.CTRL_KEYS_CAUSING_SELECTION_CHANGES_[e.keyCode])) { + this.dispatchEvent(goog.editor.Field.EventType.BEFORESELECTIONCHANGE); + this.selectionChangeTimer_.start(); + } +}; + + +/** + * Handles keyboard shortcuts on the field. Note that we bake this into our + * handleKeyPress/handleKeyDown rather than using goog.events.KeyHandler or + * goog.ui.KeyboardShortcutHandler for performance reasons. Since these + * are handled on every key stroke, we do not want to be going out to the + * event system every time. + * @param {goog.events.BrowserEvent} e The browser event. + * @private + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.Field.prototype.handleKeyboardShortcut_ = function(e) { + 'use strict'; + // Alt key is used for i18n languages to enter certain characters. like + // control + alt + z (used for IMEs) and control + alt + s for Polish. + // So we only invoke handleKeyboardShortcut for alt + shift only. + if (e.altKey && !e.shiftKey) { + return; + } + // TODO(user): goog.events.KeyHandler uses much more complicated logic + // to determine key. Consider changing to what they do. + var key = e.charCode || e.keyCode; + var stringKey = String.fromCharCode(key).toLowerCase(); + var isPrimaryModifierPressed = goog.userAgent.MAC ? e.metaKey : e.ctrlKey; + var isAltShiftPressed = e.altKey && e.shiftKey; + if (isPrimaryModifierPressed || isAltShiftPressed || + goog.editor.Field.POTENTIAL_SHORTCUT_KEYCODES_[e.keyCode]) { + if (key == 17) { // Ctrl key + // In IE and Webkit pressing Ctrl key itself results in this event. + return; + } + + // Ctrl+Cmd+Space generates a charCode for a backtick on Mac Firefox, but + // has the correct string key in the browser event. + if (goog.userAgent.MAC && goog.userAgent.GECKO && stringKey == '`' && + e.getBrowserEvent().key == ' ') { + stringKey = ' '; + } + // Converting the keyCode for "\" using fromCharCode creates "u", so we need + // to look out for it specifically. + if (e.keyCode == goog.events.KeyCodes.BACKSLASH) { + stringKey = '\\'; + } + + if (this.invokeShortCircuitingOp_( + goog.editor.PluginImpl.Op.SHORTCUT, e, stringKey, + isPrimaryModifierPressed)) { + e.preventDefault(); + // We don't call stopPropagation as some other handler outside of + // trogedit might need it. + } + } +}; + + +/** + * Executes an editing command as per the registered plugins. + * @param {string} command The command to execute. + * @param {...*} var_args Any additional parameters needed to execute the + * command. + * @return {*} False if the command wasn't handled, otherwise, the result of + * the command. + */ +goog.editor.Field.prototype.execCommand = function(command, var_args) { + 'use strict'; + var args = arguments; + var result; + + var plugins = this.indexedPlugins_[goog.editor.PluginImpl.Op.EXEC_COMMAND]; + for (var i = 0; i < plugins.length; ++i) { + // If the plugin supports the command, that means it handled the + // event and we shouldn't propagate to the other plugins. + var plugin = plugins[i]; + if (plugin.isEnabled(this) && plugin.isSupportedCommand(command)) { + result = plugin.execCommand.apply(plugin, args); + break; + } + } + + return result; +}; + + +/** + * Gets the value of command(s). + * @param {string|Array} commands String name(s) of the command. + * @return {*} Value of each command. Returns false (or array of falses) + * if designMode is off or the field is otherwise uneditable, and + * there are no activeOnUneditable plugins for the command. + */ +goog.editor.Field.prototype.queryCommandValue = function(commands) { + 'use strict'; + var isEditable = this.isLoaded() && this.isSelectionEditable(); + if (typeof commands === 'string') { + return this.queryCommandValueInternal_(commands, isEditable); + } else { + var state = {}; + for (var i = 0; i < commands.length; i++) { + state[commands[i]] = + this.queryCommandValueInternal_(commands[i], isEditable); + } + return state; + } +}; + + +/** + * Gets the value of this command. + * @param {string} command The command to check. + * @param {boolean} isEditable Whether the field is currently editable. + * @return {*} The state of this command. Null if not handled. + * False if the field is uneditable and there are no handlers for + * uneditable commands. + * @private + */ +goog.editor.Field.prototype.queryCommandValueInternal_ = function( + command, isEditable) { + 'use strict'; + var plugins = this.indexedPlugins_[goog.editor.PluginImpl.Op.QUERY_COMMAND]; + for (var i = 0; i < plugins.length; ++i) { + var plugin = plugins[i]; + if (plugin.isEnabled(this) && plugin.isSupportedCommand(command) && + (isEditable || plugin.activeOnUneditableFields())) { + return plugin.queryCommandValue(command); + } + } + return isEditable ? null : false; +}; + + +/** + * Fires a change event only if the attribute change effects the editiable + * field. We ignore events that are internal browser events (ie scrollbar + * state change) + * @param {Function} handler The function to call if this is not an internal + * browser event. + * @param {goog.events.BrowserEvent} browserEvent The browser event. + * @protected + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.Field.prototype.handleDomAttrChange = function( + handler, browserEvent) { + 'use strict'; + if (this.isEventStopped(goog.editor.Field.EventType.CHANGE)) { + return; + } + + var e = browserEvent.getBrowserEvent(); + + // For XUL elements, since we don't care what they are doing + try { + if (e.originalTarget.prefix || + /** @type {!Element} */ (e.originalTarget).nodeName == 'scrollbar') { + return; + } + } catch (ex1) { + // Some XUL nodes don't like you reading their properties. If we got + // the exception, this implies a XUL node so we can return. + return; + } + + // Check if prev and new values are different, sometimes this fires when + // nothing has really changed. + if (e.prevValue == e.newValue) { + return; + } + handler.call(this, e); +}; + + +/** + * Handle drop events. Deal with focus/selection issues and set the document + * as changed. + * @param {goog.events.BrowserEvent} e The browser event. + * @private + */ +goog.editor.Field.prototype.handleDrop_ = function(e) { + 'use strict'; + if (goog.userAgent.IE) { + // TODO(user): This should really be done in the loremipsum plugin. + this.execCommand(goog.editor.Command.CLEAR_LOREM, true); + } + + this.dispatchChange(); +}; + + +/** + * @return {HTMLIFrameElement} The iframe that's body is editable. + * @protected + */ +goog.editor.Field.prototype.getEditableIframe = function() { + 'use strict'; + var dh; + if (this.usesIframe() && (dh = this.getEditableDomHelper())) { + // If the iframe has been destroyed, the dh could still exist since the + // node may not be gc'ed, but fetching the window can fail. + var win = dh.getWindow(); + return /** @type {HTMLIFrameElement} */ (win && win.frameElement); + } + return null; +}; + + +/** + * @return {goog.dom.DomHelper?} The dom helper for the editable node. + */ +goog.editor.Field.prototype.getEditableDomHelper = function() { + 'use strict'; + return this.editableDomHelper; +}; + + +/** + * @return {goog.dom.AbstractRange?} Closure range object wrapping the selection + * in this field or null if this field is not currently editable. + */ +goog.editor.Field.prototype.getRange = function() { + 'use strict'; + var win = this.editableDomHelper && this.editableDomHelper.getWindow(); + return win && goog.dom.Range.createFromWindow(win); +}; + + +/** + * Dispatch a selection change event, optionally caused by the given browser + * event or selecting the given target. + * @param {goog.events.BrowserEvent=} opt_e Optional browser event causing this + * event. + * @param {Node=} opt_target The node the selection changed to. + */ +goog.editor.Field.prototype.dispatchSelectionChangeEvent = function( + opt_e, opt_target) { + 'use strict'; + if (this.isEventStopped(goog.editor.Field.EventType.SELECTIONCHANGE)) { + return; + } + + // The selection is editable only if the selection is inside the + // editable field. + var range = this.getRange(); + var rangeContainer = range && range.getContainerElement(); + this.isSelectionEditable_ = + !!rangeContainer && goog.dom.contains(this.getElement(), rangeContainer); + + this.dispatchCommandValueChange(); + this.dispatchEvent({ + type: goog.editor.Field.EventType.SELECTIONCHANGE, + originalType: opt_e && opt_e.type + }); + + this.invokeShortCircuitingOp_( + goog.editor.PluginImpl.Op.SELECTION, opt_e, opt_target); +}; + + +/** + * Dispatch a selection change event using a browser event that was + * asynchronously saved earlier. + * @private + */ +goog.editor.Field.prototype.handleSelectionChangeTimer_ = function() { + 'use strict'; + var t = this.selectionChangeTarget_; + this.selectionChangeTarget_ = null; + this.dispatchSelectionChangeEvent(undefined, t); +}; + + +/** + * This dispatches the beforechange event on the editable field + */ +goog.editor.Field.prototype.dispatchBeforeChange = function() { + 'use strict'; + if (this.isEventStopped(goog.editor.Field.EventType.BEFORECHANGE)) { + return; + } + + this.dispatchEvent(goog.editor.Field.EventType.BEFORECHANGE); +}; + + +/** + * This dispatches the beforetab event on the editable field. If this event is + * cancelled, then the default tab behavior is prevented. + * @param {goog.events.BrowserEvent} e The tab event. + * @private + * @return {boolean} The result of dispatchEvent. + */ +goog.editor.Field.prototype.dispatchBeforeTab_ = function(e) { + 'use strict'; + return this.dispatchEvent({ + type: goog.editor.Field.EventType.BEFORETAB, + shiftKey: e.shiftKey, + altKey: e.altKey, + ctrlKey: e.ctrlKey + }); +}; + + +/** + * Temporarily ignore change events. If the time has already been set, it will + * fire immediately now. Further setting of the timer is stopped and + * dispatching of events is stopped until startChangeEvents is called. + * @param {boolean=} opt_stopChange Whether to ignore base change events. + * @param {boolean=} opt_stopDelayedChange Whether to ignore delayed change + * events. + * @param {boolean=} opt_cancelPendingDelayedChange Whether to prevent any + * pending delayed change events from firing when we disable the event. + */ +goog.editor.Field.prototype.stopChangeEvents = function( + opt_stopChange, opt_stopDelayedChange, opt_cancelPendingDelayedChange) { + 'use strict'; + if (opt_stopChange) { + this.stopEvent(goog.editor.Field.EventType.CHANGE); + } + if (opt_stopDelayedChange) { + if (opt_cancelPendingDelayedChange) { + // Stop the delayed change timer without emitting pending events. + this.stopDelayedChange_(); + } else { + // Immediately emit pending delayed change events, which stops the timer. + this.clearDelayedChange(); + } + this.stopEvent(goog.editor.Field.EventType.DELAYEDCHANGE); + } +}; + + +/** + * Start change events again and fire once if desired. + * @param {boolean=} opt_fireChange Whether to fire the change event + * immediately. + * @param {boolean=} opt_fireDelayedChange Whether to fire the delayed change + * event immediately. + */ +goog.editor.Field.prototype.startChangeEvents = function( + opt_fireChange, opt_fireDelayedChange) { + 'use strict'; + this.startEvent(goog.editor.Field.EventType.CHANGE); + this.startEvent(goog.editor.Field.EventType.DELAYEDCHANGE); + if (opt_fireChange) { + this.handleChange(); + } + + if (opt_fireDelayedChange) { + this.dispatchDelayedChange_(); + } +}; + + +/** + * Stops the event of the given type from being dispatched. + * @param {goog.editor.Field.EventType} eventType type of event to stop. + */ +goog.editor.Field.prototype.stopEvent = function(eventType) { + 'use strict'; + this.stoppedEvents_[eventType] = 1; +}; + + +/** + * Re-starts the event of the given type being dispatched, if it had + * previously been stopped with stopEvent(). + * @param {goog.editor.Field.EventType} eventType type of event to start. + */ +goog.editor.Field.prototype.startEvent = function(eventType) { + 'use strict'; + // Toggling this bit on/off instead of deleting it/re-adding it + // saves array allocations. + this.stoppedEvents_[eventType] = 0; +}; + + +/** + * Block an event for a short amount of time. Intended + * for the situation where an event pair fires in quick succession + * (e.g., mousedown/mouseup, keydown/keyup, focus/blur), + * and we want the second event in the pair to get "debounced." + * + * WARNING: This should never be used to solve race conditions or for + * mission-critical actions. It should only be used for UI improvements, + * where it's okay if the behavior is non-deterministic. + * + * @param {goog.editor.Field.EventType} eventType type of event to debounce. + */ +goog.editor.Field.prototype.debounceEvent = function(eventType) { + 'use strict'; + this.debouncedEvents_[eventType] = Date.now(); +}; + + +/** + * Checks if the event of the given type has stopped being dispatched + * @param {goog.editor.Field.EventType} eventType type of event to check. + * @return {boolean} true if the event has been stopped with stopEvent(). + * @protected + */ +goog.editor.Field.prototype.isEventStopped = function(eventType) { + 'use strict'; + return !!this.stoppedEvents_[eventType] || + (this.debouncedEvents_[eventType] && + (Date.now() - this.debouncedEvents_[eventType] <= + goog.editor.Field.DEBOUNCE_TIME_MS_)); +}; + + +/** + * Calls a function to manipulate the dom of this field. This method should be + * used whenever Trogedit clients need to modify the dom of the field, so that + * delayed change events are handled appropriately. Extra delayed change events + * will cause undesired states to be added to the undo-redo stack. This method + * will always fire at most one delayed change event, depending on the value of + * `opt_preventDelayedChange`. + * + * @param {function()} func The function to call that will manipulate the dom. + * @param {boolean=} opt_preventDelayedChange Whether delayed change should be + * prevented after calling `func`. Defaults to always firing + * delayed change. + * @param {Object=} opt_handler Object in whose scope to call the listener. + */ +goog.editor.Field.prototype.manipulateDom = function( + func, opt_preventDelayedChange, opt_handler) { + 'use strict'; + this.stopChangeEvents(true, true); + // We don't want any problems with the passed in function permanently + // stopping change events. That would break Trogedit. + try { + func.call(opt_handler); + } finally { + // If the field isn't loaded then change and delayed change events will be + // started as part of the onload behavior. + if (this.isLoaded()) { + // We assume that func always modified the dom and so fire a single change + // event. Delayed change is only fired if not prevented by the user. + if (opt_preventDelayedChange) { + this.startEvent(goog.editor.Field.EventType.CHANGE); + this.handleChange(); + this.startEvent(goog.editor.Field.EventType.DELAYEDCHANGE); + } else { + this.dispatchChange(); + } + } + } +}; + + +/** + * Dispatches a command value change event. + * @param {Array=} opt_commands Commands whose state has + * changed. + */ +goog.editor.Field.prototype.dispatchCommandValueChange = function( + opt_commands) { + 'use strict'; + if (opt_commands) { + this.dispatchEvent({ + type: goog.editor.Field.EventType.COMMAND_VALUE_CHANGE, + commands: opt_commands + }); + } else { + this.dispatchEvent(goog.editor.Field.EventType.COMMAND_VALUE_CHANGE); + } +}; + + +/** + * Dispatches the appropriate set of change events. This only fires + * synchronous change events in blended-mode, iframe-using mozilla. It just + * starts the appropriate timer for goog.editor.Field.EventType.DELAYEDCHANGE. + * This also starts up change events again if they were stopped. + * + * @param {boolean=} opt_noDelay True if + * goog.editor.Field.EventType.DELAYEDCHANGE should be fired syncronously. + */ +goog.editor.Field.prototype.dispatchChange = function(opt_noDelay) { + 'use strict'; + this.startChangeEvents(true, opt_noDelay); +}; + + +/** + * Handle a change in the Editable Field. Marks the field has modified, + * dispatches the change event on the editable field (moz only), starts the + * timer for the delayed change event. Note that these actions only occur if + * the proper events are not stopped. + */ +goog.editor.Field.prototype.handleChange = function() { + 'use strict'; + if (this.isEventStopped(goog.editor.Field.EventType.CHANGE)) { + return; + } + + this.isModified_ = true; + this.isEverModified_ = true; + + if (this.isEventStopped(goog.editor.Field.EventType.DELAYEDCHANGE)) { + return; + } + + this.delayedChangeTimer_.start(); +}; + + +/** + * Dispatch a delayed change event. + * @private + */ +goog.editor.Field.prototype.dispatchDelayedChange_ = function() { + 'use strict'; + if (this.isEventStopped(goog.editor.Field.EventType.DELAYEDCHANGE)) { + return; + } + // Clear the delayedChangeTimer_ if it's active, since any manual call to + // dispatchDelayedChange_ is equivalent to delayedChangeTimer_.fire(). + this.delayedChangeTimer_.stop(); + this.isModified_ = false; + this.dispatchEvent(goog.editor.Field.EventType.DELAYEDCHANGE); +}; + + +/** + * Don't wait for the timer and just fire the delayed change event if it's + * pending. + */ +goog.editor.Field.prototype.clearDelayedChange = function() { + 'use strict'; + this.delayedChangeTimer_.fireIfActive(); +}; + +/** + * Stop the timer, effectively canceling any pending delayed changes. + * + * @private + */ +goog.editor.Field.prototype.stopDelayedChange_ = function() { + 'use strict'; + this.delayedChangeTimer_.stop(); +}; + +/** + * Dispatch beforefocus and focus for FF. Note that both of these actually + * happen in the document's "focus" event. Unfortunately, we don't actually + * have a way of getting in before the focus event in FF (boo! hiss!). + * In IE, we use onfocusin for before focus and onfocus for focus. + * @private + */ +goog.editor.Field.prototype.dispatchFocusAndBeforeFocus_ = function() { + 'use strict'; + this.dispatchBeforeFocus_(); + this.dispatchFocus_(); +}; + + +/** + * Dispatches a before focus event. + * @private + */ +goog.editor.Field.prototype.dispatchBeforeFocus_ = function() { + 'use strict'; + if (this.isEventStopped(goog.editor.Field.EventType.BEFOREFOCUS)) { + return; + } + + this.execCommand(goog.editor.Command.CLEAR_LOREM, true); + this.dispatchEvent(goog.editor.Field.EventType.BEFOREFOCUS); +}; + + +/** + * Dispatches a focus event. + * @private + */ +goog.editor.Field.prototype.dispatchFocus_ = function() { + 'use strict'; + if (this.isEventStopped(goog.editor.Field.EventType.FOCUS)) { + return; + } + goog.editor.Field.setActiveFieldId(this.id); + + this.isSelectionEditable_ = true; + + this.dispatchEvent(goog.editor.Field.EventType.FOCUS); + + if (goog.editor.BrowserFeature + .PUTS_CURSOR_BEFORE_FIRST_BLOCK_ELEMENT_ON_FOCUS) { + // If the cursor is at the beginning of the field, make sure that it is + // in the first user-visible line break, e.g., + // no selection:

    ...

    -->

    |cursor|...

    + //
    |cursor|

    ...

    -->

    |cursor|...

    + // |cursor|

    ...

    -->

    |cursor|...

    + var field = this.getElement(); + var range = this.getRange(); + + if (range) { + var focusNode = /** @type {!Element} */ (range.getFocusNode()); + if (range.getFocusOffset() == 0 && + (!focusNode || focusNode == field || + focusNode.tagName == goog.dom.TagName.BODY)) { + goog.editor.range.selectNodeStart(field); + } + } + } + + if (!goog.editor.BrowserFeature.CLEARS_SELECTION_WHEN_FOCUS_LEAVES && + this.usesIframe()) { + var parent = this.getEditableDomHelper().getWindow().parent; + parent.getSelection().removeAllRanges(); + } +}; + + +/** + * Dispatches a blur event. + * @protected + */ +goog.editor.Field.prototype.dispatchBlur = function() { + 'use strict'; + if (this.isEventStopped(goog.editor.Field.EventType.BLUR)) { + return; + } + + // Another field may have already been registered as active, so only + // clear out the active field id if we still think this field is active. + if (goog.editor.Field.getActiveFieldId() == this.id) { + goog.editor.Field.setActiveFieldId(null); + } + + this.isSelectionEditable_ = false; + this.dispatchEvent(goog.editor.Field.EventType.BLUR); +}; + + +/** + * @return {boolean} Whether the selection is editable. + */ +goog.editor.Field.prototype.isSelectionEditable = function() { + 'use strict'; + return this.isSelectionEditable_; +}; + + +/** + * Event handler for clicks in browsers that will follow a link when the user + * clicks, even if it's editable. We stop the click manually + * @param {goog.events.BrowserEvent} e The event. + * @private + */ +goog.editor.Field.cancelLinkClick_ = function(e) { + 'use strict'; + if (goog.dom.getAncestorByTagNameAndClass( + /** @type {Node} */ (e.target), goog.dom.TagName.A)) { + e.preventDefault(); + } +}; + + +/** + * Handle mouse down inside the editable field. + * @param {goog.events.BrowserEvent} e The event. + * @private + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.Field.prototype.handleMouseDown_ = function(e) { + 'use strict'; + goog.editor.Field.setActiveFieldId(this.id); + + // Open links in a new window if the user control + clicks. + if (goog.userAgent.IE) { + var targetElement = e.target; + if (targetElement && + /** @type {!Element} */ (targetElement).tagName == goog.dom.TagName.A && + e.ctrlKey) { + this.originalDomHelper.getWindow().open(targetElement.href); + } + } + this.waitingForMouseUp_ = true; +}; + + +/** + * Handle drag start. Needs to cancel listening for the mouse up event on the + * window. + * @param {goog.events.BrowserEvent} e The event. + * @private + */ +goog.editor.Field.prototype.handleDragStart_ = function(e) { + 'use strict'; + this.waitingForMouseUp_ = false; +}; + + +/** + * Handle mouse up inside the editable field. + * @param {goog.events.BrowserEvent} e The event. + * @private + */ +goog.editor.Field.prototype.handleMouseUp_ = function(e) { + 'use strict'; + if (this.useWindowMouseUp_ && !this.waitingForMouseUp_) { + return; + } + this.waitingForMouseUp_ = false; + + /* + * We fire a selection change event immediately for listeners that depend on + * the native browser event object (e). On IE, a listener that tries to + * retrieve the selection with goog.dom.Range may see an out-of-date + * selection range. + */ + this.dispatchEvent(goog.editor.Field.EventType.BEFORESELECTIONCHANGE); + this.dispatchSelectionChangeEvent(e); + if (goog.userAgent.IE) { + /* + * Fire a second selection change event for listeners that need an + * up-to-date selection range. Save the event's target to be sent with it + * (it's safer than saving a copy of the event itself). + */ + this.selectionChangeTarget_ = /** @type {Node} */ (e.target); + this.selectionChangeTimer_.start(); + } +}; + + +/** + * Retrieve the HTML contents of a field. + * + * Do NOT just get the innerHTML of a field directly--there's a lot of + * processing that needs to happen. + * @return {string} The scrubbed contents of the field. + */ +goog.editor.Field.prototype.getCleanContents = function() { + 'use strict'; + if (this.queryCommandValue(goog.editor.Command.USING_LOREM)) { + return goog.string.Unicode.NBSP; + } + + if (!this.isLoaded()) { + // The field is uneditable, so it's ok to read contents directly. + var elem = this.getOriginalElement(); + if (!elem) { + goog.log.log( + this.logger, goog.log.Level.SHOUT, + 'Couldn\'t get the field element to read the contents'); + } + return elem.innerHTML; + } + + var fieldCopy = this.getFieldCopy(); + + // Allow the plugins to handle their cleanup. + this.invokeOp_(goog.editor.PluginImpl.Op.CLEAN_CONTENTS_DOM, fieldCopy); + return this.reduceOp_( + goog.editor.PluginImpl.Op.CLEAN_CONTENTS_HTML, fieldCopy.innerHTML); +}; + + +/** + * Get the copy of the editable field element, which has the innerHTML set + * correctly. + * @return {!Element} The copy of the editable field. + * @protected + */ +goog.editor.Field.prototype.getFieldCopy = function() { + 'use strict'; + var field = this.getElement(); + // Deep cloneNode strips some script tag contents in IE, so we do this. + var fieldCopy = /** @type {!Element} */ (field.cloneNode(false)); + + // For some reason, when IE sets innerHtml of the cloned node, it strips + // script tags that fall at the beginning of an element. Appending a + // non-breaking space prevents this. + var html = field.innerHTML; + if (goog.userAgent.IE && html.match(/^\s* diff --git a/closure/goog/editor/focus.js b/closure/goog/editor/focus.js new file mode 100644 index 0000000000..b571f078fc --- /dev/null +++ b/closure/goog/editor/focus.js @@ -0,0 +1,25 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilties to handle focusing related to rich text editing. + */ + +goog.provide('goog.editor.focus'); + +goog.require('goog.dom.selection'); + + +/** + * Change focus to the given input field and set cursor to end of current text. + * @param {Element} inputElem Input DOM element. + * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration + */ +goog.editor.focus.focusInputField = function(inputElem) { + 'use strict'; + inputElem.focus(); + goog.dom.selection.setCursorPosition(inputElem, inputElem.value.length); +}; diff --git a/closure/goog/editor/focus_test.js b/closure/goog/editor/focus_test.js new file mode 100644 index 0000000000..4df8d7be27 --- /dev/null +++ b/closure/goog/editor/focus_test.js @@ -0,0 +1,46 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.editor.focusTest'); +goog.setTestOnly(); + +const BrowserFeature = goog.require('goog.editor.BrowserFeature'); +const focus = goog.require('goog.editor.focus'); +const selection = goog.require('goog.dom.selection'); +const testSuite = goog.require('goog.testing.testSuite'); + +testSuite({ + setUp() { + // Make sure focus is not in the input to begin with. + const dummy = document.getElementById('dummyLink'); + dummy.focus(); + }, + + /** + * Tests that focusInputField() puts focus in the input field and sets the + * cursor to the end of the text cointained inside. + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + testFocusInputField() { + const input = document.getElementById('myInput'); + assertNotEquals( + 'Input should not be focused initially', input, document.activeElement); + + focus.focusInputField(input); + if (BrowserFeature.HAS_ACTIVE_ELEMENT) { + assertEquals( + 'Input should be focused after call to focusInputField', input, + document.activeElement); + } + assertEquals( + 'Selection should start at the end of the input text', + input.value.length, selection.getStart(input)); + assertEquals( + 'Selection should end at the end of the input text', input.value.length, + selection.getEnd(input)); + }, +}); diff --git a/closure/goog/editor/focus_test_dom.html b/closure/goog/editor/focus_test_dom.html new file mode 100644 index 0000000000..08251107db --- /dev/null +++ b/closure/goog/editor/focus_test_dom.html @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/closure/goog/editor/icontent.js b/closure/goog/editor/icontent.js new file mode 100644 index 0000000000..454576774f --- /dev/null +++ b/closure/goog/editor/icontent.js @@ -0,0 +1,290 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Static functions for writing the contents of an iframe-based + * editable field. These vary significantly from browser to browser. Uses + * strings and document.write instead of DOM manipulation, because + * iframe-loading is a performance bottleneck. + */ + +goog.provide('goog.editor.icontent'); +goog.provide('goog.editor.icontent.FieldFormatInfo'); +goog.provide('goog.editor.icontent.FieldStyleInfo'); + +goog.require('goog.dom'); +goog.require('goog.dom.safe'); +goog.require('goog.editor.BrowserFeature'); +goog.require('goog.html.legacyconversions'); +goog.require('goog.style'); +goog.require('goog.userAgent'); + + + +/** + * A data structure for storing simple rendering info about a field. + * + * @param {string} fieldId The id of the field. + * @param {boolean} standards Whether the field should be rendered in + * standards mode. + * @param {boolean} blended Whether the field is in blended mode. + * @param {boolean} fixedHeight Whether the field is in fixedHeight mode. + * @param {Object=} opt_extraStyles Other style attributes for the field, + * represented as a map of strings. + * @constructor + * @final + */ +goog.editor.icontent.FieldFormatInfo = function( + fieldId, standards, blended, fixedHeight, opt_extraStyles) { + 'use strict'; + this.fieldId_ = fieldId; + this.standards_ = standards; + this.blended_ = blended; + this.fixedHeight_ = fixedHeight; + this.extraStyles_ = opt_extraStyles || {}; +}; + + + +/** + * A data structure for storing simple info about the styles of a field. + * Only needed in Firefox/Blended mode. + * @param {Element} wrapper The wrapper div around a field. + * @param {string} css The css for a field. + * @constructor + * @final + */ +goog.editor.icontent.FieldStyleInfo = function(wrapper, css) { + 'use strict'; + this.wrapper_ = wrapper; + this.css_ = css; +}; + + +/** + * Whether to always use standards-mode iframes. + * @type {boolean} + * @private + */ +goog.editor.icontent.useStandardsModeIframes_ = false; + + +/** + * Sets up goog.editor.icontent to always use standards-mode iframes. + */ +goog.editor.icontent.forceStandardsModeIframes = function() { + 'use strict'; + goog.editor.icontent.useStandardsModeIframes_ = true; +}; + + +/** + * Generate the initial iframe content. + * @param {goog.editor.icontent.FieldFormatInfo} info Formatting info about + * the field. + * @param {string} bodyHtml The HTML to insert as the iframe body. + * @param {goog.editor.icontent.FieldStyleInfo?} style Style info about + * the field, if needed. + * @return {string} The initial IFRAME content HTML. + * @private + */ +goog.editor.icontent.getInitialIframeContent_ = function( + info, bodyHtml, style) { + 'use strict'; + var html = []; + + if (info.blended_ && info.standards_ || + goog.editor.icontent.useStandardsModeIframes_) { + html.push(''); + } + + // + // NOTE(user): Override min-widths that may be set for all + // HTML/BODY nodes. A similar workaround is below for the tag. This + // can happen if the host page includes a rule like this in its CSS: + // + // html, body {min-width: 500px} + // + // In this case, the iframe's and/or may be affected. This was + // part of the problem observed in http://b/5674613. (The other part of that + // problem had to do with the presence of a spurious horizontal scrollbar, + // which caused the editor height to be computed incorrectly.) + html.push(''); + + // '); + + // + // Hidefocus is needed to ensure that IE7 doesn't show the dotted, focus + // border when you tab into the field. + html.push('', bodyHtml, ''); + + return html.join(''); +}; + + +/** + * Write the initial iframe content in normal mode. + * @param {goog.editor.icontent.FieldFormatInfo} info Formatting info about + * the field. + * @param {string} bodyHtml The HTML to insert as the iframe body. + * @param {goog.editor.icontent.FieldStyleInfo?} style Style info about + * the field, if needed. + * @param {HTMLIFrameElement} iframe The iframe. + */ +goog.editor.icontent.writeNormalInitialBlendedIframe = function( + info, bodyHtml, style, iframe) { + 'use strict'; + // Firefox blended needs to inherit all the css from the original page. + // Firefox standards mode needs to set extra style for images. + if (info.blended_) { + var field = style.wrapper_; + // If there is padding on the original field, then the iFrame will be + // positioned inside the padding by default. We don't want this, as it + // causes the contents to appear to shift, and also causes the + // scrollbars to appear inside the padding. + // + // To compensate, we set the iframe margins to offset the padding. + var paddingBox = goog.style.getPaddingBox(field); + if (paddingBox.top || paddingBox.left || paddingBox.right || + paddingBox.bottom) { + goog.style.setStyle( + iframe, 'margin', (-paddingBox.top) + 'px ' + (-paddingBox.right) + + 'px ' + (-paddingBox.bottom) + 'px ' + (-paddingBox.left) + 'px'); + } + } + + goog.editor.icontent.writeNormalInitialIframe(info, bodyHtml, style, iframe); +}; + + +/** + * Write the initial iframe content in normal mode. + * @param {goog.editor.icontent.FieldFormatInfo} info Formatting info about + * the field. + * @param {string} bodyHtml The HTML to insert as the iframe body. + * @param {goog.editor.icontent.FieldStyleInfo?} style Style info about + * the field, if needed. + * @param {HTMLIFrameElement} iframe The iframe. + */ +goog.editor.icontent.writeNormalInitialIframe = function( + info, bodyHtml, style, iframe) { + 'use strict'; + var html = + goog.editor.icontent.getInitialIframeContent_(info, bodyHtml, style); + + var doc = goog.dom.getFrameContentDocument(iframe); + doc.open(); + goog.dom.safe.documentWrite( + doc, goog.html.legacyconversions.safeHtmlFromString(html)); + doc.close(); +}; + + +/** + * Write the initial iframe content in IE/HTTPS mode. + * @param {goog.editor.icontent.FieldFormatInfo} info Formatting info about + * the field. + * @param {Document} doc The iframe document. + * @param {string} bodyHtml The HTML to insert as the iframe body. + */ +goog.editor.icontent.writeHttpsInitialIframe = function(info, doc, bodyHtml) { + 'use strict'; + var body = doc.body; + + // For HTTPS we already have a document with a doc type and a body element + // and don't want to create a new history entry which can cause data loss if + // the user clicks the back button. + if (goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) { + body.contentEditable = true; + } + body.className = 'editable'; + body.setAttribute('g_editable', true); + body.hideFocus = true; + body.id = info.fieldId_; + + goog.style.setStyle(body, info.extraStyles_); + goog.dom.safe.setInnerHtml( + body, goog.html.legacyconversions.safeHtmlFromString(bodyHtml)); +}; diff --git a/closure/goog/editor/icontent_test.js b/closure/goog/editor/icontent_test.js new file mode 100644 index 0000000000..4fcba418ce --- /dev/null +++ b/closure/goog/editor/icontent_test.js @@ -0,0 +1,218 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.editor.icontentTest'); +goog.setTestOnly(); + +const BrowserFeature = goog.require('goog.editor.BrowserFeature'); +const FieldFormatInfo = goog.require('goog.editor.icontent.FieldFormatInfo'); +const FieldStyleInfo = goog.require('goog.editor.icontent.FieldStyleInfo'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const TagName = goog.require('goog.dom.TagName'); +const dom = goog.require('goog.dom'); +const icontent = goog.require('goog.editor.icontent'); +const testSuite = goog.require('goog.testing.testSuite'); +const userAgent = goog.require('goog.userAgent'); + +let wrapperDiv; +let realIframe; +let realIframeDoc; +let propertyReplacer; + +/** + * Check a given body for the most basic properties that all iframes must have. + * @param {Element} body The actual body element + * @param {string} id The expected id + * @param {string} bodyHTML The expected innerHTML + * @param {boolean=} rtl If true, expect RTL directionality + */ +function assertBodyCorrect(body, id, bodyHTML, rtl = undefined) { + assertEquals(bodyHTML, body.innerHTML.toString()); + // We can't just check + // assert(HAS_CONTENTE_EDITABLE, !!body.contentEditable) since in + // FF 3 we don't currently use contentEditable, but body.contentEditable + // = 'inherit' and !!'inherit' = true. + if (BrowserFeature.HAS_CONTENT_EDITABLE) { + assertEquals('true', String(body.contentEditable)); + } else { + assertNotEquals('true', String(body.contentEditable)); + } + assertContains('editable', body.className.match(/\S+/g)); + assertEquals('true', String(body.getAttribute('g_editable'))); + assertEquals( + 'true', + // IE has bugs with getAttribute('hideFocus'), and + // Webkit has bugs with normal .hideFocus access. + String(userAgent.IE ? body.hideFocus : body.getAttribute('hideFocus'))); + assertEquals(id, body.id); +} + +/** @return {!Object} A mock document */ +function createMockDocument() { + return { + body: { + tagName: 'BODY', + setAttribute: function(key, val) { + /** @suppress {globalThis} suppression added to enable type checking */ + this[key] = val; + }, + getAttribute: /** + @suppress {globalThis} suppression added to enable type + checking + */ + function(key) { + return this[key]; + }, + style: {direction: ''}, + }, + }; +} +testSuite({ + setUp() { + wrapperDiv = dom.createDom( + TagName.DIV, null, realIframe = dom.createDom(TagName.IFRAME)); + dom.appendChild(document.body, wrapperDiv); + realIframeDoc = realIframe.contentWindow.document; + propertyReplacer = new PropertyReplacer(); + }, + + tearDown() { + dom.removeNode(wrapperDiv); + propertyReplacer.reset(); + }, + + /** + @suppress {checkTypes,strictMissingProperties} suppression added to enable + type checking + */ + testWriteHttpsInitialIframeContent() { + // This is not a particularly useful test; it's just a sanity check to make + // sure nothing explodes + const info = new FieldFormatInfo('id', false, false, false); + const doc = createMockDocument(); + icontent.writeHttpsInitialIframe(info, doc, 'some html'); + assertBodyCorrect(doc.body, 'id', 'some html'); + }, + + /** + @suppress {checkTypes,strictMissingProperties} suppression added to enable + type checking + */ + testWriteHttpsInitialIframeContentRtl() { + const info = new FieldFormatInfo('id', false, false, true); + const doc = createMockDocument(); + icontent.writeHttpsInitialIframe(info, doc, 'some html'); + assertBodyCorrect(doc.body, 'id', 'some html', true); + }, + + testWriteInitialIframeContentBlendedStandardsGrowing() { + if (BrowserFeature.HAS_CONTENT_EDITABLE) { + return; // only executes when using an iframe + } + + const info = new FieldFormatInfo('id', true, true, false); + const styleInfo = new FieldStyleInfo( + wrapperDiv, '.MyClass { position: absolute; top: 500px; }'); + const doc = realIframeDoc; + const html = '
    Some Html
    '; + icontent.writeNormalInitialBlendedIframe(info, html, styleInfo, realIframe); + + assertBodyCorrect(doc.body, 'id', html); + assertEquals('CSS1Compat', doc.compatMode); // standards + assertEquals('auto', doc.documentElement.style.height); // growing + assertEquals('100%', doc.body.style.height); // standards + assertEquals('hidden', doc.body.style.overflowY); // growing + assertEquals('', realIframe.style.position); // no padding on wrapper + + assertEquals(500, doc.body.firstChild.offsetTop); + assert( + dom.getElementsByTagName(TagName.STYLE, doc)[0].innerHTML.indexOf( + '-moz-force-broken-image-icon') != -1); // standards + }, + + testWriteInitialIframeContentBlendedQuirksFixedRtl() { + if (BrowserFeature.HAS_CONTENT_EDITABLE) { + return; // only executes when using an iframe + } + + const info = new FieldFormatInfo('id', false, true, true); + const styleInfo = new FieldStyleInfo(wrapperDiv, ''); + wrapperDiv.style.padding = '2px 5px'; + const doc = realIframeDoc; + const html = 'Some Html'; + icontent.writeNormalInitialBlendedIframe(info, html, styleInfo, realIframe); + + assertBodyCorrect(doc.body, 'id', html, true); + assertEquals('BackCompat', doc.compatMode); // quirks + assertEquals('100%', doc.documentElement.style.height); // fixed height + assertEquals('auto', doc.body.style.height); // quirks + assertEquals('auto', doc.body.style.overflow); // fixed height + + assertEquals('-2px', realIframe.style.marginTop); + assertEquals('-5px', realIframe.style.marginLeft); + assert( + dom.getElementsByTagName(TagName.STYLE, doc)[0].innerHTML.indexOf( + '-moz-force-broken-image-icon') == -1); // quirks + }, + + testWhiteboxStandardsFixedRtl() { + const info = new FieldFormatInfo('id', true, false, true); + const styleInfo = null; + const doc = realIframeDoc; + const html = 'Some Html'; + icontent.writeNormalInitialBlendedIframe(info, html, styleInfo, realIframe); + assertBodyCorrect(doc.body, 'id', html, true); + + // TODO(nicksantos): on Safari, there's a bug where all written iframes + // are CSS1Compat. It's fixed in the nightlies as of 3/31/08, so remove + // this guard when the latest version of Safari is on the farm. + if (!userAgent.WEBKIT) { + assertEquals( + 'BackCompat', doc.compatMode); // always use quirks in whitebox + } + }, + + testGetInitialIframeContent() { + const info = new FieldFormatInfo('id', true, false, false); + const styleInfo = null; + const html = 'Some Html'; + propertyReplacer.set(BrowserFeature, 'HAS_CONTENT_EDITABLE', false); + /** @suppress {visibility} suppression added to enable type checking */ + let htmlOut = icontent.getInitialIframeContent_(info, html, styleInfo); + assertEquals(/contentEditable/i.test(htmlOut), false); + propertyReplacer.set(BrowserFeature, 'HAS_CONTENT_EDITABLE', true); + /** @suppress {visibility} suppression added to enable type checking */ + htmlOut = icontent.getInitialIframeContent_(info, html, styleInfo); + assertEquals(/]+?contentEditable/i.test(htmlOut), true); + assertEquals(/]+?style="[^>"]*min-width:\s*0/i.test(htmlOut), true); + assertEquals(/]+?style="[^>"]*min-width:\s*0/i.test(htmlOut), true); + }, + + testIframeMinWidthOverride() { + if (BrowserFeature.HAS_CONTENT_EDITABLE) { + return; // only executes when using an iframe + } + + const info = new FieldFormatInfo('id', true, true, false); + const styleInfo = new FieldStyleInfo( + wrapperDiv, '.MyClass { position: absolute; top: 500px; }'); + const doc = realIframeDoc; + const html = '
    Some Html
    '; + icontent.writeNormalInitialBlendedIframe(info, html, styleInfo, realIframe); + + // Make sure that the minimum width isn't being inherited from the parent + // document's style. + assertTrue(doc.body.offsetWidth < 700); + }, + + testBlendedStandardsGrowingMatchesComparisonDiv() { + // TODO(nicksantos): If we ever move + // TR_EditableUtil.prototype.makeIframeField_ + // into goog.editor.icontent (and I think we should), we could actually run + // functional tests to ensure that the iframed field matches the dimensions + // of the equivalent uneditable div. Functional tests would help a lot here. + }, +}); diff --git a/closure/goog/editor/icontent_test_dom.html b/closure/goog/editor/icontent_test_dom.html new file mode 100644 index 0000000000..38f9d97614 --- /dev/null +++ b/closure/goog/editor/icontent_test_dom.html @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/closure/goog/editor/link.js b/closure/goog/editor/link.js new file mode 100644 index 0000000000..ed5943a02d --- /dev/null +++ b/closure/goog/editor/link.js @@ -0,0 +1,380 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A utility class for managing editable links. + */ + +goog.provide('goog.editor.Link'); + +goog.require('goog.dom'); +goog.require('goog.dom.NodeType'); +goog.require('goog.dom.TagName'); +goog.require('goog.editor.Command'); +goog.require('goog.editor.Field'); +goog.require('goog.editor.node'); +goog.require('goog.editor.range'); +goog.require('goog.string'); +goog.require('goog.uri.utils'); +goog.require('goog.uri.utils.ComponentIndex'); + + + +/** + * Wrap an editable link. + * @param {HTMLAnchorElement} anchor The anchor element. + * @param {boolean} isNew Whether this is a new link. + * @constructor + * @final + */ +goog.editor.Link = function(anchor, isNew) { + 'use strict'; + /** + * The link DOM element. + * @type {HTMLAnchorElement} + * @private + */ + this.anchor_ = anchor; + + /** + * Whether this link represents a link just added to the document. + * @type {boolean} + * @private + */ + this.isNew_ = isNew; + + + /** + * Any extra anchors created by the browser from a selection in the same + * operation that created the primary link + * @type {!Array} + * @private + */ + this.extraAnchors_ = []; +}; + + +/** + * @return {HTMLAnchorElement} The anchor element. + */ +goog.editor.Link.prototype.getAnchor = function() { + 'use strict'; + return this.anchor_; +}; + + +/** + * @return {!Array} The extra anchor elements, if any, + * created by the browser from a selection. + */ +goog.editor.Link.prototype.getExtraAnchors = function() { + 'use strict'; + return this.extraAnchors_; +}; + + +/** + * @return {string} The inner text for the anchor. + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.Link.prototype.getCurrentText = function() { + 'use strict'; + if (!this.currentText_) { + var anchor = this.getAnchor(); + + var leaf = goog.editor.node.getLeftMostLeaf(anchor); + if (leaf.tagName && leaf.tagName == goog.dom.TagName.IMG) { + /** + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ + this.currentText_ = leaf.getAttribute('alt') || ''; + } else { + /** + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ + this.currentText_ = goog.dom.getRawTextContent(this.getAnchor()); + } + } + return this.currentText_; +}; + + +/** + * @return {boolean} Whether the link is new. + */ +goog.editor.Link.prototype.isNew = function() { + 'use strict'; + return this.isNew_; +}; + + +/** + * Set the url without affecting the isNew() status of the link. + * @param {string} url A URL. + */ +goog.editor.Link.prototype.initializeUrl = function(url) { + 'use strict'; + this.getAnchor().href = url; +}; + + +/** + * Removes the link, leaving its contents in the document. Note that this + * object will no longer be usable/useful after this call. + */ +goog.editor.Link.prototype.removeLink = function() { + 'use strict'; + goog.dom.flattenElement(this.anchor_); + this.anchor_ = null; + while (this.extraAnchors_.length) { + goog.dom.flattenElement(/** @type {Element} */ (this.extraAnchors_.pop())); + } +}; + + +/** + * Change the link. + * @param {string} newText New text for the link. If the link contains all its + * text in one descendant, newText will only replace the text in that + * one node. Otherwise, we'll change the innerHTML of the whole + * link to newText. + * @param {string} newUrl A new URL. + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.Link.prototype.setTextAndUrl = function(newText, newUrl) { + 'use strict'; + var anchor = this.getAnchor(); + anchor.href = newUrl; + + // If the text did not change, don't update link text. + var currentText = this.getCurrentText(); + if (newText != currentText) { + var leaf = goog.editor.node.getLeftMostLeaf(anchor); + + if (leaf.tagName && leaf.tagName == goog.dom.TagName.IMG) { + leaf.setAttribute('alt', newText ? newText : ''); + } else { + if (leaf.nodeType == goog.dom.NodeType.TEXT) { + leaf = leaf.parentNode; + } + + if (goog.dom.getRawTextContent(leaf) != currentText) { + leaf = anchor; + } + + goog.dom.removeChildren(leaf); + var domHelper = goog.dom.getDomHelper(leaf); + goog.dom.appendChild(leaf, domHelper.createTextNode(newText)); + } + + // The text changed, so force getCurrentText to recompute. + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + this.currentText_ = null; + } + + this.isNew_ = false; +}; + + +/** + * Places the cursor to the right of the anchor. + * Note that this is different from goog.editor.range's placeCursorNextTo + * in that it specifically handles the placement of a cursor in browsers + * that trap you in links, by adding a space when necessary and placing the + * cursor after that space. + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.Link.prototype.placeCursorRightOf = function() { + 'use strict'; + goog.editor.range.placeCursorNextTo(this.getAnchor(), false); +}; + + +/** + * Updates the cursor position and link bubble for this link. + * @param {goog.editor.Field} field The field in which the link is created. + * @param {string} url The link url. + * @private + */ +goog.editor.Link.prototype.updateLinkDisplay_ = function(field, url) { + 'use strict'; + this.initializeUrl(url); + this.placeCursorRightOf(); + field.execCommand(goog.editor.Command.UPDATE_LINK_BUBBLE); +}; + + +/** + * @return {string?} The modified string for the link if the link + * text appears to be a valid link. Returns null if this is not + * a valid link address. + */ +goog.editor.Link.prototype.getValidLinkFromText = function() { + 'use strict'; + var text = goog.string.trim(this.getCurrentText()); + if (goog.editor.Link.isLikelyUrl(text)) { + if (text.search(/:/) < 0) { + return 'http://' + goog.string.trimLeft(text); + } + return text; + } else if (goog.editor.Link.isLikelyEmailAddress(text)) { + return 'mailto:' + text; + } + return null; +}; + + +/** + * After link creation, finish creating the link depending on the type + * of link being created. + * @param {goog.editor.Field} field The field where this link is being created. + */ +goog.editor.Link.prototype.finishLinkCreation = function(field) { + 'use strict'; + var linkFromText = this.getValidLinkFromText(); + if (linkFromText) { + this.updateLinkDisplay_(field, linkFromText); + } else { + field.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, this); + } +}; + + +/** + * Initialize a new link. + * @param {HTMLAnchorElement} anchor The anchor element. + * @param {string} url The initial URL. + * @param {string=} opt_target The target. + * @param {Array=} opt_extraAnchors Extra anchors created + * by the browser when parsing a selection. + * @return {!goog.editor.Link} The link. + */ +goog.editor.Link.createNewLink = function( + anchor, url, opt_target, opt_extraAnchors) { + 'use strict'; + var link = new goog.editor.Link(anchor, true); + link.initializeUrl(url); + + if (opt_target) { + anchor.target = opt_target; + } + if (opt_extraAnchors) { + link.extraAnchors_ = opt_extraAnchors; + } + + return link; +}; + + +/** + * Initialize a new link using text in anchor, or empty string if there is no + * likely url in the anchor. + * @param {HTMLAnchorElement} anchor The anchor element with likely url content. + * @param {string=} opt_target The target. + * @return {!goog.editor.Link} The link. + */ +goog.editor.Link.createNewLinkFromText = function(anchor, opt_target) { + 'use strict'; + var link = new goog.editor.Link(anchor, true); + var text = link.getValidLinkFromText(); + link.initializeUrl(text ? text : ''); + if (opt_target) { + anchor.target = opt_target; + } + return link; +}; + + +/** + * Returns true if str could be a URL, false otherwise + * + * Ex: TR_Util.isLikelyUrl_("http://www.google.com") == true + * TR_Util.isLikelyUrl_("www.google.com") == true + * + * @param {string} str String to check if it looks like a URL. + * @return {boolean} Whether str could be a URL. + */ +goog.editor.Link.isLikelyUrl = function(str) { + 'use strict'; + // Whitespace means this isn't a domain. + if (/\s/.test(str)) { + return false; + } + + if (goog.editor.Link.isLikelyEmailAddress(str)) { + return false; + } + + // Add a scheme if the url doesn't have one - this helps the parser. + var addedScheme = false; + if (!/^[^:\/?#.]+:/.test(str)) { + str = 'http://' + str; + addedScheme = true; + } + + // Parse the domain. + var parts = goog.uri.utils.split(str); + + // Relax the rules for special schemes. + var scheme = parts[goog.uri.utils.ComponentIndex.SCHEME]; + if (['mailto', 'aim'].indexOf(scheme) != -1) { + return true; + } + + // Require domains to contain a '.', unless the domain is fully qualified and + // forbids domains from containing invalid characters. + var domain = parts[goog.uri.utils.ComponentIndex.DOMAIN]; + if (!domain || + (addedScheme && (domain.indexOf('.') === -1 || domain.length < 3)) || + (/[^\w\d\-\u0100-\uffff.%]/.test(domain))) { + return false; + } + + // Require http and ftp paths to start with '/'. + var path = parts[goog.uri.utils.ComponentIndex.PATH]; + return !path || path.indexOf('/') == 0; +}; + + +/** + * Regular expression that matches strings that could be an email address. + * @type {RegExp} + * @private + */ +goog.editor.Link.LIKELY_EMAIL_ADDRESS_ = new RegExp( + '^' + // Test from start of string + '[\\w-]+(\\.[\\w-]+)*' + // Dot-delimited alphanumerics and dashes + // (name) + '\\@' + // @ + '([\\w-]+\\.)+' + // Alphanumerics, dashes and dots (domain) + '(\\d+|\\w\\w+)$', // Domain ends in at least one number or 2 letters + 'i'); + + +/** + * Returns true if str could be an email address, false otherwise + * + * Ex: goog.editor.Link.isLikelyEmailAddress_("some word") == false + * goog.editor.Link.isLikelyEmailAddress_("foo@foo.com") == true + * + * @param {string} str String to test for being email address. + * @return {boolean} Whether "str" looks like an email address. + */ +goog.editor.Link.isLikelyEmailAddress = function(str) { + 'use strict'; + return goog.editor.Link.LIKELY_EMAIL_ADDRESS_.test(str); +}; + + +/** + * Determines whether or not a url is an email link. + * @param {string} url A url. + * @return {boolean} Whether the url is a mailto link. + */ +goog.editor.Link.isMailto = function(url) { + 'use strict'; + return !!url && goog.string.startsWith(url, 'mailto:'); +}; diff --git a/closure/goog/editor/link_test.js b/closure/goog/editor/link_test.js new file mode 100644 index 0000000000..14d08a83e2 --- /dev/null +++ b/closure/goog/editor/link_test.js @@ -0,0 +1,361 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.editor.LinkTest'); +goog.setTestOnly(); + +const Link = goog.require('goog.editor.Link'); +const NodeType = goog.require('goog.dom.NodeType'); +const Range = goog.require('goog.dom.Range'); +const TagName = goog.require('goog.dom.TagName'); +const dom = goog.require('goog.dom'); +const testSuite = goog.require('goog.testing.testSuite'); + + +let anchor; + +testSuite({ + setUp() { + anchor = dom.createDom(TagName.A); + document.body.appendChild(anchor); + }, + + tearDown() { + dom.removeNode(anchor); + }, + + testCreateNew() { + const link = new Link(anchor, true); + assertNotNull('Should have created object', link); + assertTrue('Should be new', link.isNew()); + assertEquals('Should have correct anchor', anchor, link.getAnchor()); + assertEquals('Should be empty', '', link.getCurrentText()); + }, + + testCreateNotNew() { + const link = new Link(anchor, false); + assertNotNull('Should have created object', link); + assertFalse('Should not be new', link.isNew()); + assertEquals('Should have correct anchor', anchor, link.getAnchor()); + assertEquals('Should be empty', '', link.getCurrentText()); + }, + + testCreateNewLinkFromText() { + const url = 'http://www.google.com/'; + anchor.innerHTML = url; + const link = Link.createNewLinkFromText(anchor); + assertNotNull('Should have created object', link); + assertEquals('Should have url in anchor', url, anchor.href); + }, + + testCreateNewLinkFromTextLeadingTrailingWhitespace() { + const url = 'http://www.google.com/'; + const urlWithSpaces = ` ${url} `; + anchor.innerHTML = urlWithSpaces; + const urlWithSpacesUpdatedByBrowser = anchor.innerHTML; + const link = Link.createNewLinkFromText(anchor); + assertNotNull('Should have created object', link); + assertEquals('Should have url in anchor', url, anchor.href); + assertEquals( + 'The text should still have spaces', urlWithSpacesUpdatedByBrowser, + link.getCurrentText()); + }, + + testCreateNewLinkFromTextWithAnchor() { + const url = 'https://www.google.com/'; + anchor.innerHTML = url; + const link = Link.createNewLinkFromText(anchor, '_blank'); + assertNotNull('Should have created object', link); + assertEquals('Should have url in anchor', url, anchor.href); + assertEquals('Should have _blank target', '_blank', anchor.target); + }, + + testInitialize() { + const link = Link.createNewLink(anchor, 'http://www.google.com'); + assertNotNull('Should have created object', link); + assertTrue('Should be new', link.isNew()); + assertEquals('Should have correct anchor', anchor, link.getAnchor()); + assertEquals('Should be empty', '', link.getCurrentText()); + }, + + testInitializeWithTarget() { + const link = Link.createNewLink(anchor, 'http://www.google.com', '_blank'); + assertNotNull('Should have created object', link); + assertTrue('Should be new', link.isNew()); + assertEquals('Should have correct anchor', anchor, link.getAnchor()); + assertEquals('Should be empty', '', link.getCurrentText()); + assertEquals('Should have _blank target', '_blank', anchor.target); + }, + + testSetText() { + const link = Link.createNewLink(anchor, 'http://www.google.com', '_blank'); + assertEquals('Should be empty', '', link.getCurrentText()); + link.setTextAndUrl('Text', 'http://docs.google.com/'); + assertEquals( + 'Should point to http://docs.google.com/', 'http://docs.google.com/', + anchor.href); + assertEquals('Should have correct text', 'Text', link.getCurrentText()); + }, + + testSetBoldText() { + anchor.innerHTML = ''; + const link = Link.createNewLink(anchor, 'http://www.google.com', '_blank'); + assertEquals('Should be empty', '', link.getCurrentText()); + link.setTextAndUrl('Text', 'http://docs.google.com/'); + assertEquals( + 'Should point to http://docs.google.com/', 'http://docs.google.com/', + anchor.href); + assertEquals('Should have correct text', 'Text', link.getCurrentText()); + assertEquals( + 'Should still be bold', String(TagName.B), anchor.firstChild.tagName); + }, + + testLinkImgTag() { + anchor.innerHTML = 'alt_txt'; + const link = Link.createNewLink(anchor, 'http://www.google.com', '_blank'); + assertEquals('Test getCurrentText', 'alt_txt', link.getCurrentText()); + link.setTextAndUrl('newText', 'http://docs.google.com/'); + assertEquals('Test getCurrentText', 'newText', link.getCurrentText()); + assertEquals( + 'Should point to http://docs.google.com/', 'http://docs.google.com/', + anchor.href); + + assertEquals( + 'Should still have img tag', String(TagName.IMG), + anchor.firstChild.tagName); + + assertEquals( + 'Alt should equal "newText"', 'newText', + anchor.firstChild.getAttribute('alt')); + }, + + testLinkImgTagWithNoAlt() { + anchor.innerHTML = ''; + const link = Link.createNewLink(anchor, 'http://www.google.com', '_blank'); + assertEquals('Test getCurrentText', '', link.getCurrentText()); + }, + + testSetMixed() { + anchor.innerHTML = 'AB'; + const link = Link.createNewLink(anchor, 'http://www.google.com', '_blank'); + assertEquals('Should have text: AB', 'AB', link.getCurrentText()); + link.setTextAndUrl('Text', 'http://docs.google.com/'); + assertEquals( + 'Should point to http://docs.google.com/', 'http://docs.google.com/', + anchor.href); + assertEquals('Should have correct text', 'Text', link.getCurrentText()); + assertEquals( + 'Should not be bold', NodeType.TEXT, anchor.firstChild.nodeType); + }, + + testPlaceCursorRightOf() { + // IE can only do selections properly if the region is editable. + const ed = dom.createDom(TagName.DIV); + dom.replaceNode(ed, anchor); + ed.contentEditable = true; + ed.appendChild(anchor); + + // In order to test the cursor placement properly, we need to have + // link text. See more details in the test below. + dom.setTextContent(anchor, 'I am text'); + + const link = Link.createNewLink(anchor, 'http://www.google.com'); + link.placeCursorRightOf(); + + const range = Range.createFromWindow(); + assertTrue('Range should be collapsed', range.isCollapsed()); + const startNode = range.getStartNode(); + + // Check that the selection is the "right" place. + // + // If you query the selection, it is actually still inside the anchor, + // but if you type, it types outside the anchor. + // + // Best we can do is test that it is at the end of the anchor text. + assertEquals( + 'Selection should be in anchor text', anchor.firstChild, startNode); + assertEquals( + 'Selection should be at the end of the text', anchor.firstChild.length, + range.getStartOffset()); + + if (ed) { + dom.removeNode(ed); + } + }, + + testIsLikelyUrl() { + const good = [ + // Proper URLs + 'http://google.com', + 'http://google.com/', + 'http://192.168.1.103', + 'http://www.google.com:8083', + 'https://antoine', + 'https://foo.foo.net', + 'ftp://google.com:22/', + 'http://user@site.com', + 'ftp://user:pass@ftp.site.com', + 'http://google.com/search?q=laser%20cats', + 'aim:goim?screenname=en2es', + 'mailto:x@y.com', + + // Bad URLs a browser will accept + 'www.google.com', + 'www.amazon.co.uk', + 'amazon.co.uk', + 'foo2.foo3.com', + 'pandora.tv', + 'marketing.us', + 'del.icio.us', + 'bridge-line.com', + 'www.frigid.net:80', + 'www.google.com?q=foo', + 'www.foo.com/j%20.txt', + 'foodtv.net', + 'google.com', + 'slashdot.org', + '192.168.1.1', + 'justin.edu?kumar something', + 'google.com/search?q=hot%20pockets', + + // Due to TLD explosion, these could be URLs either now or soon. + 'ww.jester', + 'juicer.fake', + 'abs.nonsense.something', + 'filename.txt', + ]; + let i; + for (i = 0; i < good.length; i++) { + assertTrue(good[i] + ' should be good', Link.isLikelyUrl(good[i])); + } + + const bad = [ + // Definitely not URLs + 'bananas', + 'http google com', + '', + 'Sad :/', + '*garbage!.123', + 'ftp', + 'http', + '/', + 'https', + 'this is', + '*!&.banana!*&!', + 'www.jester is gone.com', + 'ftp .nospaces.net', + 'www_foo_net', + 'www.\'jester\'.net', + 'www:8080', + 'www . notnsense.com', + 'email@address.com', + '.', + 'x.', + + // URL-ish but not quite + ' http://www.google.com', + 'http://www.google.com:8081 ', + 'www.google.com foo bar', + 'google.com/search?q=not quite', + ]; + + for (i = 0; i < bad.length; i++) { + assertFalse(bad[i] + ' should be bad', Link.isLikelyUrl(bad[i])); + } + }, + + testIsLikelyEmailAddress() { + const good = [ + // Valid email addresses + 'foo@foo.com', + 'foo1@foo2.foo3.com', + 'f45_1@goog13.org', + 'user@gmail.co.uk', + 'jon-smith@crazy.net', + 'roland1@capuchino.gov', + 'ernir@gshi.nl', + 'JOON@jno.COM', + 'media@meDIa.fREnology.FR', + 'john.mail4me@del.icio.us', + 'www9@wc3.madeup1.org', + 'hi@192.168.1.103', + 'hi@192.168.1.1', + ]; + let i; + for (i = 0; i < good.length; i++) { + assertTrue(Link.isLikelyEmailAddress(good[i])); + } + + const bad = [ + // Malformed/incomplete email addresses + 'user', + '@gmail.com', + 'user@gmail', + 'user@.com', + 'user@gmail.c', + 'user@gmail.co.u', + '@ya.com', + '.@hi3.nl', + 'jim.com', + 'ed:@gmail.com', + '*!&.banana!*&!', + ':jon@gmail.com', + '3g?@bil.com', + 'adam be@hi.net', + 'john\nsmith@test.com', + 'www.\'jester\'.net', + '\'james\'@covald.net', + 'ftp://user@site.com/', + 'aim:goim?screenname=en2es', + 'user:pass@site.com', + 'user@site.com yay', + ]; + + for (i = 0; i < bad.length; i++) { + assertFalse(Link.isLikelyEmailAddress(bad[i])); + } + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testIsMailToLink() { + assertFalse(Link.isMailto()); + assertFalse(Link.isMailto(null)); + assertFalse(Link.isMailto('')); + assertFalse(Link.isMailto('http://foo.com')); + assertFalse(Link.isMailto('http://mailto:80')); + + assertTrue(Link.isMailto('mailto:')); + assertTrue(Link.isMailto('mailto://')); + assertTrue(Link.isMailto('mailto://foo@gmail.com')); + }, + + testGetValidLinkFromText() { + const textLinkPairs = [ + // input text, expected link output + 'www.foo.com', + 'http://www.foo.com', + 'user@gmail.com', + 'mailto:user@gmail.com', + 'http://www.foo.com', + 'http://www.foo.com', + 'https://this.that.edu', + 'https://this.that.edu', + 'nothing to see here', + null, + ]; + const link = new Link(anchor, true); + + for (let i = 0; i < textLinkPairs.length; i += 2) { + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + link.currentText_ = textLinkPairs[i]; + const result = link.getValidLinkFromText(); + assertEquals(textLinkPairs[i + 1], result); + } + }, +}); diff --git a/closure/goog/editor/node.js b/closure/goog/editor/node.js new file mode 100644 index 0000000000..10ca5c5d14 --- /dev/null +++ b/closure/goog/editor/node.js @@ -0,0 +1,473 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilties for working with DOM nodes related to rich text + * editing. Many of these are not general enough to go into goog.dom. + */ + +goog.provide('goog.editor.node'); + +goog.require('goog.asserts.dom'); +goog.require('goog.dom'); +goog.require('goog.dom.NodeType'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.iter.ChildIterator'); +goog.require('goog.dom.iter.SiblingIterator'); +goog.require('goog.dom.safe'); +goog.require('goog.html.legacyconversions'); +goog.require('goog.iter'); +goog.require('goog.object'); +goog.require('goog.string'); +goog.require('goog.string.Unicode'); +goog.require('goog.userAgent'); + + +/** + * Names of all block-level tags + * @type {Object} + * @private + */ +goog.editor.node.BLOCK_TAG_NAMES_ = goog.object.createSet( + goog.dom.TagName.ADDRESS, goog.dom.TagName.ARTICLE, goog.dom.TagName.ASIDE, + goog.dom.TagName.BLOCKQUOTE, goog.dom.TagName.BODY, + goog.dom.TagName.CAPTION, goog.dom.TagName.CENTER, goog.dom.TagName.COL, + goog.dom.TagName.COLGROUP, goog.dom.TagName.DETAILS, goog.dom.TagName.DIR, + goog.dom.TagName.DIV, goog.dom.TagName.DL, goog.dom.TagName.DD, + goog.dom.TagName.DT, goog.dom.TagName.FIELDSET, goog.dom.TagName.FIGCAPTION, + goog.dom.TagName.FIGURE, goog.dom.TagName.FOOTER, goog.dom.TagName.FORM, + goog.dom.TagName.H1, goog.dom.TagName.H2, goog.dom.TagName.H3, + goog.dom.TagName.H4, goog.dom.TagName.H5, goog.dom.TagName.H6, + goog.dom.TagName.HEADER, goog.dom.TagName.HGROUP, goog.dom.TagName.HR, + goog.dom.TagName.ISINDEX, goog.dom.TagName.OL, goog.dom.TagName.LI, + goog.dom.TagName.MAIN, goog.dom.TagName.MAP, goog.dom.TagName.MENU, + goog.dom.TagName.NAV, goog.dom.TagName.OPTGROUP, goog.dom.TagName.OPTION, + goog.dom.TagName.P, goog.dom.TagName.PRE, goog.dom.TagName.SECTION, + goog.dom.TagName.SUMMARY, goog.dom.TagName.TABLE, goog.dom.TagName.TBODY, + goog.dom.TagName.TD, goog.dom.TagName.TFOOT, goog.dom.TagName.TH, + goog.dom.TagName.THEAD, goog.dom.TagName.TR, goog.dom.TagName.UL); + + +/** + * Names of tags that have intrinsic content. + * TODO(robbyw): What about object, br, input, textarea, button, isindex, + * hr, keygen, select, table, tr, td? + * @type {Object} + * @private + */ +goog.editor.node.NON_EMPTY_TAGS_ = goog.object.createSet( + goog.dom.TagName.IMG, goog.dom.TagName.IFRAME, goog.dom.TagName.EMBED); + + +/** + * Check if the node is in a standards mode document. + * @param {Node} node The node to test. + * @return {boolean} Whether the node is in a standards mode document. + */ +goog.editor.node.isStandardsMode = function(node) { + 'use strict'; + return goog.dom.getDomHelper(node).isCss1CompatMode(); +}; + + +/** + * Get the right-most non-ignorable leaf node of the given node. + * @param {Node} parent The parent ndoe. + * @return {Node} The right-most non-ignorable leaf node. + */ +goog.editor.node.getRightMostLeaf = function(parent) { + 'use strict'; + var temp; + while (temp = goog.editor.node.getLastChild(parent)) { + parent = temp; + } + return parent; +}; + + +/** + * Get the left-most non-ignorable leaf node of the given node. + * @param {Node} parent The parent ndoe. + * @return {Node} The left-most non-ignorable leaf node. + */ +goog.editor.node.getLeftMostLeaf = function(parent) { + 'use strict'; + var temp; + while (temp = goog.editor.node.getFirstChild(parent)) { + parent = temp; + } + return parent; +}; + + +/** + * Version of firstChild that skips nodes that are entirely + * whitespace and comments. + * @param {Node} parent The reference node. + * @return {Node} The first child of sibling that is important according to + * goog.editor.node.isImportant, or null if no such node exists. + */ +goog.editor.node.getFirstChild = function(parent) { + 'use strict'; + return goog.editor.node.getChildHelper_(parent, false); +}; + + +/** + * Version of lastChild that skips nodes that are entirely whitespace or + * comments. (Normally lastChild is a property of all DOM nodes that gives the + * last of the nodes contained directly in the reference node.) + * @param {Node} parent The reference node. + * @return {Node} The last child of sibling that is important according to + * goog.editor.node.isImportant, or null if no such node exists. + */ +goog.editor.node.getLastChild = function(parent) { + 'use strict'; + return goog.editor.node.getChildHelper_(parent, true); +}; + + +/** + * Version of previoussibling that skips nodes that are entirely + * whitespace or comments. (Normally previousSibling is a property + * of all DOM nodes that gives the sibling node, the node that is + * a child of the same parent, that occurs immediately before the + * reference node.) + * @param {Node} sibling The reference node. + * @return {Node} The closest previous sibling to sibling that is + * important according to goog.editor.node.isImportant, or null if no such + * node exists. + */ +goog.editor.node.getPreviousSibling = function(sibling) { + 'use strict'; + return /** @type {Node} */ (goog.editor.node.getFirstValue_(goog.iter.filter( + new goog.dom.iter.SiblingIterator(sibling, false, true), + goog.editor.node.isImportant))); +}; + + +/** + * Version of nextSibling that skips nodes that are entirely whitespace or + * comments. + * @param {Node} sibling The reference node. + * @return {Node} The closest next sibling to sibling that is important + * according to goog.editor.node.isImportant, or null if no + * such node exists. + */ +goog.editor.node.getNextSibling = function(sibling) { + 'use strict'; + return /** @type {Node} */ (goog.editor.node.getFirstValue_(goog.iter.filter( + new goog.dom.iter.SiblingIterator(sibling), + goog.editor.node.isImportant))); +}; + + +/** + * Internal helper for lastChild/firstChild that skips nodes that are entirely + * whitespace or comments. + * @param {Node} parent The reference node. + * @param {boolean} isReversed Whether children should be traversed forward + * or backward. + * @return {Node} The first/last child of sibling that is important according + * to goog.editor.node.isImportant, or null if no such node exists. + * @private + */ +goog.editor.node.getChildHelper_ = function(parent, isReversed) { + 'use strict'; + return (!parent || parent.nodeType != goog.dom.NodeType.ELEMENT) ? + null : + /** @type {Node} */ + (goog.editor.node.getFirstValue_(goog.iter.filter( + new goog.dom.iter.ChildIterator( + /** @type {!Element} */ (parent), isReversed), + goog.editor.node.isImportant))); +}; + + +/** + * Utility function that returns the first value from an iterator or null if + * the iterator is empty. + * @param {goog.iter.Iterator} iterator The iterator to get a value from. + * @return {*} The first value from the iterator. + * @private + */ +goog.editor.node.getFirstValue_ = function(iterator) { + 'use strict'; + const it = iterator.next(); + if (it.done) return null; + return it.value; +}; + + +/** + * Determine if a node should be returned by the iterator functions. + * @param {Node} node An object implementing the DOM1 Node interface. + * @return {boolean} Whether the node is an element, or a text node that + * is not all whitespace. + */ +goog.editor.node.isImportant = function(node) { + 'use strict'; + // Return true if the node is not either a TextNode or an ElementNode. + return node.nodeType == goog.dom.NodeType.ELEMENT || + node.nodeType == goog.dom.NodeType.TEXT && + !goog.editor.node.isAllNonNbspWhiteSpace(node); +}; + + +/** + * Determine whether a node's text content is entirely whitespace. + * @param {Node} textNode A node implementing the CharacterData interface (i.e., + * a Text, Comment, or CDATASection node. + * @return {boolean} Whether the text content of node is whitespace, + * otherwise false. + */ +goog.editor.node.isAllNonNbspWhiteSpace = function(textNode) { + 'use strict'; + return goog.string.isBreakingWhitespace(textNode.nodeValue); +}; + + +/** + * Returns true if the node contains only whitespace and is not and does not + * contain any images, iframes or embed tags. + * @param {Node} node The node to check. + * @param {boolean=} opt_prohibitSingleNbsp By default, this function treats a + * single nbsp as empty. Set this to true to treat this case as non-empty. + * @return {boolean} Whether the node contains only whitespace. + * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration + */ +goog.editor.node.isEmpty = function(node, opt_prohibitSingleNbsp) { + 'use strict'; + var nodeData = goog.dom.getRawTextContent(node); + + if (node.getElementsByTagName) { + node = /** @type {!Element} */ (node); + for (var tag in goog.editor.node.NON_EMPTY_TAGS_) { + if (node.tagName == tag || node.getElementsByTagName(tag).length > 0) { + return false; + } + } + } + return (!opt_prohibitSingleNbsp && nodeData == goog.string.Unicode.NBSP) || + goog.string.isBreakingWhitespace(nodeData); +}; + + +/** + * Returns the length of the text in node if it is a text node, or the number + * of children of the node, if it is an element. Useful for range-manipulation + * code where you need to know the offset for the right side of the node. + * @param {Node} node The node to get the length of. + * @return {number} The length of the node. + * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration + */ +goog.editor.node.getLength = function(node) { + 'use strict'; + return node.length || node.childNodes.length; +}; + + +/** + * Search child nodes using a predicate function and return the first node that + * satisfies the condition. + * @param {Node} parent The parent node to search. + * @param {function(Node):boolean} hasProperty A function that takes a child + * node as a parameter and returns true if it meets the criteria. + * @return {?number} The index of the node found, or null if no node is found. + */ +goog.editor.node.findInChildren = function(parent, hasProperty) { + 'use strict'; + for (var i = 0, len = parent.childNodes.length; i < len; i++) { + if (hasProperty(parent.childNodes[i])) { + return i; + } + } + return null; +}; + + +/** + * Search ancestor nodes using a predicate function and returns the topmost + * ancestor in the chain of consecutive ancestors that satisfies the condition. + * + * @param {Node} node The node whose ancestors have to be searched. + * @param {function(Node): boolean} hasProperty A function that takes a parent + * node as a parameter and returns true if it meets the criteria. + * @return {Node} The topmost ancestor or null if no ancestor satisfies the + * predicate function. + */ +goog.editor.node.findHighestMatchingAncestor = function(node, hasProperty) { + 'use strict'; + var parent = node.parentNode; + var ancestor = null; + while (parent && hasProperty(parent)) { + ancestor = parent; + parent = parent.parentNode; + } + return ancestor; +}; + + +/** +* Checks if node is a block-level html element. The display css + * property is ignored. + * @param {Node} node The node to test. + * @return {boolean} Whether the node is a block-level node. + */ +goog.editor.node.isBlockTag = function(node) { + 'use strict'; + return !!goog.editor.node.BLOCK_TAG_NAMES_[ + /** @type {!Element} */ (node).tagName]; +}; + + +/** + * Skips siblings of a node that are empty text nodes. + * @param {Node} node A node. May be null. + * @return {Node} The node or the first sibling of the node that is not an + * empty text node. May be null. + */ +goog.editor.node.skipEmptyTextNodes = function(node) { + 'use strict'; + while (node && node.nodeType == goog.dom.NodeType.TEXT && !node.nodeValue) { + node = node.nextSibling; + } + return node; +}; + + +/** + * Checks if an element is a top-level editable container (meaning that + * it itself is not editable, but all its child nodes are editable). + * @param {Node} element The element to test. + * @return {boolean} Whether the element is a top-level editable container. + * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration + */ +goog.editor.node.isEditableContainer = function(element) { + 'use strict'; + return element.getAttribute && element.getAttribute('g_editable') == 'true'; +}; + + +/** + * Checks if a node is inside an editable container. + * @param {Node} node The node to test. + * @return {boolean} Whether the node is in an editable container. + */ +goog.editor.node.isEditable = function(node) { + 'use strict'; + return !!goog.dom.getAncestor(node, goog.editor.node.isEditableContainer); +}; + + +/** + * Finds the top-most DOM node inside an editable field that is an ancestor + * (or self) of a given DOM node and meets the specified criteria. + * @param {Node} node The DOM node where the search starts. + * @param {function(Node) : boolean} criteria A function that takes a DOM node + * as a parameter and returns a boolean to indicate whether the node meets + * the criteria or not. + * @return {Node} The DOM node if found, or null. + */ +goog.editor.node.findTopMostEditableAncestor = function(node, criteria) { + 'use strict'; + var targetNode = null; + while (node && !goog.editor.node.isEditableContainer(node)) { + if (criteria(node)) { + targetNode = node; + } + node = node.parentNode; + } + return targetNode; +}; + + +/** + * Splits off a subtree. + * @param {!Node} currentNode The starting splitting point. + * @param {Node=} opt_secondHalf The initial leftmost leaf the new subtree. + * If null, siblings after currentNode will be placed in the subtree, but + * no additional node will be. + * @param {Node=} opt_root The top of the tree where splitting stops at. + * @return {!Node} The new subtree. + */ +goog.editor.node.splitDomTreeAt = function( + currentNode, opt_secondHalf, opt_root) { + 'use strict'; + var parent; + while (currentNode != opt_root && (parent = currentNode.parentNode)) { + opt_secondHalf = goog.editor.node.getSecondHalfOfNode_( + parent, currentNode, opt_secondHalf); + currentNode = parent; + } + return /** @type {!Node} */ (opt_secondHalf); +}; + + +/** + * Creates a clone of node, moving all children after startNode to it. + * When firstChild is not null or undefined, it is also appended to the clone + * as the first child. + * @param {!Node} node The node to clone. + * @param {!Node} startNode All siblings after this node will be moved to the + * clone. + * @param {Node|undefined} firstChild The first child of the new cloned element. + * @return {!Node} The cloned node that now contains the children after + * startNode. + * @private + */ +goog.editor.node.getSecondHalfOfNode_ = function(node, startNode, firstChild) { + 'use strict'; + var secondHalf = /** @type {!Node} */ (node.cloneNode(false)); + while (startNode.nextSibling) { + goog.dom.appendChild(secondHalf, startNode.nextSibling); + } + if (firstChild) { + secondHalf.insertBefore(firstChild, secondHalf.firstChild); + } + return secondHalf; +}; + + +/** + * Appends all of oldNode's children to newNode. This removes all children from + * oldNode and appends them to newNode. oldNode is left with no children. + * @param {!Node} newNode Node to transfer children to. + * @param {Node} oldNode Node to transfer children from. + * @deprecated Use goog.dom.append directly instead. + */ +goog.editor.node.transferChildren = function(newNode, oldNode) { + 'use strict'; + goog.dom.append(newNode, oldNode.childNodes); +}; + + +/** + * Replaces the innerHTML of a node. + * + * IE has serious problems if you try to set innerHTML of an editable node with + * any selection. Early versions of IE tear up the old internal tree storage, to + * help avoid ref-counting loops. But this sometimes leaves the selection object + * in a bad state and leads to segfaults. + * + * Removing the nodes first prevents IE from tearing them up. This is not + * strictly necessary in nodes that do not have the selection. You should always + * use this function when setting innerHTML inside of a field. + * @param {Node} node A node. + * @param {string} html The innerHTML to set on the node. + * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration + */ +goog.editor.node.replaceInnerHtml = function(node, html) { + 'use strict'; + // Only do this IE. On gecko, we use element change events, and don't + // want to trigger spurious events. + if (goog.userAgent.IE) { + goog.dom.removeChildren(node); + } + goog.dom.safe.setInnerHtml( + goog.asserts.dom.assertIsElement(node), + goog.html.legacyconversions.safeHtmlFromString(html)); +}; diff --git a/closure/goog/editor/node_test.js b/closure/goog/editor/node_test.js new file mode 100644 index 0000000000..618ec8658a --- /dev/null +++ b/closure/goog/editor/node_test.js @@ -0,0 +1,686 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.editor.nodeTest'); +goog.setTestOnly(); + +const ExpectedFailures = goog.require('goog.testing.ExpectedFailures'); +const NodeType = goog.require('goog.dom.NodeType'); +const TagName = goog.require('goog.dom.TagName'); +const editorNode = goog.require('goog.editor.node'); +const googArray = goog.require('goog.array'); +const googDom = goog.require('goog.dom'); +const style = goog.require('goog.style'); +const testSuite = goog.require('goog.testing.testSuite'); +const testingDom = goog.require('goog.testing.dom'); +const userAgent = goog.require('goog.userAgent'); + +let expectedFailures; +let parentNode; +let childNode1; +let childNode2; +let childNode3; + +let gChildWsNode1 = null; +let gChildTextNode1 = null; +let gChildNbspNode1 = null; +let gChildMixedNode1 = null; +let gChildWsNode2a = null; +let gChildWsNode2b = null; +let gChildTextNode3a = null; +let gChildWsNode3 = null; +let gChildTextNode3b = null; + +function setUpDomTree() { + gChildWsNode1 = document.createTextNode(' \t\r\n'); + gChildTextNode1 = document.createTextNode('Child node'); + gChildNbspNode1 = document.createTextNode('\u00a0'); + gChildMixedNode1 = document.createTextNode('Text\n plus\u00a0'); + gChildWsNode2a = document.createTextNode(''); + gChildWsNode2b = document.createTextNode(' '); + gChildTextNode3a = document.createTextNode('I am a grand child'); + gChildWsNode3 = document.createTextNode(' \t \r \n'); + gChildTextNode3b = document.createTextNode('I am also a grand child'); + + childNode3.appendChild(gChildTextNode3a); + childNode3.appendChild(gChildWsNode3); + childNode3.appendChild(gChildTextNode3b); + + childNode1.appendChild(gChildMixedNode1); + childNode1.appendChild(gChildWsNode1); + childNode1.appendChild(gChildNbspNode1); + childNode1.appendChild(gChildTextNode1); + + childNode2.appendChild(gChildWsNode2a); + childNode2.appendChild(gChildWsNode2b); + document.body.appendChild(parentNode); +} + +function tearDownDomTree() { + googDom.removeChildren(childNode1); + googDom.removeChildren(childNode2); + googDom.removeChildren(childNode3); + gChildWsNode1 = null; + gChildTextNode1 = null; + gChildNbspNode1 = null; + gChildMixedNode1 = null; + gChildWsNode2a = null; + gChildWsNode2b = null; + gChildTextNode3a = null; + gChildWsNode3 = null; + gChildTextNode3b = null; +} + +function createDivWithTextNodes(var_args) { + const dom = googDom.createDom(TagName.DIV); + for (let i = 0; i < arguments.length; i++) { + googDom.appendChild(dom, googDom.createTextNode(arguments[i])); + } + return dom; +} + +testSuite({ + setUpPage() { + expectedFailures = new ExpectedFailures(); + parentNode = document.getElementById('parentNode'); + childNode1 = parentNode.childNodes[0]; + childNode2 = parentNode.childNodes[1]; + childNode3 = parentNode.childNodes[2]; + }, + + tearDown() { + expectedFailures.handleTearDown(); + }, + + testGetCompatModeQuirks() { + const quirksIfr = googDom.createElement(TagName.IFRAME); + document.body.appendChild(quirksIfr); + // Webkit used to default to standards mode, but fixed this in + // Safari 4/Chrome 2, aka, WebKit 530. + // Also IE10 fails here. + // TODO(johnlenz): IE10+ inherit quirks mode from the owner document + // according to: + // http://msdn.microsoft.com/en-us/library/ff955402(v=vs.85).aspx + // but this test shows different behavior for IE10 and 11. If we discover + // that we care about quirks mode documents we should investigate + // this failure. + expectedFailures.run(() => { + assertFalse( + 'Empty sourceless iframe is quirks mode, not standards mode', + editorNode.isStandardsMode( + googDom.getFrameContentDocument(quirksIfr))); + }); + document.body.removeChild(quirksIfr); + }, + + testGetCompatModeStandards() { + const standardsIfr = googDom.createElement(TagName.IFRAME); + document.body.appendChild(standardsIfr); + const doc = googDom.getFrameContentDocument(standardsIfr); + doc.open(); + doc.write(' '); + doc.close(); + assertTrue( + 'Iframe with DOCTYPE written in is standards mode', + editorNode.isStandardsMode(doc)); + document.body.removeChild(standardsIfr); + }, + + /** Creates a DOM tree and tests that getLeftMostLeaf returns proper node */ + testGetLeftMostLeaf() { + setUpDomTree(); + + assertEquals( + 'Should skip ws node', gChildMixedNode1, + editorNode.getLeftMostLeaf(parentNode)); + assertEquals( + 'Should skip ws node', gChildMixedNode1, + editorNode.getLeftMostLeaf(childNode1)); + assertEquals( + 'Has no non ws leaves', childNode2, + editorNode.getLeftMostLeaf(childNode2)); + assertEquals( + 'Should return first child', gChildTextNode3a, + editorNode.getLeftMostLeaf(childNode3)); + assertEquals( + 'Has no children', gChildTextNode1, + editorNode.getLeftMostLeaf(gChildTextNode1)); + + tearDownDomTree(); + }, + + /** Creates a DOM tree and tests that getRightMostLeaf returns proper node */ + testGetRightMostLeaf() { + setUpDomTree(); + + assertEquals( + 'Should return child3\'s rightmost child', gChildTextNode3b, + editorNode.getRightMostLeaf(parentNode)); + assertEquals( + 'Should skip ws node', gChildTextNode1, + editorNode.getRightMostLeaf(childNode1)); + assertEquals( + 'Has no non ws leaves', childNode2, + editorNode.getRightMostLeaf(childNode2)); + assertEquals( + 'Should return last child', gChildTextNode3b, + editorNode.getRightMostLeaf(childNode3)); + assertEquals( + 'Has no children', gChildTextNode1, + editorNode.getRightMostLeaf(gChildTextNode1)); + + tearDownDomTree(); + }, + + /** + * Creates a DOM tree and tests that getFirstChild properly ignores + * ignorable nodes + */ + testGetFirstChild() { + setUpDomTree(); + + assertNull('Has no none ws children', editorNode.getFirstChild(childNode2)); + assertEquals( + 'Should skip first child, as it is ws', gChildMixedNode1, + editorNode.getFirstChild(childNode1)); + assertEquals( + 'Should just return first child', gChildTextNode3a, + editorNode.getFirstChild(childNode3)); + assertEquals( + 'Should return first child', childNode1, + editorNode.getFirstChild(parentNode)); + + assertNull( + 'First child of a text node should return null', + editorNode.getFirstChild(gChildTextNode1)); + assertNull( + 'First child of null should return null', + editorNode.getFirstChild(null)); + + tearDownDomTree(); + }, + + /** + * Create a DOM tree and test that getLastChild properly ignores + * ignorable nodes + */ + testGetLastChild() { + setUpDomTree(); + + assertNull('Has no none ws children', editorNode.getLastChild(childNode2)); + assertEquals( + 'Should skip last child, as it is ws', gChildTextNode1, + editorNode.getLastChild(childNode1)); + assertEquals( + 'Should just return last child', gChildTextNode3b, + editorNode.getLastChild(childNode3)); + assertEquals( + 'Should return last child', childNode3, + editorNode.getLastChild(parentNode)); + + assertNull( + 'Last child of a text node should return null', + editorNode.getLastChild(gChildTextNode1)); + assertNull( + 'Last child of null should return null', + editorNode.getLastChild(gChildTextNode1)); + + tearDownDomTree(); + }, + + /** + * Test if nodes that should be ignorable return false and nodes that should + * not be ignored return true. + */ + testIsImportant() { + const wsNode = document.createTextNode(' \t\r\n'); + assertFalse( + 'White space node is ignorable', editorNode.isImportant(wsNode)); + const textNode = document.createTextNode('Hello'); + assertTrue('Text node is important', editorNode.isImportant(textNode)); + const nbspNode = document.createTextNode('\u00a0'); + assertTrue('Node with nbsp is important', editorNode.isImportant(nbspNode)); + const imageNode = googDom.createElement(TagName.IMG); + assertTrue('Image node is important', editorNode.isImportant(imageNode)); + }, + + /** + * Test that isAllNonNbspWhiteSpace returns true if node contains only + * whitespace that is not nbsp and false otherwise + */ + testIsAllNonNbspWhiteSpace() { + const wsNode = document.createTextNode(' \t\r\n'); + assertTrue( + 'String is all non nbsp', editorNode.isAllNonNbspWhiteSpace(wsNode)); + const textNode = document.createTextNode('Hello'); + assertFalse( + 'String should not be whitespace', + editorNode.isAllNonNbspWhiteSpace(textNode)); + const nbspNode = document.createTextNode('\u00a0'); + assertFalse('String has nbsp', editorNode.isAllNonNbspWhiteSpace(nbspNode)); + }, + + /** + * Creates a DOM tree and Test that getPreviousSibling properly ignores + * ignorable nodes + */ + testGetPreviousSibling() { + setUpDomTree(); + + assertNull( + 'No previous sibling', editorNode.getPreviousSibling(gChildTextNode3a)); + assertEquals( + 'Should have text sibling', gChildTextNode3a, + editorNode.getPreviousSibling(gChildWsNode3)); + assertEquals( + 'Should skip over white space sibling', gChildTextNode3a, + editorNode.getPreviousSibling(gChildTextNode3b)); + assertNull( + 'No previous sibling', editorNode.getPreviousSibling(gChildMixedNode1)); + assertEquals( + 'Should have mixed text sibling', gChildMixedNode1, + editorNode.getPreviousSibling(gChildWsNode1)); + assertEquals( + 'Should skip over white space sibling', gChildMixedNode1, + editorNode.getPreviousSibling(gChildNbspNode1)); + assertNotEquals( + 'Should not move past ws and nbsp', gChildMixedNode1, + editorNode.getPreviousSibling(gChildTextNode1)); + assertEquals( + 'Should go to child 2', childNode2, + editorNode.getPreviousSibling(childNode3)); + assertEquals( + 'Should go to child 1', childNode1, + editorNode.getPreviousSibling(childNode2)); + assertNull( + 'Only has white space siblings', + editorNode.getPreviousSibling(gChildWsNode2b)); + + tearDownDomTree(); + }, + + /** + * Creates a DOM tree and tests that getNextSibling properly ignores + * igrnorable nodes when determining the next sibling + */ + testGetNextSibling() { + setUpDomTree(); + + assertEquals( + 'Child 1 should have Child 2', childNode2, + editorNode.getNextSibling(childNode1)); + assertEquals( + 'Child 2 should have child 3', childNode3, + editorNode.getNextSibling(childNode2)); + assertNull( + 'Child 3 has no next sibling', editorNode.getNextSibling(childNode3)); + assertNotEquals( + 'Should not skip ws and nbsp nodes', gChildTextNode1, + editorNode.getNextSibling(gChildMixedNode1)); + assertNotEquals( + 'Should not skip nbsp node', gChildTextNode1, + editorNode.getNextSibling(gChildWsNode1)); + assertEquals( + 'Should have sibling', gChildTextNode1, + editorNode.getNextSibling(gChildNbspNode1)); + assertNull( + 'Should have no next sibling', + editorNode.getNextSibling(gChildTextNode1)); + assertNull( + 'Only has ws sibling', editorNode.getNextSibling(gChildWsNode2a)); + assertNull( + 'Has no next sibling', editorNode.getNextSibling(gChildWsNode2b)); + assertEquals( + 'Should skip ws node', gChildTextNode3b, + editorNode.getNextSibling(gChildTextNode3a)); + + tearDownDomTree(); + }, + + testIsEmpty() { + const textNode = document.createTextNode(''); + assertTrue( + 'Text node with no content should be empty', + editorNode.isEmpty(textNode)); + textNode.data = '\xa0'; + assertTrue( + 'Text node with nbsp should be empty', editorNode.isEmpty(textNode)); + assertFalse( + 'Text node with nbsp should not be empty when prohibited', + editorNode.isEmpty(textNode, true)); + + textNode.data = ' '; + assertTrue( + 'Text node with whitespace should be empty', + editorNode.isEmpty(textNode)); + textNode.data = 'notEmpty'; + assertFalse( + 'Text node with text should not be empty', + editorNode.isEmpty(textNode)); + + const div = googDom.createElement(TagName.DIV); + assertTrue('Empty div should be empty', editorNode.isEmpty(div)); + div.innerHTML = ''; + assertFalse( + 'Div containing an iframe is not empty', editorNode.isEmpty(div)); + div.innerHTML = ''; + assertFalse( + 'Div containing an image is not empty', editorNode.isEmpty(div)); + div.innerHTML = ''; + assertFalse( + 'Div containing an embed is not empty', editorNode.isEmpty(div)); + div.innerHTML = '
    '; + assertTrue( + 'Div containing other empty tags is empty', editorNode.isEmpty(div)); + div.innerHTML = '
    '; + assertTrue( + 'Div containing other empty tags and whitespace is empty', + editorNode.isEmpty(div)); + div.innerHTML = '
    Not empty
    '; + assertFalse( + 'Div containing tags and text is not empty', editorNode.isEmpty(div)); + + const img = googDom.createElement(TagName.IMG); + assertFalse('Empty img should not be empty', editorNode.isEmpty(img)); + + const iframe = googDom.createElement(TagName.IFRAME); + assertFalse('Empty iframe should not be empty', editorNode.isEmpty(iframe)); + + const embed = googDom.createElement(TagName.EMBED); + assertFalse('Empty embed should not be empty', editorNode.isEmpty(embed)); + }, + + /** + * Test that getLength returns 0 if the node has no length and no children, + * the # of children if the node has no length but does have children, + * and the length of the node if the node does have length + */ + testGetLength() { + const parentNode = googDom.createElement(TagName.P); + + assertEquals( + 'Length 0 and no children', 0, editorNode.getLength(parentNode)); + + const childNode1 = document.createTextNode('node 1'); + const childNode2 = document.createTextNode('node number 2'); + const childNode3 = document.createTextNode(''); + parentNode.appendChild(childNode1); + parentNode.appendChild(childNode2); + parentNode.appendChild(childNode3); + assertEquals( + 'Length 0 and 3 children', 3, editorNode.getLength(parentNode)); + assertEquals('Text node, length 6', 6, editorNode.getLength(childNode1)); + assertEquals('Text node, length 0', 0, editorNode.getLength(childNode3)); + }, + + testFindInChildrenSuccess() { + const parentNode = googDom.createElement(TagName.DIV); + parentNode.innerHTML = '
    foo
    foo2'; + + const index = editorNode.findInChildren( + parentNode, /** + @suppress {strictMissingProperties} suppression added to + enable type checking + */ + (node) => node.tagName == TagName.B); + assertEquals('Should find second child', index, 1); + }, + + testFindInChildrenFailure() { + const parentNode = googDom.createElement(TagName.DIV); + parentNode.innerHTML = '
    foo
    foo2'; + + const index = editorNode.findInChildren(parentNode, (node) => false); + assertNull('Shouldn\'t find a child', index); + }, + + testFindHighestMatchingAncestor() { + setUpDomTree(); + let predicateFunc = (node) => node.tagName == TagName.DIV; + let node = + editorNode.findHighestMatchingAncestor(gChildTextNode3a, predicateFunc); + assertNotNull('Should return an ancestor', node); + assertEquals( + 'Should have found "parentNode" as the last ' + + 'ancestor matching the predicate', + parentNode, node); + + predicateFunc = (node) => node.childNodes.length == 1; + node = + editorNode.findHighestMatchingAncestor(gChildTextNode3a, predicateFunc); + assertNull('Shouldn\'t return an ancestor', node); + + tearDownDomTree(); + }, + + testIsBlock() { + const blockDisplays = [ + 'block', + 'list-item', + 'table', + 'table-caption', + 'table-cell', + 'table-column', + 'table-column-group', + 'table-footer', + 'table-footer-group', + 'table-header-group', + 'table-row', + 'table-row-group', + ]; + + const structuralTags = [ + TagName.BODY, + TagName.FRAME, + TagName.FRAMESET, + TagName.HEAD, + TagName.HTML, + ]; + + // The following tags are considered inline in IE, except LEGEND which is + // only a block element in WEBKIT. + const ambiguousTags = [ + TagName.DETAILS, + TagName.HR, + TagName.ISINDEX, + TagName.LEGEND, + TagName.MAIN, + TagName.MAP, + TagName.NOFRAMES, + TagName.OPTGROUP, + TagName.OPTION, + TagName.SUMMARY, + ]; + + // Older versions of IE and Gecko consider the following elements to be + // inline, but IE9+ and Gecko 2.0+ recognize the new elements. + const legacyAmbiguousTags = [ + TagName.ARTICLE, + TagName.ASIDE, + TagName.FIGCAPTION, + TagName.FIGURE, + TagName.FOOTER, + TagName.HEADER, + TagName.HGROUP, + TagName.NAV, + TagName.SECTION, + ]; + + const tagsToIgnore = googArray.flatten(structuralTags, ambiguousTags); + + if (userAgent.IE && !userAgent.isDocumentModeOrHigher(9)) { + googArray.extend(tagsToIgnore, legacyAmbiguousTags); + } + + // Appending an applet tag can cause the test to hang if Java is blocked on + // the system. + tagsToIgnore.push(TagName.APPLET); + + // Appending an embed tag to the page in IE brings up a warning dialog about + // loading Java content. + if (userAgent.IE) { + tagsToIgnore.push(TagName.EMBED); + } + + const failures = []; + for (let tag in TagName) { + if (googArray.contains(tagsToIgnore, TagName[tag])) { + continue; + } + + const el = googDom.createElement(tag); + document.body.appendChild(el); + const display = style.getCascadedStyle(el, 'display') || + style.getComputedStyle(el, 'display'); + googDom.removeNode(el); + + if (editorNode.isBlockTag(el)) { + if (!googArray.contains(blockDisplays, display)) { + failures.push(`Display for ${tag} should be block-like`); + } + } else { + if (googArray.contains(blockDisplays, display)) { + failures.push(`Display for ${tag} should not be block-like`); + } + } + } + if (failures.length) { + fail(failures.join('\n')); + } + }, + + testSkipEmptyTextNodes() { + assertNull( + 'skipEmptyTextNodes should gracefully handle null', + editorNode.skipEmptyTextNodes(null)); + + const dom1 = createDivWithTextNodes('abc', '', 'xyz', '', ''); + assertEquals( + 'expected not to skip first child', dom1.firstChild, + editorNode.skipEmptyTextNodes(dom1.firstChild)); + assertEquals( + 'expected to skip second child', dom1.childNodes[2], + editorNode.skipEmptyTextNodes(dom1.childNodes[1])); + assertNull( + 'expected to skip all the rest of the children', + editorNode.skipEmptyTextNodes(dom1.childNodes[3])); + }, + + testIsEditableContainer() { + const editableContainerElement = document.getElementById('editableTest'); + assertTrue( + 'Container element should be considered editable container', + editorNode.isEditableContainer(editableContainerElement)); + + const nonEditableContainerElement = document.getElementById('parentNode'); + assertFalse( + 'Other element should not be considered editable container', + editorNode.isEditableContainer(nonEditableContainerElement)); + }, + + testIsEditable() { + const editableContainerElement = document.getElementById('editableTest'); + const childNode = editableContainerElement.firstChild; + const childElement = + googDom.getElementsByTagName(TagName.SPAN, editableContainerElement)[0]; + + assertFalse( + 'Container element should not be considered editable', + editorNode.isEditable(editableContainerElement)); + assertTrue( + 'Child text node should be considered editable', + editorNode.isEditable(childNode)); + assertTrue( + 'Child element should be considered editable', + editorNode.isEditable(childElement)); + assertTrue( + 'Grandchild node should be considered editable', + editorNode.isEditable(childElement.firstChild)); + assertFalse( + 'Other element should not be considered editable', + editorNode.isEditable(document.getElementById('parentNode'))); + }, + + testFindTopMostEditableAncestor() { + const root = document.getElementById('editableTest'); + /** @suppress {checkTypes} suppression added to enable type checking */ + const span = googDom.getElementsByTagName(TagName.SPAN, root)[0]; + const textNode = span.firstChild; + + assertEquals( + 'Should return self if self is matched.', textNode, + editorNode.findTopMostEditableAncestor( + textNode, (node) => node.nodeType == NodeType.TEXT)); + assertEquals( + 'Should not walk out of editable node.', null, + editorNode.findTopMostEditableAncestor( + textNode, /** + @suppress {strictMissingProperties} suppression added + to enable type checking + */ + (node) => node.tagName == TagName.BODY)); + assertEquals( + 'Should not match editable container.', null, + editorNode.findTopMostEditableAncestor( + textNode, /** + @suppress {strictMissingProperties} suppression added + to enable type checking + */ + (node) => node.tagName == TagName.DIV)); + assertEquals( + 'Should find node in editable container.', span, + editorNode.findTopMostEditableAncestor( + textNode, /** + @suppress {strictMissingProperties} suppression added + to enable type checking + */ + (node) => node.tagName == TagName.SPAN)); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testSplitDomTreeAt() { + const innerHTML = '

    123

    '; + const root = googDom.createElement(TagName.DIV); + + root.innerHTML = innerHTML; + let result = editorNode.splitDomTreeAt( + googDom.getElementsByTagName(TagName.B, root)[0], null, root); + testingDom.assertHtmlContentsMatch('

    12

    ', root); + testingDom.assertHtmlContentsMatch('

    3

    ', result); + + root.innerHTML = innerHTML; + result = editorNode.splitDomTreeAt( + googDom.getElementsByTagName(TagName.B, root)[0], + googDom.createTextNode('and'), root); + testingDom.assertHtmlContentsMatch('

    12

    ', root); + testingDom.assertHtmlContentsMatch('

    and3

    ', result); + }, + + testTransferChildren() { + const prefix = 'Bold 1'; + const innerHTML = 'Bold
    • Item 1
    • Item 2
    '; + + const root1 = googDom.createElement(TagName.DIV); + root1.innerHTML = innerHTML; + + const root2 = googDom.createElement(TagName.P); + root2.innerHTML = prefix; + + const b = googDom.getElementsByTagName(TagName.B, root1)[0]; + + // Transfer the children. + editorNode.transferChildren(root2, root1); + assertEquals(0, root1.childNodes.length); + testingDom.assertHtmlContentsMatch(prefix + innerHTML, root2); + assertEquals(b, googDom.getElementsByTagName(TagName.B, root2)[1]); + + // Transfer them back. + editorNode.transferChildren(root1, root2); + assertEquals(0, root2.childNodes.length); + testingDom.assertHtmlContentsMatch(prefix + innerHTML, root1); + assertEquals(b, googDom.getElementsByTagName(TagName.B, root1)[1]); + }, +}); diff --git a/closure/goog/editor/node_test_dom.html b/closure/goog/editor/node_test_dom.html new file mode 100644 index 0000000000..8a97de4a63 --- /dev/null +++ b/closure/goog/editor/node_test_dom.html @@ -0,0 +1,9 @@ + +
    +Foo +
    nodeelement
    diff --git a/closure/goog/editor/plugin.js b/closure/goog/editor/plugin.js new file mode 100644 index 0000000000..87616b8437 --- /dev/null +++ b/closure/goog/editor/plugin.js @@ -0,0 +1,26 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Aliases `goog.editor.PluginImpl`. + * + * This is done to create a target for `goog.editor.PluginImpl` that also pulls + * in `goog.editor.Field` without creating a cycle. Doing so allows downstream + * targets to depend only on `goog.editor.Plugin` without js_library complaining + * about unfullfilled forward declarations. + */ + +goog.provide('goog.editor.Plugin'); + +/** @suppress {extraRequire} This is the whole point. */ +goog.require('goog.editor.Field'); +goog.require('goog.editor.PluginImpl'); + +/** + * @constructor + * @extends {goog.editor.PluginImpl} + */ +goog.editor.Plugin = goog.editor.PluginImpl; diff --git a/closure/goog/editor/plugin_impl.js b/closure/goog/editor/plugin_impl.js new file mode 100644 index 0000000000..6764a4aa30 --- /dev/null +++ b/closure/goog/editor/plugin_impl.js @@ -0,0 +1,496 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Abstract API for TrogEdit plugins. + * + * @see ../demos/editor/editor.html + */ + +goog.provide('goog.editor.PluginImpl'); + +goog.require('goog.events.EventTarget'); +goog.require('goog.functions'); +goog.require('goog.log'); +goog.require('goog.object'); +goog.require('goog.reflect'); +goog.require('goog.userAgent'); +goog.requireType('goog.dom.DomHelper'); +goog.requireType('goog.editor.Field'); +// TODO(user): Remove the dependency on goog.editor.Command asap. Currently only +// needed for execCommand issues with links. +goog.requireType('goog.events.BrowserEvent'); + +/** + * Abstract API for trogedit plugins. + * @constructor + * @extends {goog.events.EventTarget} + * @package + */ +goog.editor.PluginImpl = function() { + 'use strict'; + goog.events.EventTarget.call(this); + + /** + * Whether this plugin is enabled for the registered field object. + * @type {boolean} + * @private + */ + this.enabled_ = this.activeOnUneditableFields(); + + /** + * The field object this plugin is attached to. + * @type {?goog.editor.Field} + * @protected + * @deprecated Use goog.editor.PluginImpl.getFieldObject and + * goog.editor.PluginImpl.setFieldObject. + */ + this.fieldObject = null; + + /** + * Indicates if this plugin should be automatically disposed when the + * registered field is disposed. This should be changed to false for + * plugins used as multi-field plugins. + * @type {boolean} + * @private + */ + this.autoDispose_ = true; + + /** + * The logger for this plugin. + * @type {?goog.log.Logger} + * @protected + */ + this.logger = goog.log.getLogger('goog.editor.Plugin'); +}; +goog.inherits(goog.editor.PluginImpl, goog.events.EventTarget); + + +/** + * @return {goog.dom.DomHelper?} The dom helper object associated with the + * currently active field. + */ +goog.editor.PluginImpl.prototype.getFieldDomHelper = function() { + 'use strict'; + return this.getFieldObject() && this.getFieldObject().getEditableDomHelper(); +}; + + +/** + * Sets the field object for use with this plugin. + * @return {goog.editor.Field} The editable field object. + * @protected + * @suppress {deprecated} Until fieldObject can be made private. + */ +goog.editor.PluginImpl.prototype.getFieldObject = function() { + 'use strict'; + return this.fieldObject; +}; + + +/** + * Sets the field object for use with this plugin. + * @param {goog.editor.Field} fieldObject The editable field object. + * @protected + * @suppress {deprecated} Until fieldObject can be made private. + */ +goog.editor.PluginImpl.prototype.setFieldObject = function(fieldObject) { + 'use strict'; + this.fieldObject = fieldObject; +}; + + +/** + * Registers the field object for use with this plugin. + * @param {goog.editor.Field} fieldObject The editable field object. + */ +goog.editor.PluginImpl.prototype.registerFieldObject = function(fieldObject) { + 'use strict'; + this.setFieldObject(fieldObject); +}; + + +/** + * Unregisters and disables this plugin for the current field object. + * @param {goog.editor.Field} fieldObj The field object. For single-field + * plugins, this parameter is ignored. + */ +goog.editor.PluginImpl.prototype.unregisterFieldObject = function(fieldObj) { + 'use strict'; + if (this.getFieldObject()) { + this.disable(this.getFieldObject()); + this.setFieldObject(null); + } +}; + + +/** + * Enables this plugin for the specified, registered field object. A field + * object should only be enabled when it is loaded. + * @param {goog.editor.Field} fieldObject The field object. + */ +goog.editor.PluginImpl.prototype.enable = function(fieldObject) { + 'use strict'; + if (this.getFieldObject() == fieldObject) { + this.enabled_ = true; + } else { + goog.log.error( + this.logger, + 'Trying to enable an unregistered field with ' + + 'this plugin.'); + } +}; + + +/** + * Disables this plugin for the specified, registered field object. + * @param {goog.editor.Field} fieldObject The field object. + */ +goog.editor.PluginImpl.prototype.disable = function(fieldObject) { + 'use strict'; + if (this.getFieldObject() == fieldObject) { + this.enabled_ = false; + } else { + goog.log.error( + this.logger, + 'Trying to disable an unregistered field ' + + 'with this plugin.'); + } +}; + + +/** + * Returns whether this plugin is enabled for the field object. + * + * @param {goog.editor.Field} fieldObject The field object. + * @return {boolean} Whether this plugin is enabled for the field object. + */ +goog.editor.PluginImpl.prototype.isEnabled = function(fieldObject) { + 'use strict'; + return this.getFieldObject() == fieldObject ? this.enabled_ : false; +}; + + +/** + * Set if this plugin should automatically be disposed when the registered + * field is disposed. + * @param {boolean} autoDispose Whether to autoDispose. + */ +goog.editor.PluginImpl.prototype.setAutoDispose = function(autoDispose) { + 'use strict'; + this.autoDispose_ = autoDispose; +}; + + +/** + * @return {boolean} Whether or not this plugin should automatically be disposed + * when it's registered field is disposed. + */ +goog.editor.PluginImpl.prototype.isAutoDispose = function() { + 'use strict'; + return this.autoDispose_; +}; + + +/** + * @return {boolean} If true, field will not disable the command + * when the field becomes uneditable. + */ +goog.editor.PluginImpl.prototype.activeOnUneditableFields = + goog.functions.FALSE; + + +/** + * @param {string} command The command to check. + * @return {boolean} If true, field will not dispatch change events + * for commands of this type. This is useful for "seamless" plugins like + * dialogs and lorem ipsum. + */ +goog.editor.PluginImpl.prototype.isSilentCommand = goog.functions.FALSE; + + +/** @override */ +goog.editor.PluginImpl.prototype.disposeInternal = function() { + 'use strict'; + if (this.getFieldObject()) { + this.unregisterFieldObject(this.getFieldObject()); + } + + goog.editor.PluginImpl.superClass_.disposeInternal.call(this); +}; + + +/** + * @return {string} The ID unique to this plugin class. Note that different + * instances off the plugin share the same classId. + */ +goog.editor.PluginImpl.prototype.getTrogClassId; + + +/** + * An enum of operations that plugins may support. + * @enum {number} + */ +goog.editor.PluginImpl.Op = { + KEYDOWN: 1, + KEYPRESS: 2, + KEYUP: 3, + SELECTION: 4, + SHORTCUT: 5, + EXEC_COMMAND: 6, + QUERY_COMMAND: 7, + PREPARE_CONTENTS_HTML: 8, + CLEAN_CONTENTS_HTML: 10, + CLEAN_CONTENTS_DOM: 11 +}; + + +/** + * A map from plugin operations to the names of the methods that + * invoke those operations. + */ +goog.editor.PluginImpl.OPCODE = + goog.object.transpose(goog.reflect.object(goog.editor.PluginImpl, { + handleKeyDown: goog.editor.PluginImpl.Op.KEYDOWN, + handleKeyPress: goog.editor.PluginImpl.Op.KEYPRESS, + handleKeyUp: goog.editor.PluginImpl.Op.KEYUP, + handleSelectionChange: goog.editor.PluginImpl.Op.SELECTION, + handleKeyboardShortcut: goog.editor.PluginImpl.Op.SHORTCUT, + execCommand: goog.editor.PluginImpl.Op.EXEC_COMMAND, + queryCommandValue: goog.editor.PluginImpl.Op.QUERY_COMMAND, + prepareContentsHtml: goog.editor.PluginImpl.Op.PREPARE_CONTENTS_HTML, + cleanContentsHtml: goog.editor.PluginImpl.Op.CLEAN_CONTENTS_HTML, + cleanContentsDom: goog.editor.PluginImpl.Op.CLEAN_CONTENTS_DOM + })); + + +/** + * A set of op codes that run even on disabled plugins. + */ +goog.editor.PluginImpl.IRREPRESSIBLE_OPS = goog.object.createSet( + goog.editor.PluginImpl.Op.PREPARE_CONTENTS_HTML, + goog.editor.PluginImpl.Op.CLEAN_CONTENTS_HTML, + goog.editor.PluginImpl.Op.CLEAN_CONTENTS_DOM); + + +/** + * Handles keydown. It is run before handleKeyboardShortcut and if it returns + * true handleKeyboardShortcut will not be called. + * @param {!goog.events.BrowserEvent} e The browser event. + * @return {boolean} Whether the event was handled and thus should *not* be + * propagated to other plugins or handleKeyboardShortcut. + */ +goog.editor.PluginImpl.prototype.handleKeyDown; + + +/** + * Handles keypress. It is run before handleKeyboardShortcut and if it returns + * true handleKeyboardShortcut will not be called. + * @param {!goog.events.BrowserEvent} e The browser event. + * @return {boolean} Whether the event was handled and thus should *not* be + * propagated to other plugins or handleKeyboardShortcut. + */ +goog.editor.PluginImpl.prototype.handleKeyPress; + + +/** + * Handles keyup. + * @param {!goog.events.BrowserEvent} e The browser event. + * @return {boolean} Whether the event was handled and thus should *not* be + * propagated to other plugins. + */ +goog.editor.PluginImpl.prototype.handleKeyUp; + + +/** + * Handles selection change. + * @param {!goog.events.BrowserEvent=} opt_e The browser event. + * @param {!Node=} opt_target The node the selection changed to. + * @return {boolean} Whether the event was handled and thus should *not* be + * propagated to other plugins. + */ +goog.editor.PluginImpl.prototype.handleSelectionChange; + + +/** + * Handles keyboard shortcuts. Preferred to using handleKey* as it will use + * the proper event based on browser and will be more performant. If + * handleKeyPress/handleKeyDown returns true, this will not be called. If the + * plugin handles the shortcut, it is responsible for dispatching appropriate + * events (change, selection change at the time of this comment). If the plugin + * calls execCommand on the editable field, then execCommand already takes care + * of dispatching events. + * NOTE: For performance reasons this is only called when any key is pressed + * in conjunction with ctrl/meta keys OR when a small subset of keys (defined + * in goog.editor.Field.POTENTIAL_SHORTCUT_KEYCODES_) are pressed without + * ctrl/meta keys. We specifically don't invoke it when altKey is pressed since + * alt key is used in many i18n UIs to enter certain characters. + * @param {!goog.events.BrowserEvent} e The browser event. + * @param {string} key The key pressed. + * @param {boolean} isModifierPressed Whether the ctrl/meta key was pressed or + * not. + * @return {boolean} Whether the event was handled and thus should *not* be + * propagated to other plugins. We also call preventDefault on the event if + * the return value is true. + */ +goog.editor.PluginImpl.prototype.handleKeyboardShortcut; + + +/** + * Handles execCommand. This default implementation handles dispatching + * BEFORECHANGE, CHANGE, and SELECTIONCHANGE events, and calls + * execCommandInternal to perform the actual command. Plugins that want to + * do their own event dispatching should override execCommand, otherwise + * it is preferred to only override execCommandInternal. + * + * This version of execCommand will only work for single field plugins. + * Multi-field plugins must override execCommand. + * + * @param {string} command The command to execute. + * @param {...?} var_args Any additional parameters needed to + * execute the command. + * @return {*} The result of the execCommand, if any. + */ +goog.editor.PluginImpl.prototype.execCommand = function(command, var_args) { + 'use strict'; + // TODO(user): Replace all uses of isSilentCommand with plugins that just + // override this base execCommand method. + var silent = this.isSilentCommand(command); + if (silent) { + this.getFieldObject().stopChangeEvents( + /* opt_stopChange= */ true, /* opt_stopDelayedChange= */ true); + } else { + // Stop listening to mutation events in Firefox while text formatting + // is happening. This prevents us from trying to size the field in the + // middle of an execCommand, catching the field in a strange intermediary + // state where both replacement nodes and original nodes are appended to + // the dom. Note that change events get turned back on by + // fieldObj.dispatchChange. + if (goog.userAgent.GECKO) { + this.getFieldObject().stopChangeEvents(true, true); + } + + this.getFieldObject().dispatchBeforeChange(); + } + + try { + var result = this.execCommandInternal.apply(this, arguments); + } finally { + // If the above execCommandInternal call throws an exception, we still need + // to turn change events back on (see http://b/issue?id=1471355). + // NOTE: If if you add to or change the methods called in this finally + // block, please add them as expected calls to the unit test function + // testExecCommandException(). + if (silent) { + this.getFieldObject().startChangeEvents( + /* opt_fireChange= */ false, /* opt_fireDelayedChange= */ false); + } else { + // dispatchChange includes a call to startChangeEvents, which unwinds the + // call to stopChangeEvents made before the try block. + this.getFieldObject().dispatchChange(); + this.getFieldObject().dispatchSelectionChangeEvent(); + } + } + + return result; +}; + + +/** + * Handles execCommand. This default implementation does nothing, and is + * called by execCommand, which handles event dispatching. This method should + * be overriden by plugins that don't need to do their own event dispatching. + * If custom event dispatching is needed, execCommand shoul be overriden + * instead. + * + * TODO(user): This pattern makes accurate typing impossible. + * + * @param {?} command `extends string` The command to execute. + * @param {...?} var_args Any additional parameters needed to + * execute the command. + * @return {*} The result of the execCommand, if any. + * @protected + */ +goog.editor.PluginImpl.prototype.execCommandInternal; + + +/** + * Gets the state of this command if this plugin serves that command. + * @param {string} command The command to check. + * @return {*} The value of the command. + */ +goog.editor.PluginImpl.prototype.queryCommandValue; + + +/** + * Prepares the given HTML for editing. Strips out content that should not + * appear in an editor, and normalizes content as appropriate. The inverse + * of cleanContentsHtml. + * + * This op is invoked even on disabled plugins. + * + * @param {string} originalHtml The original HTML. + * @param {Object} styles A map of strings. If the plugin wants to add + * any styles to the field element, it should add them as key-value + * pairs to this object. + * @return {string} New HTML that's ok for editing. + */ +goog.editor.PluginImpl.prototype.prepareContentsHtml; + + +/** + * Cleans the contents of the node passed to it. The node contents are modified + * directly, and the modifications will subsequently be used, for operations + * such as saving the innerHTML of the editor etc. Since the plugins act on + * the DOM directly, this method can be very expensive. + * + * This op is invoked even on disabled plugins. + * + * @param {!Element} fieldCopy The copy of the editable field which + * needs to be cleaned up. + */ +goog.editor.PluginImpl.prototype.cleanContentsDom; + + +/** + * Cleans the html contents of Trogedit. Both cleanContentsDom and + * and cleanContentsHtml will be called on contents extracted from Trogedit. + * The inverse of prepareContentsHtml. + * + * This op is invoked even on disabled plugins. + * + * @param {string} originalHtml The trogedit HTML. + * @return {string} Cleaned-up HTML. + */ +goog.editor.PluginImpl.prototype.cleanContentsHtml; + + +/** + * Whether the string corresponds to a command this plugin handles. + * @param {string} command Command string to check. + * @return {boolean} Whether the plugin handles this type of command. + */ +goog.editor.PluginImpl.prototype.isSupportedCommand = function(command) { + 'use strict'; + return false; +}; + + +/** + * Saves the field's scroll position. See b/7279077 for context. + * Currently only does anything in Edge, since all other browsers + * already seem to work correctly. + * @return {function()} A function to restore the current scroll position. + * @protected + */ +goog.editor.PluginImpl.prototype.saveScrollPosition = function() { + 'use strict'; + if (this.getFieldObject() && goog.userAgent.EDGE) { + var win = this.getFieldObject().getEditableDomHelper().getWindow(); + return win.scrollTo.bind(win, win.scrollX, win.scrollY); + } + return function() {}; +}; diff --git a/closure/goog/editor/plugin_test.js b/closure/goog/editor/plugin_test.js new file mode 100644 index 0000000000..8c352d3adc --- /dev/null +++ b/closure/goog/editor/plugin_test.js @@ -0,0 +1,193 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.editor.PluginTest'); +goog.setTestOnly(); + +const Field = goog.require('goog.editor.Field'); +const Plugin = goog.require('goog.editor.Plugin'); +const StrictMock = goog.require('goog.testing.StrictMock'); +const functions = goog.require('goog.functions'); +const testSuite = goog.require('goog.testing.testSuite'); +const userAgent = goog.require('goog.userAgent'); + +let plugin; +let fieldObject; + +testSuite({ + setUp() { + plugin = new Plugin(); + fieldObject = {}; + }, + + tearDown() { + plugin.dispose(); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testRegisterFieldObject() { + plugin.registerFieldObject(fieldObject); + assertEquals( + 'Register field object must be stored in protected field.', fieldObject, + plugin.fieldObject); + + assertFalse( + 'Newly registered plugin must not be enabled.', + plugin.isEnabled(fieldObject)); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testUnregisterFieldObject() { + plugin.registerFieldObject(fieldObject); + plugin.enable(fieldObject); + plugin.unregisterFieldObject(fieldObject); + + assertNull( + 'fieldObject property must be undefined after ' + + 'unregistering a field object.', + plugin.fieldObject); + assertFalse( + 'Unregistered field object must not be enabled', + plugin.isEnabled(fieldObject)); + }, + + testEnable() { + plugin.registerFieldObject(fieldObject); + plugin.enable(fieldObject); + + assertTrue( + 'Enabled field object must be enabled according to isEnabled().', + plugin.isEnabled(fieldObject)); + }, + + testDisable() { + plugin.registerFieldObject(fieldObject); + plugin.enable(fieldObject); + plugin.disable(fieldObject); + + assertFalse( + 'Disabled field object must be disabled according to ' + + 'isEnabled().', + plugin.isEnabled(fieldObject)); + }, + + testIsEnabled() { + // Other base cases covered while testing enable() and disable(). + + assertFalse( + 'Unregistered field object must be disabled according ' + + 'to isEnabled().', + plugin.isEnabled(fieldObject)); + }, + + testIsSupportedCommand() { + assertFalse( + 'Base plugin class must not support any commands.', + plugin.isSupportedCommand('+indent')); + }, + + /** + @suppress {missingProperties} suppression added to enable type + checking + */ + testExecCommand() { + const mockField = new StrictMock(Field); + plugin.registerFieldObject(mockField); + + if (userAgent.GECKO) { + mockField.stopChangeEvents(true, true); + } + mockField.dispatchBeforeChange(); + // Note(user): dispatch change turns back on (delayed) change events. + mockField.dispatchChange(); + mockField.dispatchSelectionChangeEvent(); + mockField.$replay(); + + let passedArg; + let passedCommand; + + /** @suppress {visibility} suppression added to enable type checking */ + plugin.execCommandInternal = (command, arg) => { + passedCommand = command; + passedArg = arg; + }; + plugin.execCommand('+indent', true); + + // Verify that execCommand dispatched the expected events. + mockField.$verify(); + mockField.$reset(); + // Verify that execCommandInternal was called with the correct + // arguments. + assertEquals('+indent', passedCommand); + assertTrue(passedArg); + + plugin.isSilentCommand = functions.constant(true); + mockField.stopChangeEvents(true, true); + mockField.startChangeEvents(false, false); + mockField.$replay(); + plugin.execCommand('+outdent', false); + // Verify that execCommand on a silent plugin dispatched no events. + mockField.$verify(); + // Verify that execCommandInternal was called with the correct + // arguments. + assertEquals('+outdent', passedCommand); + assertFalse(passedArg); + }, + + /** + Regression test for http://b/issue?id=1471355 . + @suppress {missingProperties} suppression added to enable type checking + */ + testExecCommandException() { + const mockField = new StrictMock(Field); + plugin.registerFieldObject(mockField); + /** + * @suppress {visibility,duplicate} suppression added to enable type + * checking + */ + plugin.execCommandInternal = () => { + throw 1; + }; + + if (userAgent.GECKO) { + mockField.stopChangeEvents(true, true); + } + mockField.dispatchBeforeChange(); + // Note(user): dispatch change turns back on (delayed) change events. + mockField.dispatchChange(); + mockField.dispatchSelectionChangeEvent(); + mockField.$replay(); + + assertThrows('Exception should not be swallowed', () => { + plugin.execCommand(); + }); + + // Verifies that cleanup is done despite the exception. + mockField.$verify(); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testDisposed() { + plugin.registerFieldObject(fieldObject); + plugin.dispose(); + assert(plugin.getDisposed()); + assertNull( + 'Disposed plugin must not have a field object.', plugin.fieldObject); + assertFalse( + 'Disposed plugin must not have an enabled field object.', + plugin.isEnabled(fieldObject)); + }, + + testIsAndSetAutoDispose() { + assertTrue('Plugin must start auto-disposable', plugin.isAutoDispose()); + + plugin.setAutoDispose(false); + assertFalse(plugin.isAutoDispose()); + + plugin.setAutoDispose(true); + assertTrue(plugin.isAutoDispose()); + }, +}); diff --git a/closure/goog/editor/plugins/BUILD b/closure/goog/editor/plugins/BUILD new file mode 100644 index 0000000000..13c0625b95 --- /dev/null +++ b/closure/goog/editor/plugins/BUILD @@ -0,0 +1,384 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "abstractbubbleplugin", + srcs = ["abstractbubbleplugin.js"], + lenient = True, + deps = [ + "//closure/goog/array", + "//closure/goog/dom", + "//closure/goog/dom:classlist", + "//closure/goog/dom:nodetype", + "//closure/goog/dom:range", + "//closure/goog/dom:tagname", + "//closure/goog/editor:plugin", + "//closure/goog/editor:style", + "//closure/goog/events", + "//closure/goog/events:actioneventwrapper", + "//closure/goog/events:browserevent", + "//closure/goog/events:eventhandler", + "//closure/goog/events:eventtype", + "//closure/goog/events:keycodes", + "//closure/goog/functions", + "//closure/goog/string", + "//closure/goog/ui:component", + "//closure/goog/ui/editor:bubble", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "abstractdialogplugin", + srcs = ["abstractdialogplugin.js"], + lenient = True, + deps = [ + "//closure/goog/dom", + "//closure/goog/dom:abstractrange", + "//closure/goog/dom:range", + "//closure/goog/editor:field", + "//closure/goog/editor:plugin", + "//closure/goog/editor:range", + "//closure/goog/events", + "//closure/goog/events:event", + "//closure/goog/ui/editor:abstractdialog", + ], +) + +closure_js_library( + name = "abstracttabhandler", + srcs = ["abstracttabhandler.js"], + lenient = True, + deps = [ + "//closure/goog/editor:plugin", + "//closure/goog/events:browserevent", + "//closure/goog/events:keycodes", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "basictextformatter", + srcs = ["basictextformatter.js"], + lenient = True, + deps = [ + "//closure/goog/array", + "//closure/goog/dom", + "//closure/goog/dom:abstractrange", + "//closure/goog/dom:nodetype", + "//closure/goog/dom:range", + "//closure/goog/dom:safe", + "//closure/goog/dom:tagname", + "//closure/goog/editor:browserfeature", + "//closure/goog/editor:command", + "//closure/goog/editor:link", + "//closure/goog/editor:node", + "//closure/goog/editor:plugin", + "//closure/goog/editor:range", + "//closure/goog/editor:style", + "//closure/goog/html:safehtml", + "//closure/goog/html:uncheckedconversions", + "//closure/goog/iter", + "//closure/goog/log", + "//closure/goog/object", + "//closure/goog/string", + "//closure/goog/string:const", + "//closure/goog/style", + "//closure/goog/ui/editor:messages", + "//closure/goog/useragent", + # TODO(pcj): where is the replacement for this? + # "//third_party/javascript/safevalues/dom", + ], +) + +closure_js_library( + name = "blockquote", + srcs = ["blockquote.js"], + lenient = True, + deps = [ + "//closure/goog/dom", + "//closure/goog/dom:classlist", + "//closure/goog/dom:nodetype", + "//closure/goog/dom:tagname", + "//closure/goog/editor:command", + "//closure/goog/editor:node", + "//closure/goog/editor:plugin", + "//closure/goog/functions", + "//closure/goog/log", + ], +) + +closure_js_library( + name = "emoticons", + srcs = ["emoticons.js"], + lenient = True, + deps = [ + "//closure/goog/dom:tagname", + "//closure/goog/editor:plugin", + "//closure/goog/editor:range", + "//closure/goog/functions", + "//closure/goog/ui/emoji", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "enterhandler", + srcs = ["enterhandler.js"], + lenient = True, + deps = [ + ":blockquote", + "//closure/goog/dom", + "//closure/goog/dom:abstractrange", + "//closure/goog/dom:nodeoffset", + "//closure/goog/dom:nodetype", + "//closure/goog/dom:range", + "//closure/goog/dom:tagname", + "//closure/goog/editor:browserfeature", + "//closure/goog/editor:node", + "//closure/goog/editor:plugin", + "//closure/goog/editor:range", + "//closure/goog/editor:style", + "//closure/goog/events:browserevent", + "//closure/goog/events:event", + "//closure/goog/events:keycodes", + "//closure/goog/functions", + "//closure/goog/object", + "//closure/goog/string", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "firststrong", + srcs = ["firststrong.js"], + lenient = True, + deps = [ + "//closure/goog/dom:nodetype", + "//closure/goog/dom:tagiterator", + "//closure/goog/dom:tagname", + "//closure/goog/editor:command", + "//closure/goog/editor:field", + "//closure/goog/editor:node", + "//closure/goog/editor:plugin", + "//closure/goog/editor:range", + "//closure/goog/i18n:bidi", + "//closure/goog/i18n:uchar", + "//closure/goog/iter", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "headerformatter", + srcs = ["headerformatter.js"], + lenient = True, + deps = [ + "//closure/goog/editor:command", + "//closure/goog/editor:plugin", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "linkbubble", + srcs = ["linkbubble.js"], + lenient = True, + deps = [ + ":abstractbubbleplugin", + "//closure/goog/a11y/aria:announcer", + "//closure/goog/a11y/aria:attributes", + "//closure/goog/array", + "//closure/goog/dom", + "//closure/goog/dom:range", + "//closure/goog/dom:tagname", + "//closure/goog/editor:command", + "//closure/goog/editor:link", + "//closure/goog/events:browserevent", + "//closure/goog/functions", + "//closure/goog/string", + "//closure/goog/style", + "//closure/goog/ui/editor:messages", + "//closure/goog/uri:utils", + "//closure/goog/window", + ], +) + +closure_js_library( + name = "linkdialogplugin", + srcs = ["linkdialogplugin.js"], + lenient = True, + deps = [ + ":abstractdialogplugin", + "//closure/goog/array", + "//closure/goog/dom", + "//closure/goog/editor:command", + "//closure/goog/editor:link", + "//closure/goog/events:event", + "//closure/goog/events:eventhandler", + "//closure/goog/functions", + "//closure/goog/html:safehtml", + "//closure/goog/ui/editor:abstractdialog", + "//closure/goog/ui/editor:linkdialog", + "//closure/goog/uri:utils", + ], +) + +closure_js_library( + name = "linkshortcutplugin", + srcs = ["linkshortcutplugin.js"], + lenient = True, + deps = [ + "//closure/goog/editor:command", + "//closure/goog/editor:link", + "//closure/goog/editor:plugin", + ], +) + +closure_js_library( + name = "listtabhandler", + srcs = ["listtabhandler.js"], + lenient = True, + deps = [ + ":abstracttabhandler", + "//closure/goog/dom", + "//closure/goog/dom:tagname", + "//closure/goog/editor:command", + "//closure/goog/iter", + ], +) + +closure_js_library( + name = "loremipsum", + srcs = ["loremipsum.js"], + lenient = True, + deps = [ + "//closure/goog/asserts", + "//closure/goog/dom", + "//closure/goog/editor:command", + "//closure/goog/editor:field", + "//closure/goog/editor:node", + "//closure/goog/editor:plugin", + "//closure/goog/functions", + "//closure/goog/html:safehtml", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "removeformatting", + srcs = ["removeformatting.js"], + lenient = True, + deps = [ + "//closure/goog/dom", + "//closure/goog/dom:abstractrange", + "//closure/goog/dom:nodetype", + "//closure/goog/dom:range", + "//closure/goog/dom:safe", + "//closure/goog/dom:tagname", + "//closure/goog/editor:node", + "//closure/goog/editor:plugin", + "//closure/goog/editor:range", + "//closure/goog/html:legacyconversions", + "//closure/goog/html:safehtml", + "//closure/goog/labs/useragent:platform", + "//closure/goog/string", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "spacestabhandler", + srcs = ["spacestabhandler.js"], + lenient = True, + deps = [ + ":abstracttabhandler", + "//closure/goog/dom:tagname", + "//closure/goog/editor:range", + ], +) + +closure_js_library( + name = "tableeditor", + srcs = ["tableeditor.js"], + lenient = True, + deps = [ + "//closure/goog/array", + "//closure/goog/dom", + "//closure/goog/dom:abstractrange", + "//closure/goog/dom:range", + "//closure/goog/dom:tagname", + "//closure/goog/editor:node", + "//closure/goog/editor:plugin", + "//closure/goog/editor:range", + "//closure/goog/editor:table", + "//closure/goog/object", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "tagonenterhandler", + srcs = ["tagonenterhandler.js"], + lenient = True, + deps = [ + ":enterhandler", + "//closure/goog/dom", + "//closure/goog/dom:abstractrange", + "//closure/goog/dom:nodetype", + "//closure/goog/dom:range", + "//closure/goog/dom:tagname", + "//closure/goog/editor:command", + "//closure/goog/editor:node", + "//closure/goog/editor:range", + "//closure/goog/editor:style", + "//closure/goog/events:keycodes", + "//closure/goog/functions", + "//closure/goog/string", + "//closure/goog/style", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "undoredo", + srcs = ["undoredo.js"], + lenient = True, + deps = [ + ":undoredomanager", + ":undoredostate", + "//closure/goog/dom", + "//closure/goog/dom:abstractrange", + "//closure/goog/dom:nodeoffset", + "//closure/goog/dom:range", + "//closure/goog/editor:command", + "//closure/goog/editor:field", + "//closure/goog/editor:node", + "//closure/goog/editor:plugin", + "//closure/goog/events", + "//closure/goog/events:event", + "//closure/goog/log", + "//closure/goog/object", + ], +) + +closure_js_library( + name = "undoredomanager", + srcs = ["undoredomanager.js"], + lenient = True, + deps = [ + ":undoredostate", + "//closure/goog/events", + "//closure/goog/events:eventtarget", + ], +) + +closure_js_library( + name = "undoredostate", + srcs = ["undoredostate.js"], + lenient = True, + deps = ["//closure/goog/events:eventtarget"], +) diff --git a/closure/goog/editor/plugins/abstractbubbleplugin.js b/closure/goog/editor/plugins/abstractbubbleplugin.js new file mode 100644 index 0000000000..ed1371be1d --- /dev/null +++ b/closure/goog/editor/plugins/abstractbubbleplugin.js @@ -0,0 +1,743 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Base class for bubble plugins. + */ + +goog.provide('goog.editor.plugins.AbstractBubblePlugin'); + +goog.require('goog.array'); +goog.require('goog.dom'); +goog.require('goog.dom.NodeType'); +goog.require('goog.dom.Range'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.classlist'); +goog.require('goog.editor.Plugin'); +goog.require('goog.editor.style'); +goog.require('goog.events'); +goog.require('goog.events.EventHandler'); +goog.require('goog.events.EventType'); +goog.require('goog.events.KeyCodes'); +goog.require('goog.events.actionEventWrapper'); +goog.require('goog.functions'); +goog.require('goog.string.Unicode'); +goog.require('goog.ui.Component'); +goog.require('goog.ui.editor.Bubble'); +goog.require('goog.userAgent'); +goog.requireType('goog.events.BrowserEvent'); + + + +/** + * Base class for bubble plugins. This is used for to connect user behavior + * in the editor to a goog.ui.editor.Bubble UI element that allows + * the user to modify the properties of an element on their page (e.g. the alt + * text of an image tag). + * + * Subclasses should override the abstract method getBubbleTargetFromSelection() + * with code to determine if the current selection should activate the bubble + * type. The other abstract method createBubbleContents() should be overriden + * with code to create the inside markup of the bubble. The base class creates + * the rest of the bubble. + * + * @constructor + * @extends {goog.editor.Plugin} + */ +goog.editor.plugins.AbstractBubblePlugin = function() { + 'use strict'; + goog.editor.plugins.AbstractBubblePlugin.base(this, 'constructor'); + + /** + * Place to register events the plugin listens to. + * @type {goog.events.EventHandler< + * !goog.editor.plugins.AbstractBubblePlugin>} + * @protected + */ + this.eventRegister = new goog.events.EventHandler(this); + this.registerDisposable(this.eventRegister); + + /** + * Instance factory function that creates a bubble UI component. If set to a + * non-null value, this function will be used to create a bubble instead of + * the global factory function. It takes as parameters the bubble parent + * element and the z index to draw the bubble at. + * @type {?function(!Element, number): !goog.ui.editor.Bubble} + * @private + */ + this.bubbleFactory_ = null; +}; +goog.inherits(goog.editor.plugins.AbstractBubblePlugin, goog.editor.Plugin); + + +/** + * The css class name of option link elements. + * @type {string} + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.OPTION_LINK_CLASSNAME_ = + goog.getCssName('tr_option-link'); + + +/** + * The css class name of link elements. + * @type {string} + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.LINK_CLASSNAME_ = + goog.getCssName('tr_bubble_link'); + + +/** + * A class name to mark elements that should be reachable by keyboard tabbing. + * @type {string} + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_ = + goog.getCssName('tr_bubble_tabbable'); + + +/** + * The constant string used to separate option links. + * @type {string} + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.DASH_NBSP_STRING = + goog.string.Unicode.NBSP + '-' + goog.string.Unicode.NBSP; + + +/** + * Default factory function for creating a bubble UI component. + * @param {!Element} parent The parent element for the bubble. + * @param {number} zIndex The z index to draw the bubble at. + * @return {!goog.ui.editor.Bubble} The new bubble component. + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.defaultBubbleFactory_ = function( + parent, zIndex) { + 'use strict'; + return new goog.ui.editor.Bubble(parent, zIndex); +}; + + +/** + * Global factory function that creates a bubble UI component. It takes as + * parameters the bubble parent element and the z index to draw the bubble at. + * @type {function(!Element, number): !goog.ui.editor.Bubble} + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_ = + goog.editor.plugins.AbstractBubblePlugin.defaultBubbleFactory_; + + +/** + * Sets the global bubble factory function. + * @param {function(!Element, number): !goog.ui.editor.Bubble} + * bubbleFactory Function that creates a bubble for the given bubble parent + * element and z index. + */ +goog.editor.plugins.AbstractBubblePlugin.setBubbleFactory = function( + bubbleFactory) { + 'use strict'; + goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_ = bubbleFactory; +}; + + +/** + * Map from field id to shared bubble object. + * @type {!Object} + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.bubbleMap_ = {}; + + +/** + * The optional parent of the bubble. If null or not set, we will use the + * application document. This is useful when you have an editor embedded in + * a scrolling DIV. + * @type {Element|undefined} + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.bubbleParent_; + + +/** + * The id of the panel this plugin added to the shared bubble. Null when + * this plugin doesn't currently have a panel in a bubble. + * @type {string?} + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.panelId_ = null; + + +/** + * Whether this bubble should support tabbing through elements. False + * by default. + * @type {boolean} + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.keyboardNavigationEnabled_ = + false; + + +/** + * Sets the instance bubble factory function. If set to a non-null value, this + * function will be used to create a bubble instead of the global factory + * function. + * @param {?function(!Element, number): !goog.ui.editor.Bubble} bubbleFactory + * Function that creates a bubble for the given bubble parent element and z + * index. Null to reset the factory function. + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.setBubbleFactory = function( + bubbleFactory) { + 'use strict'; + this.bubbleFactory_ = bubbleFactory; +}; + + +/** + * Sets whether the bubble should support tabbing through elements. + * @param {boolean} keyboardNavigationEnabled + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.enableKeyboardNavigation = + function(keyboardNavigationEnabled) { + 'use strict'; + this.keyboardNavigationEnabled_ = keyboardNavigationEnabled; +}; + + +/** + * Sets the bubble parent. + * @param {Element} bubbleParent An element where the bubble will be + * anchored. If null, we will use the application document. This + * is useful when you have an editor embedded in a scrolling div. + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.setBubbleParent = function( + bubbleParent) { + 'use strict'; + this.bubbleParent_ = bubbleParent; +}; + + +/** + * Returns the bubble map. Subclasses may override to use a separate map. + * @return {!Object} + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleMap = function() { + 'use strict'; + return goog.editor.plugins.AbstractBubblePlugin.bubbleMap_; +}; + + +/** + * @return {goog.dom.DomHelper} The dom helper for the bubble window. + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleDom = function() { + 'use strict'; + return this.dom_; +}; + + +/** @override */ +goog.editor.plugins.AbstractBubblePlugin.prototype.getTrogClassId = + goog.functions.constant('AbstractBubblePlugin'); + + +/** + * Returns the element whose properties the bubble manipulates. + * @return {Element} The target element. + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.getTargetElement = + function() { + 'use strict'; + return this.targetElement_; +}; + + +/** @override */ +goog.editor.plugins.AbstractBubblePlugin.prototype.handleKeyUp = function(e) { + 'use strict'; + // For example, when an image is selected, pressing any key overwrites + // the image and the panel should be hidden. + // Therefore we need to track key presses when the bubble is showing. + if (this.isVisible()) { + this.handleSelectionChange(); + } + return false; +}; + + +/** + * Pops up a property bubble for the given selection if appropriate and closes + * open property bubbles if no longer needed. This should not be overridden. + * @override + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.handleSelectionChange = + function(opt_e, opt_target) { + 'use strict'; + var selectedElement; + if (opt_e) { + selectedElement = /** @type {Element} */ (opt_e.target); + } else if (opt_target) { + selectedElement = /** @type {Element} */ (opt_target); + } else { + var range = this.getFieldObject().getRange(); + if (range) { + var startNode = range.getStartNode(); + var endNode = range.getEndNode(); + var startOffset = range.getStartOffset(); + var endOffset = range.getEndOffset(); + // Sometimes in IE, the range will be collapsed, but think the end node + // and start node are different (although in the same visible position). + // In this case, favor the position IE thinks is the start node. + if (goog.userAgent.IE && range.isCollapsed() && startNode != endNode) { + range = goog.dom.Range.createCaret(startNode, startOffset); + } + if (startNode.nodeType == goog.dom.NodeType.ELEMENT && + startNode == endNode && startOffset == endOffset - 1) { + var element = startNode.childNodes[startOffset]; + if (element.nodeType == goog.dom.NodeType.ELEMENT) { + selectedElement = /** @type {!Element} */ (element); + } + } + } + selectedElement = selectedElement || range && range.getContainerElement(); + } + return this.handleSelectionChangeInternal(selectedElement); +}; + + +/** + * Pops up a property bubble for the given selection if appropriate and closes + * open property bubbles if no longer needed. + * @param {Element?} selectedElement The selected element. + * @return {boolean} Always false, allowing every bubble plugin to handle the + * event. + * @protected + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.plugins.AbstractBubblePlugin.prototype + .handleSelectionChangeInternal = function(selectedElement) { + 'use strict'; + if (selectedElement) { + var bubbleTarget = this.getBubbleTargetFromSelection(selectedElement); + if (bubbleTarget) { + if (bubbleTarget != this.targetElement_ || !this.panelId_) { + // Make sure any existing panel of the same type is closed before + // creating a new one. + if (this.panelId_) { + this.closeBubble(); + } + this.createBubble(bubbleTarget); + } + return false; + } + } + + if (this.panelId_) { + this.closeBubble(); + } + + return false; +}; + + +/** + * Should be overriden by subclasses to return the bubble target element or + * null if an element of their required type isn't found. + * @param {Element} selectedElement The target of the selection change event or + * the parent container of the current entire selection. + * @return {Element?} The HTML bubble target element or null if no element of + * the required type is not found. + */ +goog.editor.plugins.AbstractBubblePlugin.prototype + .getBubbleTargetFromSelection = goog.abstractMethod; + + +/** @override */ +goog.editor.plugins.AbstractBubblePlugin.prototype.disable = function(field) { + 'use strict'; + // When the field is made uneditable, dispose of the bubble. We do this + // because the next time the field is made editable again it may be in + // a different document / iframe. + if (field.isUneditable()) { + var bubbleMap = this.getBubbleMap(); + var bubble = bubbleMap[field.id]; + if (bubble) { + if (field == this.getFieldObject()) { + this.closeBubble(); + } + bubble.dispose(); + delete bubbleMap[field.id]; + } + } +}; + + +/** + * @return {!goog.ui.editor.Bubble} The shared bubble object for the field this + * plugin is registered on. Creates it if necessary. + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.getSharedBubble_ = + function() { + 'use strict'; + var bubbleParent = /** @type {!Element} */ ( + this.bubbleParent_ || this.getFieldObject().getAppWindow().document.body); + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + this.dom_ = goog.dom.getDomHelper(bubbleParent); + + var bubbleMap = this.getBubbleMap(); + var bubble = bubbleMap[this.getFieldObject().id]; + if (!bubble) { + var factory = this.bubbleFactory_ || + goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_; + bubble = + factory.call(null, bubbleParent, this.getFieldObject().getBaseZindex()); + bubbleMap[this.getFieldObject().id] = bubble; + } + return bubble; +}; + + +/** + * Creates and shows the property bubble. + * @param {Element} targetElement The target element of the bubble. + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.createBubble = function( + targetElement) { + 'use strict'; + var bubble = this.getSharedBubble_(); + if (!bubble.hasPanelOfType(this.getBubbleType())) { + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + this.targetElement_ = targetElement; + + this.panelId_ = bubble.addPanel( + this.getBubbleType(), this.getBubbleTitle(), targetElement, + goog.bind(this.createBubbleContents, this), + this.shouldPreferBubbleAboveElement()); + this.eventRegister.listen( + bubble, goog.ui.Component.EventType.HIDE, this.handlePanelClosed_); + + this.onShow(); + + if (this.keyboardNavigationEnabled_) { + this.eventRegister.listen( + bubble.getContentElement(), goog.events.EventType.KEYDOWN, + this.onBubbleKey_); + } + } +}; + + +/** + * @return {string} The type of bubble shown by this plugin. Usually the tag + * name of the element this bubble targets. + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleType = function() { + 'use strict'; + return ''; +}; + + +/** + * @return {string} The title for bubble shown by this plugin. Defaults to no + * title. Should be overridden by subclasses. + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleTitle = function() { + 'use strict'; + return ''; +}; + + +/** + * @return {boolean} Whether the bubble should prefer placement above the + * target element. + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype + .shouldPreferBubbleAboveElement = goog.functions.FALSE; + + +/** + * Should be overriden by subclasses to add the type specific contents to the + * bubble. + * @param {Element} bubbleContainer The container element of the bubble to + * which the contents should be added. + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.createBubbleContents = + goog.abstractMethod; + + +/** + * Register the handler for the target's CLICK event. + * @param {Element} target The event source element. + * @param {Function} handler The event handler. + * @protected + * @deprecated Use goog.editor.plugins.AbstractBubblePlugin. + * registerActionHandler to register click and enter events. + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.registerClickHandler = + function(target, handler) { + 'use strict'; + this.registerActionHandler(target, handler); +}; + + +/** + * Register the handler for the target's CLICK and ENTER key events. + * @param {Element} target The event source element. + * @param {Function} handler The event handler. + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.registerActionHandler = + function(target, handler) { + 'use strict'; + this.eventRegister.listenWithWrapper( + target, goog.events.actionEventWrapper, handler); +}; + + +/** + * Closes the bubble. + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.closeBubble = function() { + 'use strict'; + if (this.panelId_) { + this.getSharedBubble_().removePanel(this.panelId_); + this.handlePanelClosed_(); + } +}; + + +/** + * Called after the bubble is shown. The default implementation does nothing. + * Override it to provide your own one. + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.onShow = function() {}; + + +/** + * Called when the bubble is closed or hidden. The default implementation does + * nothing. + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.cleanOnBubbleClose = + function() {}; + + +/** + * Handles when the bubble panel is closed. Invoked when the entire bubble is + * hidden and also directly when the panel is closed manually. + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.handlePanelClosed_ = + function() { + 'use strict'; + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + this.targetElement_ = null; + this.panelId_ = null; + this.eventRegister.removeAll(); + this.cleanOnBubbleClose(); +}; + + +/** + * In case the keyboard navigation is enabled, this will set focus on the first + * tabbable element in the bubble when TAB is clicked. + * @override + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.handleKeyDown = function(e) { + 'use strict'; + if (this.keyboardNavigationEnabled_ && this.isVisible() && + e.keyCode == goog.events.KeyCodes.TAB && !e.shiftKey) { + var bubbleEl = this.getSharedBubble_().getContentElement(); + var tabbable = goog.dom.getElementByClass( + goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_, bubbleEl); + if (tabbable) { + tabbable.focus(); + e.preventDefault(); + return true; + } + } + return false; +}; + + +/** + * Handles a key event on the bubble. This ensures that the focus loops through + * the tabbable elements found in the bubble and then the focus is got by the + * field element. + * @param {goog.events.BrowserEvent} e The event. + * @private + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.onBubbleKey_ = function(e) { + 'use strict'; + if (this.isVisible() && e.keyCode == goog.events.KeyCodes.TAB) { + var bubbleEl = this.getSharedBubble_().getContentElement(); + var tabbables = goog.dom.getElementsByClass( + goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_, bubbleEl); + var tabbable = e.shiftKey ? tabbables[0] : goog.array.peek(tabbables); + var tabbingOutOfBubble = tabbable == e.target; + if (tabbingOutOfBubble) { + this.getFieldObject().focus(); + e.preventDefault(); + } + } +}; + + +/** + * @return {boolean} Whether the bubble is visible. + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.isVisible = function() { + 'use strict'; + return !!this.panelId_; +}; + + +/** + * Reposition the property bubble. + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.reposition = function() { + 'use strict'; + var bubble = this.getSharedBubble_(); + if (bubble) { + bubble.reposition(); + } +}; + + +/** + * Helper method that creates option links (such as edit, test, remove) + * @param {string} id String id for the span id. + * @return {Element} The option link element. + * @protected + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.createLinkOption = function( + id) { + 'use strict'; + // Dash plus link are together in a span so we can hide/show them easily + return this.dom_.createDom( + goog.dom.TagName.SPAN, { + id: id, + className: + goog.editor.plugins.AbstractBubblePlugin.OPTION_LINK_CLASSNAME_ + }, + this.dom_.createTextNode( + goog.editor.plugins.AbstractBubblePlugin.DASH_NBSP_STRING)); +}; + + +/** + * Helper method that creates a link with text set to linkText and optionally + * wires up a listener for the CLICK event or the link. The link is navigable by + * tabs if `enableKeyboardNavigation(true)` was called. + * @param {string} linkId The id of the link. + * @param {string} linkText Text of the link. + * @param {Function=} opt_onClick Optional function to call when the link is + * clicked. + * @param {Element=} opt_container If specified, location to insert link. If no + * container is specified, the old link is removed and replaced. + * @return {Element} The link element. + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.createLink = function( + linkId, linkText, opt_onClick, opt_container) { + 'use strict'; + var link = this.createLinkHelper(linkId, linkText, false, opt_container); + if (opt_onClick) { + this.registerActionHandler(link, opt_onClick); + } + return link; +}; + + +/** + * Helper method to create a link to insert into the bubble. The link is + * navigable by tabs if `enableKeyboardNavigation(true)` was called. + * @param {string} linkId The id of the link. + * @param {string} linkText Text of the link. + * @param {boolean} isAnchor Set to true to create an actual anchor tag + * instead of a span. Actual links are right clickable (e.g. to open in + * a new window) and also update window status on hover. + * @param {Element=} opt_container If specified, location to insert link. If no + * container is specified, the old link is removed and replaced. + * @return {Element} The link element. + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.createLinkHelper = function( + linkId, linkText, isAnchor, opt_container) { + 'use strict'; + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + var link = this.dom_.createDom( + isAnchor ? goog.dom.TagName.A : goog.dom.TagName.SPAN, + {className: goog.editor.plugins.AbstractBubblePlugin.LINK_CLASSNAME_}, + linkText); + if (this.keyboardNavigationEnabled_) { + this.setTabbable(link); + } + link.setAttribute('role', 'link'); + this.setupLink(link, linkId, opt_container); + goog.editor.style.makeUnselectable(link, this.eventRegister); + return link; +}; + + +/** + * Makes the given element tabbable. + * + *

    Elements created by createLink[Helper] are tabbable even without + * calling this method. Call it for other elements if needed. + * + *

    If tabindex is not already set in the element, this function sets it to 0. + * You'll usually want to also call `enableKeyboardNavigation(true)`. + * + * @param {!Element} element + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.setTabbable = function( + element) { + 'use strict'; + if (!element.hasAttribute('tabindex')) { + element.setAttribute('tabindex', 0); + } + goog.dom.classlist.add( + element, goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_); +}; + + +/** + * Inserts a link in the given container if it is specified or removes + * the old link with this id and replaces it with the new link + * @param {Element} link Html element to insert. + * @param {string} linkId Id of the link. + * @param {Element=} opt_container If specified, location to insert link. + * @protected + */ +goog.editor.plugins.AbstractBubblePlugin.prototype.setupLink = function( + link, linkId, opt_container) { + 'use strict'; + if (opt_container) { + opt_container.appendChild(/** @type {!Node} */ (link)); + } else { + /** @suppress {strictMissingProperties} Added to tighten compiler checks */ + var oldLink = this.dom_.getElement(linkId); + if (oldLink) { + goog.dom.replaceNode(link, oldLink); + } + } + + link.id = linkId; +}; diff --git a/closure/goog/editor/plugins/abstractbubbleplugin_test.js b/closure/goog/editor/plugins/abstractbubbleplugin_test.js new file mode 100644 index 0000000000..820e449a90 --- /dev/null +++ b/closure/goog/editor/plugins/abstractbubbleplugin_test.js @@ -0,0 +1,496 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.editor.plugins.AbstractBubblePluginTest'); +goog.setTestOnly(); + +const AbstractBubblePlugin = goog.require('goog.editor.plugins.AbstractBubblePlugin'); +const BrowserEvent = goog.require('goog.events.BrowserEvent'); +const Bubble = goog.require('goog.ui.editor.Bubble'); +const EventType = goog.require('goog.events.EventType'); +const FieldMock = goog.require('goog.testing.editor.FieldMock'); +const GoogTestingEvent = goog.require('goog.testing.events.Event'); +const KeyCodes = goog.require('goog.events.KeyCodes'); +const TagName = goog.require('goog.dom.TagName'); +const TestHelper = goog.require('goog.testing.editor.TestHelper'); +const dom = goog.require('goog.dom'); +const events = goog.require('goog.testing.events'); +const functions = goog.require('goog.functions'); +const style = goog.require('goog.style'); +const testSuite = goog.require('goog.testing.testSuite'); +const userAgent = goog.require('goog.userAgent'); + +let testHelper; +let fieldDiv; +const COMMAND = 'base'; +let fieldMock; +let bubblePlugin; +let link; +let link2; + +/** + * This is a helper function for setting up the targetElement with a + * given direction. + * @param {string} dir The direction of the targetElement, 'ltr' or 'rtl'. + */ +function prepareTargetWithGivenDirection(dir) { + style.setStyle(document.body, 'direction', dir); + + fieldDiv.style.direction = dir; + fieldDiv.innerHTML = 'Google'; + link = fieldDiv.firstChild; + + fieldMock.$replay(); + /** @suppress {visibility} suppression added to enable type checking */ + bubblePlugin.createBubbleContents = (bubbleContainer) => { + bubbleContainer.innerHTML = '

    B
    '; + style.setStyle(bubbleContainer, 'border', '1px solid white'); + }; + bubblePlugin.registerFieldObject(fieldMock); + bubblePlugin.enable(fieldMock); + bubblePlugin.createBubble(link); +} + +/** + * Similar in intent to mock reset, but implemented by recreating the mock + * variable. $reset() can't work because it will reset general any-time + * expectations done in the fieldMock constructor. + */ +function resetFieldMock() { + fieldMock = new FieldMock(); + /** + * @suppress {visibility,checkTypes} suppression added to enable type + * checking + */ + bubblePlugin.fieldObject = fieldMock; +} + +function helpTestCreateBubble(fn = undefined) { + fieldMock.$replay(); + let numCalled = 0; + /** + * @suppress {visibility,duplicate} suppression added to enable type checking + */ + bubblePlugin.createBubbleContents = (bubbleContainer) => { + numCalled++; + assertNotNull('bubbleContainer should not be null', bubbleContainer); + }; + if (fn) { + fn(); + } + bubblePlugin.createBubble(link); + assertEquals('createBubbleContents should be called', 1, numCalled); + fieldMock.$verify(); +} + +/** + * Sends a tab key event to the bubble. + * @return {boolean} whether the bubble hanlded the event. + */ +function simulateTabKeyOnBubble() { + return simulateKeyDownOnBubble(KeyCodes.TAB, false); +} + +/** + * Sends a key event to the bubble. + * @param {number} keyCode + * @param {boolean} isCtrl + * @return {boolean} whether the bubble hanlded the event. + */ +function simulateKeyDownOnBubble(keyCode, isCtrl) { + // In some browsers (e.g. FireFox) the editable field is marked with + // designMode on. In the test setting (and not in production setting), the + // bubble element shares the same window and hence the designMode. In this + // mode, activeElement remains the and isn't changed along with the + // focus as a result of tab key. + if (userAgent.GECKO) { + /** @suppress {visibility} suppression added to enable type checking */ + bubblePlugin.getSharedBubble_() + .getContentElement() + .ownerDocument.designMode = 'off'; + } + + const event = new GoogTestingEvent(EventType.KEYDOWN, null); + event.keyCode = keyCode; + event.ctrlKey = isCtrl; + return bubblePlugin.handleKeyDown(event); +} + +function assertFocused(element) { + assertEquals('unexpected focus', element, document.activeElement); +} + +function assertNotFocused(element) { + assertNotEquals('unexpected focus', element, document.activeElement); +} +testSuite({ + setUpPage() { + fieldDiv = dom.getElement('field'); + const viewportSize = dom.getViewportSize(); + // Some tests depends on enough size of viewport. + if (viewportSize.width < 600 || viewportSize.height < 440) { + window.moveTo(0, 0); + window.resizeTo(640, 480); + } + }, + + setUp() { + testHelper = new TestHelper(fieldDiv); + testHelper.setUpEditableElement(); + fieldMock = new FieldMock(); + + /** @suppress {checkTypes} suppression added to enable type checking */ + bubblePlugin = new AbstractBubblePlugin(COMMAND); + /** + * @suppress {visibility,checkTypes} suppression added to enable type + * checking + */ + bubblePlugin.fieldObject = fieldMock; + + fieldDiv.innerHTML = 'Google' + + 'Google2'; + link = fieldDiv.firstChild; + link2 = fieldDiv.lastChild; + + window.scrollTo(0, 0); + style.setStyle(document.body, 'direction', 'ltr'); + style.setStyle(document.getElementById('field'), 'position', 'static'); + }, + + tearDown() { + bubblePlugin.closeBubble(); + testHelper.tearDownEditableElement(); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testCreateBubble(fn = undefined) { + helpTestCreateBubble(fn); + assertTrue(bubblePlugin.getSharedBubble_() instanceof Bubble); + + assertTrue('Bubble should be visible', bubblePlugin.isVisible()); + }, + + testOpeningBubbleCallsOnShow() { + let numCalled = 0; + this.testCreateBubble(() => { + /** @suppress {visibility} suppression added to enable type checking */ + bubblePlugin.onShow = () => { + numCalled++; + }; + }); + + assertEquals('onShow should be called', 1, numCalled); + fieldMock.$verify(); + }, + + testCloseBubble() { + this.testCreateBubble(); + + bubblePlugin.closeBubble(); + assertFalse('Bubble should not be visible', bubblePlugin.isVisible()); + fieldMock.$verify(); + }, + + /** + @suppress {missingProperties,visibility} suppression added to enable type + checking + */ + testZindexBehavior() { + // Don't use the default return values. + fieldMock.$reset(); + fieldMock.getAppWindow().$anyTimes().$returns(window); + fieldMock.getEditableDomHelper().$anyTimes().$returns( + dom.getDomHelper(document)); + fieldMock.getBaseZindex().$returns(2); + /** @suppress {visibility} suppression added to enable type checking */ + bubblePlugin.createBubbleContents = functions.UNDEFINED; + fieldMock.$replay(); + + bubblePlugin.createBubble(link); + assertEquals( + '2', + '' + bubblePlugin.getSharedBubble_().bubbleContainer_.style.zIndex); + + fieldMock.$verify(); + }, + + /** + @suppress {visibility,missingProperties} suppression added to enable type + checking + */ + testNoTwoBubblesOpenAtSameTime() { + fieldMock.$replay(); + const origClose = goog.bind(bubblePlugin.closeBubble, bubblePlugin); + let numTimesCloseCalled = 0; + bubblePlugin.closeBubble = () => { + numTimesCloseCalled++; + origClose(); + }; + bubblePlugin.getBubbleTargetFromSelection = functions.identity; + /** @suppress {visibility} suppression added to enable type checking */ + bubblePlugin.createBubbleContents = functions.UNDEFINED; + + bubblePlugin.handleSelectionChangeInternal(link); + assertEquals(0, numTimesCloseCalled); + assertEquals(link, bubblePlugin.targetElement_); + fieldMock.$verify(); + + bubblePlugin.handleSelectionChangeInternal(link2); + assertEquals(1, numTimesCloseCalled); + assertEquals(link2, bubblePlugin.targetElement_); + fieldMock.$verify(); + }, + + /** @suppress {missingProperties} suppression added to enable type checking */ + testHandleSelectionChangeWithEvent() { + fieldMock.$replay(); + /** @suppress {checkTypes} suppression added to enable type checking */ + const fakeEvent = new BrowserEvent({type: 'mouseup', target: link}); + bubblePlugin.getBubbleTargetFromSelection = functions.identity; + /** @suppress {visibility} suppression added to enable type checking */ + bubblePlugin.createBubbleContents = functions.UNDEFINED; + bubblePlugin.handleSelectionChange(fakeEvent); + assertTrue('Bubble should have been opened', bubblePlugin.isVisible()); + assertEquals( + 'Bubble target should be provided event\'s target', link, + bubblePlugin.targetElement_); + }, + + /** @suppress {missingProperties} suppression added to enable type checking */ + testHandleSelectionChangeWithTarget() { + fieldMock.$replay(); + bubblePlugin.getBubbleTargetFromSelection = functions.identity; + /** @suppress {visibility} suppression added to enable type checking */ + bubblePlugin.createBubbleContents = functions.UNDEFINED; + bubblePlugin.handleSelectionChange(undefined, link2); + assertTrue('Bubble should have been opened', bubblePlugin.isVisible()); + assertEquals( + 'Bubble target should be provided target', link2, + bubblePlugin.targetElement_); + }, + + /** Regression test for @bug 2945341 */ + testSelectOneTextCharacterNoError() { + fieldMock.$replay(); + bubblePlugin.getBubbleTargetFromSelection = functions.identity; + /** @suppress {visibility} suppression added to enable type checking */ + bubblePlugin.createBubbleContents = functions.UNDEFINED; + // Select first char of first link's text node. + testHelper.select(link.firstChild, 0, link.firstChild, 1); + // This should execute without js errors. + bubblePlugin.handleSelectionChange(); + assertTrue('Bubble should have been opened', bubblePlugin.isVisible()); + fieldMock.$verify(); + }, + + /** + @suppress {visibility,missingProperties} suppression added to enable type + checking + */ + testTabKeyEvents() { + fieldMock.$replay(); + bubblePlugin.enableKeyboardNavigation(true); + bubblePlugin.getBubbleTargetFromSelection = functions.identity; + let nonTabbable1; + let tabbable1; + let tabbable2; + let nonTabbable2; + /** + * @suppress {visibility,duplicate} suppression added to enable type + * checking + */ + bubblePlugin.createBubbleContents = (container) => { + nonTabbable1 = dom.createDom(TagName.DIV); + tabbable1 = dom.createDom(TagName.DIV); + tabbable2 = dom.createDom(TagName.DIV); + nonTabbable2 = dom.createDom(TagName.DIV); + dom.append(container, nonTabbable1, tabbable1, tabbable2, nonTabbable2); + bubblePlugin.setTabbable(tabbable1); + bubblePlugin.setTabbable(tabbable2); + }; + bubblePlugin.handleSelectionChangeInternal(link); + assertTrue('Bubble should be visible', bubblePlugin.isVisible()); + + const tabHandledByBubble = simulateTabKeyOnBubble(); + assertTrue( + 'The action should be handled by the plugin', tabHandledByBubble); + assertFocused(tabbable1); + + // Tab on the first tabbable. The test framework doesn't easily let us + // verify the desired behavior - namely, that the second tabbable gets + // focused - but we verify that the field doesn't get the focus. + events.fireKeySequence(tabbable1, KeyCodes.TAB); + + fieldMock.$verify(); + + // Tabbing on the last tabbable should trigger focus() of the target field. + resetFieldMock(); + fieldMock.focus(); + fieldMock.$replay(); + events.fireKeySequence(tabbable2, KeyCodes.TAB); + fieldMock.$verify(); + }, + + /** + @suppress {visibility,missingProperties} suppression added to enable type + checking + */ + testTabKeyEventsWithShiftKey() { + fieldMock.$replay(); + bubblePlugin.enableKeyboardNavigation(true); + bubblePlugin.getBubbleTargetFromSelection = functions.identity; + let nonTabbable; + let tabbable1; + let tabbable2; + /** + * @suppress {visibility,duplicate} suppression added to enable type + * checking + */ + bubblePlugin.createBubbleContents = (container) => { + nonTabbable = dom.createDom(TagName.DIV); + tabbable1 = dom.createDom(TagName.DIV); + // The test acts only on one tabbable, but we give another one to make + // sure that the tabbable we act on is not also the last. + tabbable2 = dom.createDom(TagName.DIV); + dom.append(container, nonTabbable, tabbable1, tabbable2); + bubblePlugin.setTabbable(tabbable1); + bubblePlugin.setTabbable(tabbable2); + }; + bubblePlugin.handleSelectionChangeInternal(link); + + assertTrue('Bubble should be visible', bubblePlugin.isVisible()); + + const tabHandledByBubble = simulateTabKeyOnBubble(); + assertTrue( + 'The action should be handled by the plugin', tabHandledByBubble); + assertFocused(tabbable1); + fieldMock.$verify(); + + // Shift-tabbing on the first tabbable should trigger focus() of the target + // field. + resetFieldMock(); + fieldMock.focus(); + fieldMock.$replay(); + events.fireKeySequence(tabbable1, KeyCodes.TAB, {shiftKey: true}); + fieldMock.$verify(); + }, + + /** + @suppress {visibility,missingProperties} suppression added to enable type + checking + */ + testLinksAreTabbable() { + fieldMock.$replay(); + bubblePlugin.enableKeyboardNavigation(true); + bubblePlugin.getBubbleTargetFromSelection = functions.identity; + let nonTabbable1; + let nonTabbable2; + let bubbleLink1; + let bubbleLink2; + /** + * @suppress {visibility,duplicate} suppression added to enable type + * checking + */ + bubblePlugin.createBubbleContents = function(container) { + nonTabbable1 = dom.createDom(TagName.DIV); + dom.appendChild(container, nonTabbable1); + bubbleLink1 = this.createLink('linkInBubble1', 'Foo', false, container); + bubbleLink2 = this.createLink('linkInBubble2', 'Bar', false, container); + nonTabbable2 = dom.createDom(TagName.DIV); + dom.appendChild(container, nonTabbable2); + }; + bubblePlugin.handleSelectionChangeInternal(link); + assertTrue('Bubble should be visible', bubblePlugin.isVisible()); + + const tabHandledByBubble = simulateTabKeyOnBubble(); + assertTrue( + 'The action should be handled by the plugin', tabHandledByBubble); + assertFocused(bubbleLink1); + + fieldMock.$verify(); + + // Tabbing on the last link should trigger focus() of the target field. + resetFieldMock(); + fieldMock.focus(); + fieldMock.$replay(); + events.fireKeySequence(bubbleLink2, KeyCodes.TAB); + fieldMock.$verify(); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testTabKeyNoEffectKeyboardNavDisabled() { + fieldMock.$replay(); + bubblePlugin.getBubbleTargetFromSelection = functions.identity; + let bubbleLink; + /** + * @suppress {visibility,duplicate} suppression added to enable type + * checking + */ + bubblePlugin.createBubbleContents = function(container) { + bubbleLink = this.createLink('linkInBubble', 'Foo', false, container); + }; + bubblePlugin.handleSelectionChangeInternal(link); + + assertTrue('Bubble should be visible', bubblePlugin.isVisible()); + + const tabHandledByBubble = simulateTabKeyOnBubble(); + assertFalse( + 'The action should not be handled by the plugin', tabHandledByBubble); + assertNotFocused(bubbleLink); + + // Verify that tabbing the link doesn't cause focus of the field. + events.fireKeySequence(bubbleLink, KeyCodes.TAB); + + fieldMock.$verify(); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testOtherKeyEventNoEffectKeyboardNavEnabled() { + fieldMock.$replay(); + bubblePlugin.enableKeyboardNavigation(true); + bubblePlugin.getBubbleTargetFromSelection = functions.identity; + let bubbleLink; + /** + * @suppress {visibility,duplicate} suppression added to enable type + * checking + */ + bubblePlugin.createBubbleContents = function(container) { + bubbleLink = this.createLink('linkInBubble', 'Foo', false, container); + }; + bubblePlugin.handleSelectionChangeInternal(link); + + assertTrue('Bubble should be visible', bubblePlugin.isVisible()); + + // Test pressing CTRL + B: this should not have any effect. + const keyHandledByBubble = simulateKeyDownOnBubble(KeyCodes.B, true); + + assertFalse( + 'The action should not be handled by the plugin', keyHandledByBubble); + assertNotFocused(bubbleLink); + + fieldMock.$verify(); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testSetTabbableSetsTabIndex() { + const element1 = dom.createDom(TagName.DIV); + const element2 = dom.createDom(TagName.DIV); + element1.setAttribute('tabIndex', '1'); + + bubblePlugin.setTabbable(element1); + bubblePlugin.setTabbable(element2); + + assertEquals('1', element1.getAttribute('tabIndex')); + assertEquals('0', element2.getAttribute('tabIndex')); + }, + + testDisable() { + this.testCreateBubble(); + fieldMock.setUneditable(true); + bubblePlugin.disable(fieldMock); + bubblePlugin.closeBubble(); + }, +}); diff --git a/closure/goog/editor/plugins/abstractbubbleplugin_test_dom.html b/closure/goog/editor/plugins/abstractbubbleplugin_test_dom.html new file mode 100644 index 0000000000..7f3885a1d7 --- /dev/null +++ b/closure/goog/editor/plugins/abstractbubbleplugin_test_dom.html @@ -0,0 +1,9 @@ + + +
    +
    \ No newline at end of file diff --git a/closure/goog/editor/plugins/abstractdialogplugin.js b/closure/goog/editor/plugins/abstractdialogplugin.js new file mode 100644 index 0000000000..d8852b3329 --- /dev/null +++ b/closure/goog/editor/plugins/abstractdialogplugin.js @@ -0,0 +1,331 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview An abstract superclass for TrogEdit dialog plugins. Each + * Trogedit dialog has its own plugin. + */ + +goog.provide('goog.editor.plugins.AbstractDialogPlugin'); +goog.provide('goog.editor.plugins.AbstractDialogPlugin.EventType'); + +goog.require('goog.dom'); +goog.require('goog.dom.Range'); +goog.require('goog.editor.Field'); +goog.require('goog.editor.Plugin'); +goog.require('goog.editor.range'); +goog.require('goog.events'); +goog.require('goog.ui.editor.AbstractDialog'); +goog.requireType('goog.dom.SavedRange'); +goog.requireType('goog.events.Event'); + + +// *** Public interface ***************************************************** // + + + +/** + * An abstract superclass for a Trogedit plugin that creates exactly one + * dialog. By default dialogs are not reused -- each time execCommand is called, + * a new instance of the dialog object is created (and the old one disposed of). + * To enable reusing of the dialog object, subclasses should call + * setReuseDialog() after calling the superclass constructor. + * @param {string} command The command that this plugin handles. + * @constructor + * @extends {goog.editor.Plugin} + */ +goog.editor.plugins.AbstractDialogPlugin = function(command) { + 'use strict'; + goog.editor.plugins.AbstractDialogPlugin.base(this, 'constructor'); + + /** + * The command that this plugin handles. + * @private {string} + */ + this.command_ = command; + + /** @private {function()} */ + this.restoreScrollPosition_ = function() {}; + + /** + * The current dialog that was created and opened by this plugin. + * @private {?goog.ui.editor.AbstractDialog} + */ + this.dialog_ = null; + + /** + * Whether this plugin should reuse the same instance of the dialog each time + * execCommand is called or create a new one. + * @private {boolean} + */ + this.reuseDialog_ = false; + + /** + * Mutex to prevent recursive calls to disposeDialog_. + * @private {boolean} + */ + this.isDisposingDialog_ = false; + + /** + * SavedRange representing the selection before the dialog was opened. + * @private {?goog.dom.SavedRange} + */ + this.savedRange_ = null; +}; +goog.inherits(goog.editor.plugins.AbstractDialogPlugin, goog.editor.Plugin); + + +/** @override */ +goog.editor.plugins.AbstractDialogPlugin.prototype.isSupportedCommand = + function(command) { + 'use strict'; + return command == this.command_; +}; + + +/** + * Handles execCommand. Dialog plugins don't make any changes when they open a + * dialog, just when the dialog closes (because only modal dialogs are + * supported). Hence this method does not dispatch the change events that the + * superclass method does. + * @param {string} command The command to execute. + * @param {...*} var_args Any additional parameters needed to + * execute the command. + * @return {*} The result of the execCommand, if any. + * @override + */ +goog.editor.plugins.AbstractDialogPlugin.prototype.execCommand = function( + command, var_args) { + 'use strict'; + return this.execCommandInternal.apply(this, arguments); +}; + + +// *** Events *************************************************************** // + + +/** + * Event type constants for events the dialog plugins fire. + * @enum {string} + */ +goog.editor.plugins.AbstractDialogPlugin.EventType = { + // This event is fired when a dialog has been opened. + OPENED: 'dialogOpened', + // This event is fired when a dialog has been closed. + CLOSED: 'dialogClosed' +}; + + +// *** Protected interface ************************************************** // + + +/** + * Creates a new instance of this plugin's dialog. Must be overridden by + * subclasses. + * Implementations should expect that the editor is inactive and cannot be + * focused, nor will its caret position (or selection) be determinable until + * after the dialogs goog.ui.PopupBase.EventType.HIDE event has been handled. + * @param {!goog.dom.DomHelper} dialogDomHelper The dom helper to be used to + * create the dialog. + * @param {*=} opt_arg The dialog specific argument. Concrete subclasses should + * declare a specific type. + * @return {goog.ui.editor.AbstractDialog} The newly created dialog. + * @protected + */ +goog.editor.plugins.AbstractDialogPlugin.prototype.createDialog = + goog.abstractMethod; + + +/** + * Returns the current dialog that was created and opened by this plugin. + * @return {goog.ui.editor.AbstractDialog} The current dialog that was created + * and opened by this plugin. + * @protected + */ +goog.editor.plugins.AbstractDialogPlugin.prototype.getDialog = function() { + 'use strict'; + return this.dialog_; +}; + + +/** + * Sets whether this plugin should reuse the same instance of the dialog each + * time execCommand is called or create a new one. This is intended for use by + * subclasses only, hence protected. + * @param {boolean} reuse Whether to reuse the dialog. + * @protected + */ +goog.editor.plugins.AbstractDialogPlugin.prototype.setReuseDialog = function( + reuse) { + 'use strict'; + this.reuseDialog_ = reuse; +}; + + +/** + * Handles execCommand by opening the dialog. Dispatches + * {@link goog.editor.plugins.AbstractDialogPlugin.EventType.OPENED} after the + * dialog is shown. + * @param {string} command The command to execute. + * @param {*=} opt_arg The dialog specific argument. Should be the same as + * {@link createDialog}. + * @return {*} Always returns true, indicating the dialog was shown. + * @protected + * @override + */ +goog.editor.plugins.AbstractDialogPlugin.prototype.execCommandInternal = + function(command, opt_arg) { + 'use strict'; + // If this plugin should not reuse dialog instances, first dispose of the + // previous dialog. + if (!this.reuseDialog_) { + this.disposeDialog_(); + } + // If there is no dialog yet (or we aren't reusing the previous one), create + // one. + if (!this.dialog_) { + this.dialog_ = this.createDialog( + // TODO(user): Add Field.getAppDomHelper. (Note dom helper will + // need to be updated if setAppWindow is called by clients.) + goog.dom.getDomHelper(this.getFieldObject().getAppWindow()), opt_arg); + } + + // Since we're opening a dialog, we need to clear the selection because the + // focus will be going to the dialog, and if we leave an selection in the + // editor while another selection is active in the dialog as the user is + // typing, some browsers will screw up the original selection. But first we + // save it so we can restore it when the dialog closes. + // getRange may return null if there is no selection in the field. + var tempRange = this.getFieldObject().getRange(); + // saveUsingDom() did not work as well as saveUsingNormalizedCarets(), + // not sure why. + + this.restoreScrollPosition_ = this.saveScrollPosition(); + this.savedRange_ = + tempRange && goog.editor.range.saveUsingNormalizedCarets(tempRange); + goog.dom.Range.clearSelection( + this.getFieldObject().getEditableDomHelper().getWindow()); + + // Listen for the dialog closing so we can clean up. + goog.events.listenOnce( + this.dialog_, goog.ui.editor.AbstractDialog.EventType.AFTER_HIDE, + this.handleAfterHide, false, this); + + this.getFieldObject().setModalMode(true); + this.dialog_.show(); + this.dispatchEvent(goog.editor.plugins.AbstractDialogPlugin.EventType.OPENED); + + // Since the selection has left the document, dispatch a selection + // change event. + this.getFieldObject().dispatchSelectionChangeEvent(); + + return true; +}; + + +/** + * Cleans up after the dialog has closed, including restoring the selection to + * what it was before the dialog was opened. If a subclass modifies the editable + * field's content such that the original selection is no longer valid (usually + * the case when the user clicks OK, and sometimes also on Cancel), it is that + * subclass' responsibility to place the selection in the desired place during + * the OK or Cancel (or other) handler. In that case, this method will leave the + * selection in place. + * @param {goog.events.Event} e The AFTER_HIDE event object. + * @protected + */ +goog.editor.plugins.AbstractDialogPlugin.prototype.handleAfterHide = function( + e) { + 'use strict'; + this.getFieldObject().setModalMode(false); + this.restoreOriginalSelection(); + this.restoreScrollPosition_(); + + if (!this.reuseDialog_) { + this.disposeDialog_(); + } + + this.dispatchEvent(goog.editor.plugins.AbstractDialogPlugin.EventType.CLOSED); + + // Since the selection has returned to the document, dispatch a selection + // change event. + this.getFieldObject().dispatchSelectionChangeEvent(); + + // When the dialog closes due to pressing enter or escape, that happens on the + // keydown event. But the browser will still fire a keyup event after that, + // which is caught by the editable field and causes it to try to fire a + // selection change event. To avoid that, we "debounce" the selection change + // event, meaning the editable field will not fire that event if the keyup + // that caused it immediately after this dialog was hidden ("immediately" + // means a small number of milliseconds defined by the editable field). + this.getFieldObject().debounceEvent( + goog.editor.Field.EventType.SELECTIONCHANGE); +}; + + +/** + * Restores the selection in the editable field to what it was before the dialog + * was opened. This is not guaranteed to work if the contents of the field + * have changed. + * @protected + */ +goog.editor.plugins.AbstractDialogPlugin.prototype.restoreOriginalSelection = + function() { + 'use strict'; + this.getFieldObject().restoreSavedRange(this.savedRange_); + this.savedRange_ = null; +}; + + +/** + * Cleans up the structure used to save the original selection before the dialog + * was opened. Should be used by subclasses that don't restore the original + * selection via restoreOriginalSelection. + * @protected + */ +goog.editor.plugins.AbstractDialogPlugin.prototype.disposeOriginalSelection = + function() { + 'use strict'; + if (this.savedRange_) { + this.savedRange_.dispose(); + this.savedRange_ = null; + } +}; + + +/** @override */ +goog.editor.plugins.AbstractDialogPlugin.prototype.disposeInternal = + function() { + 'use strict'; + this.disposeDialog_(); + goog.editor.plugins.AbstractDialogPlugin.base(this, 'disposeInternal'); +}; + + +// *** Private implementation *********************************************** // + + +/** + * Disposes of the dialog if needed. It is this abstract class' responsibility + * to dispose of the dialog. The "if needed" refers to the fact this method + * might be called twice (nested calls, not sequential) in the dispose flow, so + * if the dialog was already disposed once it should not be disposed again. + * @private + */ +goog.editor.plugins.AbstractDialogPlugin.prototype.disposeDialog_ = function() { + 'use strict'; + // Wrap disposing the dialog in a mutex. Otherwise disposing it would cause it + // to get hidden (if it is still open) and fire AFTER_HIDE, which in + // turn would cause the dialog to be disposed again (closure only flags an + // object as disposed after the dispose call chain completes, so it doesn't + // prevent recursive dispose calls). + if (this.dialog_ && !this.isDisposingDialog_) { + this.isDisposingDialog_ = true; + this.dialog_.dispose(); + this.dialog_ = null; + this.isDisposingDialog_ = false; + } +}; diff --git a/closure/goog/editor/plugins/abstractdialogplugin_test.js b/closure/goog/editor/plugins/abstractdialogplugin_test.js new file mode 100644 index 0000000000..2939c41053 --- /dev/null +++ b/closure/goog/editor/plugins/abstractdialogplugin_test.js @@ -0,0 +1,402 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.editor.plugins.AbstractDialogPluginTest'); +goog.setTestOnly(); + +const AbstractDialog = goog.require('goog.ui.editor.AbstractDialog'); +const AbstractDialogPlugin = goog.require('goog.editor.plugins.AbstractDialogPlugin'); +const ArgumentMatcher = goog.require('goog.testing.mockmatchers.ArgumentMatcher'); +const EventHandler = goog.require('goog.events.EventHandler'); +const Field = goog.require('goog.editor.Field'); +const FieldMock = goog.require('goog.testing.editor.FieldMock'); +const GoogEvent = goog.require('goog.events.Event'); +const MockClock = goog.require('goog.testing.MockClock'); +const MockControl = goog.require('goog.testing.MockControl'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const SafeHtml = goog.require('goog.html.SafeHtml'); +const SavedRange = goog.require('goog.dom.SavedRange'); +const TagName = goog.require('goog.dom.TagName'); +const TestHelper = goog.require('goog.testing.editor.TestHelper'); +const dom = goog.require('goog.dom'); +const events = goog.require('goog.testing.events'); +const functions = goog.require('goog.functions'); +const testSuite = goog.require('goog.testing.testSuite'); +const userAgent = goog.require('goog.userAgent'); + +let plugin; +let mockCtrl; +let mockField; +let mockSavedRange; +let mockOpenedHandler; +let mockClosedHandler; + +const COMMAND = 'myCommand'; +const stubs = new PropertyReplacer(); + +let mockClock; +let fieldObj; +let fieldElem; +let mockHandler; + +function setUpMockRange() { + mockSavedRange = mockCtrl.createLooseMock(SavedRange); + mockSavedRange.restore(); + + stubs.setPath( + 'goog.editor.range.saveUsingNormalizedCarets', + functions.constant(mockSavedRange)); +} + +/** + * Creates a concrete instance of AbstractDialog by adding + * a plain implementation of createDialogControl(). + * @param {dom.DomHelper} domHelper The dom helper to be used to create the + * dialog. + * @return {!AbstractDialog} The created dialog. + */ +function createDialog(domHelper) { + const dialog = new AbstractDialog(domHelper); + /** @suppress {visibility} suppression added to enable type checking */ + dialog.createDialogControl = () => new AbstractDialog.Builder(dialog).build(); + return dialog; +} + +/** + * Creates a concrete instance of the abstract class + * AbstractDialogPlugin + * and registers it with the mock editable field being used. + * @return {!AbstractDialogPlugin} The created plugin. + * @suppress {checkTypes} suppression added to enable type checking + */ +function createDialogPlugin() { + const plugin = new AbstractDialogPlugin(COMMAND); + /** @suppress {visibility} suppression added to enable type checking */ + plugin.createDialog = createDialog; + /** + * @suppress {strictMissingProperties,visibility} suppression added to enable + * type checking + */ + plugin.returnControlToEditableField = plugin.restoreOriginalSelection; + plugin.registerFieldObject(mockField); + plugin.addEventListener( + AbstractDialogPlugin.EventType.OPENED, mockOpenedHandler); + plugin.addEventListener( + AbstractDialogPlugin.EventType.CLOSED, mockClosedHandler); + return plugin; +} + +/** + * Sets up the mock event handler to expect an OPENED event. + * @suppress {missingProperties} suppression added to enable type checking + */ +function expectOpened(/** number= */ times = undefined) { + mockOpenedHandler.handleEvent(new ArgumentMatcher( + (arg) => arg.type == AbstractDialogPlugin.EventType.OPENED)); + mockField.dispatchSelectionChangeEvent(); + if (times) { + mockOpenedHandler.$times(times); + mockField.$times(times); + } +} + +/** + * Sets up the mock event handler to expect a CLOSED event. + * @suppress {missingProperties} suppression added to enable type checking + */ +function expectClosed(/** number= */ times = undefined) { + mockClosedHandler.handleEvent(new ArgumentMatcher( + (arg) => arg.type == AbstractDialogPlugin.EventType.CLOSED)); + mockField.dispatchSelectionChangeEvent(); + if (times) { + mockClosedHandler.$times(times); + mockField.$times(times); + } +} + +/** + * Setup a real editable field (instead of a mock) and register the plugin to + * it. + */ +function setUpRealEditableField() { + fieldElem = dom.createElement(TagName.DIV); + fieldElem.id = 'myField'; + document.body.appendChild(fieldElem); + fieldObj = new Field('myField', document); + fieldObj.makeEditable(); + // Register the plugin to that field. + plugin.getTrogClassId = functions.constant('myClassId'); + fieldObj.registerPlugin(plugin); +} + +/** Tear down the real editable field. */ +function tearDownRealEditableField() { + if (fieldObj) { + fieldObj.makeUneditable(); + fieldObj.dispose(); + fieldObj = null; + } + if (fieldElem && fieldElem.parentNode == document.body) { + document.body.removeChild(fieldElem); + } +} + +testSuite({ + /** @suppress {missingProperties} suppression added to enable type checking */ + setUp() { + mockCtrl = new MockControl(); + mockOpenedHandler = mockCtrl.createLooseMock(EventHandler); + mockClosedHandler = mockCtrl.createLooseMock(EventHandler); + + /** @suppress {checkTypes} suppression added to enable type checking */ + mockField = new FieldMock(undefined, undefined, {}); + mockCtrl.addMock(mockField); + mockField.focus(); + + plugin = createDialogPlugin(); + }, + + tearDown() { + stubs.reset(); + tearDownRealEditableField(); + if (mockClock) { + // Crucial to letting time operations work normally in the rest of tests. + mockClock.dispose(); + } + if (plugin) { + mockField.$setIgnoreUnexpectedCalls(true); + plugin.dispose(); + } + }, + + /** + * Tests the simple flow of calling execCommand (which opens the + * dialog) and immediately disposing of the plugin (which closes the dialog). + * @param {boolean=} reuse Whether to set the plugin to reuse its dialog. + * @suppress {missingProperties,visibility} suppression added to enable type + * checking + */ + testExecAndDispose(reuse = undefined) { + setUpMockRange(); + expectOpened(); + expectClosed(); + mockField.debounceEvent(Field.EventType.SELECTIONCHANGE); + mockCtrl.$replayAll(); + if (reuse) { + plugin.setReuseDialog(true); + } + assertFalse( + 'Dialog should not be open yet', + !!plugin.getDialog() && plugin.getDialog().isOpen()); + + plugin.execCommand(COMMAND); + assertTrue( + 'Dialog should be open now', + !!plugin.getDialog() && plugin.getDialog().isOpen()); + + /** @suppress {visibility} suppression added to enable type checking */ + const tempDialog = plugin.getDialog(); + plugin.dispose(); + assertFalse( + 'Dialog should not still be open after disposal', tempDialog.isOpen()); + mockCtrl.$verifyAll(); + }, + + /** Tests execCommand and dispose while reusing the dialog. */ + testExecAndDisposeReuse() { + this.testExecAndDispose(true); + }, + + /** + * Tests the flow of calling execCommand (which opens the dialog) and + * then hiding it (simulating that a user did somthing to cause the dialog to + * close). + * @param {boolean=} reuse Whether to set the plugin to reuse its dialog. + * @suppress {missingProperties,visibility} suppression added to enable type + * checking + */ + testExecAndHide(reuse = undefined) { + setUpMockRange(); + expectOpened(); + expectClosed(); + mockField.debounceEvent(Field.EventType.SELECTIONCHANGE); + mockCtrl.$replayAll(); + if (reuse) { + plugin.setReuseDialog(true); + } + assertFalse( + 'Dialog should not be open yet', + !!plugin.getDialog() && plugin.getDialog().isOpen()); + + plugin.execCommand(COMMAND); + assertTrue( + 'Dialog should be open now', + !!plugin.getDialog() && plugin.getDialog().isOpen()); + + /** @suppress {visibility} suppression added to enable type checking */ + const tempDialog = plugin.getDialog(); + plugin.getDialog().hide(); + assertFalse( + 'Dialog should not still be open after hiding', tempDialog.isOpen()); + if (reuse) { + assertFalse( + 'Dialog should not be disposed after hiding (will be reused)', + tempDialog.isDisposed()); + } else { + assertTrue( + 'Dialog should be disposed after hiding', tempDialog.isDisposed()); + } + plugin.dispose(); + mockCtrl.$verifyAll(); + }, + + /** Tests execCommand and hide while reusing the dialog. */ + testExecAndHideReuse() { + this.testExecAndHide(true); + }, + + /** + * Tests the flow of calling execCommand (which opens a dialog) and + * then calling it again before the first dialog is closed. This is not + * something anyone should be doing since dialogs are (usually?) modal so the + * user can't do another execCommand before closing the first dialog. But + * since the API makes it possible, I thought it would be good to guard + * against and unit test. + * @param {boolean=} reuse Whether to set the plugin to reuse its dialog. + * @suppress {visibility,missingProperties} suppression added to enable type + * checking + */ + testExecTwice(reuse = undefined) { + setUpMockRange(); + if (reuse) { + expectOpened(2); // The second exec should cause a second OPENED event. + // But the dialog was not closed between exec calls, so only one CLOSED is + // expected. + expectClosed(); + plugin.setReuseDialog(true); + mockField.debounceEvent(Field.EventType.SELECTIONCHANGE); + } else { + expectOpened(2); // The second exec should cause a second OPENED event. + // The first dialog will be disposed so there should be two CLOSED events. + expectClosed(2); + mockSavedRange.restore(); // Expected 2x, once already recorded in setup. + mockField.focus(); // Expected 2x, once already recorded in setup. + mockField.debounceEvent(Field.EventType.SELECTIONCHANGE); + mockField.$times(2); + } + mockCtrl.$replayAll(); + + assertFalse( + 'Dialog should not be open yet', + !!plugin.getDialog() && plugin.getDialog().isOpen()); + + plugin.execCommand(COMMAND); + assertTrue( + 'Dialog should be open now', + !!plugin.getDialog() && plugin.getDialog().isOpen()); + + /** @suppress {visibility} suppression added to enable type checking */ + const tempDialog = plugin.getDialog(); + plugin.execCommand(COMMAND); + if (reuse) { + assertTrue( + 'Reused dialog should still be open after second exec', + tempDialog.isOpen()); + assertFalse( + 'Reused dialog should not be disposed after second exec', + tempDialog.isDisposed()); + } else { + assertFalse( + 'First dialog should not still be open after opening second', + tempDialog.isOpen()); + assertTrue( + 'First dialog should be disposed after opening second', + tempDialog.isDisposed()); + } + plugin.dispose(); + mockCtrl.$verifyAll(); + }, + + /** Tests execCommand twice while reusing the dialog. */ + testExecTwiceReuse() { + this.testExecTwice(true); + }, + + /** + * Tests that the selection is cleared when the dialog opens and is + * correctly restored after it closes. + * @suppress {visibility} suppression added to enable type checking + */ + testRestoreSelection() { + setUpRealEditableField(); + + fieldObj.setSafeHtml(false, SafeHtml.htmlEscape('12345')); + const elem = fieldObj.getElement(); + const helper = new TestHelper(elem); + helper.select('12345', 1, '12345', 4); // Selects '234'. + + assertEquals( + 'Incorrect text selected before dialog is opened', '234', + fieldObj.getRange().getText()); + plugin.execCommand(COMMAND); + if (!userAgent.IE) { + // IE returns some bogus range when field doesn't have selection. + // Opera can't remove the selection from a whitebox field. + assertNull( + 'There should be no selection while dialog is open', + fieldObj.getRange()); + } + plugin.getDialog().hide(); + assertEquals( + 'Incorrect text selected after dialog is closed', '234', + fieldObj.getRange().getText()); + }, + + /** + * Tests that after the dialog is hidden via a keystroke, the editable field + * doesn't fire an extra SELECTIONCHANGE event due to the keyup from that + * keystroke. + * There is also a robot test in dialog_robot.html to test debouncing the + * SELECTIONCHANGE event when the dialog closes. + * @suppress {visibility,checkTypes} suppression added to enable type checking + */ + testDebounceSelectionChange() { + mockClock = new MockClock(true); + // Initial time is 0 which evaluates to false in debouncing implementation. + mockClock.tick(1); + + setUpRealEditableField(); + + // Set up a mock event handler to make sure selection change isn't fired + // more than once on close and a second time on close. + let count = 0; + fieldObj.addEventListener(Field.EventType.SELECTIONCHANGE, (e) => { + count++; + }); + + assertEquals(0, count); + plugin.execCommand(COMMAND); + assertEquals(1, count); + plugin.getDialog().hide(); + assertEquals(2, count); + + // Fake the keyup event firing on the field after the dialog closes. + /** @suppress {visibility} suppression added to enable type checking */ + const e = new GoogEvent('keyup', plugin.fieldObject.getElement()); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.keyCode = 13; + events.fireBrowserEvent(e); + + // Tick the mock clock so that selection change tries to fire. + mockClock.tick(Field.SELECTION_CHANGE_FREQUENCY_ + 1); + + // Ensure the handler did not fire again. + assertEquals(2, count); + }, +}); diff --git a/closure/goog/editor/plugins/abstracttabhandler.js b/closure/goog/editor/plugins/abstracttabhandler.js new file mode 100644 index 0000000000..5b6ec79a3f --- /dev/null +++ b/closure/goog/editor/plugins/abstracttabhandler.js @@ -0,0 +1,71 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Abstract Editor plugin class to handle tab keys. Has one + * abstract method which should be overriden to handle a tab key press. + */ + +goog.provide('goog.editor.plugins.AbstractTabHandler'); + +goog.require('goog.editor.Plugin'); +goog.require('goog.events.KeyCodes'); +goog.require('goog.userAgent'); +goog.requireType('goog.events.BrowserEvent'); + + + +/** + * Plugin to handle tab keys. Specific tab behavior defined by subclasses. + * + * @constructor + * @extends {goog.editor.Plugin} + */ +goog.editor.plugins.AbstractTabHandler = function() { + 'use strict'; + goog.editor.Plugin.call(this); +}; +goog.inherits(goog.editor.plugins.AbstractTabHandler, goog.editor.Plugin); + + +/** @override */ +goog.editor.plugins.AbstractTabHandler.prototype.getTrogClassId = + goog.abstractMethod; + + +/** @override */ +goog.editor.plugins.AbstractTabHandler.prototype.handleKeyboardShortcut = + function(e, key, isModifierPressed) { + 'use strict'; + // If a dialog doesn't have selectable field, Moz grabs the event and + // performs actions in editor window. This solves that problem and allows + // the event to be passed on to proper handlers. + if (goog.userAgent.GECKO && this.getFieldObject().inModalMode()) { + return false; + } + + // Don't handle Ctrl+Tab since the user is most likely trying to switch + // browser tabs. See bug 1305086. + // FF3 on Mac sends Ctrl-Tab to trogedit and we end up inserting a tab, but + // then it also switches the tabs. See bug 1511681. Note that we don't use + // isModifierPressed here since isModifierPressed is true only if metaKey + // is true on Mac. + if (e.keyCode == goog.events.KeyCodes.TAB && !e.metaKey && !e.ctrlKey) { + return this.handleTabKey(e); + } + + return false; +}; + + +/** + * Handle a tab key press. + * @param {!goog.events.BrowserEvent} e The key event. + * @return {boolean} Whether this event was handled by this plugin. + * @protected + */ +goog.editor.plugins.AbstractTabHandler.prototype.handleTabKey = + goog.abstractMethod; diff --git a/closure/goog/editor/plugins/abstracttabhandler_test.js b/closure/goog/editor/plugins/abstracttabhandler_test.js new file mode 100644 index 0000000000..8d7efea9fa --- /dev/null +++ b/closure/goog/editor/plugins/abstracttabhandler_test.js @@ -0,0 +1,108 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.editor.plugins.AbstractTabHandlerTest'); +goog.setTestOnly(); + +const AbstractTabHandler = goog.require('goog.editor.plugins.AbstractTabHandler'); +const BrowserEvent = goog.require('goog.events.BrowserEvent'); +const Field = goog.require('goog.editor.Field'); +const FieldMock = goog.require('goog.testing.editor.FieldMock'); +const KeyCodes = goog.require('goog.events.KeyCodes'); +const StrictMock = goog.require('goog.testing.StrictMock'); +const testSuite = goog.require('goog.testing.testSuite'); +const userAgent = goog.require('goog.userAgent'); + +let tabHandler; +let editableField; +let handleTabKeyCalled = false; + +testSuite({ + /** @suppress {checkTypes} suppression added to enable type checking */ + setUp() { + editableField = new FieldMock(); + /** @suppress {checkTypes} suppression added to enable type checking */ + editableField.inModalMode = Field.prototype.inModalMode; + /** @suppress {checkTypes} suppression added to enable type checking */ + editableField.setModalMode = Field.prototype.setModalMode; + + tabHandler = new AbstractTabHandler(); + tabHandler.registerFieldObject(editableField); + /** @suppress {visibility} suppression added to enable type checking */ + tabHandler.handleTabKey = (e) => { + handleTabKeyCalled = true; + return true; + }; + }, + + tearDown() { + tabHandler.dispose(); + }, + + testHandleKey() { + const event = new StrictMock(BrowserEvent); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + event.keyCode = KeyCodes.TAB; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + event.ctrlKey = false; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + event.metaKey = false; + + assertTrue( + 'Event must be handled when no modifier keys are pressed.', + tabHandler.handleKeyboardShortcut(event, '', false)); + assertTrue(handleTabKeyCalled); + handleTabKeyCalled = false; + + editableField.setModalMode(true); + if (userAgent.GECKO) { + assertFalse( + 'Event must not be handled when in modal mode', + tabHandler.handleKeyboardShortcut(event, '', false)); + assertFalse(handleTabKeyCalled); + } else { + assertTrue( + 'Event must be handled when in modal mode', + tabHandler.handleKeyboardShortcut(event, '', false)); + assertTrue(handleTabKeyCalled); + handleTabKeyCalled = false; + } + + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + event.ctrlKey = true; + assertFalse( + 'Plugin must never handle tab key press when ctrlKey is pressed.', + tabHandler.handleKeyboardShortcut(event, '', false)); + assertFalse(handleTabKeyCalled); + + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + event.ctrlKey = false; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + event.metaKey = true; + assertFalse( + 'Plugin must never handle tab key press when metaKey is pressed.', + tabHandler.handleKeyboardShortcut(event, '', false)); + assertFalse(handleTabKeyCalled); + }, +}); diff --git a/closure/goog/editor/plugins/basictextformatter.js b/closure/goog/editor/plugins/basictextformatter.js new file mode 100644 index 0000000000..70aff65fa0 --- /dev/null +++ b/closure/goog/editor/plugins/basictextformatter.js @@ -0,0 +1,1936 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Functions to style text. + */ + +goog.provide('goog.editor.plugins.BasicTextFormatter'); +goog.provide('goog.editor.plugins.BasicTextFormatter.COMMAND'); + +goog.require('goog.array'); +goog.require('goog.dom'); +goog.require('goog.dom.NodeType'); +goog.require('goog.dom.Range'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.safe'); +goog.require('goog.editor.BrowserFeature'); +goog.require('goog.editor.Command'); +goog.require('goog.editor.Link'); +goog.require('goog.editor.Plugin'); +goog.require('goog.editor.node'); +goog.require('goog.editor.range'); +goog.require('goog.editor.style'); +goog.require('goog.html.SafeHtml'); +goog.require('goog.html.uncheckedconversions'); +goog.require('goog.iter'); +goog.require('goog.log'); +goog.require('goog.object'); +goog.require('goog.string'); +goog.require('goog.string.Const'); +goog.require('goog.string.Unicode'); +goog.require('goog.style'); +goog.require('goog.ui.editor.messages'); +goog.require('goog.userAgent'); +goog.requireType('goog.dom.AbstractRange'); + + + +/** + * Functions to style text (e.g. underline, make bold, etc.) + * @constructor + * @extends {goog.editor.Plugin} + */ +goog.editor.plugins.BasicTextFormatter = function() { + 'use strict'; + goog.editor.Plugin.call(this); +}; +goog.inherits(goog.editor.plugins.BasicTextFormatter, goog.editor.Plugin); + + +/** @override */ +goog.editor.plugins.BasicTextFormatter.prototype.getTrogClassId = function() { + 'use strict'; + return 'BTF'; +}; + + +/** + * Logging object. + * @type {goog.log.Logger} + * @protected + * @override + */ +goog.editor.plugins.BasicTextFormatter.prototype.logger = + goog.log.getLogger('goog.editor.plugins.BasicTextFormatter'); + + +/** + * Commands implemented by this plugin. + * @enum {string} + */ +goog.editor.plugins.BasicTextFormatter.COMMAND = { + LINK: '+link', + CREATE_LINK: '+createLink', + FORMAT_BLOCK: '+formatBlock', + INDENT: '+indent', + OUTDENT: '+outdent', + STRIKE_THROUGH: '+strikeThrough', + HORIZONTAL_RULE: '+insertHorizontalRule', + SUBSCRIPT: '+subscript', + SUPERSCRIPT: '+superscript', + UNDERLINE: '+underline', + BOLD: '+bold', + ITALIC: '+italic', + FONT_SIZE: '+fontSize', + FONT_FACE: '+fontName', + FONT_COLOR: '+foreColor', + BACKGROUND_COLOR: '+backColor', + ORDERED_LIST: '+insertOrderedList', + UNORDERED_LIST: '+insertUnorderedList', + JUSTIFY_CENTER: '+justifyCenter', + JUSTIFY_FULL: '+justifyFull', + JUSTIFY_RIGHT: '+justifyRight', + JUSTIFY_LEFT: '+justifyLeft' +}; + + +/** + * Inverse map of execCommand strings to + * {@link goog.editor.plugins.BasicTextFormatter.COMMAND} constants. Used to + * determine whether a string corresponds to a command this plugin + * handles in O(1) time. + * @type {Object} + * @private + */ +goog.editor.plugins.BasicTextFormatter.SUPPORTED_COMMANDS_ = + goog.object.transpose(goog.editor.plugins.BasicTextFormatter.COMMAND); + + +/** + * Whether the string corresponds to a command this plugin handles. + * @param {string} command Command string to check. + * @return {boolean} Whether the string corresponds to a command + * this plugin handles. + * @override + */ +goog.editor.plugins.BasicTextFormatter.prototype.isSupportedCommand = function( + command) { + 'use strict'; + // TODO(user): restore this to simple check once table editing + // is moved out into its own plugin + return command in goog.editor.plugins.BasicTextFormatter.SUPPORTED_COMMANDS_; +}; + + +/** + * Array of execCommand strings which should be silent. + * @type {!Array} + * @private + */ +goog.editor.plugins.BasicTextFormatter.SILENT_COMMANDS_ = + [goog.editor.plugins.BasicTextFormatter.COMMAND.CREATE_LINK]; + + +/** + * Whether the string corresponds to a command that should be silent. + * @override + */ +goog.editor.plugins.BasicTextFormatter.prototype.isSilentCommand = function( + command) { + 'use strict'; + return goog.array.contains( + goog.editor.plugins.BasicTextFormatter.SILENT_COMMANDS_, command); +}; + + +/** + * @return {goog.dom.AbstractRange} The closure range object that wraps the + * current user selection. + * @private + */ +goog.editor.plugins.BasicTextFormatter.prototype.getRange_ = function() { + 'use strict'; + return this.getFieldObject().getRange(); +}; + + +/** + * @return {!Document} The document object associated with the currently active + * field. + * @private + */ +goog.editor.plugins.BasicTextFormatter.prototype.getDocument_ = function() { + 'use strict'; + return this.getFieldDomHelper().getDocument(); +}; + + +/** + * Execute a user-initiated command. + * @param {string} command Command to execute. + * @param {...*} var_args For color commands, this + * should be the hex color (with the #). For FORMAT_BLOCK, this should be + * the goog.editor.plugins.BasicTextFormatter.BLOCK_COMMAND. + * It will be unused for other commands. + * @return {Object|undefined} The result of the command. + * @override + */ +goog.editor.plugins.BasicTextFormatter.prototype.execCommandInternal = function( + command, var_args) { + 'use strict'; + let preserveDir, styleWithCss, needsFormatBlockDiv, hasPlaceholderSelection; + var result; + var opt_arg = arguments[1]; + let hasPlaceholderContent = false; + let placeholderValue; + + switch (command) { + case goog.editor.plugins.BasicTextFormatter.COMMAND.BACKGROUND_COLOR: + // Don't bother for no color selected, color picker is resetting itself. + if (opt_arg !== null) { + if (goog.editor.BrowserFeature.EATS_EMPTY_BACKGROUND_COLOR) { + this.applyBgColorManually_(opt_arg); + } else { + this.execCommandHelper_(command, opt_arg); + } + } + break; + + case goog.editor.plugins.BasicTextFormatter.COMMAND.CREATE_LINK: + result = this.createLink_(arguments[1], arguments[2], arguments[3]); + break; + + case goog.editor.plugins.BasicTextFormatter.COMMAND.LINK: + result = this.toggleLink_(opt_arg); + break; + + case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_CENTER: + case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_FULL: + case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_RIGHT: + case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_LEFT: + this.justify_(command); + break; + + default: + if (goog.userAgent.IE && + command == + goog.editor.plugins.BasicTextFormatter.COMMAND.FORMAT_BLOCK && + opt_arg) { + // IE requires that the argument be in the form of an opening + // tag, like

    , including angle brackets. WebKit will accept + // the arguemnt with or without brackets, and Firefox pre-3 supports + // only a fixed subset of tags with brackets, and prefers without. + // So we only add them IE only. + opt_arg = '<' + opt_arg + '>'; + } + + if (command == + goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_COLOR && + opt_arg === null) { + // If we don't have a color, then FONT_COLOR is a no-op. + break; + } + + switch (command) { + case goog.editor.plugins.BasicTextFormatter.COMMAND.INDENT: + case goog.editor.plugins.BasicTextFormatter.COMMAND.OUTDENT: + if (goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS) { + if (goog.userAgent.GECKO) { + styleWithCss = true; + } + } + // Fall through. + + case goog.editor.plugins.BasicTextFormatter.COMMAND.ORDERED_LIST: + case goog.editor.plugins.BasicTextFormatter.COMMAND.UNORDERED_LIST: + if (goog.editor.BrowserFeature.LEAVES_P_WHEN_REMOVING_LISTS && + this.queryCommandStateInternal_(this.getDocument_(), command)) { + // IE leaves behind P tags when unapplying lists. + // If we're not in P-mode, then we want divs + // So, unlistify, then convert the Ps into divs. + needsFormatBlockDiv = + this.getFieldObject().queryCommandValue( + goog.editor.Command.DEFAULT_TAG) != goog.dom.TagName.P; + } else if (!goog.editor.BrowserFeature.CAN_LISTIFY_BR) { + // IE doesn't convert BRed line breaks into separate list items. + // So convert the BRs to divs, then do the listify. + this.convertBreaksToDivs_(); + } + + // This fix only works in Gecko. + if (goog.userAgent.GECKO && + goog.editor.BrowserFeature.FORGETS_FORMATTING_WHEN_LISTIFYING && + !this.queryCommandValue(command)) { + /** @suppress {strictPrimitiveOperators} */ + hasPlaceholderSelection |= this.beforeInsertListGecko_(); + } + + const selection = + this.getFieldDomHelper().getDocument().getSelection(); + if (selection.rangeCount === 1) { + placeholderValue = goog.string.createUniqueString(); + const placeholderNode = goog.dom.createDom(goog.dom.TagName.SPAN); + const safePlaceholderAnchorContent = + goog.html.SafeHtml.htmlEscape(placeholderValue); + goog.dom.safe.setInnerHtml( + placeholderNode, safePlaceholderAnchorContent); + if (selection.isCollapsed) { + // If the selection is collapsed, insert placeholder content to + // keep the selection as we add the list, so we don't lose cursor + // position. + // Mark that we need to delete the placeholder selection later. + hasPlaceholderSelection = true; + + goog.dom.Range.createFromBrowserRange(selection.getRangeAt(0)) + .replaceContentsWithNode(placeholderNode); + goog.dom.Range.createFromNodeContents(placeholderNode).select(); + } else if (goog.userAgent.WEBKIT) { + // For webkit, we need insert unselected, unformatted content + // at the start of the LI to prevent the list being split into 2 + // lists, and delete the placeholder content after. + const parentListItem = goog.dom.getAncestorByTagNameAndClass( + selection.anchorNode, goog.dom.TagName.LI); + if (parentListItem) { + goog.dom.insertChildAt(parentListItem, placeholderNode, 0); + hasPlaceholderContent = true; + } + } + } + + // Fall through to preserveDir block + + case goog.editor.plugins.BasicTextFormatter.COMMAND.FORMAT_BLOCK: + // Both FF & IE may lose directionality info. Save/restore it. + // TODO(user): Does Safari also need this? + // TODO (user): This isn't ideal because it uses a string + // literal, so if the plugin name changes, it would break. We need a + // better solution. See also other places in code that use + // this.getPluginByClassId('Bidi'). + preserveDir = !!this.getFieldObject().getPluginByClassId('Bidi'); + break; + + case goog.editor.plugins.BasicTextFormatter.COMMAND.SUBSCRIPT: + case goog.editor.plugins.BasicTextFormatter.COMMAND.SUPERSCRIPT: + if (goog.editor.BrowserFeature.NESTS_SUBSCRIPT_SUPERSCRIPT) { + // This browser nests subscript and superscript when both are + // applied, instead of canceling out the first when applying the + // second. + this.applySubscriptSuperscriptWorkarounds_(command); + } + break; + + case goog.editor.plugins.BasicTextFormatter.COMMAND.UNDERLINE: + case goog.editor.plugins.BasicTextFormatter.COMMAND.BOLD: + case goog.editor.plugins.BasicTextFormatter.COMMAND.ITALIC: + // If we are applying the formatting, then we want to have + // styleWithCSS false so that we generate html tags (like ). If we + // are unformatting something, we want to have styleWithCSS true so + // that we can unformat both html tags and inline styling. + // TODO(user): What about WebKit and Opera? + styleWithCss = goog.userAgent.GECKO && + goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS && + this.queryCommandValue(command); + break; + + case goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_COLOR: + case goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_FACE: + // It is very expensive in FF (order of magnitude difference) to use + // font tags instead of styled spans. Whenever possible, + // force FF to use spans. + // Font size is very expensive too, but FF always uses font tags, + // regardless of which styleWithCSS value you use. + styleWithCss = goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS && + goog.userAgent.GECKO; + } + + /** + * Cases where we just use the default execCommand (in addition + * to the above fall-throughs) + * goog.editor.plugins.BasicTextFormatter.COMMAND.STRIKE_THROUGH: + * goog.editor.plugins.BasicTextFormatter.COMMAND.HORIZONTAL_RULE: + * goog.editor.plugins.BasicTextFormatter.COMMAND.SUBSCRIPT: + * goog.editor.plugins.BasicTextFormatter.COMMAND.SUPERSCRIPT: + * goog.editor.plugins.BasicTextFormatter.COMMAND.UNDERLINE: + * goog.editor.plugins.BasicTextFormatter.COMMAND.BOLD: + * goog.editor.plugins.BasicTextFormatter.COMMAND.ITALIC: + * goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_SIZE: + * goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_FACE: + */ + this.execCommandHelper_(command, opt_arg, preserveDir, !!styleWithCss); + + if (hasPlaceholderSelection) { + this.safeExecCommand_('Delete', true); + } + + if (hasPlaceholderContent && placeholderValue) { + // Unfortunately, the browser sometimes removes the element we added and + // creates a new element with the same content or appends the text to + // an existing text node, so we can't add an + // id/class to the placeholder node and rely on that to find/delete the + // content, and instead have to manually search for the content. + // Example: + //
      + //
    1. goog_12345abc
    2. + //
    3. def
    4. + //
    + // can become + // goog_12345abc
    + // def
    + // after execCommand is called + const POTENTIAL_PLACEHOLDER_TAGS = [goog.dom.TagName.SPAN, '#text']; + const placeholderNode = goog.dom.findNode( + this.getFieldObject().getElement(), + node => POTENTIAL_PLACEHOLDER_TAGS.includes(node.nodeName) && + node.textContent.includes(placeholderValue)); + if (placeholderNode) { + if (placeholderNode.textContent === placeholderValue) { + goog.dom.removeNode(placeholderNode); + } else { + placeholderNode.textContent = + placeholderNode.textContent.replaceAll(placeholderValue, ''); + } + } + } + + if (needsFormatBlockDiv) { + this.safeExecCommand_('FormatBlock', '
    '); + } + } + // FF loses focus, so we have to set the focus back to the document or the + // user can't type after selecting from menu. In IE, focus is set correctly + // and resetting it here messes it up. + if (goog.userAgent.GECKO && !this.getFieldObject().inModalMode()) { + this.focusField_(); + } + return result; +}; + + +/** + * Focuses on the field. + * @private + */ +goog.editor.plugins.BasicTextFormatter.prototype.focusField_ = function() { + 'use strict'; + this.getFieldDomHelper().getWindow().focus(); +}; + + +/** + * Gets the command value. + * @param {string} command The command value to get. + * @return {string|boolean|null} The current value of the command in the given + * selection. NOTE: This return type list is not documented in MSDN or MDC + * and has been constructed from experience. Please update it + * if necessary. + * @override + */ +goog.editor.plugins.BasicTextFormatter.prototype.queryCommandValue = function( + command) { + 'use strict'; + var styleWithCss; + switch (command) { + case goog.editor.plugins.BasicTextFormatter.COMMAND.LINK: + return this.isNodeInState_(goog.dom.TagName.A); + + case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_CENTER: + case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_FULL: + case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_RIGHT: + case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_LEFT: + return this.isJustification_(command); + + case goog.editor.plugins.BasicTextFormatter.COMMAND.FORMAT_BLOCK: + // TODO(nicksantos): See if we can use queryCommandValue here. + return goog.editor.plugins.BasicTextFormatter.getSelectionBlockState_( + this.getFieldObject().getRange()); + + case goog.editor.plugins.BasicTextFormatter.COMMAND.INDENT: + case goog.editor.plugins.BasicTextFormatter.COMMAND.OUTDENT: + case goog.editor.plugins.BasicTextFormatter.COMMAND.HORIZONTAL_RULE: + // TODO: See if there are reasonable results to return for + // these commands. + return false; + + case goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_SIZE: + case goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_FACE: + case goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_COLOR: + case goog.editor.plugins.BasicTextFormatter.COMMAND.BACKGROUND_COLOR: + // We use queryCommandValue here since we don't just want to know if a + // color/fontface/fontsize is applied, we want to know WHICH one it is. + return this.queryCommandValueInternal_( + this.getDocument_(), command, + (goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS && + goog.userAgent.GECKO) ?? + undefined); + + case goog.editor.plugins.BasicTextFormatter.COMMAND.UNDERLINE: + case goog.editor.plugins.BasicTextFormatter.COMMAND.BOLD: + case goog.editor.plugins.BasicTextFormatter.COMMAND.ITALIC: + styleWithCss = + goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS && goog.userAgent.GECKO; + + default: + /** + * goog.editor.plugins.BasicTextFormatter.COMMAND.STRIKE_THROUGH + * goog.editor.plugins.BasicTextFormatter.COMMAND.SUBSCRIPT + * goog.editor.plugins.BasicTextFormatter.COMMAND.SUPERSCRIPT + * goog.editor.plugins.BasicTextFormatter.COMMAND.UNDERLINE + * goog.editor.plugins.BasicTextFormatter.COMMAND.BOLD + * goog.editor.plugins.BasicTextFormatter.COMMAND.ITALIC + * goog.editor.plugins.BasicTextFormatter.COMMAND.ORDERED_LIST + * goog.editor.plugins.BasicTextFormatter.COMMAND.UNORDERED_LIST + */ + // This only works for commands that use the default execCommand + return this.queryCommandStateInternal_( + this.getDocument_(), command, styleWithCss ?? undefined); + } +}; + + +/** + * @override + */ +goog.editor.plugins.BasicTextFormatter.prototype.prepareContentsHtml = function( + html) { + 'use strict'; + // If the browser collapses empty nodes and the field has only a script + // tag in it, then it will collapse this node. Which will mean the user + // can't click into it to edit it. + if (goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES && + html.match(/^\s*' + + '

    My Header

    My text.
    My bold text.
    ' + + '
    My\npreformatted 
    HTML.
    5 < 10' + + ''; +let mockClock; +let mockClockTicks; + +testSuite({ + setUp() { + mockClockTicks = 0; + mockClock = new MockClock(); + mockClock.getCurrentTime = () => mockClockTicks++; + mockClock.install(); + }, + + tearDown() { + if (mockClock) { + mockClock.uninstall(); + } + }, + + testSimpleHtml() { + const actual = HtmlPrettyPrinter.format('
    bold'); + assertEquals('
    \nbold\n', actual); + assertEquals(actual, HtmlPrettyPrinter.format(actual)); + }, + + testSimpleHtmlMixedCase() { + const actual = HtmlPrettyPrinter.format('
    bold'); + assertEquals('
    \nbold\n', actual); + assertEquals(actual, HtmlPrettyPrinter.format(actual)); + }, + + testComplexHtml() { + const actual = HtmlPrettyPrinter.format(COMPLEX_HTML); + const expected = ']>\n' + + '\n' + + '\n' + + 'My HTML\n' + + '' + + '\n' + + '\n' + + '\n' + + '

    My Header

    \n' + + 'My text.
    \n' + + 'My bold text.\n' + + '
    \n' + + '
    My\npreformatted 
    HTML.
    \n' + + '5 < 10' + + '\n' + + '\n'; + assertEquals(expected, actual); + assertEquals(actual, HtmlPrettyPrinter.format(actual)); + }, + + testTimeout() { + const pp = new HtmlPrettyPrinter(3); + const actual = pp.format(COMPLEX_HTML); + const expected = ']>\n' + + '\n' + + 'My HTML' + + '' + + '

    My Header

    My text.
    My bold text.
    ' + + '
    My\npreformatted 
    HTML.
    5 < 10' + + '\n'; + assertEquals(expected, actual); + }, + + testKeepLeadingIndent() { + const original = ' Bold Ital '; + const expected = ' Bold Ital\n'; + assertEquals(expected, HtmlPrettyPrinter.format(original)); + }, + + testTrimLeadingLineBreaks() { + const original = '\n \t\r\n \n Bold Ital '; + const expected = ' Bold Ital\n'; + assertEquals(expected, HtmlPrettyPrinter.format(original)); + }, + + testExtraLines() { + const original = '
    \ntombrat'; + assertEquals( + `${original} +`, + HtmlPrettyPrinter.format(original)); + }, + + testCrlf() { + const original = '
    \r\none\r\ntwo
    '; + assertEquals( + `${original} +`, + HtmlPrettyPrinter.format(original)); + }, + + testEndInLineBreak() { + assertEquals('foo\n', HtmlPrettyPrinter.format('foo')); + assertEquals('foo\n', HtmlPrettyPrinter.format('foo\n')); + assertEquals('foo\n', HtmlPrettyPrinter.format('foo\n\n')); + assertEquals('foo
    \n', HtmlPrettyPrinter.format('foo
    ')); + assertEquals('foo
    \n', HtmlPrettyPrinter.format('foo
    \n')); + }, + + testTable() { + const original = '' + + '' + + '' + + '
    one.oneone.two
    two.onetwo.two
    '; + const expected = '\n' + + '\n\n\n\n' + + '\n\n\n\n' + + '
    one.oneone.two
    two.onetwo.two
    \n'; + assertEquals(expected, HtmlPrettyPrinter.format(original)); + }, + + /** + * We have a sanity check in HtmlPrettyPrinter to make sure the regex index + * advances after every match. We should never hit this, but we include it on + * the chance there is some corner case where the pattern would match but not + * process a new token. It's not generally a good idea to break the + * implementation to test behavior, but this is the easiest way to mimic a + * bad internal state. + */ + testRegexMakesProgress() { + /** @suppress {visibility} suppression added to enable type checking */ + const original = HtmlPrettyPrinter.TOKEN_REGEX_; + + try { + // This regex matches \B, an index between 2 word characters, so the regex + // index does not advance when matching this. + /** + * @suppress {visibility,const} suppression added to enable type checking + */ + HtmlPrettyPrinter.TOKEN_REGEX_ = + /(?:\B|||<(\/?)(\w+)[^>]*>|[^<]+|<)/g; + + // It would work on this string. + assertEquals('f o o\n', HtmlPrettyPrinter.format('f o o')); + + // But not this one. + const ex = assertThrows( + 'should have failed for invalid regex - endless loop', + goog.partial(HtmlPrettyPrinter.format, COMPLEX_HTML)); + assertEquals( + 'Regex failed to make progress through source html.', ex.message); + } finally { + /** + * @suppress {visibility,constantProperty} suppression added to enable + * type checking + */ + HtmlPrettyPrinter.TOKEN_REGEX_ = original; + } + }, + + /** + * FF3.0 doesn't like \n between and . + * See b/1520665. + */ + testLists() { + const original = '
    • one
      • two
    • three
    '; + const expected = + '
    • one
    • \n
      • two
      \n
    • three
    \n'; + assertEquals(expected, HtmlPrettyPrinter.format(original)); + }, + + /** + * We have a sanity check in HtmlPrettyPrinter to make sure the regex fully + * tokenizes the string. We should never hit this, but we include it on the + * chance there is some corner case where the pattern would miss a section of + * original string. It's not generally a good idea to break the + * implementation to test behavior, but this is the easiest way to mimic a + * bad internal state. + */ + testAvoidDataLoss() { + /** @suppress {visibility} suppression added to enable type checking */ + const original = HtmlPrettyPrinter.TOKEN_REGEX_; + + try { + // This regex does not match stranded '<' characters, so does not fully + // tokenize the string. + /** + * @suppress {visibility,constantProperty} suppression added to enable + * type checking + */ + HtmlPrettyPrinter.TOKEN_REGEX_ = + /(?:||<(\/?)(\w+)[^>]*>|[^<]+)/g; + + // It would work on this string. + assertEquals('foo\n', HtmlPrettyPrinter.format('foo')); + + // But not this one. + const ex = assertThrows( + 'should have failed for invalid regex - data loss', + goog.partial(HtmlPrettyPrinter.format, COMPLEX_HTML)); + assertEquals('Lost data pretty printing html.', ex.message); + } finally { + /** + * @suppress {visibility,constantProperty} suppression added to enable + * type checking + */ + HtmlPrettyPrinter.TOKEN_REGEX_ = original; + } + }, +}); diff --git a/closure/goog/format/internationalizedemailaddress.js b/closure/goog/format/internationalizedemailaddress.js new file mode 100644 index 0000000000..9aa4e03798 --- /dev/null +++ b/closure/goog/format/internationalizedemailaddress.js @@ -0,0 +1,255 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Provides functions to parse and manipulate internationalized + * email addresses. This is useful in the context of Email Address + * Internationalization (EAI) as defined by RFC6530. + */ + +goog.provide('goog.format.InternationalizedEmailAddress'); + +goog.require('goog.format.EmailAddress'); + +goog.require('goog.string'); + + + +/** + * Formats an email address string for display, and allows for extraction of + * the individual components of the address. + * @param {string=} opt_address The email address. + * @param {string=} opt_name The name associated with the email address. + * @constructor + * @extends {goog.format.EmailAddress} + */ +goog.format.InternationalizedEmailAddress = function(opt_address, opt_name) { + 'use strict'; + goog.format.InternationalizedEmailAddress.base( + this, 'constructor', opt_address, opt_name); +}; +goog.inherits( + goog.format.InternationalizedEmailAddress, goog.format.EmailAddress); + + +/** + * A string representing the RegExp for the local part of an EAI email address. + * @private + */ +goog.format.InternationalizedEmailAddress.EAI_LOCAL_PART_REGEXP_STR_ = + '((?!\\s)[+a-zA-Z0-9_.!#$%&\'*\\/=?^`{|}~\u0080-\uFFFFFF-])+'; + + +/** + * A string representing the RegExp for a label in the domain part of an EAI + * email address. + * @private + */ +goog.format.InternationalizedEmailAddress.EAI_LABEL_CHAR_REGEXP_STR_ = + '(?!\\s)[a-zA-Z0-9\u0080-\u3001\u3003-\uFF0D\uFF0F-\uFF60\uFF62-\uFFFFFF-]'; + + +/** + * A string representing the RegExp for the domain part of an EAI email address. + * @private + */ +goog.format.InternationalizedEmailAddress.EAI_DOMAIN_PART_REGEXP_STR_ = + // A unicode character (ASCII or Unicode excluding periods) + '(' + goog.format.InternationalizedEmailAddress.EAI_LABEL_CHAR_REGEXP_STR_ + + // Such character 1+ times, followed by a Unicode period. All 1+ times. + '+[\\.\\uFF0E\\u3002\\uFF61])+' + + // And same thing but without a period in the end + goog.format.InternationalizedEmailAddress.EAI_LABEL_CHAR_REGEXP_STR_ + + '{2,63}'; + + +/** + * Match string for address separators. This list is the result of the + * discussion in b/16241003. + * @type {string} + * @private + */ +goog.format.InternationalizedEmailAddress.ADDRESS_SEPARATORS_ = + ',' + // U+002C ( , ) COMMA + ';' + // U+003B ( ; ) SEMICOLON + '\u055D' + // ( ՝ ) ARMENIAN COMMA + '\u060C' + // ( ، ) ARABIC COMMA + '\u1363' + // ( ፣ ) ETHIOPIC COMMA + '\u1802' + // ( ᠂ ) MONGOLIAN COMMA + '\u1808' + // ( ᠈ ) MONGOLIAN MANCHU COMMA + '\u2E41' + // ( ⹁ ) REVERSED COMMA + '\u3001' + // ( 、 ) IDEOGRAPHIC COMMA + '\uFF0C' + // ( , ) FULLWIDTH COMMA + '\u061B' + // ( ‎؛‎ ) ARABIC SEMICOLON + '\u1364' + // ( ፤ ) ETHIOPIC SEMICOLON + '\uFF1B' + // ( ; ) FULLWIDTH SEMICOLON + '\uFF64' + // ( 、 ) HALFWIDTH IDEOGRAPHIC COMMA + '\u104A'; // ( ၊ ) MYANMAR SIGN LITTLE SECTION + + +/** + * Match string for characters that, when in a display name, require it to be + * quoted. + * @type {string} + * @private + */ +goog.format.InternationalizedEmailAddress.CHARS_REQUIRE_QUOTES_ = + goog.format.EmailAddress.SPECIAL_CHARS + + goog.format.InternationalizedEmailAddress.ADDRESS_SEPARATORS_; + + +/** + * A RegExp to match the local part of an EAI email address. + * @private {!RegExp} + */ +goog.format.InternationalizedEmailAddress.EAI_LOCAL_PART_ = new RegExp( + '^' + goog.format.InternationalizedEmailAddress.EAI_LOCAL_PART_REGEXP_STR_ + + '$'); + + +/** + * A RegExp to match the domain part of an EAI email address. + * @private {!RegExp} + */ +goog.format.InternationalizedEmailAddress.EAI_DOMAIN_PART_ = new RegExp( + '^' + + goog.format.InternationalizedEmailAddress.EAI_DOMAIN_PART_REGEXP_STR_ + + '$'); + + +/** + * A RegExp to match an EAI email address. + * @private {!RegExp} + */ +goog.format.InternationalizedEmailAddress.EAI_EMAIL_ADDRESS_ = new RegExp( + '^' + goog.format.InternationalizedEmailAddress.EAI_LOCAL_PART_REGEXP_STR_ + + '@' + + goog.format.InternationalizedEmailAddress.EAI_DOMAIN_PART_REGEXP_STR_ + + '$'); + + +/** + * Checks if the provided string is a valid local part (part before the '@') of + * an EAI email address. + * @param {string} str The local part to check. + * @return {boolean} Whether the provided string is a valid local part. + */ +goog.format.InternationalizedEmailAddress.isValidLocalPartSpec = function(str) { + 'use strict'; + if (str == null) { + return false; + } + return goog.format.InternationalizedEmailAddress.EAI_LOCAL_PART_.test(str); +}; + + +/** + * Checks if the provided string is a valid domain part (part after the '@') of + * an EAI email address. + * @param {string} str The domain part to check. + * @return {boolean} Whether the provided string is a valid domain part. + */ +goog.format.InternationalizedEmailAddress.isValidDomainPartSpec = function( + str) { + 'use strict'; + if (str == null) { + return false; + } + return goog.format.InternationalizedEmailAddress.EAI_DOMAIN_PART_.test(str); +}; + + +/** @override */ +goog.format.InternationalizedEmailAddress.prototype.isValid = function() { + 'use strict'; + return goog.format.InternationalizedEmailAddress.isValidAddrSpec( + this.address); +}; + + +/** + * Checks if the provided string is a valid email address. Supports both + * simple email addresses (address specs) and addresses that contain display + * names. + * @param {string} str The email address to check. + * @return {boolean} Whether the provided string is a valid address. + */ +goog.format.InternationalizedEmailAddress.isValidAddress = function(str) { + 'use strict'; + if (str == null) { + return false; + } + return goog.format.InternationalizedEmailAddress.parse(str).isValid(); +}; + + +/** + * Checks if the provided string is a valid address spec (local@domain.com). + * @param {string} str The email address to check. + * @return {boolean} Whether the provided string is a valid address spec. + */ +goog.format.InternationalizedEmailAddress.isValidAddrSpec = function(str) { + 'use strict'; + if (str == null) { + return false; + } + + // This is a fairly naive implementation, but it covers 99% of use cases. + // For more details, see http://en.wikipedia.org/wiki/Email_address#Syntax + return goog.format.InternationalizedEmailAddress.EAI_EMAIL_ADDRESS_.test(str); +}; + + +/** + * Parses a string containing email addresses of the form + * "name" <address> into an array of email addresses. + * @param {string} str The address list. + * @return {!Array} The parsed emails. + */ +goog.format.InternationalizedEmailAddress.parseList = function(str) { + 'use strict'; + return goog.format.EmailAddress.parseListInternal( + str, goog.format.InternationalizedEmailAddress.parse, + goog.format.InternationalizedEmailAddress.isAddressSeparator); +}; + + +/** + * Parses an email address of the form "name" <address> into + * an email address. + * @param {string} addr The address string. + * @return {!goog.format.EmailAddress} The parsed address. + */ +goog.format.InternationalizedEmailAddress.parse = function(addr) { + 'use strict'; + return goog.format.EmailAddress.parseInternal( + addr, goog.format.InternationalizedEmailAddress); +}; + + +/** + * @param {string} ch The character to test. + * @return {boolean} Whether the provided character is an address separator. + */ +goog.format.InternationalizedEmailAddress.isAddressSeparator = function(ch) { + 'use strict'; + return goog.string.contains( + goog.format.InternationalizedEmailAddress.ADDRESS_SEPARATORS_, ch); +}; + + +/** + * Return the address in a standard format: + * - remove extra spaces. + * - Surround name with quotes if it contains special characters. + * @return {string} The cleaned address. + * @override + */ +goog.format.InternationalizedEmailAddress.prototype.toString = function() { + 'use strict'; + return this.toStringInternal( + goog.format.InternationalizedEmailAddress.CHARS_REQUIRE_QUOTES_); +}; diff --git a/closure/goog/format/internationalizedemailaddress_test.js b/closure/goog/format/internationalizedemailaddress_test.js new file mode 100644 index 0000000000..ce24b80dec --- /dev/null +++ b/closure/goog/format/internationalizedemailaddress_test.js @@ -0,0 +1,374 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.format.InternationalizedEmailAddressTest'); +goog.setTestOnly(); + +const InternationalizedEmailAddress = goog.require('goog.format.InternationalizedEmailAddress'); +const testSuite = goog.require('goog.testing.testSuite'); + +/** + * Asserts that the given validation function generates the expected outcome for + * a set of expected valid and a second set of expected invalid addresses. + * containing the specified address strings, irrespective of their order. + * @param {function(string):boolean} testFunc Validation function to be tested. + * @param {!Array} valid List of addresses that should be valid. + * @param {!Array} invalid List of addresses that should be invalid. + * @private + */ +function doIsValidTest(testFunc, valid, invalid) { + valid.forEach(str => { + assertTrue(`"${str}" should be valid.`, testFunc(str)); + }); + invalid.forEach(str => { + assertFalse(`"${str}" should be invalid.`, testFunc(str)); + }); +} + +/** + * Asserts that parsing the inputString produces a list of email addresses + * containing the specified address strings, irrespective of their order. + * @param {string} inputString A raw address list. + * @param {!Array} expectedList The expected results. + * @param {string=} opt_message An assertion message. + * @return {string} the resulting email address objects. + * @suppress {checkTypes} suppression added to enable type checking + */ +function assertParsedList(inputString, expectedList, opt_message) { + const message = opt_message || 'Should parse address correctly'; + const result = InternationalizedEmailAddress.parseList(inputString); + assertEquals( + 'Should have correct # of addresses', expectedList.length, result.length); + for (let i = 0; i < expectedList.length; ++i) { + assertEquals(message, expectedList[i], result[i].getAddress()); + } + return result; +} + +testSuite({ + testParseList() { + // Test only the new cases added by EAI (other cases covered in parent + // class test) + assertParsedList( + '', ['me.みんあ@me.xn--l8jtg9b']); + }, + + testIsEaiValid() { + const valid = [ + 'e@b.eu', + '', + 'eric ', + '"e" ', + 'a@FOO.MUSEUM', + 'bla@b.co.ac.uk', + 'bla@a.b.com', + 'o\'hara@gm.com', + 'plus+is+allowed@gmail.com', + '!/#$%&\'*+-=~|`{}?^_@expample.com', + 'confirm-bhk=modulo.org@yahoogroups.com', + 'み.ん-あ@みんあ.みんあ', + 'みんあ@test.com', + 'test@test.みんあ', + 'test@みんあ.com', + 'me.みんあ@me.xn--l8jtg9b', + 'みんあ@me.xn--l8jtg9b', + 'fullwidthfullstop@sld' + + '\uff0e' + + 'tld', + 'ideographicfullstop@sld' + + '\u3002' + + 'tld', + 'halfwidthideographicfullstop@sld' + + '\uff61' + + 'tld', + ]; + const invalid = [ + null, + undefined, + 'e', + '', + 'e @c.com', + 'a@b', + 'foo.com', + 'foo@c..com', + 'test@gma=il.com', + 'aaa@gmail', + 'has some spaces@gmail.com', + 'has@three@at@signs.com', + '@no-local-part.com', + ]; + doIsValidTest(InternationalizedEmailAddress.isValidAddress, valid, invalid); + }, + + testIsValidLocalPart() { + const valid = [ + 'e', + 'a.b+foo', + 'o\'hara', + 'user+someone', + '!/#$%&\'*+-=~|`{}?^_', + 'confirm-bhk=modulo.org', + 'me.みんあ', + 'みんあ', + ]; + const invalid = [ + null, + undefined, + 'A@b@c', + 'a"b(c)d,e:f;gi[j\\k]l', + 'just"not"right', + 'this is"not\\allowed', + 'this\\ still\"not\\\\allowed', + 'has some spaces', + ]; + doIsValidTest( + InternationalizedEmailAddress.isValidLocalPartSpec, valid, invalid); + }, + + testIsValidDomainPart() { + const valid = [ + 'example.com', + 'dept.example.org', + 'long.domain.with.lots.of.dots', + 'me.xn--l8jtg9b', + 'me.みんあ', + 'sld.looooooongtld', + 'sld' + + '\uff0e' + + 'tld', + 'sld' + + '\u3002' + + 'tld', + 'sld' + + '\uff61' + + 'tld', + ]; + const invalid = [ + null, + undefined, + '', + '@has.an.at.sign', + '..has.leading.dots', + 'gma=il.com', + 'DoesNotHaveADot', + 'aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffffgggggggggg', + ]; + doIsValidTest( + InternationalizedEmailAddress.isValidDomainPartSpec, valid, invalid); + }, + + testparseListWithAdditionalSeparators() { + assertParsedList( + '\u055D ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+055D'); + assertParsedList( + '\u055D \u055D', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+055D'); + + assertParsedList( + '\u060C ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+060C'); + assertParsedList( + '\u060C \u060C', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+060C'); + + assertParsedList( + '\u1363 ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+1363'); + assertParsedList( + '\u1363 \u1363', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+1363'); + + assertParsedList( + '\u1802 ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+1802'); + assertParsedList( + '\u1802 \u1802', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+1802'); + + assertParsedList( + '\u1808 ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+1808'); + assertParsedList( + '\u1808 \u1808', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+1808'); + + assertParsedList( + '\u2E41 ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+2E41'); + assertParsedList( + '\u2E41 \u2E41', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+2E41'); + + assertParsedList( + '\u3001 ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+3001'); + assertParsedList( + '\u3001 \u3001', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+3001'); + + assertParsedList( + '\uFF0C ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+FF0C'); + assertParsedList( + '\uFF0C \uFF0C', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+FF0C'); + + assertParsedList( + '\u0613 ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+0613'); + assertParsedList( + '\u0613 \u0613', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+0613'); + + assertParsedList( + '\u1364 ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+1364'); + assertParsedList( + '\u1364 \u1364', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+1364'); + + assertParsedList( + '\uFF1B ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+FF1B'); + assertParsedList( + '\uFF1B \uFF1B', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+FF1B'); + + assertParsedList( + '\uFF64 ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+FF64'); + assertParsedList( + '\uFF64 \uFF64', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+FF64'); + + assertParsedList( + '\u104A ', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with U+104A'); + assertParsedList( + '\u104A \u104A', + ['foo@gmail.com', 'bar@gmail.com'], + 'Failed to parse 2 email addresses with trailing U+104A'); + }, + + testToString() { + const f = (str) => InternationalizedEmailAddress.parse(str).toString(); + + // No modification. + assertEquals('JOHN Doe ', f('JOHN Doe ')); + + // Extra spaces. + assertEquals( + 'JOHN Doe ', f(' JOHN Doe ')); + + // No name. + assertEquals('john@gmail.com', f('')); + assertEquals('john@gmail.com', f('john@gmail.com')); + + // No address. + assertEquals('JOHN Doe', f('JOHN Doe <>')); + + // Already quoted. + assertEquals( + '"JOHN, Doe" ', f('"JOHN, Doe" ')); + + // Needless quotes. + assertEquals('JOHN Doe ', f('"JOHN Doe" ')); + // Not quoted-string, but has double quotes. + assertEquals( + '"JOHN, Doe" ', f('JOHN, "Doe" ')); + + // No special characters other than quotes. + assertEquals('JOHN Doe ', f('JOHN "Doe" ')); + + // Escaped quotes are also removed. + assertEquals( + '"JOHN, Doe" ', f('JOHN, \\"Doe\\" ')); + + // Characters that require quoting for the display name. + assertEquals( + '"JOHN, Doe" ', f('JOHN, Doe ')); + assertEquals( + '"JOHN; Doe" ', f('JOHN; Doe ')); + assertEquals( + '"JOHN\u055D Doe" ', + f('JOHN\u055D Doe ')); + assertEquals( + '"JOHN\u060C Doe" ', + f('JOHN\u060C Doe ')); + assertEquals( + '"JOHN\u1363 Doe" ', + f('JOHN\u1363 Doe ')); + assertEquals( + '"JOHN\u1802 Doe" ', + f('JOHN\u1802 Doe ')); + assertEquals( + '"JOHN\u1808 Doe" ', + f('JOHN\u1808 Doe ')); + assertEquals( + '"JOHN\u2E41 Doe" ', + f('JOHN\u2E41 Doe ')); + assertEquals( + '"JOHN\u3001 Doe" ', + f('JOHN\u3001 Doe ')); + assertEquals( + '"JOHN\uFF0C Doe" ', + f('JOHN\uFF0C Doe ')); + assertEquals( + '"JOHN\u061B Doe" ', + f('JOHN\u061B Doe ')); + assertEquals( + '"JOHN\u1364 Doe" ', + f('JOHN\u1364 Doe ')); + assertEquals( + '"JOHN\uFF1B Doe" ', + f('JOHN\uFF1B Doe ')); + assertEquals( + '"JOHN\uFF64 Doe" ', + f('JOHN\uFF64 Doe ')); + assertEquals( + '"JOHN(Johnny) Doe" ', + f('JOHN(Johnny) Doe ')); + assertEquals( + '"JOHN[Johnny] Doe" ', + f('JOHN[Johnny] Doe ')); + assertEquals( + '"JOHN@work Doe" ', + f('JOHN@work Doe ')); + assertEquals( + '"JOHN:theking Doe" ', + f('JOHN:theking Doe ')); + assertEquals( + '"JOHN\\\\ Doe" ', f('JOHN\\ Doe ')); + assertEquals( + '"JOHN.com Doe" ', f('JOHN.com Doe ')); + }, +}); diff --git a/closure/goog/format/jsonprettyprinter.js b/closure/goog/format/jsonprettyprinter.js new file mode 100644 index 0000000000..01000ab462 --- /dev/null +++ b/closure/goog/format/jsonprettyprinter.js @@ -0,0 +1,471 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Creates a string of a JSON object, properly indented for + * display. + */ + +goog.provide('goog.format.JsonPrettyPrinter'); +goog.provide('goog.format.JsonPrettyPrinter.SafeHtmlDelimiters'); +goog.provide('goog.format.JsonPrettyPrinter.TextDelimiters'); + +goog.require('goog.html.SafeHtml'); +goog.require('goog.json'); +goog.require('goog.json.Serializer'); +goog.require('goog.string'); +goog.require('goog.string.format'); + + + +/** + * Formats a JSON object as a string, properly indented for display. Supports + * displaying the string as text or html. Users can also specify their own + * set of delimiters for different environments. For example, the JSON object: + * + * {"a": 1, "b": {"c": null, "d": true, "e": [1, 2]}} + * + * Will be displayed like this: + * + * { + * "a": 1, + * "b": { + * "c": null, + * "d": true, + * "e": [ + * 1, + * 2 + * ] + * } + * } + * @param {?goog.format.JsonPrettyPrinter.TextDelimiters=} opt_delimiters + * Container for the various strings to use to delimit objects, arrays, + * newlines, and other pieces of the output. + * @constructor + */ +goog.format.JsonPrettyPrinter = function(opt_delimiters) { + 'use strict'; + /** + * The set of characters to use as delimiters. + * @private @const {!goog.format.JsonPrettyPrinter.TextDelimiters} + */ + this.delimiters_ = + opt_delimiters || new goog.format.JsonPrettyPrinter.TextDelimiters(); + + /** + * Used to serialize property names and values. + * @private @const {!goog.json.Serializer} + */ + this.jsonSerializer_ = new goog.json.Serializer(); +}; + + +/** + * Formats a JSON object as a string, properly indented for display. + * @param {*} json The object to pretty print. It could be a JSON object, a + * string representing a JSON object, or any other type. + * @return {string} Returns a string of the JSON object, properly indented for + * display. + */ +goog.format.JsonPrettyPrinter.prototype.format = function(json) { + 'use strict'; + var buffer = this.format_(json); + var output = ''; + for (var i = 0; i < buffer.length; i++) { + var item = buffer[i]; + output += item instanceof goog.html.SafeHtml ? + goog.html.SafeHtml.unwrap(item) : + item; + } + return output; +}; + + +/** + * Formats a JSON object as a SafeHtml, properly indented for display. + * @param {*} json The object to pretty print. It could be a JSON object, a + * string representing a JSON object, or any other type. + * @return {!goog.html.SafeHtml} A HTML code of the JSON object. + */ +goog.format.JsonPrettyPrinter.prototype.formatSafeHtml = function(json) { + 'use strict'; + return goog.html.SafeHtml.concat(this.format_(json)); +}; + + +/** + * Formats a JSON object and returns an output buffer. + * @param {*} json The object to pretty print. + * @return {!Array} + * @private + */ +goog.format.JsonPrettyPrinter.prototype.format_ = function(json) { + 'use strict'; + // If input is undefined, null, or empty, return an empty string. + if (json == null) { + return []; + } + if (typeof json === 'string') { + if (goog.string.isEmptyOrWhitespace(json)) { + return []; + } + // Try to coerce a string into a JSON object. + json = JSON.parse(json); + } + var outputBuffer = []; + this.printObject_(json, outputBuffer, 0); + return outputBuffer; +}; + + +/** + * Formats a property value based on the type of the propery. + * @param {*} val The object to format. + * @param {!Array} outputBuffer The buffer to write + * the response to. + * @param {number} indent The number of spaces to indent each line of the + * output. + * @private + * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration + */ +goog.format.JsonPrettyPrinter.prototype.printObject_ = function( + val, outputBuffer, indent) { + 'use strict'; + var typeOf = goog.typeOf(val); + switch (typeOf) { + case 'null': + case 'boolean': + case 'number': + case 'string': + // "null", "boolean", "number" and "string" properties are printed + // directly to the output. + this.printValue_( + /** @type {null|string|boolean|number} */ (val), typeOf, + outputBuffer); + break; + case 'array': + // Example of how an array looks when formatted + // (using the default delimiters): + // [ + // 1, + // 2, + // 3 + // ] + outputBuffer.push(this.delimiters_.arrayStart); + var i = 0; + // Iterate through the array and format each element. + for (i = 0; i < val.length; i++) { + if (i > 0) { + // There are multiple elements, add a comma to separate them. + outputBuffer.push(this.delimiters_.propertySeparator); + } + outputBuffer.push(this.delimiters_.lineBreak); + this.printSpaces_(indent + this.delimiters_.indent, outputBuffer); + this.printObject_( + val[i], outputBuffer, indent + this.delimiters_.indent); + } + // If there are no properties in this object, don't put a line break + // between the beginning "[" and ending "]", so the output of an empty + // array looks like []. + if (i > 0) { + outputBuffer.push(this.delimiters_.lineBreak); + this.printSpaces_(indent, outputBuffer); + } + outputBuffer.push(this.delimiters_.arrayEnd); + break; + case 'object': + // Example of how an object looks when formatted + // (using the default delimiters): + // { + // "a": 1, + // "b": 2, + // "c": "3" + // } + outputBuffer.push(this.delimiters_.objectStart); + var propertyCount = 0; + // Iterate through the object and display each property. + for (var name in val) { + if (!val.hasOwnProperty(name)) { + continue; + } + if (propertyCount > 0) { + // There are multiple properties, add a comma to separate them. + outputBuffer.push(this.delimiters_.propertySeparator); + } + outputBuffer.push(this.delimiters_.lineBreak); + this.printSpaces_(indent + this.delimiters_.indent, outputBuffer); + this.printName_(name, outputBuffer); + outputBuffer.push( + this.delimiters_.nameValueSeparator, this.delimiters_.space); + this.printObject_( + val[name], outputBuffer, indent + this.delimiters_.indent); + propertyCount++; + } + // If there are no properties in this object, don't put a line break + // between the beginning "{" and ending "}", so the output of an empty + // object looks like {}. + if (propertyCount > 0) { + outputBuffer.push(this.delimiters_.lineBreak); + this.printSpaces_(indent, outputBuffer); + } + outputBuffer.push(this.delimiters_.objectEnd); + break; + // Other types, such as "function", aren't expected in JSON, and their + // behavior is undefined. In these cases, just print an empty string to the + // output buffer. This allows the pretty printer to continue while still + // outputing well-formed JSON. + default: + this.printValue_('', 'unknown', outputBuffer); + } +}; + + +/** + * Prints a property name to the output. + * @param {string} name The property name. + * @param {!Array} outputBuffer The buffer to write + * the response to. + * @private + */ +goog.format.JsonPrettyPrinter.prototype.printName_ = function( + name, outputBuffer) { + 'use strict'; + outputBuffer.push( + this.delimiters_.formatName(this.jsonSerializer_.serialize(name))); +}; + + +/** + * Prints a property name to the output. + * @param {string|boolean|number|null} val The property value. + * @param {string} typeOf The type of the value. Used to customize + * value-specific css in the display. This allows clients to distinguish + * between different types in css. For example, the client may define two + * classes: "goog-jsonprettyprinter-propertyvalue-string" and + * "goog-jsonprettyprinter-propertyvalue-number" to assign a different color + * to string and number values. + * @param {!Array} outputBuffer The buffer to write + * the response to. + * @private + */ +goog.format.JsonPrettyPrinter.prototype.printValue_ = function( + val, typeOf, outputBuffer) { + 'use strict'; + var value = this.jsonSerializer_.serialize(val); + outputBuffer.push(this.delimiters_.formatValue(value, typeOf)); +}; + + +/** + * Print a number of space characters to the output. + * @param {number} indent The number of spaces to indent the line. + * @param {!Array} outputBuffer The buffer to write + * the response to. + * @private + */ +goog.format.JsonPrettyPrinter.prototype.printSpaces_ = function( + indent, outputBuffer) { + 'use strict'; + outputBuffer.push(goog.string.repeat(this.delimiters_.space, indent)); +}; + + + +/** + * A container for the delimiting characters used to display the JSON string + * to a text display. Each delimiter is a publicly accessible property of + * the object, which makes it easy to tweak delimiters to specific environments. + * @constructor + */ +goog.format.JsonPrettyPrinter.TextDelimiters = function() {}; + + +/** + * Represents a space character in the output. Used to indent properties a + * certain number of spaces, and to separate property names from property + * values. + * @type {string} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.space = ' '; + + +/** + * Represents a newline character in the output. Used to begin a new line. + * @type {string|!goog.html.SafeHtml} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.lineBreak = '\n'; + + +/** + * Represents the start of an object in the output. + * @type {string} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.objectStart = '{'; + + +/** + * Represents the end of an object in the output. + * @type {string} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.objectEnd = '}'; + + +/** + * Represents the start of an array in the output. + * @type {string} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.arrayStart = '['; + + +/** + * Represents the end of an array in the output. + * @type {string} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.arrayEnd = ']'; + + +/** + * Represents the string used to separate properties in the output. + * @type {string} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.propertySeparator = ','; + + +/** + * Represents the string used to separate property names from property values in + * the output. + * @type {string|!goog.html.SafeHtml} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.nameValueSeparator = ':'; + + +/** + * A string that's placed before a property name in the output. Useful for + * wrapping a property name in an html tag. + * @type {string} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.preName = ''; + + +/** + * A string that's placed after a property name in the output. Useful for + * wrapping a property name in an html tag. + * @type {string} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.postName = ''; + + +/** + * Formats a property name before adding it to the output. + * @param {string} name The property name. + * @return {string|!goog.html.SafeHtml} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.formatName = function( + name) { + 'use strict'; + return this.preName + name + this.postName; +}; + + +/** + * A string that's placed before a property value in the output. Useful for + * wrapping a property value in an html tag. + * @type {string} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.preValue = ''; + + +/** + * A string that's placed after a property value in the output. Useful for + * wrapping a property value in an html tag. + * @type {string} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.postValue = ''; + + +/** + * Formats a value before adding it to the output. + * @param {string} value The value. + * @param {string} typeOf The type of the value obtained by goog.typeOf. + * @return {string|!goog.html.SafeHtml} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.formatValue = function( + value, typeOf) { + 'use strict'; + return goog.string.format(this.preValue, typeOf) + value + this.postValue; +}; + + +/** + * Represents the number of spaces to indent each sub-property of the JSON. + * @type {number} + */ +goog.format.JsonPrettyPrinter.TextDelimiters.prototype.indent = 2; + + + +/** + * A container for the delimiting characters used to display the JSON string + * to an HTML <pre> or <code> element. + * It escapes the names and values before they are added to the output. + * Use this class together with goog.format.JsonPrettyPrinter#formatSafeHtml. + * @constructor + * @extends {goog.format.JsonPrettyPrinter.TextDelimiters} + */ +goog.format.JsonPrettyPrinter.SafeHtmlDelimiters = function() { + 'use strict'; + goog.format.JsonPrettyPrinter.TextDelimiters.call(this); +}; +goog.inherits( + goog.format.JsonPrettyPrinter.SafeHtmlDelimiters, + goog.format.JsonPrettyPrinter.TextDelimiters); + + +/** @override */ +goog.format.JsonPrettyPrinter.SafeHtmlDelimiters.prototype.formatName = + function(name) { + 'use strict'; + var classes = goog.getCssName('goog-jsonprettyprinter-propertyname'); + return goog.html.SafeHtml.create('span', {'class': classes}, name); +}; + + +/** @override */ +goog.format.JsonPrettyPrinter.SafeHtmlDelimiters.prototype.formatValue = + function(value, typeOf) { + 'use strict'; + var classes = this.getValueCssName(typeOf); + return goog.html.SafeHtml.create('span', {'class': classes}, value); +}; + + +/** + * Return a class name for the given type. + * @param {string} typeOf The type of the value. + * @return {string} + * @protected + */ +goog.format.JsonPrettyPrinter.SafeHtmlDelimiters.prototype.getValueCssName = + function(typeOf) { + 'use strict'; + // This switch is needed because goog.getCssName requires a constant string. + switch (typeOf) { + case 'null': + return goog.getCssName('goog-jsonprettyprinter-propertyvalue-null'); + case 'boolean': + return goog.getCssName('goog-jsonprettyprinter-propertyvalue-boolean'); + case 'number': + return goog.getCssName('goog-jsonprettyprinter-propertyvalue-number'); + case 'string': + return goog.getCssName('goog-jsonprettyprinter-propertyvalue-string'); + case 'array': + return goog.getCssName('goog-jsonprettyprinter-propertyvalue-array'); + case 'object': + return goog.getCssName('goog-jsonprettyprinter-propertyvalue-object'); + default: + return goog.getCssName('goog-jsonprettyprinter-propertyvalue-unknown'); + } +}; diff --git a/closure/goog/format/jsonprettyprinter_test.js b/closure/goog/format/jsonprettyprinter_test.js new file mode 100644 index 0000000000..43aec34973 --- /dev/null +++ b/closure/goog/format/jsonprettyprinter_test.js @@ -0,0 +1,97 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.format.JsonPrettyPrinterTest'); +goog.setTestOnly(); + +const JsonPrettyPrinter = goog.require('goog.format.JsonPrettyPrinter'); +const testSuite = goog.require('goog.testing.testSuite'); + +let formatter; + +testSuite({ + setUp() { + formatter = new JsonPrettyPrinter(); + }, + + testUndefined() { + assertEquals('', formatter.format()); + }, + + testNull() { + assertEquals('', formatter.format(null)); + }, + + testBoolean() { + assertEquals('true', formatter.format(true)); + }, + + testNumber() { + assertEquals('1', formatter.format(1)); + }, + + testEmptyString() { + assertEquals('', formatter.format('')); + }, + + testWhitespaceString() { + assertEquals('', formatter.format(' ')); + }, + + testString() { + assertEquals('{}', formatter.format('{}')); + }, + + testEmptyArray() { + assertEquals('[]', formatter.format([])); + }, + + testArrayOneElement() { + assertEquals('[\n 1\n]', formatter.format([1])); + }, + + testArrayMultipleElements() { + assertEquals('[\n 1,\n 2,\n 3\n]', formatter.format([1, 2, 3])); + }, + + testFunction() { + assertEquals('{\n "a": "1",\n "b": ""\n}', formatter.format({ + 'a': '1', + 'b': function() { + return null; + } + })); + }, + + testObject() { + assertEquals('{}', formatter.format({})); + }, + + testObjectMultipleProperties() { + assertEquals( + '{\n "a": null,\n "b": true,\n "c": 1,\n "d": "d",\n "e":' + + ' [\n 1,\n 2,\n 3\n ],\n "f": {\n "g": 1,\n "h": "h"\n' + + ' }\n}', + formatter.format({ + 'a': null, + 'b': true, + 'c': 1, + 'd': 'd', + 'e': [1, 2, 3], + 'f': {'g': 1, 'h': 'h'}, + })); + }, + + testSafeHtmlDelimiters() { + const htmlFormatter = + new JsonPrettyPrinter(new JsonPrettyPrinter.SafeHtmlDelimiters()); + assertEquals( + '{\n "' + + 'a<b": ">"\n}', + htmlFormatter.formatSafeHtml({'a'}).getTypedStringValue()); + }, +}); diff --git a/closure/goog/fs/BUILD b/closure/goog/fs/BUILD new file mode 100644 index 0000000000..ce3300ae0b --- /dev/null +++ b/closure/goog/fs/BUILD @@ -0,0 +1,126 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "blob", + srcs = ["blob.js"], + lenient = True, +) + +alias( + name = "entry", + actual = ":filesystem", +) + +closure_js_library( + name = "entryimpl", + srcs = ["entryimpl.js"], + lenient = True, + deps = [ + ":error", + ":filesystem", + ":filewriter", + "//closure/goog/array", + "//closure/goog/functions", + "//closure/goog/string", + "//third_party/closure/goog/mochikit/async:deferred", + ], +) + +closure_js_library( + name = "error", + srcs = ["error.js"], + lenient = True, + deps = [ + "//closure/goog/asserts", + "//closure/goog/debug:error", + "//closure/goog/object", + "//closure/goog/string", + ], +) + +closure_js_library( + name = "filereader", + srcs = ["filereader.js"], + lenient = True, + deps = [ + ":error", + ":progressevent", + "//closure/goog/events:eventtarget", + "//third_party/closure/goog/mochikit/async:deferred", + ], +) + +closure_js_library( + name = "filesaver", + srcs = ["filesaver.js"], + lenient = True, + deps = [ + ":error", + ":progressevent", + "//closure/goog/events:eventtarget", + ], +) + +closure_js_library( + name = "filesystem", + srcs = [ + "entry.js", + "filesystem.js", + ], + lenient = True, + deps = [ + ":error", + ":filewriter", + "//third_party/closure/goog/mochikit/async:deferred", + ], +) + +closure_js_library( + name = "filesystemimpl", + srcs = ["filesystemimpl.js"], + lenient = True, + deps = [ + ":entryimpl", + ":filesystem", + ], +) + +closure_js_library( + name = "filewriter", + srcs = ["filewriter.js"], + lenient = True, + deps = [ + ":error", + ":filesaver", + ], +) + +closure_js_library( + name = "fs", + srcs = ["fs.js"], + lenient = True, + deps = [ + ":error", + ":filereader", + ":filesystemimpl", + ":url", + "//third_party/closure/goog/mochikit/async:deferred", + ], +) + +closure_js_library( + name = "progressevent", + srcs = ["progressevent.js"], + lenient = True, + deps = ["//closure/goog/events:event"], +) + +closure_js_library( + name = "url", + srcs = ["url.js"], + lenient = True, +) diff --git a/closure/goog/fs/blob.js b/closure/goog/fs/blob.js new file mode 100644 index 0000000000..56c4b1dcbc --- /dev/null +++ b/closure/goog/fs/blob.js @@ -0,0 +1,79 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Wrappers for the HTML5 File API. These wrappers closely mirror + * the underlying APIs, but use Closure-style events and Deferred return values. + * Their existence also makes it possible to mock the FileSystem API for testing + * in browsers that don't support it natively. + * + * When adding public functions to anything under this namespace, be sure to add + * its mock counterpart to goog.testing.fs. + */ + +goog.provide('goog.fs.blob'); + + + +/** + * Concatenates one or more values together and converts them to a Blob. + * + * @param {...(string|!Blob|!ArrayBuffer)} var_args The values that will make up + * the resulting blob. + * @return {!Blob} The blob. + */ +goog.fs.blob.getBlob = function(var_args) { + 'use strict'; + const BlobBuilder = goog.global.BlobBuilder || goog.global.WebKitBlobBuilder; + + if (BlobBuilder !== undefined) { + const bb = new BlobBuilder(); + for (let i = 0; i < arguments.length; i++) { + bb.append(arguments[i]); + } + return bb.getBlob(); + } else { + return goog.fs.blob.getBlobWithProperties( + Array.prototype.slice.call(arguments)); + } +}; + + +/** + * Creates a blob with the given properties. + * See https://developer.mozilla.org/en-US/docs/Web/API/Blob for more details. + * + * @param {!Array} parts The values that will make up + * the resulting blob (subset supported by both BlobBuilder.append() and + * Blob constructor). + * @param {string=} opt_type The MIME type of the Blob. + * @param {string=} opt_endings Specifies how strings containing newlines are to + * be written out. + * @return {!Blob} The blob. + */ +goog.fs.blob.getBlobWithProperties = function(parts, opt_type, opt_endings) { + 'use strict'; + const BlobBuilder = goog.global.BlobBuilder || goog.global.WebKitBlobBuilder; + + if (BlobBuilder !== undefined) { + const bb = new BlobBuilder(); + for (let i = 0; i < parts.length; i++) { + bb.append(parts[i], opt_endings); + } + return bb.getBlob(opt_type); + } else if (goog.global.Blob !== undefined) { + const properties = {}; + if (opt_type) { + properties['type'] = opt_type; + } + if (opt_endings) { + properties['endings'] = opt_endings; + } + return new Blob(parts, properties); + } else { + throw new Error('This browser doesn\'t seem to support creating Blobs'); + } +}; diff --git a/closure/goog/fs/entry.js b/closure/goog/fs/entry.js new file mode 100644 index 0000000000..dc274b8168 --- /dev/null +++ b/closure/goog/fs/entry.js @@ -0,0 +1,267 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Wrappers for HTML5 Entry objects. These are all in the same + * file to avoid circular dependency issues. + * + * When adding or modifying functionality in this namespace, be sure to update + * the mock counterparts in goog.testing.fs. + */ +goog.provide('goog.fs.DirectoryEntry'); +goog.provide('goog.fs.DirectoryEntry.Behavior'); +goog.provide('goog.fs.Entry'); +goog.provide('goog.fs.FileEntry'); + +goog.requireType('goog.async.Deferred'); +goog.requireType('goog.fs.FileSystem'); +goog.requireType('goog.fs.FileWriter'); + + + +/** + * The interface for entries in the filesystem. + * @interface + */ +goog.fs.Entry = function() {}; + + +/** + * @return {boolean} Whether or not this entry is a file. + */ +goog.fs.Entry.prototype.isFile = function() {}; + + +/** + * @return {boolean} Whether or not this entry is a directory. + */ +goog.fs.Entry.prototype.isDirectory = function() {}; + + +/** + * @return {string} The name of this entry. + */ +goog.fs.Entry.prototype.getName = function() {}; + + +/** + * @return {string} The full path to this entry. + */ +goog.fs.Entry.prototype.getFullPath = function() {}; + + +/** + * @return {!goog.fs.FileSystem} The filesystem backing this entry. + */ +goog.fs.Entry.prototype.getFileSystem = function() {}; + + +/** + * Retrieves the last modified date for this entry. + * + * @return {!goog.async.Deferred} The deferred Date for this entry. If an error + * occurs, the errback is called with a {@link goog.fs.Error}. + */ +goog.fs.Entry.prototype.getLastModified = function() {}; + + +/** + * Retrieves the metadata for this entry. + * + * @return {!goog.async.Deferred} The deferred Metadata for this entry. If an + * error occurs, the errback is called with a {@link goog.fs.Error}. + */ +goog.fs.Entry.prototype.getMetadata = function() {}; + + +/** + * Move this entry to a new location. + * + * @param {!goog.fs.DirectoryEntry} parent The new parent directory. + * @param {string=} opt_newName The new name of the entry. If omitted, the entry + * retains its original name. + * @return {!goog.async.Deferred} The deferred {@link goog.fs.FileEntry} or + * {@link goog.fs.DirectoryEntry} for the new entry. If an error occurs, the + * errback is called with a {@link goog.fs.Error}. + */ +goog.fs.Entry.prototype.moveTo = function(parent, opt_newName) {}; + + +/** + * Copy this entry to a new location. + * + * @param {!goog.fs.DirectoryEntry} parent The new parent directory. + * @param {string=} opt_newName The name of the new entry. If omitted, the new + * entry has the same name as the original. + * @return {!goog.async.Deferred} The deferred {@link goog.fs.FileEntry} or + * {@link goog.fs.DirectoryEntry} for the new entry. If an error occurs, the + * errback is called with a {@link goog.fs.Error}. + */ +goog.fs.Entry.prototype.copyTo = function(parent, opt_newName) {}; + + +/** + * Wrap an HTML5 entry object in an appropriate subclass instance. + * + * @param {!Entry} entry The underlying Entry object. + * @return {!goog.fs.Entry} The appropriate subclass wrapper. + * @protected + */ +goog.fs.Entry.prototype.wrapEntry = function(entry) {}; + + +/** + * Get the URL for this file. + * + * @param {string=} opt_mimeType The MIME type that will be served for the URL. + * @return {string} The URL. + */ +goog.fs.Entry.prototype.toUrl = function(opt_mimeType) {}; + + +/** + * Get the URI for this file. + * + * @deprecated Use {@link #toUrl} instead. + * @param {string=} opt_mimeType The MIME type that will be served for the URI. + * @return {string} The URI. + */ +goog.fs.Entry.prototype.toUri = function(opt_mimeType) {}; + + +/** + * Remove this entry. + * + * @return {!goog.async.Deferred} A deferred object. If the removal succeeds, + * the callback is called with true. If an error occurs, the errback is + * called a {@link goog.fs.Error}. + */ +goog.fs.Entry.prototype.remove = function() {}; + + +/** + * Gets the parent directory. + * + * @return {!goog.async.Deferred} The deferred {@link goog.fs.DirectoryEntry}. + * If an error occurs, the errback is called with a {@link goog.fs.Error}. + */ +goog.fs.Entry.prototype.getParent = function() {}; + + + +/** + * A directory in a local FileSystem. + * + * @interface + * @extends {goog.fs.Entry} + */ +goog.fs.DirectoryEntry = function() {}; + + +/** + * Behaviors for getting files and directories. + * @enum {number} + */ +goog.fs.DirectoryEntry.Behavior = { + /** + * Get the file if it exists, error out if it doesn't. + */ + DEFAULT: 1, + /** + * Get the file if it exists, create it if it doesn't. + */ + CREATE: 2, + /** + * Error out if the file exists, create it if it doesn't. + */ + CREATE_EXCLUSIVE: 3 +}; + + +/** + * Get a file in the directory. + * + * @param {string} path The path to the file, relative to this directory. + * @param {goog.fs.DirectoryEntry.Behavior=} opt_behavior The behavior for + * handling an existing file, or the lack thereof. + * @return {!goog.async.Deferred} The deferred {@link goog.fs.FileEntry}. If an + * error occurs, the errback is called with a {@link goog.fs.Error}. + */ +goog.fs.DirectoryEntry.prototype.getFile = function(path, opt_behavior) {}; + + +/** + * Get a directory within this directory. + * + * @param {string} path The path to the directory, relative to this directory. + * @param {goog.fs.DirectoryEntry.Behavior=} opt_behavior The behavior for + * handling an existing directory, or the lack thereof. + * @return {!goog.async.Deferred} The deferred {@link goog.fs.DirectoryEntry}. + * If an error occurs, the errback is called a {@link goog.fs.Error}. + */ +goog.fs.DirectoryEntry.prototype.getDirectory = function(path, opt_behavior) {}; + + +/** + * Opens the directory for the specified path, creating the directory and any + * intermediate directories as necessary. + * + * @param {string} path The directory path to create. May be absolute or + * relative to the current directory. The parent directory ".." and current + * directory "." are supported. + * @return {!goog.async.Deferred} A deferred {@link goog.fs.DirectoryEntry} for + * the requested path. If an error occurs, the errback is called with a + * {@link goog.fs.Error}. + */ +goog.fs.DirectoryEntry.prototype.createPath = function(path) {}; + + +/** + * Gets a list of all entries in this directory. + * + * @return {!goog.async.Deferred} The deferred list of {@link goog.fs.Entry} + * results. If an error occurs, the errback is called with a + * {@link goog.fs.Error}. + */ +goog.fs.DirectoryEntry.prototype.listDirectory = function() {}; + + +/** + * Removes this directory and all its contents. + * + * @return {!goog.async.Deferred} A deferred object. If the removal succeeds, + * the callback is called with true. If an error occurs, the errback is + * called a {@link goog.fs.Error}. + */ +goog.fs.DirectoryEntry.prototype.removeRecursively = function() {}; + + + +/** + * A file in a local filesystem. + * + * @interface + * @extends {goog.fs.Entry} + */ +goog.fs.FileEntry = function() {}; + + +/** + * Create a writer for writing to the file. + * + * @return {!goog.async.Deferred} If an error occurs, the + * errback is called with a {@link goog.fs.Error}. + */ +goog.fs.FileEntry.prototype.createWriter = function() {}; + + +/** + * Get the file contents as a File blob. + * + * @return {!goog.async.Deferred} If an error occurs, the errback is + * called with a {@link goog.fs.Error}. + */ +goog.fs.FileEntry.prototype.file = function() {}; diff --git a/closure/goog/fs/entryimpl.js b/closure/goog/fs/entryimpl.js new file mode 100644 index 0000000000..a62310d3e1 --- /dev/null +++ b/closure/goog/fs/entryimpl.js @@ -0,0 +1,435 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Concrete implementations of the + * goog.fs.DirectoryEntry, and goog.fs.FileEntry interfaces. + */ +goog.provide('goog.fs.DirectoryEntryImpl'); +goog.provide('goog.fs.EntryImpl'); +goog.provide('goog.fs.FileEntryImpl'); + +goog.require('goog.async.Deferred'); +goog.require('goog.fs.DirectoryEntry'); +goog.require('goog.fs.Entry'); +goog.require('goog.fs.Error'); +goog.require('goog.fs.FileEntry'); +goog.require('goog.fs.FileWriter'); +goog.require('goog.functions'); +goog.require('goog.string'); +goog.requireType('goog.fs.FileSystem'); + + + +/** + * Base class for concrete implementations of goog.fs.Entry. + * @param {!goog.fs.FileSystem} fs The wrapped filesystem. + * @param {!Entry} entry The underlying Entry object. + * @constructor + * @implements {goog.fs.Entry} + */ +goog.fs.EntryImpl = function(fs, entry) { + 'use strict'; + /** + * The wrapped filesystem. + * + * @type {!goog.fs.FileSystem} + * @private + */ + this.fs_ = fs; + + /** + * The underlying Entry object. + * + * @type {!Entry} + * @private + */ + this.entry_ = entry; +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.isFile = function() { + 'use strict'; + return this.entry_.isFile; +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.isDirectory = function() { + 'use strict'; + return this.entry_.isDirectory; +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.getName = function() { + 'use strict'; + return this.entry_.name; +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.getFullPath = function() { + 'use strict'; + return this.entry_.fullPath; +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.getFileSystem = function() { + 'use strict'; + return this.fs_; +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.getLastModified = function() { + 'use strict'; + return this.getMetadata().addCallback(function(metadata) { + 'use strict'; + return metadata.modificationTime; + }); +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.getMetadata = function() { + 'use strict'; + const d = new goog.async.Deferred(); + + this.entry_.getMetadata(function(metadata) { + 'use strict'; + d.callback(metadata); + }, goog.bind(function(err) { + 'use strict'; + const msg = 'retrieving metadata for ' + this.getFullPath(); + d.errback(new goog.fs.Error(err, msg)); + }, this)); + return d; +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.moveTo = function(parent, opt_newName) { + 'use strict'; + const d = new goog.async.Deferred(); + this.entry_.moveTo( + /** @type {!goog.fs.DirectoryEntryImpl} */ (parent).dir_, opt_newName, + goog.bind(function(entry) { + 'use strict'; + d.callback(this.wrapEntry(entry)); + }, this), goog.bind(function(err) { + 'use strict'; + const msg = 'moving ' + this.getFullPath() + ' into ' + + parent.getFullPath() + + (opt_newName ? ', renaming to ' + opt_newName : ''); + d.errback(new goog.fs.Error(err, msg)); + }, this)); + return d; +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.copyTo = function(parent, opt_newName) { + 'use strict'; + const d = new goog.async.Deferred(); + this.entry_.copyTo( + /** @type {!goog.fs.DirectoryEntryImpl} */ (parent).dir_, opt_newName, + goog.bind(function(entry) { + 'use strict'; + d.callback(this.wrapEntry(entry)); + }, this), goog.bind(function(err) { + 'use strict'; + const msg = 'copying ' + this.getFullPath() + ' into ' + + parent.getFullPath() + + (opt_newName ? ', renaming to ' + opt_newName : ''); + d.errback(new goog.fs.Error(err, msg)); + }, this)); + return d; +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.wrapEntry = function(entry) { + 'use strict'; + return entry.isFile ? + new goog.fs.FileEntryImpl(this.fs_, /** @type {!FileEntry} */ (entry)) : + new goog.fs.DirectoryEntryImpl( + this.fs_, /** @type {!DirectoryEntry} */ (entry)); +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.toUrl = function(opt_mimeType) { + 'use strict'; + return this.entry_.toURL(opt_mimeType); +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.toUri = goog.fs.EntryImpl.prototype.toUrl; + + +/** @override */ +goog.fs.EntryImpl.prototype.remove = function() { + 'use strict'; + const d = new goog.async.Deferred(); + this.entry_.remove( + goog.bind(d.callback, d, true /* result */), goog.bind(function(err) { + 'use strict'; + const msg = 'removing ' + this.getFullPath(); + d.errback(new goog.fs.Error(err, msg)); + }, this)); + return d; +}; + + +/** @override */ +goog.fs.EntryImpl.prototype.getParent = function() { + 'use strict'; + const d = new goog.async.Deferred(); + this.entry_.getParent(goog.bind(function(parent) { + 'use strict'; + d.callback(new goog.fs.DirectoryEntryImpl(this.fs_, parent)); + }, this), goog.bind(function(err) { + 'use strict'; + const msg = 'getting parent of ' + this.getFullPath(); + d.errback(new goog.fs.Error(err, msg)); + }, this)); + return d; +}; + + + +/** + * A directory in a local FileSystem. + * + * This should not be instantiated directly. Instead, it should be accessed via + * {@link goog.fs.FileSystem#getRoot} or + * {@link goog.fs.DirectoryEntry#getDirectoryEntry}. + * + * @param {!goog.fs.FileSystem} fs The wrapped filesystem. + * @param {!DirectoryEntry} dir The underlying DirectoryEntry object. + * @constructor + * @extends {goog.fs.EntryImpl} + * @implements {goog.fs.DirectoryEntry} + * @final + */ +goog.fs.DirectoryEntryImpl = function(fs, dir) { + 'use strict'; + goog.fs.DirectoryEntryImpl.base(this, 'constructor', fs, dir); + + /** + * The underlying DirectoryEntry object. + * + * @type {!DirectoryEntry} + * @private + */ + this.dir_ = dir; +}; +goog.inherits(goog.fs.DirectoryEntryImpl, goog.fs.EntryImpl); + + +/** @override */ +goog.fs.DirectoryEntryImpl.prototype.getFile = function(path, opt_behavior) { + 'use strict'; + const d = new goog.async.Deferred(); + this.dir_.getFile( + path, this.getOptions_(opt_behavior), goog.bind(function(entry) { + 'use strict'; + d.callback(new goog.fs.FileEntryImpl(this.fs_, entry)); + }, this), goog.bind(function(err) { + 'use strict'; + const msg = 'loading file ' + path + ' from ' + this.getFullPath(); + d.errback(new goog.fs.Error(err, msg)); + }, this)); + return d; +}; + + +/** @override */ +goog.fs.DirectoryEntryImpl.prototype.getDirectory = function( + path, opt_behavior) { + 'use strict'; + const d = new goog.async.Deferred(); + this.dir_.getDirectory( + path, this.getOptions_(opt_behavior), goog.bind(function(entry) { + 'use strict'; + d.callback(new goog.fs.DirectoryEntryImpl(this.fs_, entry)); + }, this), goog.bind(function(err) { + 'use strict'; + const msg = 'loading directory ' + path + ' from ' + this.getFullPath(); + d.errback(new goog.fs.Error(err, msg)); + }, this)); + return d; +}; + + +/** @override */ +goog.fs.DirectoryEntryImpl.prototype.createPath = function(path) { + 'use strict'; + // If the path begins at the root, reinvoke createPath on the root directory. + if (goog.string.startsWith(path, '/')) { + const root = this.getFileSystem().getRoot(); + if (this.getFullPath() != root.getFullPath()) { + return root.createPath(path); + } + } + + // Filter out any empty path components caused by '//' or a leading slash. + const parts = path.split('/').filter(goog.functions.identity); + + /** + * @param {goog.fs.DirectoryEntryImpl} dir + * @return {!goog.async.Deferred} + */ + function getNextDirectory(dir) { + if (!parts.length) { + return goog.async.Deferred.succeed(dir); + } + + let def; + const nextDir = parts.shift(); + + if (nextDir == '..') { + def = dir.getParent(); + } else if (nextDir == '.') { + def = goog.async.Deferred.succeed(dir); + } else { + def = dir.getDirectory(nextDir, goog.fs.DirectoryEntry.Behavior.CREATE); + } + return def.addCallback(getNextDirectory); + } + + return getNextDirectory(this); +}; + + +/** @override */ +goog.fs.DirectoryEntryImpl.prototype.listDirectory = function() { + 'use strict'; + const d = new goog.async.Deferred(); + const reader = this.dir_.createReader(); + const results = []; + + const errorCallback = goog.bind(function(err) { + 'use strict'; + const msg = 'listing directory ' + this.getFullPath(); + d.errback(new goog.fs.Error(err, msg)); + }, this); + + const successCallback = goog.bind(function(entries) { + 'use strict'; + if (entries.length) { + for (let i = 0, entry; entry = entries[i]; i++) { + results.push(this.wrapEntry(entry)); + } + reader.readEntries(successCallback, errorCallback); + } else { + d.callback(results); + } + }, this); + + reader.readEntries(successCallback, errorCallback); + return d; +}; + + +/** @override */ +goog.fs.DirectoryEntryImpl.prototype.removeRecursively = function() { + 'use strict'; + const d = new goog.async.Deferred(); + this.dir_.removeRecursively( + goog.bind(d.callback, d, true /* result */), goog.bind(function(err) { + 'use strict'; + const msg = 'removing ' + this.getFullPath() + ' recursively'; + d.errback(new goog.fs.Error(err, msg)); + }, this)); + return d; +}; + + +/** + * Converts a value in the Behavior enum into an options object expected by the + * File API. + * + * @param {goog.fs.DirectoryEntry.Behavior=} opt_behavior The behavior for + * existing files. + * @return {!Object} The options object expected by the File API. + * @private + */ +goog.fs.DirectoryEntryImpl.prototype.getOptions_ = function(opt_behavior) { + 'use strict'; + if (opt_behavior == goog.fs.DirectoryEntry.Behavior.CREATE) { + return {'create': true}; + } else if (opt_behavior == goog.fs.DirectoryEntry.Behavior.CREATE_EXCLUSIVE) { + return {'create': true, 'exclusive': true}; + } else { + return {}; + } +}; + + + +/** + * A file in a local filesystem. + * + * This should not be instantiated directly. Instead, it should be accessed via + * {@link goog.fs.DirectoryEntry#getFile}. + * + * @param {!goog.fs.FileSystem} fs The wrapped filesystem. + * @param {!FileEntry} file The underlying FileEntry object. + * @constructor + * @extends {goog.fs.EntryImpl} + * @implements {goog.fs.FileEntry} + * @final + */ +goog.fs.FileEntryImpl = function(fs, file) { + 'use strict'; + goog.fs.FileEntryImpl.base(this, 'constructor', fs, file); + + /** + * The underlying FileEntry object. + * + * @type {!FileEntry} + * @private + */ + this.file_ = file; +}; +goog.inherits(goog.fs.FileEntryImpl, goog.fs.EntryImpl); + + +/** @override */ +goog.fs.FileEntryImpl.prototype.createWriter = function() { + 'use strict'; + const d = new goog.async.Deferred(); + this.file_.createWriter(function(w) { + 'use strict'; + d.callback(new goog.fs.FileWriter(w)); + }, goog.bind(function(err) { + 'use strict'; + const msg = 'creating writer for ' + this.getFullPath(); + d.errback(new goog.fs.Error(err, msg)); + }, this)); + return d; +}; + + +/** @override */ +goog.fs.FileEntryImpl.prototype.file = function() { + 'use strict'; + const d = new goog.async.Deferred(); + this.file_.file(function(f) { + 'use strict'; + d.callback(f); + }, goog.bind(function(err) { + 'use strict'; + const msg = 'getting file for ' + this.getFullPath(); + d.errback(new goog.fs.Error(err, msg)); + }, this)); + return d; +}; diff --git a/closure/goog/fs/error.js b/closure/goog/fs/error.js new file mode 100644 index 0000000000..adf12e79ca --- /dev/null +++ b/closure/goog/fs/error.js @@ -0,0 +1,181 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A wrapper for the HTML5 FileError object. + */ + + +// TODO(user): We're trying to migrate all ES5 subclasses of Closure +// Library to ES6. In ES6 this cannot be referenced before super is called. This +// file has at least one this before a super call (in ES5) and cannot be +// automatically upgraded to ES6 as a result. Please fix this if you have a +// chance. Note: This can sometimes be caused by not calling the super +// constructor at all. You can run the conversion tool yourself to see what it +// does on this file: blaze run //javascript/refactoring/es6_classes:convert. + +goog.provide('goog.fs.DOMErrorLike'); +goog.provide('goog.fs.Error'); +goog.provide('goog.fs.Error.ErrorCode'); + +goog.require('goog.asserts'); +goog.require('goog.debug.Error'); +goog.require('goog.object'); +goog.require('goog.string'); + +/** @record */ +goog.fs.DOMErrorLike = function() {}; + +/** @type {string|undefined} */ +goog.fs.DOMErrorLike.prototype.name; + +/** @type {!goog.fs.Error.ErrorCode|undefined} */ +goog.fs.DOMErrorLike.prototype.code; + + + +/** + * A filesystem error. Since the filesystem API is asynchronous, stack traces + * are less useful for identifying where errors come from, so this includes a + * large amount of metadata in the message. + * + * @param {!DOMError|!goog.fs.DOMErrorLike} error + * @param {string} action The action being undertaken when the error was raised. + * @constructor + * @extends {goog.debug.Error} + * @final + */ +goog.fs.Error = function(error, action) { + 'use strict'; + /** @type {string} */ + this.name; + + /** + * @type {!goog.fs.Error.ErrorCode} + * @deprecated Use the 'name' or 'message' field instead. + */ + this.code; + + if (error.name !== undefined) { + this.name = error.name; + // TODO(user): Remove warning suppression after JSCompiler stops + // firing a spurious warning here. + /** @suppress {deprecated} */ + this.code = goog.fs.Error.getCodeFromName_(error.name); + } else { + const code = + /** @type {!goog.fs.Error.ErrorCode} */ (goog.asserts.assertNumber( + /** @type {!goog.fs.DOMErrorLike} */ (error).code)); + this.code = code; + this.name = goog.fs.Error.getNameFromCode_(code); + } + goog.fs.Error.base( + this, 'constructor', goog.string.subs('%s %s', this.name, action)); +}; +goog.inherits(goog.fs.Error, goog.debug.Error); + + +/** + * Names of errors that may be thrown by the File API, the File System API, or + * the File Writer API. + * + * @see http://dev.w3.org/2006/webapi/FileAPI/#ErrorAndException + * @see http://www.w3.org/TR/file-system-api/#definitions + * @see http://dev.w3.org/2009/dap/file-system/file-writer.html#definitions + * @enum {string} + */ +goog.fs.Error.ErrorName = { + ABORT: 'AbortError', + ENCODING: 'EncodingError', + INVALID_MODIFICATION: 'InvalidModificationError', + INVALID_STATE: 'InvalidStateError', + NOT_FOUND: 'NotFoundError', + NOT_READABLE: 'NotReadableError', + NO_MODIFICATION_ALLOWED: 'NoModificationAllowedError', + PATH_EXISTS: 'PathExistsError', + QUOTA_EXCEEDED: 'QuotaExceededError', + SECURITY: 'SecurityError', + SYNTAX: 'SyntaxError', + TYPE_MISMATCH: 'TypeMismatchError' +}; + + +/** + * Error codes for file errors. + * @see http://www.w3.org/TR/file-system-api/#idl-def-FileException + * + * @enum {number} + * @deprecated Use the 'name' or 'message' attribute instead. + */ +goog.fs.Error.ErrorCode = { + NOT_FOUND: 1, + SECURITY: 2, + ABORT: 3, + NOT_READABLE: 4, + ENCODING: 5, + NO_MODIFICATION_ALLOWED: 6, + INVALID_STATE: 7, + SYNTAX: 8, + INVALID_MODIFICATION: 9, + QUOTA_EXCEEDED: 10, + TYPE_MISMATCH: 11, + PATH_EXISTS: 12 +}; + + +/** + * @param {goog.fs.Error.ErrorCode|undefined} code + * @return {string} name + * @private + */ +goog.fs.Error.getNameFromCode_ = function(code) { + 'use strict'; + const name = goog.object.findKey(goog.fs.Error.NameToCodeMap_, function(c) { + 'use strict'; + return code == c; + }); + if (name === undefined) { + throw new Error('Invalid code: ' + code); + } + return name; +}; + + +/** + * Returns the code that corresponds to the given name. + * @param {string} name + * @return {goog.fs.Error.ErrorCode} code + * @private + */ +goog.fs.Error.getCodeFromName_ = function(name) { + 'use strict'; + return goog.fs.Error.NameToCodeMap_[name]; +}; + + +/** + * Mapping from error names to values from the ErrorCode enum. + * @see http://www.w3.org/TR/file-system-api/#definitions. + * @private {!Object} + */ +goog.fs.Error.NameToCodeMap_ = { + [goog.fs.Error.ErrorName.ABORT]: goog.fs.Error.ErrorCode.ABORT, + [goog.fs.Error.ErrorName.ENCODING]: goog.fs.Error.ErrorCode.ENCODING, + [goog.fs.Error.ErrorName.INVALID_MODIFICATION]: + goog.fs.Error.ErrorCode.INVALID_MODIFICATION, + [goog.fs.Error.ErrorName.INVALID_STATE]: + goog.fs.Error.ErrorCode.INVALID_STATE, + [goog.fs.Error.ErrorName.NOT_FOUND]: goog.fs.Error.ErrorCode.NOT_FOUND, + [goog.fs.Error.ErrorName.NOT_READABLE]: goog.fs.Error.ErrorCode.NOT_READABLE, + [goog.fs.Error.ErrorName.NO_MODIFICATION_ALLOWED]: + goog.fs.Error.ErrorCode.NO_MODIFICATION_ALLOWED, + [goog.fs.Error.ErrorName.PATH_EXISTS]: goog.fs.Error.ErrorCode.PATH_EXISTS, + [goog.fs.Error.ErrorName.QUOTA_EXCEEDED]: + goog.fs.Error.ErrorCode.QUOTA_EXCEEDED, + [goog.fs.Error.ErrorName.SECURITY]: goog.fs.Error.ErrorCode.SECURITY, + [goog.fs.Error.ErrorName.SYNTAX]: goog.fs.Error.ErrorCode.SYNTAX, + [goog.fs.Error.ErrorName.TYPE_MISMATCH]: goog.fs.Error.ErrorCode.TYPE_MISMATCH +}; diff --git a/closure/goog/fs/filereader.js b/closure/goog/fs/filereader.js new file mode 100644 index 0000000000..cf9e4b9065 --- /dev/null +++ b/closure/goog/fs/filereader.js @@ -0,0 +1,296 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A wrapper for the HTML5 FileReader object. + */ + +goog.provide('goog.fs.FileReader'); +goog.provide('goog.fs.FileReader.EventType'); +goog.provide('goog.fs.FileReader.ReadyState'); + +goog.require('goog.async.Deferred'); +goog.require('goog.events.EventTarget'); +goog.require('goog.fs.Error'); +goog.require('goog.fs.ProgressEvent'); + + + +/** + * An object for monitoring the reading of files. This emits ProgressEvents of + * the types listed in {@link goog.fs.FileReader.EventType}. + * + * @constructor + * @extends {goog.events.EventTarget} + * @final + */ +goog.fs.FileReader = function() { + 'use strict'; + goog.fs.FileReader.base(this, 'constructor'); + + /** + * The underlying FileReader object. + * + * @type {!FileReader} + * @private + */ + this.reader_ = new FileReader(); + + this.reader_.onloadstart = goog.bind(this.dispatchProgressEvent_, this); + this.reader_.onprogress = goog.bind(this.dispatchProgressEvent_, this); + this.reader_.onload = goog.bind(this.dispatchProgressEvent_, this); + this.reader_.onabort = goog.bind(this.dispatchProgressEvent_, this); + this.reader_.onerror = goog.bind(this.dispatchProgressEvent_, this); + this.reader_.onloadend = goog.bind(this.dispatchProgressEvent_, this); +}; +goog.inherits(goog.fs.FileReader, goog.events.EventTarget); + + +/** + * Possible states for a FileReader. + * + * @enum {number} + */ +goog.fs.FileReader.ReadyState = { + /** + * The object has been constructed, but there is no pending read. + */ + INIT: 0, + /** + * Data is being read. + */ + LOADING: 1, + /** + * The data has been read from the file, the read was aborted, or an error + * occurred. + */ + DONE: 2 +}; + + +/** + * Events emitted by a FileReader. + * + * @enum {string} + */ +goog.fs.FileReader.EventType = { + /** + * Emitted when the reading begins. readyState will be LOADING. + */ + LOAD_START: 'loadstart', + /** + * Emitted when progress has been made in reading the file. readyState will be + * LOADING. + */ + PROGRESS: 'progress', + /** + * Emitted when the data has been successfully read. readyState will be + * LOADING. + */ + LOAD: 'load', + /** + * Emitted when the reading has been aborted. readyState will be LOADING. + */ + ABORT: 'abort', + /** + * Emitted when an error is encountered or the reading has been aborted. + * readyState will be LOADING. + */ + ERROR: 'error', + /** + * Emitted when the reading is finished, whether successfully or not. + * readyState will be DONE. + */ + LOAD_END: 'loadend' +}; + + +/** + * Abort the reading of the file. + */ +goog.fs.FileReader.prototype.abort = function() { + 'use strict'; + try { + this.reader_.abort(); + } catch (e) { + throw new goog.fs.Error(e, 'aborting read'); + } +}; + + +/** + * @return {goog.fs.FileReader.ReadyState} The current state of the FileReader. + */ +goog.fs.FileReader.prototype.getReadyState = function() { + 'use strict'; + return /** @type {goog.fs.FileReader.ReadyState} */ (this.reader_.readyState); +}; + + +/** + * @return {*} The result of the file read. + */ +goog.fs.FileReader.prototype.getResult = function() { + 'use strict'; + return this.reader_.result; +}; + + +/** + * @return {goog.fs.Error} The error encountered while reading, if any. + */ +goog.fs.FileReader.prototype.getError = function() { + 'use strict'; + return this.reader_.error && + new goog.fs.Error(this.reader_.error, 'reading file'); +}; + + +/** + * Wrap a progress event emitted by the underlying file reader and re-emit it. + * + * @param {!ProgressEvent} event The underlying event. + * @private + */ +goog.fs.FileReader.prototype.dispatchProgressEvent_ = function(event) { + 'use strict'; + this.dispatchEvent(new goog.fs.ProgressEvent(event, this)); +}; + + +/** @override */ +goog.fs.FileReader.prototype.disposeInternal = function() { + 'use strict'; + goog.fs.FileReader.base(this, 'disposeInternal'); + delete this.reader_; +}; + + +/** + * Starts reading a blob as a binary string. + * @param {!Blob} blob The blob to read. + */ +goog.fs.FileReader.prototype.readAsBinaryString = function(blob) { + 'use strict'; + this.reader_.readAsBinaryString(blob); +}; + + +/** + * Reads a blob as a binary string. + * @param {!Blob} blob The blob to read. + * @return {!goog.async.Deferred} The deferred Blob contents as a binary string. + * If an error occurs, the errback is called with a {@link goog.fs.Error}. + */ +goog.fs.FileReader.readAsBinaryString = function(blob) { + 'use strict'; + const reader = new goog.fs.FileReader(); + const d = goog.fs.FileReader.createDeferred_(reader); + reader.readAsBinaryString(blob); + return d; +}; + + +/** + * Starts reading a blob as an array buffer. + * @param {!Blob} blob The blob to read. + */ +goog.fs.FileReader.prototype.readAsArrayBuffer = function(blob) { + 'use strict'; + this.reader_.readAsArrayBuffer(blob); +}; + + +/** + * Reads a blob as an array buffer. + * @param {!Blob} blob The blob to read. + * @return {!goog.async.Deferred} The deferred Blob contents as an array buffer. + * If an error occurs, the errback is called with a {@link goog.fs.Error}. + */ +goog.fs.FileReader.readAsArrayBuffer = function(blob) { + 'use strict'; + const reader = new goog.fs.FileReader(); + const d = goog.fs.FileReader.createDeferred_(reader); + reader.readAsArrayBuffer(blob); + return d; +}; + + +/** + * Starts reading a blob as text. + * @param {!Blob} blob The blob to read. + * @param {string=} opt_encoding The name of the encoding to use. + */ +goog.fs.FileReader.prototype.readAsText = function(blob, opt_encoding) { + 'use strict'; + this.reader_.readAsText(blob, opt_encoding); +}; + + +/** + * Reads a blob as text. + * @param {!Blob} blob The blob to read. + * @param {string=} opt_encoding The name of the encoding to use. + * @return {!goog.async.Deferred} The deferred Blob contents as text. + * If an error occurs, the errback is called with a {@link goog.fs.Error}. + */ +goog.fs.FileReader.readAsText = function(blob, opt_encoding) { + 'use strict'; + const reader = new goog.fs.FileReader(); + const d = goog.fs.FileReader.createDeferred_(reader); + reader.readAsText(blob, opt_encoding); + return d; +}; + + +/** + * Starts reading a blob as a data URL. + * @param {!Blob} blob The blob to read. + */ +goog.fs.FileReader.prototype.readAsDataUrl = function(blob) { + 'use strict'; + this.reader_.readAsDataURL(blob); +}; + + +/** + * Reads a blob as a data URL. + * @param {!Blob} blob The blob to read. + * @return {!goog.async.Deferred} The deferred Blob contents as a data URL. + * If an error occurs, the errback is called with a {@link goog.fs.Error}. + */ +goog.fs.FileReader.readAsDataUrl = function(blob) { + 'use strict'; + const reader = new goog.fs.FileReader(); + const d = goog.fs.FileReader.createDeferred_(reader); + reader.readAsDataUrl(blob); + return d; +}; + + +/** + * Creates a new deferred object for the results of a read method. + * @param {goog.fs.FileReader} reader The reader to create a deferred for. + * @return {!goog.async.Deferred} The deferred results. + * @private + */ +goog.fs.FileReader.createDeferred_ = function(reader) { + 'use strict'; + const deferred = new goog.async.Deferred(); + reader.listen( + goog.fs.FileReader.EventType.LOAD_END, goog.partial(function(d, r, e) { + 'use strict'; + const result = r.getResult(); + const error = r.getError(); + if (result != null && !error) { + d.callback(result); + } else { + d.errback(error); + } + r.dispose(); + }, deferred, reader)); + return deferred; +}; diff --git a/closure/goog/fs/filesaver.js b/closure/goog/fs/filesaver.js new file mode 100644 index 0000000000..972ea602a1 --- /dev/null +++ b/closure/goog/fs/filesaver.js @@ -0,0 +1,163 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A wrapper for the HTML5 FileSaver object. + */ + +goog.provide('goog.fs.FileSaver'); +goog.provide('goog.fs.FileSaver.EventType'); +goog.provide('goog.fs.FileSaver.ReadyState'); + +goog.require('goog.events.EventTarget'); +goog.require('goog.fs.Error'); +goog.require('goog.fs.ProgressEvent'); + + + +/** + * An object for monitoring the saving of files. This emits ProgressEvents of + * the types listed in {@link goog.fs.FileSaver.EventType}. + * + * This should not be instantiated directly. Instead, its subclass + * {@link goog.fs.FileWriter} should be accessed via + * {@link goog.fs.FileEntry#createWriter}. + * + * @param {!FileSaver} fileSaver The underlying FileSaver object. + * @constructor + * @extends {goog.events.EventTarget} + */ +goog.fs.FileSaver = function(fileSaver) { + 'use strict'; + goog.fs.FileSaver.base(this, 'constructor'); + + /** + * The underlying FileSaver object. + * + * @type {!FileSaver} + * @private + */ + this.saver_ = fileSaver; + + this.saver_.onwritestart = goog.bind(this.dispatchProgressEvent_, this); + this.saver_.onprogress = goog.bind(this.dispatchProgressEvent_, this); + this.saver_.onwrite = goog.bind(this.dispatchProgressEvent_, this); + this.saver_.onabort = goog.bind(this.dispatchProgressEvent_, this); + this.saver_.onerror = goog.bind(this.dispatchProgressEvent_, this); + this.saver_.onwriteend = goog.bind(this.dispatchProgressEvent_, this); +}; +goog.inherits(goog.fs.FileSaver, goog.events.EventTarget); + + +/** + * Possible states for a FileSaver. + * + * @enum {number} + */ +goog.fs.FileSaver.ReadyState = { + /** + * The object has been constructed, but there is no pending write. + */ + INIT: 0, + /** + * Data is being written. + */ + WRITING: 1, + /** + * The data has been written to the file, the write was aborted, or an error + * occurred. + */ + DONE: 2 +}; + + +/** + * Events emitted by a FileSaver. + * + * @enum {string} + */ +goog.fs.FileSaver.EventType = { + /** + * Emitted when the writing begins. readyState will be WRITING. + */ + WRITE_START: 'writestart', + /** + * Emitted when progress has been made in saving the file. readyState will be + * WRITING. + */ + PROGRESS: 'progress', + /** + * Emitted when the data has been successfully written. readyState will be + * WRITING. + */ + WRITE: 'write', + /** + * Emitted when the writing has been aborted. readyState will be WRITING. + */ + ABORT: 'abort', + /** + * Emitted when an error is encountered or the writing has been aborted. + * readyState will be WRITING. + */ + ERROR: 'error', + /** + * Emitted when the writing is finished, whether successfully or not. + * readyState will be DONE. + */ + WRITE_END: 'writeend' +}; + + +/** + * Abort the writing of the file. + */ +goog.fs.FileSaver.prototype.abort = function() { + 'use strict'; + try { + this.saver_.abort(); + } catch (e) { + throw new goog.fs.Error(e, 'aborting save'); + } +}; + + +/** + * @return {goog.fs.FileSaver.ReadyState} The current state of the FileSaver. + */ +goog.fs.FileSaver.prototype.getReadyState = function() { + 'use strict'; + return /** @type {goog.fs.FileSaver.ReadyState} */ (this.saver_.readyState); +}; + + +/** + * @return {goog.fs.Error} The error encountered while writing, if any. + */ +goog.fs.FileSaver.prototype.getError = function() { + 'use strict'; + return this.saver_.error && + new goog.fs.Error(this.saver_.error, 'saving file'); +}; + + +/** + * Wrap a progress event emitted by the underlying file saver and re-emit it. + * + * @param {!ProgressEvent} event The underlying event. + * @private + */ +goog.fs.FileSaver.prototype.dispatchProgressEvent_ = function(event) { + 'use strict'; + this.dispatchEvent(new goog.fs.ProgressEvent(event, this)); +}; + + +/** @override */ +goog.fs.FileSaver.prototype.disposeInternal = function() { + 'use strict'; + delete this.saver_; + goog.fs.FileSaver.base(this, 'disposeInternal'); +}; diff --git a/closure/goog/fs/filesystem.js b/closure/goog/fs/filesystem.js new file mode 100644 index 0000000000..11174b22ee --- /dev/null +++ b/closure/goog/fs/filesystem.js @@ -0,0 +1,34 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A wrapper for the HTML5 FileSystem object. + */ + +goog.provide('goog.fs.FileSystem'); + +goog.requireType('goog.fs.DirectoryEntry'); + + + +/** + * A local filesystem. + * + * @interface + */ +goog.fs.FileSystem = function() {}; + + +/** + * @return {string} The name of the filesystem. + */ +goog.fs.FileSystem.prototype.getName = function() {}; + + +/** + * @return {!goog.fs.DirectoryEntry} The root directory of the filesystem. + */ +goog.fs.FileSystem.prototype.getRoot = function() {}; diff --git a/closure/goog/fs/filesystemimpl.js b/closure/goog/fs/filesystemimpl.js new file mode 100644 index 0000000000..c803a8ab98 --- /dev/null +++ b/closure/goog/fs/filesystemimpl.js @@ -0,0 +1,61 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Concrete implementation of the goog.fs.FileSystem interface + * using an HTML FileSystem object. + */ +goog.provide('goog.fs.FileSystemImpl'); + +goog.require('goog.fs.DirectoryEntryImpl'); +goog.require('goog.fs.FileSystem'); + + + +/** + * A local filesystem. + * + * This shouldn't be instantiated directly. Instead, it should be accessed via + * {@link goog.fs.getTemporary} or {@link goog.fs.getPersistent}. + * + * @param {!FileSystem} fs The underlying FileSystem object. + * @constructor + * @implements {goog.fs.FileSystem} + * @final + */ +goog.fs.FileSystemImpl = function(fs) { + 'use strict'; + /** + * The underlying FileSystem object. + * + * @type {!FileSystem} + * @private + */ + this.fs_ = fs; +}; + + +/** @override */ +goog.fs.FileSystemImpl.prototype.getName = function() { + 'use strict'; + return this.fs_.name; +}; + + +/** @override */ +goog.fs.FileSystemImpl.prototype.getRoot = function() { + 'use strict'; + return new goog.fs.DirectoryEntryImpl(this, this.fs_.root); +}; + + +/** + * @return {!FileSystem} The underlying FileSystem object. + */ +goog.fs.FileSystemImpl.prototype.getBrowserFileSystem = function() { + 'use strict'; + return this.fs_; +}; diff --git a/closure/goog/fs/filewriter.js b/closure/goog/fs/filewriter.js new file mode 100644 index 0000000000..6a8606c198 --- /dev/null +++ b/closure/goog/fs/filewriter.js @@ -0,0 +1,108 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A wrapper for the HTML5 FileWriter object. + * + * When adding or modifying functionality in this namespace, be sure to update + * the mock counterparts in goog.testing.fs. + */ + +goog.provide('goog.fs.FileWriter'); + +goog.require('goog.fs.Error'); +goog.require('goog.fs.FileSaver'); + + + +/** + * An object for monitoring the saving of files, as well as other fine-grained + * writing operations. + * + * This should not be instantiated directly. Instead, it should be accessed via + * {@link goog.fs.FileEntry#createWriter}. + * + * @param {!FileWriter} writer The underlying FileWriter object. + * @constructor + * @extends {goog.fs.FileSaver} + * @final + */ +goog.fs.FileWriter = function(writer) { + 'use strict'; + goog.fs.FileWriter.base(this, 'constructor', writer); + + /** + * The underlying FileWriter object. + * + * @type {!FileWriter} + * @private + */ + this.writer_ = writer; +}; +goog.inherits(goog.fs.FileWriter, goog.fs.FileSaver); + + +/** + * @return {number} The byte offset at which the next write will occur. + */ +goog.fs.FileWriter.prototype.getPosition = function() { + 'use strict'; + return this.writer_.position; +}; + + +/** + * @return {number} The length of the file. + */ +goog.fs.FileWriter.prototype.getLength = function() { + 'use strict'; + return this.writer_.length; +}; + + +/** + * Write data to the file. + * + * @param {!Blob} blob The data to write. + */ +goog.fs.FileWriter.prototype.write = function(blob) { + 'use strict'; + try { + this.writer_.write(blob); + } catch (e) { + throw new goog.fs.Error(e, 'writing file'); + } +}; + + +/** + * Set the file position at which the next write will occur. + * + * @param {number} offset An absolute byte offset into the file. + */ +goog.fs.FileWriter.prototype.seek = function(offset) { + 'use strict'; + try { + this.writer_.seek(offset); + } catch (e) { + throw new goog.fs.Error(e, 'seeking in file'); + } +}; + + +/** + * Changes the length of the file to that specified. + * + * @param {number} size The new size of the file, in bytes. + */ +goog.fs.FileWriter.prototype.truncate = function(size) { + 'use strict'; + try { + this.writer_.truncate(size); + } catch (e) { + throw new goog.fs.Error(e, 'truncating file'); + } +}; diff --git a/closure/goog/fs/fs.js b/closure/goog/fs/fs.js new file mode 100644 index 0000000000..818a0f8d44 --- /dev/null +++ b/closure/goog/fs/fs.js @@ -0,0 +1,125 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Wrappers for the HTML5 File API. These wrappers closely mirror + * the underlying APIs, but use Closure-style events and Deferred return values. + * Their existence also makes it possible to mock the FileSystem API for testing + * in browsers that don't support it natively. + * + * When adding public functions to anything under this namespace, be sure to add + * its mock counterpart to goog.testing.fs. + */ + +goog.provide('goog.fs'); + +goog.require('goog.async.Deferred'); +goog.require('goog.fs.Error'); +goog.require('goog.fs.FileSystemImpl'); + + +/** + * Get a wrapped FileSystem object. + * + * @param {goog.fs.FileSystemType_} type The type of the filesystem to get. + * @param {number} size The size requested for the filesystem, in bytes. + * @return {!goog.async.Deferred} The deferred {@link goog.fs.FileSystem}. If an + * error occurs, the errback is called with a {@link goog.fs.Error}. + * @private + */ +goog.fs.get_ = function(type, size) { + 'use strict'; + const requestFileSystem = + goog.global.requestFileSystem || goog.global.webkitRequestFileSystem; + + if (typeof requestFileSystem !== 'function') { + return goog.async.Deferred.fail(new Error('File API unsupported')); + } + + const d = new goog.async.Deferred(); + requestFileSystem( + type, size, + function(fs) { + 'use strict'; + d.callback(new goog.fs.FileSystemImpl(fs)); + }, + function(err) { + 'use strict'; + d.errback(new goog.fs.Error(err, 'requesting filesystem')); + }); + return d; +}; + + +/** + * The two types of filesystem. + * + * @enum {number} + * @private + */ +goog.fs.FileSystemType_ = { + /** + * A temporary filesystem may be deleted by the user agent at its discretion. + */ + TEMPORARY: 0, + /** + * A persistent filesystem will never be deleted without the user's or + * application's authorization. + */ + PERSISTENT: 1 +}; + + +/** + * Returns a temporary FileSystem object. A temporary filesystem may be deleted + * by the user agent at its discretion. + * + * @param {number} size The size requested for the filesystem, in bytes. + * @return {!goog.async.Deferred} The deferred {@link goog.fs.FileSystem}. If an + * error occurs, the errback is called with a {@link goog.fs.Error}. + */ +goog.fs.getTemporary = function(size) { + 'use strict'; + return goog.fs.get_(goog.fs.FileSystemType_.TEMPORARY, size); +}; + + +/** + * Returns a persistent FileSystem object. A persistent filesystem will never be + * deleted without the user's or application's authorization. + * + * @param {number} size The size requested for the filesystem, in bytes. + * @return {!goog.async.Deferred} The deferred {@link goog.fs.FileSystem}. If an + * error occurs, the errback is called with a {@link goog.fs.Error}. + */ +goog.fs.getPersistent = function(size) { + 'use strict'; + return goog.fs.get_(goog.fs.FileSystemType_.PERSISTENT, size); +}; + + +/** + * Slices the blob. The returned blob contains data from the start byte + * (inclusive) till the end byte (exclusive). Negative indices can be used + * to count bytes from the end of the blob (-1 == blob.size - 1). Indices + * are always clamped to blob range. If end is omitted, all the data till + * the end of the blob is taken. + * + * @param {!Blob} blob The blob to be sliced. + * @param {number} start Index of the starting byte. + * @param {number=} opt_end Index of the ending byte. + * @return {Blob} The blob slice or null if not supported. + */ +goog.fs.sliceBlob = function(blob, start, opt_end) { + 'use strict'; + if (opt_end === undefined) { + opt_end = blob.size; + } + if (blob.slice) { + return blob.slice(start, opt_end); + } + return null; +}; diff --git a/closure/goog/fs/fs_test.js b/closure/goog/fs/fs_test.js new file mode 100644 index 0000000000..c60abc6e34 --- /dev/null +++ b/closure/goog/fs/fs_test.js @@ -0,0 +1,665 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.fsTest'); +goog.setTestOnly(); + +const FsDirectoryEntry = goog.require('goog.fs.DirectoryEntry'); +const FsError = goog.require('goog.fs.Error'); +const FsFileReader = goog.require('goog.fs.FileReader'); +const FsFileSaver = goog.require('goog.fs.FileSaver'); +const GoogPromise = goog.require('goog.Promise'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const TagName = goog.require('goog.dom.TagName'); +const dom = goog.require('goog.dom'); +const events = goog.require('goog.events'); +const googArray = goog.require('goog.array'); +const googFs = goog.require('goog.fs'); +const googFsBlob = goog.require('goog.fs.blob'); +const googString = goog.require('goog.string'); +const testSuite = goog.require('goog.testing.testSuite'); + +const TEST_DIR = 'goog-fs-test-dir'; + +const fsExists = (globalThis.requestFileSystem !== undefined) || + globalThis.webkitRequestFileSystem !== undefined; +/** @suppress {checkTypes} suppression added to enable type checking */ +const deferredFs = fsExists ? googFs.getTemporary() : null; +const stubs = new PropertyReplacer(); + +function loadTestDir() { + return deferredFs.then( + (fs) => fs.getRoot().getDirectory( + TEST_DIR, FsDirectoryEntry.Behavior.CREATE)); +} + +function loadFile(filename, behavior) { + return loadTestDir().then((dir) => dir.getFile(filename, behavior)); +} + +function loadDirectory(filename, behavior) { + return loadTestDir().then((dir) => dir.getDirectory(filename, behavior)); +} + +function startWrite(content, file) { + return file.createWriter() + .then(goog.partial(checkReadyState, FsFileSaver.ReadyState.INIT)) + .then((writer) => { + writer.write(googFsBlob.getBlob(content)); + return writer; + }) + .then(goog.partial(checkReadyState, FsFileSaver.ReadyState.WRITING)); +} + +function waitForEvent(type, target) { + let done; + const promise = new GoogPromise((_done) => { + done = _done; + }); + events.listenOnce(target, type, done); + return promise; +} + +function writeToFile(content, file) { + return startWrite(content, file) + .then(goog.partial(waitForEvent, FsFileSaver.EventType.WRITE)) + .then(() => file); +} + +function checkFileContent(content, file) { + return checkFileContentAs(content, 'Text', undefined, file); +} + +function checkFileContentWithEncoding(content, encoding, file) { + return checkFileContentAs(content, 'Text', encoding, file); +} + +function checkFileContentAs(content, filetype, encoding, file) { + return file.file() + .then((blob) => FsFileReader[`readAs${filetype}`](blob, encoding)) + .then(goog.partial(checkEquals, content)); +} + +function checkEquals(a, b) { + if (a instanceof ArrayBuffer && b instanceof ArrayBuffer) { + assertEquals(a.byteLength, b.byteLength); + const viewA = new DataView(a); + const viewB = new DataView(b); + for (let i = 0; i < a.byteLength; i++) { + assertEquals(viewA.getUint8(i), viewB.getUint8(i)); + } + } else { + assertEquals(a, b); + } +} + +/** @suppress {checkTypes} suppression added to enable type checking */ +function checkFileRemoved(filename) { + return loadFile(filename).then( + goog.partial(fail, 'expected file to be removed'), (err) => { + assertEquals(err.code, FsError.ErrorCode.NOT_FOUND); + }); +} + +function checkReadyState(expectedState, writer) { + assertEquals(expectedState, writer.getReadyState()); + return writer; +} + +function splitArgs(fn) { + return (args) => fn(args[0], args[1]); +} +testSuite({ + setUpPage() { + if (!fsExists) { + return; + } + + return loadTestDir().then(null, (err) => { + let msg; + if (err.code == FsError.ErrorCode.QUOTA_EXCEEDED) { + msg = err.message + '. If you\'re using Chrome, you probably need to ' + + 'pass --unlimited-quota-for-files on the command line.'; + } else if ( + err.code == FsError.ErrorCode.SECURITY && + window.location.href.match(/^file:/)) { + msg = err.message + '. file:// URLs can\'t access the filesystem API.'; + } else { + msg = err.message; + } + const body = dom.getDocument().body; + dom.insertSiblingBefore( + dom.createDom(TagName.H1, {}, msg), body.childNodes[0]); + }); + }, + + tearDown() { + if (!fsExists) { + return; + } + + return loadTestDir().then((dir) => dir.removeRecursively()); + }, + + testUnavailableTemporaryFilesystem() { + stubs.set(globalThis, 'requestFileSystem', null); + stubs.set(globalThis, 'webkitRequestFileSystem', null); + + return googFs.getTemporary(1024).then( + fail, /** + @suppress {strictMissingProperties} suppression added to + enable type checking + */ + (e) => { + assertEquals('File API unsupported', e.message); + }); + }, + + testUnavailablePersistentFilesystem() { + stubs.set(globalThis, 'requestFileSystem', null); + stubs.set(globalThis, 'webkitRequestFileSystem', null); + + return googFs.getPersistent(2048).then( + fail, /** + @suppress {strictMissingProperties} suppression added to + enable type checking + */ + (e) => { + assertEquals('File API unsupported', e.message); + }); + }, + + testIsFile() { + if (!fsExists) { + return; + } + + return loadFile('test', FsDirectoryEntry.Behavior.CREATE) + .then((fileEntry) => { + assertFalse(fileEntry.isDirectory()); + assertTrue(fileEntry.isFile()); + }); + }, + + testIsDirectory() { + if (!fsExists) { + return; + } + + return loadDirectory('test', FsDirectoryEntry.Behavior.CREATE) + .then((fileEntry) => { + assertTrue(fileEntry.isDirectory()); + assertFalse(fileEntry.isFile()); + }); + }, + + testReadFileUtf16() { + if (!fsExists) { + return; + } + const str = 'test content'; + const buf = new ArrayBuffer(str.length * 2); + const arr = new Uint16Array(buf); + for (let i = 0; i < str.length; i++) { + arr[i] = str.charCodeAt(i); + } + + return loadFile('test', FsDirectoryEntry.Behavior.CREATE) + .then(goog.partial(writeToFile, arr.buffer)) + .then(goog.partial(checkFileContentWithEncoding, str, 'UTF-16')); + }, + + testReadFileUtf8() { + if (!fsExists) { + return; + } + const str = 'test content'; + const buf = new ArrayBuffer(str.length); + const arr = new Uint8Array(buf); + for (let i = 0; i < str.length; i++) { + arr[i] = str.charCodeAt(i) & 0xff; + } + + return loadFile('test', FsDirectoryEntry.Behavior.CREATE) + .then(goog.partial(writeToFile, arr.buffer)) + .then(goog.partial(checkFileContentWithEncoding, str, 'UTF-8')); + }, + + testReadFileAsArrayBuffer() { + if (!fsExists) { + return; + } + const str = 'test content'; + const buf = new ArrayBuffer(str.length); + const arr = new Uint8Array(buf); + for (let i = 0; i < str.length; i++) { + arr[i] = str.charCodeAt(i) & 0xff; + } + + return loadFile('test', FsDirectoryEntry.Behavior.CREATE) + .then(goog.partial(writeToFile, arr.buffer)) + .then(goog.partial( + checkFileContentAs, arr.buffer, 'ArrayBuffer', undefined)); + }, + + testReadFileAsBinaryString() { + if (!fsExists) { + return; + } + const str = 'test content'; + const buf = new ArrayBuffer(str.length); + const arr = new Uint8Array(buf); + for (let i = 0; i < str.length; i++) { + arr[i] = str.charCodeAt(i); + } + + return loadFile('test', FsDirectoryEntry.Behavior.CREATE) + .then(goog.partial(writeToFile, arr.buffer)) + .then(goog.partial(checkFileContentAs, str, 'BinaryString', undefined)); + }, + + testWriteFile() { + if (!fsExists) { + return; + } + + return loadFile('test', FsDirectoryEntry.Behavior.CREATE) + .then(goog.partial(writeToFile, 'test content')) + .then(goog.partial(checkFileContent, 'test content')); + }, + + testRemoveFile() { + if (!fsExists) { + return; + } + + return loadFile('test', FsDirectoryEntry.Behavior.CREATE) + .then(goog.partial(writeToFile, 'test content')) + .then((file) => file.remove()) + .then(goog.partial(checkFileRemoved, 'test')); + }, + + testMoveFile() { + if (!fsExists) { + return; + } + + const deferredSubdir = + loadDirectory('subdir', FsDirectoryEntry.Behavior.CREATE); + const deferredWrittenFile = + loadFile('test', FsDirectoryEntry.Behavior.CREATE) + .then(goog.partial(writeToFile, 'test content')); + return GoogPromise.all([deferredSubdir, deferredWrittenFile]) + .then(splitArgs((dir, file) => file.moveTo(dir))) + .then(goog.partial(checkFileContent, 'test content')) + .then(goog.partial(checkFileRemoved, 'test')); + }, + + testCopyFile() { + if (!fsExists) { + return; + } + + const deferredFile = loadFile('test', FsDirectoryEntry.Behavior.CREATE); + const deferredSubdir = + loadDirectory('subdir', FsDirectoryEntry.Behavior.CREATE); + const deferredWrittenFile = + deferredFile.then(goog.partial(writeToFile, 'test content')); + return GoogPromise.all([deferredSubdir, deferredWrittenFile]) + .then(splitArgs((dir, file) => file.copyTo(dir))) + .then(goog.partial(checkFileContent, 'test content')) + .then(() => deferredFile) + .then(goog.partial(checkFileContent, 'test content')); + }, + + /** @suppress {uselessCode} suppression added to enable type checking */ + testAbortWrite() { + // TODO(nicksantos): This test is broken in newer versions of chrome. + // We don't know why yet. + if (true) return; + + if (!fsExists) { + return; + } + + const deferredFile = loadFile('test', FsDirectoryEntry.Behavior.CREATE); + return deferredFile.then(goog.partial(startWrite, 'test content')) + .then((writer) => new GoogPromise((resolve) => { + events.listenOnce(writer, FsFileSaver.EventType.ABORT, resolve); + writer.abort(); + })) + .then(/** + @suppress {checkTypes} suppression added to enable type + checking + */ + () => loadFile('test')) + .then(goog.partial(checkFileContent, '')); + }, + + testSeek() { + if (!fsExists) { + return; + } + + const deferredFile = loadFile('test', FsDirectoryEntry.Behavior.CREATE); + return deferredFile.then(goog.partial(writeToFile, 'test content')) + .then((file) => file.createWriter()) + .then(goog.partial(checkReadyState, FsFileSaver.ReadyState.INIT)) + .then((writer) => { + writer.seek(5); + writer.write(googFsBlob.getBlob('stuff and things')); + return writer; + }) + .then(goog.partial(checkReadyState, FsFileSaver.ReadyState.WRITING)) + .then(goog.partial(waitForEvent, FsFileSaver.EventType.WRITE)) + .then(() => deferredFile) + .then(goog.partial(checkFileContent, 'test stuff and things')); + }, + + testTruncate() { + if (!fsExists) { + return; + } + + const deferredFile = loadFile('test', FsDirectoryEntry.Behavior.CREATE); + return deferredFile.then(goog.partial(writeToFile, 'test content')) + .then((file) => file.createWriter()) + .then(goog.partial(checkReadyState, FsFileSaver.ReadyState.INIT)) + .then((writer) => { + writer.truncate(4); + return writer; + }) + .then(goog.partial(checkReadyState, FsFileSaver.ReadyState.WRITING)) + .then(goog.partial(waitForEvent, FsFileSaver.EventType.WRITE)) + .then(() => deferredFile) + .then(goog.partial(checkFileContent, 'test')); + }, + + testGetLastModified() { + if (!fsExists) { + return; + } + const now = Date.now(); + return loadFile('test', FsDirectoryEntry.Behavior.CREATE) + .then((entry) => entry.getLastModified()) + .then((date) => { + assertRoughlyEquals( + 'Expected the last modified date to be within ' + + 'a few milliseconds of the test start time.', + now, date.getTime(), 2000); + }); + }, + + testCreatePath() { + if (!fsExists) { + return; + } + + return loadTestDir() + .then((testDir) => testDir.createPath('foo')) + .then((fooDir) => { + assertEquals('/goog-fs-test-dir/foo', fooDir.getFullPath()); + return fooDir.createPath('bar/baz/bat'); + }) + .then((batDir) => { + assertEquals( + '/goog-fs-test-dir/foo/bar/baz/bat', batDir.getFullPath()); + }); + }, + + testCreateAbsolutePath() { + if (!fsExists) { + return; + } + + return loadTestDir() + .then((testDir) => testDir.createPath(`/${TEST_DIR}/fee/fi/fo/fum`)) + .then((absDir) => { + assertEquals('/goog-fs-test-dir/fee/fi/fo/fum', absDir.getFullPath()); + }); + }, + + testCreateRelativePath() { + if (!fsExists) { + return; + } + + return loadTestDir() + .then((dir) => dir.createPath(`../${TEST_DIR}/dir`)) + .then((relDir) => { + assertEquals('/goog-fs-test-dir/dir', relDir.getFullPath()); + return relDir.createPath('.'); + }) + .then((sameDir) => { + assertEquals('/goog-fs-test-dir/dir', sameDir.getFullPath()); + return sameDir.createPath('./././.'); + }) + .then((reallySameDir) => { + assertEquals('/goog-fs-test-dir/dir', reallySameDir.getFullPath()); + return reallySameDir.createPath('./new/../..//dir/./new////.'); + }) + .then((newDir) => { + assertEquals('/goog-fs-test-dir/dir/new', newDir.getFullPath()); + }); + }, + + testCreateBadPath() { + if (!fsExists) { + return; + } + + return loadTestDir() + .then(() => loadTestDir()) + .then((dir) => { + // There is only one layer of parent directory from the test dir. + return dir.createPath(`../../../../${TEST_DIR}/baz/bat`); + }) + .then((batDir) => { + assertEquals( + 'The parent directory of the root directory should ' + + 'point back to the root directory.', + '/goog-fs-test-dir/baz/bat', batDir.getFullPath()); + }) + . + + then(() => loadTestDir()) + .then((dir) => { + // An empty path should return the same as the input directory. + return dir.createPath(''); + }) + .then((testDir) => { + assertEquals('/goog-fs-test-dir', testDir.getFullPath()); + }); + }, + + testGetAbsolutePaths() { + if (!fsExists) { + return; + } + + return loadFile('foo', FsDirectoryEntry.Behavior.CREATE) + .then(() => loadTestDir()) + .then((testDir) => testDir.getDirectory('/')) + .then((root) => { + assertEquals('/', root.getFullPath()); + return root.getDirectory(`/${TEST_DIR}`); + }) + .then((testDir) => { + assertEquals('/goog-fs-test-dir', testDir.getFullPath()); + return testDir.getDirectory(`//${TEST_DIR}////`); + }) + .then((testDir) => { + assertEquals('/goog-fs-test-dir', testDir.getFullPath()); + return testDir.getDirectory('////'); + }) + .then((testDir) => { + assertEquals('/', testDir.getFullPath()); + }); + }, + + testListEmptyDirectory() { + if (!fsExists) { + return; + } + + return loadTestDir().then((dir) => dir.listDirectory()).then((entries) => { + assertArrayEquals([], entries); + }); + }, + + testListDirectory() { + if (!fsExists) { + return; + } + + return loadDirectory('testDir', FsDirectoryEntry.Behavior.CREATE) + .then(() => loadFile('testFile', FsDirectoryEntry.Behavior.CREATE)) + .then(() => loadTestDir()) + .then((testDir) => testDir.listDirectory()) + .then((entries) => { + // Verify the contents of the directory listing. + assertEquals(2, entries.length); + + const dir = + googArray.find(entries, (entry) => entry.getName() == 'testDir'); + assertNotNull(dir); + assertTrue(dir.isDirectory()); + + const file = + googArray.find(entries, (entry) => entry.getName() == 'testFile'); + assertNotNull(file); + assertTrue(file.isFile()); + }); + }, + + /** @suppress {uselessCode} suppression added to enable type checking */ + testListBigDirectory() { + // TODO(nicksantos): This test is broken in newer versions of chrome. + // We don't know why yet. + if (true) return; + + if (!fsExists) { + return; + } + + function getFileName(i) { + return 'file' + googString.padNumber(i, String(count).length); + } + + // NOTE: This was intended to verify that the results from repeated + // DirectoryReader.readEntries() callbacks are appropriately concatenated. + // In current versions of Chrome (March 2011), all results are returned in + // the first callback regardless of directory size. The count can be + // increased in the future to test batched result lists once they are + // implemented. + const count = 100; + + const expectedNames = []; + + const def = GoogPromise.resolve(); + for (let i = 0; i < count; i++) { + const name = getFileName(i); + expectedNames.push(name); + + def.then(() => loadFile(name, FsDirectoryEntry.Behavior.CREATE)); + } + + return def.then(() => loadTestDir()) + .then((testDir) => testDir.listDirectory()) + .then((entries) => { + assertEquals(count, entries.length); + + assertSameElements( + expectedNames, + googArray.map(entries, (entry) => entry.getName())); + assertTrue(googArray.every(entries, (entry) => entry.isFile())); + }); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testSliceBlob() { + // A mock blob object whose slice returns the parameters it was called with. + const blob = { + 'size': 10, + 'slice': function(start, end) { + return [start, end]; + }, + }; + + // Expect slice to be called with no change to parameters + assertArrayEquals([2, 10], googFs.sliceBlob(blob, 2)); + assertArrayEquals([-2, 10], googFs.sliceBlob(blob, -2)); + assertArrayEquals([3, 6], googFs.sliceBlob(blob, 3, 6)); + assertArrayEquals([3, -6], googFs.sliceBlob(blob, 3, -6)); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testGetBlobThrowsError() { + stubs.remove(globalThis, 'BlobBuilder'); + stubs.remove(globalThis, 'WebKitBlobBuilder'); + stubs.remove(globalThis, 'Blob'); + + try { + googFsBlob.getBlob(); + fail(); + } catch (e) { + assertEquals( + 'This browser doesn\'t seem to support creating Blobs', e.message); + } + + stubs.reset(); + }, + + testGetBlobWithProperties() { + // Skip test if browser doesn't support Blob API. + if (typeof (globalThis.Blob) != 'function') { + return; + } + + const blob = + googFsBlob.getBlobWithProperties(['test'], 'text/test', 'native'); + assertEquals('text/test', blob.type); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testGetBlobWithPropertiesThrowsError() { + stubs.remove(globalThis, 'BlobBuilder'); + stubs.remove(globalThis, 'WebKitBlobBuilder'); + stubs.remove(globalThis, 'Blob'); + + try { + googFsBlob.getBlobWithProperties(); + fail(); + } catch (e) { + assertEquals( + 'This browser doesn\'t seem to support creating Blobs', e.message); + } + + stubs.reset(); + }, + + /** @suppress {missingProperties} suppression added to enable type checking */ + testGetBlobWithPropertiesUsingBlobBuilder() { + function BlobBuilder() { + this.parts = []; + this.append = function(value, endings) { + this.parts.push({value: value, endings: endings}); + }; + this.getBlob = function(type) { + return {type: type, builder: this}; + }; + } + stubs.set(globalThis, 'BlobBuilder', BlobBuilder); + + const blob = + googFsBlob.getBlobWithProperties(['test'], 'text/test', 'native'); + assertEquals('text/test', blob.type); + assertEquals('test', blob.builder.parts[0].value); + assertEquals('native', blob.builder.parts[0].endings); + + stubs.reset(); + }, +}); diff --git a/closure/goog/fs/fs_test_dom.html b/closure/goog/fs/fs_test_dom.html new file mode 100644 index 0000000000..104bc3189f --- /dev/null +++ b/closure/goog/fs/fs_test_dom.html @@ -0,0 +1,8 @@ + +
    +
    \ No newline at end of file diff --git a/closure/goog/fs/progressevent.js b/closure/goog/fs/progressevent.js new file mode 100644 index 0000000000..a4eb3939d5 --- /dev/null +++ b/closure/goog/fs/progressevent.js @@ -0,0 +1,64 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A wrapper for the HTML5 File ProgressEvent objects. + */ +goog.provide('goog.fs.ProgressEvent'); + +goog.require('goog.events.Event'); + + + +/** + * A wrapper for the progress events emitted by the File APIs. + * + * @param {!ProgressEvent} event The underlying event object. + * @param {!Object} target The file access object emitting the event. + * @extends {goog.events.Event} + * @constructor + * @final + */ +goog.fs.ProgressEvent = function(event, target) { + 'use strict'; + goog.fs.ProgressEvent.base(this, 'constructor', event.type, target); + + /** + * The underlying event object. + * @type {!ProgressEvent} + * @private + */ + this.event_ = event; +}; +goog.inherits(goog.fs.ProgressEvent, goog.events.Event); + + +/** + * @return {boolean} Whether or not the total size of the of the file being + * saved is known. + */ +goog.fs.ProgressEvent.prototype.isLengthComputable = function() { + 'use strict'; + return this.event_.lengthComputable; +}; + + +/** + * @return {number} The number of bytes saved so far. + */ +goog.fs.ProgressEvent.prototype.getLoaded = function() { + 'use strict'; + return this.event_.loaded; +}; + + +/** + * @return {number} The total number of bytes in the file being saved. + */ +goog.fs.ProgressEvent.prototype.getTotal = function() { + 'use strict'; + return this.event_.total; +}; diff --git a/closure/goog/fs/url.js b/closure/goog/fs/url.js new file mode 100644 index 0000000000..d34703b310 --- /dev/null +++ b/closure/goog/fs/url.js @@ -0,0 +1,112 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Wrapper for URL and its createObjectUrl and revokeObjectUrl + * methods that are part of the HTML5 File API. + */ + +goog.provide('goog.fs.url'); + + +/** + * Creates a blob URL for a blob object. + * Throws an error if the browser does not support Object Urls. + * + * @param {!File|!Blob|!MediaSource|!MediaStream} obj The object for which + * to create the URL. + * @return {string} The URL for the object. + */ +goog.fs.url.createObjectUrl = function(obj) { + 'use strict'; + return goog.fs.url.getUrlObject_().createObjectURL(obj); +}; + + +/** + * Revokes a URL created by {@link goog.fs.url.createObjectUrl}. + * Throws an error if the browser does not support Object Urls. + * + * @param {string} url The URL to revoke. + * @return {void} + */ +goog.fs.url.revokeObjectUrl = function(url) { + 'use strict'; + goog.fs.url.getUrlObject_().revokeObjectURL(url); +}; + + +/** + * @record + * @private + */ +goog.fs.url.UrlObject_ = function() {}; + +/** + * @param {!File|!Blob|!MediaSource|!MediaStream} arg + * @return {string} + */ +goog.fs.url.UrlObject_.prototype.createObjectURL = function(arg) {}; + +/** + * @param {string} s + * @return {void} + */ +goog.fs.url.UrlObject_.prototype.revokeObjectURL = function(s) {}; + + +/** + * Get the object that has the createObjectURL and revokeObjectURL functions for + * this browser. + * + * @return {!goog.fs.url.UrlObject_} The object for this browser. + * @private + */ +goog.fs.url.getUrlObject_ = function() { + 'use strict'; + const urlObject = goog.fs.url.findUrlObject_(); + if (urlObject != null) { + return urlObject; + } else { + throw new Error('This browser doesn\'t seem to support blob URLs'); + } +}; + + +/** + * Finds the object that has the createObjectURL and revokeObjectURL functions + * for this browser. + * + * @return {?goog.fs.url.UrlObject_} The object for this browser or null if the + * browser does not support Object Urls. + * @private + */ +goog.fs.url.findUrlObject_ = function() { + 'use strict'; + // This is what the spec says to do + // http://dev.w3.org/2006/webapi/FileAPI/#dfn-createObjectURL + if (goog.global.URL !== undefined && + goog.global.URL.createObjectURL !== undefined) { + return /** @type {!goog.fs.url.UrlObject_} */ (goog.global.URL); + // This is what the spec used to say to do + } else if (goog.global.createObjectURL !== undefined) { + return /** @type {!goog.fs.url.UrlObject_} */ (goog.global); + } else { + return null; + } +}; + + +/** + * Checks whether this browser supports Object Urls. If not, calls to + * createObjectUrl and revokeObjectUrl will result in an error. + * + * @return {boolean} True if this browser supports Object Urls. + */ +goog.fs.url.browserSupportsObjectUrls = function() { + 'use strict'; + return goog.fs.url.findUrlObject_() != null; +}; diff --git a/closure/goog/fs/url_test.js b/closure/goog/fs/url_test.js new file mode 100644 index 0000000000..87fbd6e54d --- /dev/null +++ b/closure/goog/fs/url_test.js @@ -0,0 +1,43 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.urlTest'); +goog.setTestOnly(); + +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const testSuite = goog.require('goog.testing.testSuite'); +const url = goog.require('goog.fs.url'); + +const stubs = new PropertyReplacer(); + +testSuite({ + /** @suppress {checkTypes} suppression added to enable type checking */ + testBrowserSupportsObjectUrls() { + stubs.remove(globalThis, 'URL'); + stubs.remove(globalThis, 'webkitURL'); + stubs.remove(globalThis, 'createObjectURL'); + + assertFalse(url.browserSupportsObjectUrls()); + try { + url.createObjectUrl(); + fail(); + } catch (e) { + assertEquals( + 'This browser doesn\'t seem to support blob URLs', e.message); + } + + const objectUrl = {}; + function createObjectURL() { + return objectUrl; + } + stubs.set(globalThis, 'createObjectURL', createObjectURL); + + assertTrue(url.browserSupportsObjectUrls()); + assertEquals(objectUrl, url.createObjectUrl()); + + stubs.reset(); + }, +}); diff --git a/closure/goog/functions/BUILD b/closure/goog/functions/BUILD new file mode 100644 index 0000000000..a163d48c68 --- /dev/null +++ b/closure/goog/functions/BUILD @@ -0,0 +1,11 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "functions", + srcs = ["functions.js"], + lenient = True, +) diff --git a/closure/goog/functions/functions.js b/closure/goog/functions/functions.js new file mode 100644 index 0000000000..6c63da3761 --- /dev/null +++ b/closure/goog/functions/functions.js @@ -0,0 +1,584 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utilities for creating functions. Loosely inspired by these + * java classes from the Guava library: + * com.google.common.base.Functions + * https://google.github.io/guava/releases/snapshot-jre/api/docs/index.html?com/google/common/base/Functions.html + * + * com.google.common.base.Predicates + * https://google.github.io/guava/releases/snapshot-jre/api/docs/index.html?com/google/common/base/Predicates.html + * + * More about these can be found at + * https://github.com/google/guava/wiki/FunctionalExplained + */ + + +goog.provide('goog.functions'); + + +/** + * Creates a function that always returns the same value. + * @param {T} retValue The value to return. + * @return {function():T} The new function. + * @template T + */ +goog.functions.constant = function(retValue) { + 'use strict'; + return function() { + 'use strict'; + return retValue; + }; +}; + + +/** + * Always returns false. + * @type {function(...): boolean} + */ +goog.functions.FALSE = function() { + 'use strict'; + return false; +}; + + +/** + * Always returns true. + * @type {function(...): boolean} + */ +goog.functions.TRUE = function() { + 'use strict'; + return true; +}; + + +/** + * Always returns `null`. + * @type {function(...): null} + */ +goog.functions.NULL = function() { + 'use strict'; + return null; +}; + + +/** + * Always returns `undefined`. + * @type {function(...): undefined} + */ +goog.functions.UNDEFINED = function() { + return undefined; +}; + +/** + * Always returns `undefined` (loosely-typed version). + * @type {!Function} + */ +goog.functions.EMPTY = /** @type {?} */ (goog.functions.UNDEFINED); + + +/** + * A simple function that returns the first argument of whatever is passed + * into it. + * @param {T=} opt_returnValue The single value that will be returned. + * @param {...*} var_args Optional trailing arguments. These are ignored. + * @return {T} The first argument passed in, or undefined if nothing was passed. + * @template T + */ +goog.functions.identity = function(opt_returnValue, var_args) { + 'use strict'; + return opt_returnValue; +}; + + +/** + * Creates a function that always throws an error with the given message. + * @param {string} message The error message. + * @return {!Function} The error-throwing function. + */ +goog.functions.error = function(message) { + 'use strict'; + return function() { + 'use strict'; + throw new Error(message); + }; +}; + + +/** + * Creates a function that throws the given object. + * @param {*} err An object to be thrown. + * @return {!Function} The error-throwing function. + */ +goog.functions.fail = function(err) { + 'use strict'; + return function() { + 'use strict'; + throw err; + }; +}; + + +/** + * Given a function, create a function that keeps opt_numArgs arguments and + * silently discards all additional arguments. + * @param {Function} f The original function. + * @param {number=} opt_numArgs The number of arguments to keep. Defaults to 0. + * @return {!Function} A version of f that only keeps the first opt_numArgs + * arguments. + */ +goog.functions.lock = function(f, opt_numArgs) { + 'use strict'; + opt_numArgs = opt_numArgs || 0; + return function() { + 'use strict'; + const self = /** @type {*} */ (this); + return f.apply(self, Array.prototype.slice.call(arguments, 0, opt_numArgs)); + }; +}; + + +/** + * Creates a function that returns its nth argument. + * @param {number} n The position of the return argument. + * @return {!Function} A new function. + */ +goog.functions.nth = function(n) { + 'use strict'; + return function() { + 'use strict'; + return arguments[n]; + }; +}; + + +/** + * Like goog.partial(), except that arguments are added after arguments to the + * returned function. + * + * Usage: + * function f(arg1, arg2, arg3, arg4) { ... } + * var g = goog.functions.partialRight(f, arg3, arg4); + * g(arg1, arg2); + * + * @param {!Function} fn A function to partially apply. + * @param {...*} var_args Additional arguments that are partially applied to fn + * at the end. + * @return {!Function} A partially-applied form of the function goog.partial() + * was invoked as a method of. + */ +goog.functions.partialRight = function(fn, var_args) { + 'use strict'; + const rightArgs = Array.prototype.slice.call(arguments, 1); + return function() { + 'use strict'; + // Even in strict mode, IE10/11 and Edge (non-Chromium) use global context + // when free-calling functions. To catch cases where people were using this + // erroneously, we explicitly change the context to undefined to match + // strict mode specifications. + let self = /** @type {*} */ (this); + if (self === goog.global) { + self = undefined; + } + const newArgs = Array.prototype.slice.call(arguments); + newArgs.push.apply(newArgs, rightArgs); + return fn.apply(self, newArgs); + }; +}; + + +/** + * Given a function, create a new function that swallows its return value + * and replaces it with a new one. + * @param {Function} f A function. + * @param {T} retValue A new return value. + * @return {function(...?):T} A new function. + * @template T + */ +goog.functions.withReturnValue = function(f, retValue) { + 'use strict'; + return goog.functions.sequence(f, goog.functions.constant(retValue)); +}; + + +/** + * Creates a function that returns whether its argument equals the given value. + * + * Example: + * var key = goog.object.findKey(obj, goog.functions.equalTo('needle')); + * + * @param {*} value The value to compare to. + * @param {boolean=} opt_useLooseComparison Whether to use a loose (==) + * comparison rather than a strict (===) one. Defaults to false. + * @return {function(*):boolean} The new function. + */ +goog.functions.equalTo = function(value, opt_useLooseComparison) { + 'use strict'; + return function(other) { + 'use strict'; + return opt_useLooseComparison ? (value == other) : (value === other); + }; +}; + + +/** + * Creates the composition of the functions passed in. + * For example, (goog.functions.compose(f, g))(a) is equivalent to f(g(a)). + * @param {function(...?):T} fn The final function. + * @param {...Function} var_args A list of functions. + * @return {function(...?):T} The composition of all inputs. + * @template T + */ +goog.functions.compose = function(fn, var_args) { + 'use strict'; + const functions = arguments; + const length = functions.length; + return function() { + 'use strict'; + const self = /** @type {*} */ (this); + let result; + if (length) { + result = functions[length - 1].apply(self, arguments); + } + + for (let i = length - 2; i >= 0; i--) { + result = functions[i].call(self, result); + } + return result; + }; +}; + + +/** + * Creates a function that calls the functions passed in in sequence, and + * returns the value of the last function. For example, + * (goog.functions.sequence(f, g))(x) is equivalent to f(x),g(x). + * @param {...Function} var_args A list of functions. + * @return {!Function} A function that calls all inputs in sequence. + */ +goog.functions.sequence = function(var_args) { + 'use strict'; + const functions = arguments; + const length = functions.length; + return function() { + 'use strict'; + const self = /** @type {*} */ (this); + let result; + for (let i = 0; i < length; i++) { + result = functions[i].apply(self, arguments); + } + return result; + }; +}; + + +/** + * Creates a function that returns true if each of its components evaluates + * to true. The components are evaluated in order, and the evaluation will be + * short-circuited as soon as a function returns false. + * For example, (goog.functions.and(f, g))(x) is equivalent to f(x) && g(x). + * @param {...Function} var_args A list of functions. + * @return {function(...?):boolean} A function that ANDs its component + * functions. + */ +goog.functions.and = function(var_args) { + 'use strict'; + const functions = arguments; + const length = functions.length; + return function() { + 'use strict'; + const self = /** @type {*} */ (this); + for (let i = 0; i < length; i++) { + if (!functions[i].apply(self, arguments)) { + return false; + } + } + return true; + }; +}; + + +/** + * Creates a function that returns true if any of its components evaluates + * to true. The components are evaluated in order, and the evaluation will be + * short-circuited as soon as a function returns true. + * For example, (goog.functions.or(f, g))(x) is equivalent to f(x) || g(x). + * @param {...Function} var_args A list of functions. + * @return {function(...?):boolean} A function that ORs its component + * functions. + */ +goog.functions.or = function(var_args) { + 'use strict'; + const functions = arguments; + const length = functions.length; + return function() { + 'use strict'; + const self = /** @type {*} */ (this); + for (let i = 0; i < length; i++) { + if (functions[i].apply(self, arguments)) { + return true; + } + } + return false; + }; +}; + + +/** + * Creates a function that returns the Boolean opposite of a provided function. + * For example, (goog.functions.not(f))(x) is equivalent to !f(x). + * @param {!Function} f The original function. + * @return {function(...?):boolean} A function that delegates to f and returns + * opposite. + */ +goog.functions.not = function(f) { + 'use strict'; + return function() { + 'use strict'; + const self = /** @type {*} */ (this); + return !f.apply(self, arguments); + }; +}; + + +/** + * Generic factory function to construct an object given the constructor + * and the arguments. Intended to be bound to create object factories. + * + * Example: + * + * var factory = goog.partial(goog.functions.create, Class); + * + * @param {function(new:T, ...)} constructor The constructor for the Object. + * @param {...*} var_args The arguments to be passed to the constructor. + * @return {T} A new instance of the class given in `constructor`. + * @template T + * @deprecated This function does not work with ES6 class constructors. Use + * arrow functions + spread args instead. + */ +goog.functions.create = function(constructor, var_args) { + 'use strict'; + /** + * @constructor + * @final + */ + const temp = function() {}; + temp.prototype = constructor.prototype; + + // obj will have constructor's prototype in its chain and + // 'obj instanceof constructor' will be true. + const obj = new temp(); + + // obj is initialized by constructor. + // arguments is only array-like so lacks shift(), but can be used with + // the Array prototype function. + constructor.apply(obj, Array.prototype.slice.call(arguments, 1)); + return obj; +}; + + +/** + * @define {boolean} Whether the return value cache should be used. + * This should only be used to disable caches when testing. + */ +goog.functions.CACHE_RETURN_VALUE = + goog.define('goog.functions.CACHE_RETURN_VALUE', true); + + +/** + * Gives a wrapper function that caches the return value of a parameterless + * function when first called. + * + * When called for the first time, the given function is called and its + * return value is cached (thus this is only appropriate for idempotent + * functions). Subsequent calls will return the cached return value. This + * allows the evaluation of expensive functions to be delayed until first used. + * + * To cache the return values of functions with parameters, see goog.memoize. + * + * @param {function():T} fn A function to lazily evaluate. + * @return {function():T} A wrapped version the function. + * @template T + */ +goog.functions.cacheReturnValue = function(fn) { + 'use strict'; + let called = false; + let value; + + return function() { + 'use strict'; + if (!goog.functions.CACHE_RETURN_VALUE) { + return fn(); + } + + if (!called) { + value = fn(); + called = true; + } + + return value; + }; +}; + + +/** + * Wraps a function to allow it to be called, at most, once. All + * additional calls are no-ops. + * + * This is particularly useful for initialization functions + * that should be called, at most, once. + * + * @param {function():*} f Function to call. + * @return {function():undefined} Wrapped function. + */ +goog.functions.once = function(f) { + 'use strict'; + // Keep a reference to the function that we null out when we're done with + // it -- that way, the function can be GC'd when we're done with it. + let inner = f; + return function() { + 'use strict'; + if (inner) { + const tmp = inner; + inner = null; + tmp(); + } + }; +}; + + +/** + * Wraps a function to allow it to be called, at most, once per interval + * (specified in milliseconds). If the wrapper function is called N times within + * that interval, only the Nth call will go through. + * + * This is particularly useful for batching up repeated actions where the + * last action should win. This can be used, for example, for refreshing an + * autocomplete pop-up every so often rather than updating with every keystroke, + * since the final text typed by the user is the one that should produce the + * final autocomplete results. For more stateful debouncing with support for + * pausing, resuming, and canceling debounced actions, use + * `goog.async.Debouncer`. + * + * @param {function(this:SCOPE, ...?)} f Function to call. + * @param {number} interval Interval over which to debounce. The function will + * only be called after the full interval has elapsed since the last call. + * @param {SCOPE=} opt_scope Object in whose scope to call the function. + * @return {function(...?): undefined} Wrapped function. + * @template SCOPE + */ +goog.functions.debounce = function(f, interval, opt_scope) { + 'use strict'; + let timeout = 0; + return /** @type {function(...?)} */ (function(var_args) { + 'use strict'; + goog.global.clearTimeout(timeout); + const args = arguments; + timeout = goog.global.setTimeout(function() { + 'use strict'; + f.apply(opt_scope, args); + }, interval); + }); +}; + + +/** + * Wraps a function to allow it to be called, at most, once per interval + * (specified in milliseconds). If the wrapper function is called N times in + * that interval, both the 1st and the Nth calls will go through. + * + * This is particularly useful for limiting repeated user requests where the + * the last action should win, but you also don't want to wait until the end of + * the interval before sending a request out, as it leads to a perception of + * slowness for the user. + * + * @param {function(this:SCOPE, ...?)} f Function to call. + * @param {number} interval Interval over which to throttle. The function can + * only be called once per interval. + * @param {SCOPE=} opt_scope Object in whose scope to call the function. + * @return {function(...?): undefined} Wrapped function. + * @template SCOPE + */ +goog.functions.throttle = function(f, interval, opt_scope) { + 'use strict'; + let timeout = 0; + let shouldFire = false; + let storedArgs = []; + + const handleTimeout = function() { + 'use strict'; + timeout = 0; + if (shouldFire) { + shouldFire = false; + fire(); + } + }; + + const fire = function() { + 'use strict'; + timeout = goog.global.setTimeout(handleTimeout, interval); + let args = storedArgs; + storedArgs = []; // Avoid a space leak by clearing stored arguments. + f.apply(opt_scope, args); + }; + + return /** @type {function(...?)} */ (function(var_args) { + 'use strict'; + storedArgs = arguments; + if (!timeout) { + fire(); + } else { + shouldFire = true; + } + }); +}; + + +/** + * Wraps a function to allow it to be called, at most, once per interval + * (specified in milliseconds). If the wrapper function is called N times within + * that interval, only the 1st call will go through. + * + * This is particularly useful for limiting repeated user requests where the + * first request is guaranteed to have all the data required to perform the + * final action, so there's no need to wait until the end of the interval before + * sending the request out. + * + * @param {function(this:SCOPE, ...?)} f Function to call. + * @param {number} interval Interval over which to rate-limit. The function will + * only be called once per interval, and ignored for the remainer of the + * interval. + * @param {SCOPE=} opt_scope Object in whose scope to call the function. + * @return {function(...?): undefined} Wrapped function. + * @template SCOPE + */ +goog.functions.rateLimit = function(f, interval, opt_scope) { + 'use strict'; + let timeout = 0; + + const handleTimeout = function() { + 'use strict'; + timeout = 0; + }; + + return /** @type {function(...?)} */ (function(var_args) { + 'use strict'; + if (!timeout) { + timeout = goog.global.setTimeout(handleTimeout, interval); + f.apply(opt_scope, arguments); + } + }); +}; + +/** + * Returns true if the specified value is a function. + * @param {*} val Variable to test. + * @return {boolean} Whether variable is a function. + */ +goog.functions.isFunction = (val) => { + return typeof val === 'function'; +}; diff --git a/closure/goog/functions/functions_test.js b/closure/goog/functions/functions_test.js new file mode 100644 index 0000000000..a1835cfb4d --- /dev/null +++ b/closure/goog/functions/functions_test.js @@ -0,0 +1,736 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @fileoverview Unit tests for functions. */ + +goog.module('goog.functionsTest'); +goog.setTestOnly(); + +const MockClock = goog.require('goog.testing.MockClock'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const functions = goog.require('goog.functions'); +const recordFunction = goog.require('goog.testing.recordFunction'); +const testSuite = goog.require('goog.testing.testSuite'); + +const fTrue = makeCallOrderLogger('fTrue', true); +const gFalse = makeCallOrderLogger('gFalse', false); +const hTrue = makeCallOrderLogger('hTrue', true); + +const stubs = new PropertyReplacer(); +let callOrder = null; + +const foo = 'global'; +const obj = { + foo: 'obj' +}; + +/** @suppress {globalThis} suppression added to enable type checking */ +function getFoo(arg1, arg2) { + return {foo: this.foo, arg1: arg1, arg2: arg2}; +} + +function makeCallOrderLogger(name, returnValue) { + return () => { + callOrder.push(name); + return returnValue; + }; +} + +function assertCallOrderAndReset(expectedArray) { + assertArrayEquals(expectedArray, callOrder); + callOrder = []; +} + +/** + * Wraps a `recordFunction` with the specified decorator and + * executes a list of command sequences, asserting that in each case the + * decorated function is called the expected number of times. + * @param {function():*} decorator The async decorator to test. + * @param {!Object} expectedCommandSequenceCalls An object + * mapping string command sequences (where 'f' is 'fire' and 'w' is 'wait') + * to the number times we expect a decorated function to be called during + * the execution of those commands. + * @suppress {checkTypes} suppression added to enable type checking + */ +function assertAsyncDecoratorCommandSequenceCalls( + decorator, expectedCommandSequenceCalls) { + const interval = 500; + + const mockClock = new MockClock(true); + for (let commandSequence in expectedCommandSequenceCalls) { + const recordedFunction = recordFunction(); + /** @suppress {checkTypes} suppression added to enable type checking */ + const f = decorator(recordedFunction, interval); + + for (let i = 0; i < commandSequence.length; ++i) { + switch (commandSequence[i]) { + case 'f': + f(); + break; + case 'w': + mockClock.tick(interval); + break; + } + } + + const expectedCalls = expectedCommandSequenceCalls[commandSequence]; + assertEquals( + `Expected ${expectedCalls} calls for command sequence "` + + commandSequence + '" (' + + Array.prototype.map + .call( + commandSequence, + command => { + switch (command) { + case 'f': + return 'fire'; + case 'w': + return 'wait'; + } + }) + .join(' -> ') + + ')', + expectedCalls, recordedFunction.getCallCount()); + } + mockClock.uninstall(); +} + +testSuite({ + setUp() { + callOrder = []; + }, + + tearDown() { + stubs.reset(); + }, + + testTrue() { + assertTrue(functions.TRUE()); + }, + + testFalse() { + assertFalse(functions.FALSE()); + }, + + testLock() { + function add(var_args) { + let result = 0; + for (let i = 0; i < arguments.length; i++) { + result += arguments[i]; + } + return result; + } + + assertEquals(6, add(1, 2, 3)); + assertEquals(0, functions.lock(add)(1, 2, 3)); + assertEquals(3, functions.lock(add, 2)(1, 2, 3)); + assertEquals(6, goog.partial(add, 1, 2)(3)); + assertEquals(3, functions.lock(goog.partial(add, 1, 2))(3)); + }, + + testNth() { + assertEquals(1, functions.nth(0)(1)); + assertEquals(2, functions.nth(1)(1, 2)); + assertEquals('a', functions.nth(0)('a', 'b')); + assertEquals(undefined, functions.nth(0)()); + assertEquals(undefined, functions.nth(1)(true)); + assertEquals(undefined, functions.nth(-1)()); + }, + + testPartialRight() { + const f = (x, y) => x / y; + const g = functions.partialRight(f, 2); + assertEquals(2, g(4)); + + const h = functions.partialRight(f, 4, 2); + assertEquals(2, h()); + + const i = functions.partialRight(f); + assertEquals(2, i(4, 2)); + }, + + testPartialRightFreeFunction() { + const f = function(x, y) { + assertUndefined(this); + return x / y; + }; + const g = functions.partialRight(f, 2); + const h = functions.partialRight(g, 4); + assertEquals(2, h()); + }, + + testPartialRightWithCall() { + const obj = {}; + const f = function(x, y) { + assertEquals(obj, this); + return x / y; + }; + const g = functions.partialRight(f, 2); + const h = functions.partialRight(g, 4); + assertEquals(2, h.call(obj)); + }, + + testPartialRightAndBind() { + // This ensures that this "survives" through a partialRight. + const p = functions.partialRight(getFoo, 'dog'); + const b = goog.bind(p, obj, 'hot'); + + const res = b(); + assertEquals(obj.foo, res.foo); + assertEquals('hot', res.arg1); + assertEquals('dog', res.arg2); + }, + + testBindAndPartialRight() { + // This ensures that this "survives" through a partialRight. + const b = goog.bind(getFoo, obj, 'hot'); + const p = functions.partialRight(b, 'dog'); + + const res = p(); + assertEquals(obj.foo, res.foo); + assertEquals('hot', res.arg1); + assertEquals('dog', res.arg2); + }, + + testPartialRightMultipleCalls() { + const f = recordFunction(); + + const a = functions.partialRight(f, 'foo'); + const b = functions.partialRight(a, 'bar'); + + a(); + a(); + b(); + b(); + + assertEquals(4, f.getCallCount()); + + const calls = f.getCalls(); + assertArrayEquals(['foo'], calls[0].getArguments()); + assertArrayEquals(['foo'], calls[1].getArguments()); + assertArrayEquals(['bar', 'foo'], calls[2].getArguments()); + assertArrayEquals(['bar', 'foo'], calls[3].getArguments()); + }, + + testIdentity() { + assertEquals(3, functions.identity(3)); + assertEquals(3, functions.identity(3, 4, 5, 6)); + assertEquals('Hi there', functions.identity('Hi there')); + assertEquals(null, functions.identity(null)); + assertEquals(undefined, functions.identity()); + + const arr = [1, 'b', null]; + assertEquals(arr, functions.identity(arr)); + const obj = {a: 'ay', b: 'bee', c: 'see'}; + assertEquals(obj, functions.identity(obj)); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testConstant() { + assertEquals(3, functions.constant(3)()); + assertEquals(undefined, functions.constant()()); + }, + + testError() { + const f = functions.error('x'); + const e = assertThrows( + 'A function created by goog.functions.error must throw an error', f); + assertEquals('x', e.message); + }, + + testFail() { + const obj = {}; + const f = functions.fail(obj); + const e = assertThrows( + 'A function created by goog.functions.raise must throw its input', f); + assertEquals(obj, e); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testCompose() { + const add2 = (x) => x + 2; + + const doubleValue = (x) => x * 2; + + assertEquals(6, functions.compose(doubleValue, add2)(1)); + assertEquals(4, functions.compose(add2, doubleValue)(1)); + assertEquals(6, functions.compose(add2, add2, doubleValue)(1)); + assertEquals( + 12, functions.compose(doubleValue, add2, add2, doubleValue)(1)); + assertUndefined(functions.compose()(1)); + assertEquals(3, functions.compose(add2)(1)); + + const add2Numbers = (x, y) => x + y; + assertEquals(17, functions.compose(add2Numbers)(10, 7)); + assertEquals(34, functions.compose(doubleValue, add2Numbers)(10, 7)); + }, + + testAdd() { + assertUndefined(functions.sequence()()); + assertCallOrderAndReset([]); + + assert(functions.sequence(fTrue)()); + assertCallOrderAndReset(['fTrue']); + + assertFalse(functions.sequence(fTrue, gFalse)()); + assertCallOrderAndReset(['fTrue', 'gFalse']); + + assert(functions.sequence(fTrue, gFalse, hTrue)()); + assertCallOrderAndReset(['fTrue', 'gFalse', 'hTrue']); + + assert(functions.sequence(functions.identity)(true)); + assertFalse(functions.sequence(functions.identity)(false)); + }, + + testAnd() { + // the return value is unspecified for an empty and + functions.and()(); + assertCallOrderAndReset([]); + + assert(functions.and(fTrue)()); + assertCallOrderAndReset(['fTrue']); + + assertFalse(functions.and(fTrue, gFalse)()); + assertCallOrderAndReset(['fTrue', 'gFalse']); + + assertFalse(functions.and(fTrue, gFalse, hTrue)()); + assertCallOrderAndReset(['fTrue', 'gFalse']); + + assert(functions.and(functions.identity)(true)); + assertFalse(functions.and(functions.identity)(false)); + }, + + testOr() { + // the return value is unspecified for an empty or + functions.or()(); + assertCallOrderAndReset([]); + + assert(functions.or(fTrue)()); + assertCallOrderAndReset(['fTrue']); + + assert(functions.or(fTrue, gFalse)()); + assertCallOrderAndReset(['fTrue']); + + assert(functions.or(fTrue, gFalse, hTrue)()); + assertCallOrderAndReset(['fTrue']); + + assert(functions.or(functions.identity)(true)); + assertFalse(functions.or(functions.identity)(false)); + }, + + testNot() { + assertTrue(functions.not(gFalse)()); + assertCallOrderAndReset(['gFalse']); + + assertTrue(functions.not(functions.identity)(false)); + assertFalse(functions.not(functions.identity)(true)); + + const f = (a, b) => { + assertEquals(1, a); + assertEquals(2, b); + return false; + }; + + assertTrue(functions.not(f)(1, 2)); + }, + + testCreate(expectedArray) { + const tempConstructor = function(a, b) { + this.foo = a; + this.bar = b; + }; + + const factory = goog.partial(functions.create, tempConstructor, 'baz'); + const instance = factory('qux'); + + assert(instance instanceof tempConstructor); + assertEquals(instance.foo, 'baz'); + assertEquals(instance.bar, 'qux'); + }, + + testWithReturnValue() { + const obj = {}; + const f = function(a, b) { + assertEquals(obj, this); + assertEquals(1, a); + assertEquals(2, b); + }; + assertTrue(functions.withReturnValue(f, true).call(obj, 1, 2)); + assertFalse(functions.withReturnValue(f, false).call(obj, 1, 2)); + }, + + testEqualTo() { + assertTrue(functions.equalTo(42)(42)); + assertFalse(functions.equalTo(42)(13)); + assertFalse(functions.equalTo(42)('a string')); + + assertFalse(functions.equalTo(42)('42')); + assertTrue(functions.equalTo(42, true)('42')); + + assertTrue(functions.equalTo(0)(0)); + assertFalse(functions.equalTo(0)('')); + assertFalse(functions.equalTo(0)(1)); + + assertTrue(functions.equalTo(0, true)(0)); + assertTrue(functions.equalTo(0, true)('')); + assertFalse(functions.equalTo(0, true)(1)); + }, + + testCacheReturnValue() { + const returnFive = () => 5; + + const recordedReturnFive = recordFunction(returnFive); + const cachedRecordedReturnFive = + functions.cacheReturnValue(recordedReturnFive); + + assertEquals(0, recordedReturnFive.getCallCount()); + assertEquals(5, cachedRecordedReturnFive()); + assertEquals(1, recordedReturnFive.getCallCount()); + assertEquals(5, cachedRecordedReturnFive()); + assertEquals(1, recordedReturnFive.getCallCount()); + }, + + testCacheReturnValueFlagEnabled() { + let count = 0; + const returnIncrementingInteger = () => { + count++; + return count; + }; + + const recordedFunction = recordFunction(returnIncrementingInteger); + const cachedRecordedFunction = functions.cacheReturnValue(recordedFunction); + + assertEquals(0, recordedFunction.getCallCount()); + assertEquals(1, cachedRecordedFunction()); + assertEquals(1, recordedFunction.getCallCount()); + assertEquals(1, cachedRecordedFunction()); + assertEquals(1, recordedFunction.getCallCount()); + assertEquals(1, cachedRecordedFunction()); + }, + + testCacheReturnValueFlagDisabled() { + stubs.set(functions, 'CACHE_RETURN_VALUE', false); + + let count = 0; + const returnIncrementingInteger = () => { + count++; + return count; + }; + + const recordedFunction = recordFunction(returnIncrementingInteger); + const cachedRecordedFunction = functions.cacheReturnValue(recordedFunction); + + assertEquals(0, recordedFunction.getCallCount()); + assertEquals(1, cachedRecordedFunction()); + assertEquals(1, recordedFunction.getCallCount()); + assertEquals(2, cachedRecordedFunction()); + assertEquals(2, recordedFunction.getCallCount()); + assertEquals(3, cachedRecordedFunction()); + }, + + testOnce() { + const recordedFunction = recordFunction(); + const f = functions.once(recordedFunction); + + assertEquals(0, recordedFunction.getCallCount()); + f(); + assertEquals(1, recordedFunction.getCallCount()); + f(); + assertEquals(1, recordedFunction.getCallCount()); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testDebounce() { + // Encoded sequences of commands to perform mapped to expected # of calls. + // f: fire + // w: wait (for the timer to elapse) + assertAsyncDecoratorCommandSequenceCalls(functions.debounce, { + 'f': 0, + 'ff': 0, + 'fff': 0, + 'fw': 1, + 'ffw': 1, + 'fffw': 1, + 'fwffwf': 2, + 'ffwwwffwwfwf': 3, + }); + }, + + testDebounceScopeBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + const x = {'y': 0}; + functions.debounce(function() { + ++this['y']; + }, interval, x)(); + assertEquals(0, x['y']); + + mockClock.tick(interval); + assertEquals(1, x['y']); + + mockClock.uninstall(); + }, + + testDebounceArgumentBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + let calls = 0; + const debouncedFn = functions.debounce((a, b, c) => { + ++calls; + assertEquals(3, a); + assertEquals('string', b); + assertEquals(false, c); + }, interval); + + debouncedFn(3, 'string', false); + mockClock.tick(interval); + assertEquals(1, calls); + + // goog.functions.debounce should always pass the last arguments passed to + // the decorator into the decorated function, even if called multiple times. + debouncedFn(); + mockClock.tick(interval / 2); + debouncedFn(8, null, true); + debouncedFn(3, 'string', false); + mockClock.tick(interval); + assertEquals(2, calls); + + mockClock.uninstall(); + }, + + testDebounceArgumentAndScopeBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + const x = {'calls': 0}; + const debouncedFn = functions.debounce(function(a, b, c) { + ++this['calls']; + assertEquals(3, a); + assertEquals('string', b); + assertEquals(false, c); + }, interval, x); + + debouncedFn(3, 'string', false); + mockClock.tick(interval); + assertEquals(1, x['calls']); + + // goog.functions.debounce should always pass the last arguments passed to + // the decorator into the decorated function, even if called multiple times. + debouncedFn(); + mockClock.tick(interval / 2); + debouncedFn(8, null, true); + debouncedFn(3, 'string', false); + mockClock.tick(interval); + assertEquals(2, x['calls']); + + mockClock.uninstall(); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testThrottle() { + // Encoded sequences of commands to perform mapped to expected # of calls. + // f: fire + // w: wait (for the timer to elapse) + assertAsyncDecoratorCommandSequenceCalls(functions.throttle, { + 'f': 1, + 'ff': 1, + 'fff': 1, + 'fw': 1, + 'ffw': 2, + 'fwf': 2, + 'fffw': 2, + 'fwfff': 2, + 'fwfffw': 3, + 'fwffwf': 3, + 'ffwf': 2, + 'ffwff': 2, + 'ffwfw': 3, + 'ffwffwf': 3, + 'ffwffwff': 3, + 'ffwffwffw': 4, + 'ffwwwffwwfw': 5, + 'ffwwwffwwfwf': 6, + }); + }, + + testThrottleScopeBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + const x = {'y': 0}; + functions.throttle(function() { + ++this['y']; + }, interval, x)(); + assertEquals(1, x['y']); + + mockClock.uninstall(); + }, + + testThrottleArgumentBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + let calls = 0; + const throttledFn = functions.throttle((a, b, c) => { + ++calls; + assertEquals(3, a); + assertEquals('string', b); + assertEquals(false, c); + }, interval); + + throttledFn(3, 'string', false); + assertEquals(1, calls); + + // goog.functions.throttle should always pass the last arguments passed to + // the decorator into the decorated function, even if called multiple times. + throttledFn(); + mockClock.tick(interval / 2); + throttledFn(8, null, true); + throttledFn(3, 'string', false); + mockClock.tick(interval); + assertEquals(2, calls); + + mockClock.uninstall(); + }, + + testThrottleArgumentAndScopeBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + const x = {'calls': 0}; + const throttledFn = functions.throttle(function(a, b, c) { + ++this['calls']; + assertEquals(3, a); + assertEquals('string', b); + assertEquals(false, c); + }, interval, x); + + throttledFn(3, 'string', false); + assertEquals(1, x['calls']); + + // goog.functions.throttle should always pass the last arguments passed to + // the decorator into the decorated function, even if called multiple times. + throttledFn(); + mockClock.tick(interval / 2); + throttledFn(8, null, true); + throttledFn(3, 'string', false); + mockClock.tick(interval); + assertEquals(2, x['calls']); + + mockClock.uninstall(); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testRateLimit() { + // Encoded sequences of commands to perform mapped to expected # of calls. + // f: fire + // w: wait (for the timer to elapse) + assertAsyncDecoratorCommandSequenceCalls(functions.rateLimit, { + 'f': 1, + 'ff': 1, + 'fff': 1, + 'fw': 1, + 'ffw': 1, + 'fwf': 2, + 'fffw': 1, + 'fwfff': 2, + 'fwfffw': 2, + 'fwffwf': 3, + 'ffwf': 2, + 'ffwff': 2, + 'ffwfw': 2, + 'ffwffwf': 3, + 'ffwffwff': 3, + 'ffwffwffw': 3, + 'ffwwwffwwfw': 3, + 'ffwwwffwwfwf': 4, + }); + }, + + testRateLimitScopeBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + const x = {'y': 0}; + functions.rateLimit(function() { + ++this['y']; + }, interval, x)(); + assertEquals(1, x['y']); + + mockClock.uninstall(); + }, + + testRateLimitArgumentBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + let calls = 0; + const rateLimitedFn = functions.rateLimit((a, b, c) => { + ++calls; + assertEquals(3, a); + assertEquals('string', b); + assertEquals(false, c); + }, interval); + + rateLimitedFn(3, 'string', false); + assertEquals(1, calls); + + // goog.functions.rateLimit should always pass the first arguments passed to + // the + // decorator into the decorated function, even if called multiple times. + rateLimitedFn(); + mockClock.tick(interval / 2); + rateLimitedFn(8, null, true); + mockClock.tick(interval); + rateLimitedFn(3, 'string', false); + assertEquals(2, calls); + + mockClock.uninstall(); + }, + + testRateLimitArgumentAndScopeBinding() { + const interval = 500; + const mockClock = new MockClock(true); + + const x = {'calls': 0}; + const rateLimitedFn = functions.rateLimit(function(a, b, c) { + ++this['calls']; + assertEquals(3, a); + assertEquals('string', b); + assertEquals(false, c); + }, interval, x); + + rateLimitedFn(3, 'string', false); + assertEquals(1, x['calls']); + + // goog.functions.rateLimit should always pass the last arguments passed to + // the + // decorator into the decorated function, even if called multiple times. + rateLimitedFn(); + mockClock.tick(interval / 2); + rateLimitedFn(8, null, true); + mockClock.tick(interval); + rateLimitedFn(3, 'string', false); + assertEquals(2, x['calls']); + + mockClock.uninstall(); + }, + + testIsFunction() { + assertTrue(functions.isFunction(() => {})); + assertTrue(functions.isFunction(function() {})); + assertTrue(functions.isFunction(class {})); + assertTrue(functions.isFunction(function*() {})); + assertTrue(functions.isFunction(async function() {})); + assertFalse(functions.isFunction(0)); + assertFalse(functions.isFunction(false)); + assertFalse(functions.isFunction('')); + assertFalse(functions.isFunction({})); + assertFalse(functions.isFunction([])); + } +}); diff --git a/closure/goog/fx/BUILD b/closure/goog/fx/BUILD new file mode 100644 index 0000000000..941680b8cc --- /dev/null +++ b/closure/goog/fx/BUILD @@ -0,0 +1,195 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "abstractdragdrop", + srcs = ["abstractdragdrop.js"], + lenient = True, + deps = [ + ":dragger", + "//closure/goog/array", + "//closure/goog/asserts", + "//closure/goog/dom", + "//closure/goog/dom:classlist", + "//closure/goog/events", + "//closure/goog/events:browserevent", + "//closure/goog/events:event", + "//closure/goog/events:eventhandler", + "//closure/goog/events:eventtarget", + "//closure/goog/events:eventtype", + "//closure/goog/math:box", + "//closure/goog/math:coordinate", + "//closure/goog/style", + ], +) + +closure_js_library( + name = "animation", + srcs = ["animation.js"], + lenient = True, + deps = [ + ":transition", + ":transitionbase", + "//closure/goog/array", + "//closure/goog/asserts", + "//closure/goog/events:event", + "//closure/goog/fx/anim", + ], +) + +closure_js_library( + name = "animationqueue", + srcs = ["animationqueue.js"], + lenient = True, + deps = [ + ":animation", + ":transition", + ":transitionbase", + "//closure/goog/array", + "//closure/goog/asserts", + "//closure/goog/events", + "//closure/goog/events:event", + ], +) + +closure_js_library( + name = "cssspriteanimation", + srcs = ["cssspriteanimation.js"], + lenient = True, + deps = [ + ":animation", + "//closure/goog/math:box", + "//closure/goog/math:size", + ], +) + +closure_js_library( + name = "dom", + srcs = ["dom.js"], + lenient = True, + deps = [ + ":animation", + ":transition", + "//closure/goog/color", + "//closure/goog/events", + "//closure/goog/events:eventhandler", + "//closure/goog/style", + "//closure/goog/style:bidi", + ], +) + +closure_js_library( + name = "dragdrop", + srcs = ["dragdrop.js"], + lenient = True, + deps = [":abstractdragdrop"], +) + +closure_js_library( + name = "dragdropgroup", + srcs = ["dragdropgroup.js"], + lenient = True, + deps = [ + ":abstractdragdrop", + "//closure/goog/dom", + ], +) + +closure_js_library( + name = "dragger", + srcs = ["dragger.js"], + lenient = True, + deps = [ + "//closure/goog/dom", + "//closure/goog/dom:tagname", + "//closure/goog/events", + "//closure/goog/events:browserevent", + "//closure/goog/events:event", + "//closure/goog/events:eventhandler", + "//closure/goog/events:eventtarget", + "//closure/goog/events:eventtype", + "//closure/goog/math:coordinate", + "//closure/goog/math:rect", + "//closure/goog/style", + "//closure/goog/style:bidi", + "//closure/goog/useragent", + ], +) + +closure_js_library( + name = "draglistgroup", + srcs = ["draglistgroup.js"], + lenient = True, + deps = [ + ":dragger", + "//closure/goog/asserts", + "//closure/goog/disposable", + "//closure/goog/dom", + "//closure/goog/dom:classlist", + "//closure/goog/events", + "//closure/goog/events:browserevent", + "//closure/goog/events:event", + "//closure/goog/events:eventhandler", + "//closure/goog/events:eventid", + "//closure/goog/events:eventtarget", + "//closure/goog/events:eventtype", + "//closure/goog/math:coordinate", + "//closure/goog/math:rect", + "//closure/goog/string", + "//closure/goog/style", + ], +) + +closure_js_library( + name = "dragscrollsupport", + srcs = ["dragscrollsupport.js"], + lenient = True, + deps = [ + "//closure/goog/disposable", + "//closure/goog/dom", + "//closure/goog/events:event", + "//closure/goog/events:eventhandler", + "//closure/goog/events:eventtype", + "//closure/goog/math:coordinate", + "//closure/goog/math:rect", + "//closure/goog/style", + "//closure/goog/timer", + ], +) + +closure_js_library( + name = "easing", + srcs = ["easing.js"], + lenient = True, +) + +closure_js_library( + name = "fx", + srcs = ["fx.js"], + lenient = True, + deps = [ + ":animation", + ":easing", + ":transition", + "//closure/goog/asserts", + ], +) + +closure_js_library( + name = "transition", + srcs = ["transition.js"], + lenient = True, +) + +closure_js_library( + name = "transitionbase", + srcs = ["transitionbase.js"], + lenient = True, + deps = [ + ":transition", + "//closure/goog/events:eventtarget", + ], +) diff --git a/closure/goog/fx/abstractdragdrop.js b/closure/goog/fx/abstractdragdrop.js new file mode 100644 index 0000000000..3a82670ec5 --- /dev/null +++ b/closure/goog/fx/abstractdragdrop.js @@ -0,0 +1,1636 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Abstract Base Class for Drag and Drop. + * + * Provides functionality for implementing drag and drop classes. Also provides + * support classes and events. + */ + +goog.provide('goog.fx.AbstractDragDrop'); +goog.provide('goog.fx.AbstractDragDrop.EventType'); +goog.provide('goog.fx.DragDropEvent'); +goog.provide('goog.fx.DragDropItem'); + +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.dom'); +goog.require('goog.dom.classlist'); +goog.require('goog.events'); +goog.require('goog.events.Event'); +goog.require('goog.events.EventHandler'); +goog.require('goog.events.EventTarget'); +goog.require('goog.events.EventType'); +goog.require('goog.fx.Dragger'); +goog.require('goog.math.Box'); +goog.require('goog.math.Coordinate'); +goog.require('goog.style'); +goog.requireType('goog.events.BrowserEvent'); +goog.requireType('goog.fx.DragEvent'); + + + +/** + * Abstract class that provides reusable functionality for implementing drag + * and drop functionality. + * + * This class also allows clients to define their own subtargeting function + * so that drop areas can have finer granularity than a single element. This is + * accomplished by using a client provided function to map from element and + * coordinates to a subregion id. + * + * This class can also be made aware of scrollable containers that contain + * drop targets by calling addScrollableContainer. This will cause dnd to + * take changing scroll positions into account while a drag is occurring. + * + * @extends {goog.events.EventTarget} + * @constructor + * @struct + */ +goog.fx.AbstractDragDrop = function() { + 'use strict'; + goog.fx.AbstractDragDrop.base(this, 'constructor'); + + /** + * List of items that makes up the drag source or drop target. + * @protected {Array} + * @suppress {underscore|visibility} + */ + this.items_ = []; + + /** + * List of associated drop targets. + * @private {Array} + */ + this.targets_ = []; + + /** + * Scrollable containers to account for during drag + * @private {Array} + */ + this.scrollableContainers_ = []; + + /** + * Flag indicating if it's a drag source, set by addTarget. + * @private {boolean} + */ + this.isSource_ = false; + + /** + * Flag indicating if it's a drop target, set when added as target to another + * DragDrop object. + * @private {boolean} + */ + this.isTarget_ = false; + + /** + * Subtargeting function accepting args: + * (goog.fx.DragDropItem, goog.math.Box, number, number) + * @private {?Function} + */ + this.subtargetFunction_; + + /** + * Last active subtarget. + * @private {?Object} + */ + this.activeSubtarget_; + + /** + * Class name to add to source elements being dragged. Set by setDragClass. + * @private {?string} + */ + this.dragClass_; + + /** + * Class name to add to source elements. Set by setSourceClass. + * @private {?string} + */ + this.sourceClass_; + + /** + * Class name to add to target elements. Set by setTargetClass. + * @private {?string} + */ + this.targetClass_; + + /** + * The SCROLL event target used to make drag element follow scrolling. + * @private {?EventTarget} + */ + this.scrollTarget_; + + /** + * Dummy target, {@see maybeCreateDummyTargetForPosition_}. + * @private {?goog.fx.ActiveDropTarget_} + */ + this.dummyTarget_; + + /** + * Whether the object has been initialized. + * @private {boolean} + */ + this.initialized_ = false; + + /** @private {?Element} */ + this.dragEl_; + + /** @private {?Array} */ + this.targetList_; + + /** @private {?goog.math.Box} */ + this.targetBox_; + + /** @private {?goog.fx.ActiveDropTarget_} */ + this.activeTarget_; + + /** @private {?goog.fx.DragDropItem} */ + this.dragItem_; + + /** @private {?goog.fx.Dragger} */ + this.dragger_; +}; +goog.inherits(goog.fx.AbstractDragDrop, goog.events.EventTarget); + + +/** + * Minimum size (in pixels) for a dummy target. If the box for the target is + * less than the specified size it's not created. + * @const {number} + * @private + */ +goog.fx.AbstractDragDrop.DUMMY_TARGET_MIN_SIZE_ = 10; + + +/** + * Constants for event names + * @const + */ +goog.fx.AbstractDragDrop.EventType = { + /** @const */ + DRAGOVER: 'dragover', + /** @const */ + DRAGOUT: 'dragout', + /** @const */ + DRAG: 'drag', + /** @const */ + DROP: 'drop', + /** @const */ + DRAGSTART: 'dragstart', + /** @const */ + DRAGEND: 'dragend' +}; + + +/** + * Constant for distance threshold, in pixels, an element has to be moved to + * initiate a drag operation. + * @type {number} + */ +goog.fx.AbstractDragDrop.initDragDistanceThreshold = 5; + + +/** + * Set class to add to source elements being dragged. + * + * @param {string} className Class to be added. Must be a single, valid + * classname. + */ +goog.fx.AbstractDragDrop.prototype.setDragClass = function(className) { + 'use strict'; + this.dragClass_ = className; +}; + + +/** + * Set class to add to source elements. + * + * @param {string} className Class to be added. Must be a single, valid + * classname. + */ +goog.fx.AbstractDragDrop.prototype.setSourceClass = function(className) { + 'use strict'; + this.sourceClass_ = className; +}; + + +/** + * Set class to add to target elements. + * + * @param {string} className Class to be added. Must be a single, valid + * classname. + */ +goog.fx.AbstractDragDrop.prototype.setTargetClass = function(className) { + 'use strict'; + this.targetClass_ = className; +}; + + +/** + * Whether the control has been initialized. + * + * @return {boolean} True if it's been initialized. + */ +goog.fx.AbstractDragDrop.prototype.isInitialized = function() { + 'use strict'; + return this.initialized_; +}; + + +/** + * Add item to drag object. + * + * @param {Element|string} element Dom Node, or string representation of node + * id, to be used as drag source/drop target. + * @throws Error Thrown if called on instance of abstract class + */ +goog.fx.AbstractDragDrop.prototype.addItem = goog.abstractMethod; + + +/** + * Associate drop target with drag element. + * + * @param {goog.fx.AbstractDragDrop} target Target to add. + */ +goog.fx.AbstractDragDrop.prototype.addTarget = function(target) { + 'use strict'; + this.targets_.push(target); + target.isTarget_ = true; + this.isSource_ = true; +}; + + +/** + * Removes the specified target from the list of drop targets. + * + * @param {!goog.fx.AbstractDragDrop} target Target to remove. + */ +goog.fx.AbstractDragDrop.prototype.removeTarget = function(target) { + 'use strict'; + goog.array.remove(this.targets_, target); + if (this.activeTarget_ && this.activeTarget_.target_ == target) { + this.activeTarget_ = null; + } + this.recalculateDragTargets(); +}; + + +/** + * Sets the SCROLL event target to make drag element follow scrolling. + * + * @param {EventTarget} scrollTarget The element that dispatches SCROLL events. + */ +goog.fx.AbstractDragDrop.prototype.setScrollTarget = function(scrollTarget) { + 'use strict'; + this.scrollTarget_ = scrollTarget; +}; + + +/** + * Initialize drag and drop functionality for sources/targets already added. + * Sources/targets added after init has been called will initialize themselves + * one by one. + */ +goog.fx.AbstractDragDrop.prototype.init = function() { + 'use strict'; + if (this.initialized_) { + return; + } + for (var item, i = 0; item = this.items_[i]; i++) { + this.initItem(item); + } + + this.initialized_ = true; +}; + + +/** + * Initializes a single item. + * + * @param {goog.fx.DragDropItem} item Item to initialize. + * @protected + */ +goog.fx.AbstractDragDrop.prototype.initItem = function(item) { + 'use strict'; + if (this.isSource_) { + goog.events.listen( + item.element, goog.events.EventType.MOUSEDOWN, item.mouseDown_, false, + item); + if (this.sourceClass_) { + goog.dom.classlist.add( + goog.asserts.assert(item.element), this.sourceClass_); + } + } + + if (this.isTarget_ && this.targetClass_) { + goog.dom.classlist.add( + goog.asserts.assert(item.element), this.targetClass_); + } +}; + + +/** + * Called when removing an item. Removes event listeners and classes. + * + * @param {goog.fx.DragDropItem} item Item to dispose. + * @protected + */ +goog.fx.AbstractDragDrop.prototype.disposeItem = function(item) { + 'use strict'; + if (this.isSource_) { + goog.events.unlisten( + item.element, goog.events.EventType.MOUSEDOWN, item.mouseDown_, false, + item); + if (this.sourceClass_) { + goog.dom.classlist.remove( + goog.asserts.assert(item.element), this.sourceClass_); + } + } + if (this.isTarget_ && this.targetClass_) { + goog.dom.classlist.remove( + goog.asserts.assert(item.element), this.targetClass_); + } + item.dispose(); +}; + + +/** + * Removes all items. + */ +goog.fx.AbstractDragDrop.prototype.removeItems = function() { + 'use strict'; + for (var item, i = 0; item = this.items_[i]; i++) { + this.disposeItem(item); + } + this.items_.length = 0; +}; + + +/** + * Starts a drag event for an item if the mouse button stays pressed and the + * cursor moves a few pixels. Allows dragging of items without first having to + * register them with addItem. + * + * @param {goog.events.BrowserEvent} event Mouse down event. + * @param {goog.fx.DragDropItem} item Item that's being dragged. + */ +goog.fx.AbstractDragDrop.prototype.maybeStartDrag = function(event, item) { + 'use strict'; + item.maybeStartDrag_(event, item.element); +}; + + +/** + * Event handler that's used to start drag. + * + * @param {goog.events.BrowserEvent} event Mouse move event. + * @param {goog.fx.DragDropItem} item Item that's being dragged. + */ +goog.fx.AbstractDragDrop.prototype.startDrag = function(event, item) { + 'use strict'; + // Prevent a new drag operation from being started if another one is already + // in progress (could happen if the mouse was released outside of the + // document). + if (this.dragItem_) { + return; + } + + this.dragItem_ = item; + + // Dispatch DRAGSTART event + var dragStartEvent = new goog.fx.DragDropEvent( + goog.fx.AbstractDragDrop.EventType.DRAGSTART, this, this.dragItem_, + undefined, // opt_target + undefined, // opt_targetItem + undefined, // opt_targetElement + undefined, // opt_clientX + undefined, // opt_clientY + undefined, // opt_x + undefined, // opt_y + undefined, // opt_subtarget + event); + if (this.dispatchEvent(dragStartEvent) == false) { + this.dragItem_ = null; + return; + } + + // Get the source element and create a drag element for it. + var el = item.getCurrentDragElement(); + this.dragEl_ = this.createDragElement(el); + var doc = goog.dom.getOwnerDocument(el); + doc.body.appendChild(/** @type {!Node} */ (this.dragEl_)); + + this.dragger_ = this.createDraggerFor(el, this.dragEl_, event); + this.dragger_.setScrollTarget(this.scrollTarget_); + + goog.events.listen( + this.dragger_, goog.fx.Dragger.EventType.DRAG, this.moveDrag_, false, + this); + + goog.events.listen( + this.dragger_, goog.fx.Dragger.EventType.END, this.endDrag, false, this); + + // IE may issue a 'selectstart' event when dragging over an iframe even when + // default mousemove behavior is suppressed. If the default selectstart + // behavior is not suppressed, elements dragged over will show as selected. + goog.events.listen( + doc.body, goog.events.EventType.SELECTSTART, this.suppressSelect_); + + this.recalculateDragTargets(); + this.recalculateScrollableContainers(); + this.activeTarget_ = null; + this.initScrollableContainerListeners_(); + this.dragger_.startDrag(event); + + event.preventDefault(); +}; + + +/** + * Recalculates the geometry of this source's drag targets. Call this + * if the position or visibility of a drag target has changed during + * a drag, or if targets are added or removed. + * + * TODO(user): this is an expensive operation; more efficient APIs + * may be necessary. + */ +goog.fx.AbstractDragDrop.prototype.recalculateDragTargets = function() { + 'use strict'; + this.targetList_ = []; + for (var target, i = 0; target = this.targets_[i]; i++) { + for (var itm, j = 0; itm = target.items_[j]; j++) { + this.addDragTarget_(target, itm); + } + } + if (!this.targetBox_) { + this.targetBox_ = new goog.math.Box(0, 0, 0, 0); + } +}; + + +/** + * Recalculates the current scroll positions of scrollable containers and + * allocates targets. Call this if the position of a container changed or if + * targets are added or removed. + */ +goog.fx.AbstractDragDrop.prototype.recalculateScrollableContainers = + function() { + 'use strict'; + var container, i, j, target; + for (i = 0; container = this.scrollableContainers_[i]; i++) { + container.containedTargets_ = []; + container.savedScrollLeft_ = container.element_.scrollLeft; + container.savedScrollTop_ = container.element_.scrollTop; + var pos = goog.style.getPageOffset(container.element_); + var size = goog.style.getSize(container.element_); + container.box_ = new goog.math.Box( + pos.y, pos.x + size.width, pos.y + size.height, pos.x); + } + + for (i = 0; target = this.targetList_[i]; i++) { + for (j = 0; container = this.scrollableContainers_[j]; j++) { + if (goog.dom.contains(container.element_, target.element_)) { + container.containedTargets_.push(target); + target.scrollableContainer_ = container; + } + } + } +}; + + +/** + * Creates the Dragger for the drag element. + * @param {Element} sourceEl Drag source element. + * @param {Element} el the element created by createDragElement(). + * @param {goog.events.BrowserEvent} event Mouse down event for start of drag. + * @return {!goog.fx.Dragger} The new Dragger. + * @protected + */ +goog.fx.AbstractDragDrop.prototype.createDraggerFor = function( + sourceEl, el, event) { + 'use strict'; + // Position the drag element. + var pos = this.getDragElementPosition(sourceEl, el, event); + el.style.position = 'absolute'; + el.style.left = pos.x + 'px'; + el.style.top = pos.y + 'px'; + return new goog.fx.Dragger(el); +}; + + +/** + * Event handler that's used to stop drag. Fires a drop event if over a valid + * target. + * + * @param {goog.fx.DragEvent} event Drag event. + */ +goog.fx.AbstractDragDrop.prototype.endDrag = function(event) { + 'use strict'; + var activeTarget = event.dragCanceled ? null : this.activeTarget_; + if (activeTarget && activeTarget.target_) { + var clientX = event.clientX; + var clientY = event.clientY; + var scroll = this.getScrollPos(); + var x = clientX + scroll.x; + var y = clientY + scroll.y; + + var subtarget; + // If a subtargeting function is enabled get the current subtarget + if (this.subtargetFunction_) { + subtarget = + this.subtargetFunction_(activeTarget.item_, activeTarget.box_, x, y); + } + + var dragEvent = new goog.fx.DragDropEvent( + goog.fx.AbstractDragDrop.EventType.DRAG, this, this.dragItem_, + activeTarget.target_, activeTarget.item_, activeTarget.element_, + clientX, clientY, x, y); + this.dispatchEvent(dragEvent); + + var dropEvent = new goog.fx.DragDropEvent( + goog.fx.AbstractDragDrop.EventType.DROP, this, this.dragItem_, + activeTarget.target_, activeTarget.item_, activeTarget.element_, + clientX, clientY, x, y, subtarget, event.browserEvent); + activeTarget.target_.dispatchEvent(dropEvent); + } + + var dragEndEvent = new goog.fx.DragDropEvent( + goog.fx.AbstractDragDrop.EventType.DRAGEND, this, this.dragItem_, + activeTarget ? activeTarget.target_ : undefined, + activeTarget ? activeTarget.item_ : undefined, + activeTarget ? activeTarget.element_ : undefined); + this.dispatchEvent(dragEndEvent); + + goog.events.unlisten( + this.dragger_, goog.fx.Dragger.EventType.DRAG, this.moveDrag_, false, + this); + goog.events.unlisten( + this.dragger_, goog.fx.Dragger.EventType.END, this.endDrag, false, this); + var doc = goog.dom.getOwnerDocument(this.dragItem_.getCurrentDragElement()); + goog.events.unlisten( + doc.body, goog.events.EventType.SELECTSTART, this.suppressSelect_); + + + this.afterEndDrag(this.activeTarget_ ? this.activeTarget_.item_ : null); +}; + + +/** + * Called after a drag operation has finished. + * + * @param {goog.fx.DragDropItem=} opt_dropTarget Target for successful drop. + * @protected + */ +goog.fx.AbstractDragDrop.prototype.afterEndDrag = function(opt_dropTarget) { + 'use strict'; + this.disposeDrag(); +}; + + +/** + * Called once a drag operation has finished. Removes event listeners and + * elements. + * + * @protected + */ +goog.fx.AbstractDragDrop.prototype.disposeDrag = function() { + 'use strict'; + this.disposeScrollableContainerListeners_(); + this.dragger_.dispose(); + + goog.dom.removeNode(this.dragEl_); + delete this.dragItem_; + delete this.dragEl_; + delete this.dragger_; + delete this.targetList_; + delete this.activeTarget_; +}; + + +/** + * Event handler for drag events. Determines the active drop target, if any, and + * fires dragover and dragout events appropriately. + * + * @param {goog.fx.DragEvent} event Drag event. + * @private + */ +goog.fx.AbstractDragDrop.prototype.moveDrag_ = function(event) { + 'use strict'; + var position = this.getEventPosition(event); + var x = position.x; + var y = position.y; + + var activeTarget = this.activeTarget_; + + this.dispatchEvent(new goog.fx.DragDropEvent( + goog.fx.AbstractDragDrop.EventType.DRAG, this, this.dragItem_, + activeTarget ? activeTarget.target_ : undefined, + activeTarget ? activeTarget.item_ : undefined, + activeTarget ? activeTarget.element_ : undefined, event.clientX, + event.clientY, x, y)); + + // Check if we're still inside the bounds of the active target, if not fire + // a dragout event and proceed to find a new target. + var subtarget; + if (activeTarget) { + // If a subtargeting function is enabled get the current subtarget + if (this.subtargetFunction_ && activeTarget.target_) { + subtarget = + this.subtargetFunction_(activeTarget.item_, activeTarget.box_, x, y); + } + + if (activeTarget.box_.contains(position) && + subtarget == this.activeSubtarget_) { + return; + } + + if (activeTarget.target_) { + var sourceDragOutEvent = new goog.fx.DragDropEvent( + goog.fx.AbstractDragDrop.EventType.DRAGOUT, this, this.dragItem_, + activeTarget.target_, activeTarget.item_, activeTarget.element_); + this.dispatchEvent(sourceDragOutEvent); + + // The event should be dispatched the by target DragDrop so that the + // target DragDrop can manage these events without having to know what + // sources this is a target for. + var targetDragOutEvent = new goog.fx.DragDropEvent( + goog.fx.AbstractDragDrop.EventType.DRAGOUT, this, this.dragItem_, + activeTarget.target_, activeTarget.item_, activeTarget.element_, + undefined, undefined, undefined, undefined, this.activeSubtarget_); + activeTarget.target_.dispatchEvent(targetDragOutEvent); + } + this.activeSubtarget_ = subtarget; + this.activeTarget_ = null; + } + + // Check if inside target box + if (this.targetBox_.contains(position)) { + // Search for target and fire a dragover event if found + activeTarget = this.activeTarget_ = this.getTargetFromPosition_(position); + if (activeTarget && activeTarget.target_) { + // If a subtargeting function is enabled get the current subtarget + if (this.subtargetFunction_) { + subtarget = this.subtargetFunction_( + activeTarget.item_, activeTarget.box_, x, y); + } + var sourceDragOverEvent = new goog.fx.DragDropEvent( + goog.fx.AbstractDragDrop.EventType.DRAGOVER, this, this.dragItem_, + activeTarget.target_, activeTarget.item_, activeTarget.element_); + sourceDragOverEvent.subtarget = subtarget; + this.dispatchEvent(sourceDragOverEvent); + + // The event should be dispatched by the target DragDrop so that the + // target DragDrop can manage these events without having to know what + // sources this is a target for. + var targetDragOverEvent = new goog.fx.DragDropEvent( + goog.fx.AbstractDragDrop.EventType.DRAGOVER, this, this.dragItem_, + activeTarget.target_, activeTarget.item_, activeTarget.element_, + event.clientX, event.clientY, undefined, undefined, subtarget); + activeTarget.target_.dispatchEvent(targetDragOverEvent); + + } else if (!activeTarget) { + // If no target was found create a dummy one so we won't have to iterate + // over all possible targets for every move event. + this.activeTarget_ = this.maybeCreateDummyTargetForPosition_(x, y); + } + } +}; + + +/** + * Event handler for suppressing selectstart events. Selecting should be + * disabled while dragging. + * + * @param {goog.events.Event} event The selectstart event to suppress. + * @return {boolean} Whether to perform default behavior. + * @private + */ +goog.fx.AbstractDragDrop.prototype.suppressSelect_ = function(event) { + 'use strict'; + return false; +}; + + +/** + * Sets up listeners for the scrollable containers that keep track of their + * scroll positions. + * @private + */ +goog.fx.AbstractDragDrop.prototype.initScrollableContainerListeners_ = + function() { + 'use strict'; + var container, i; + for (i = 0; container = this.scrollableContainers_[i]; i++) { + goog.events.listen( + container.element_, goog.events.EventType.SCROLL, + this.containerScrollHandler_, false, this); + } +}; + + +/** + * Cleans up the scrollable container listeners. + * @private + */ +goog.fx.AbstractDragDrop.prototype.disposeScrollableContainerListeners_ = + function() { + 'use strict'; + for (var i = 0, container; container = this.scrollableContainers_[i]; i++) { + goog.events.unlisten( + container.element_, 'scroll', this.containerScrollHandler_, false, + this); + container.containedTargets_ = []; + } +}; + + +/** + * Makes drag and drop aware of a target container that could scroll mid drag. + * @param {Element} element The scroll container. + */ +goog.fx.AbstractDragDrop.prototype.addScrollableContainer = function(element) { + 'use strict'; + this.scrollableContainers_.push(new goog.fx.ScrollableContainer_(element)); +}; + + +/** + * Removes all scrollable containers. + */ +goog.fx.AbstractDragDrop.prototype.removeAllScrollableContainers = function() { + 'use strict'; + this.disposeScrollableContainerListeners_(); + this.scrollableContainers_ = []; +}; + + +/** + * Event handler for containers scrolling. + * @param {goog.events.BrowserEvent} e The event. + * @suppress {visibility} TODO(martone): update dependent projects. + * @private + */ +goog.fx.AbstractDragDrop.prototype.containerScrollHandler_ = function(e) { + 'use strict'; + for (var i = 0, container; container = this.scrollableContainers_[i]; i++) { + if (e.target == container.element_) { + var deltaTop = container.savedScrollTop_ - container.element_.scrollTop; + var deltaLeft = + container.savedScrollLeft_ - container.element_.scrollLeft; + container.savedScrollTop_ = container.element_.scrollTop; + container.savedScrollLeft_ = container.element_.scrollLeft; + + // When the container scrolls, it's possible that one of the targets will + // move to the region contained by the dummy target. Since we don't know + // which sides (if any) of the dummy target are defined by targets + // contained by this container, we are conservative and just shrink it. + if (this.dummyTarget_ && this.activeTarget_ == this.dummyTarget_) { + if (deltaTop > 0) { + this.dummyTarget_.box_.top += deltaTop; + } else { + this.dummyTarget_.box_.bottom += deltaTop; + } + if (deltaLeft > 0) { + this.dummyTarget_.box_.left += deltaLeft; + } else { + this.dummyTarget_.box_.right += deltaLeft; + } + } + for (var j = 0, target; target = container.containedTargets_[j]; j++) { + var box = target.box_; + box.top += deltaTop; + box.left += deltaLeft; + box.bottom += deltaTop; + box.right += deltaLeft; + + this.calculateTargetBox_(box); + } + } + } + this.dragger_.onScroll_(e); +}; + + +/** + * Set a function that provides subtargets. A subtargeting function + * returns an arbitrary identifier for each subtarget of an element. + * DnD code will generate additional drag over / out events when + * switching from subtarget to subtarget. This is useful for instance + * if you are interested if you are on the top half or the bottom half + * of the element. + * The provided function will be given the DragDropItem, box, x, y + * box is the current window coordinates occupied by element + * x, y is the mouse position in window coordinates + * + * @param {Function} f The new subtarget function. + */ +goog.fx.AbstractDragDrop.prototype.setSubtargetFunction = function(f) { + 'use strict'; + this.subtargetFunction_ = f; +}; + + +/** + * Creates an element for the item being dragged. + * + * @param {Element} sourceEl Drag source element. + * @return {Element} The new drag element. + */ +goog.fx.AbstractDragDrop.prototype.createDragElement = function(sourceEl) { + 'use strict'; + var dragEl = this.createDragElementInternal(sourceEl); + goog.asserts.assert(dragEl); + if (this.dragClass_) { + goog.dom.classlist.add(dragEl, this.dragClass_); + } + + return dragEl; +}; + + +/** + * Returns the position for the drag element. + * + * @param {Element} el Drag source element. + * @param {Element} dragEl The dragged element created by createDragElement(). + * @param {goog.events.BrowserEvent} event Mouse down event for start of drag. + * @return {!goog.math.Coordinate} The position for the drag element. + */ +goog.fx.AbstractDragDrop.prototype.getDragElementPosition = function( + el, dragEl, event) { + 'use strict'; + var pos = goog.style.getPageOffset(el); + + // Subtract margin from drag element position twice, once to adjust the + // position given by the original node and once for the drag node. + var marginBox = goog.style.getMarginBox(el); + pos.x -= (marginBox.left || 0) * 2; + pos.y -= (marginBox.top || 0) * 2; + + return pos; +}; + + +/** + * Returns the dragger object. + * + * @return {goog.fx.Dragger} The dragger object used by this drag and drop + * instance. + */ +goog.fx.AbstractDragDrop.prototype.getDragger = function() { + 'use strict'; + return this.dragger_; +}; + + +/** + * Creates copy of node being dragged. + * + * @param {Element} sourceEl Element to copy. + * @return {!Element} The clone of `sourceEl`. + * @deprecated Use goog.fx.Dragger.cloneNode(). + * @private + */ +goog.fx.AbstractDragDrop.prototype.cloneNode_ = function(sourceEl) { + 'use strict'; + return goog.fx.Dragger.cloneNode(sourceEl); +}; + + +/** + * Generates an element to follow the cursor during dragging, given a drag + * source element. The default behavior is simply to clone the source element, + * but this may be overridden in subclasses. This method is called by + * `createDragElement()` before the drag class is added. + * + * @param {Element} sourceEl Drag source element. + * @return {!Element} The new drag element. + * @protected + * @suppress {deprecated} + */ +goog.fx.AbstractDragDrop.prototype.createDragElementInternal = function( + sourceEl) { + 'use strict'; + return this.cloneNode_(sourceEl); +}; + + +/** + * Add possible drop target for current drag operation. + * + * @param {goog.fx.AbstractDragDrop} target Drag handler. + * @param {goog.fx.DragDropItem} item Item that's being dragged. + * @private + */ +goog.fx.AbstractDragDrop.prototype.addDragTarget_ = function(target, item) { + 'use strict'; + // Get all the draggable elements and add each one. + var draggableElements = item.getDraggableElements(); + for (var i = 0; i < draggableElements.length; i++) { + var draggableElement = draggableElements[i]; + + // Determine target position and dimension + var box = this.getElementBox(item, draggableElement); + + this.targetList_.push( + new goog.fx.ActiveDropTarget_(box, target, item, draggableElement)); + + this.calculateTargetBox_(box); + } +}; + + +/** + * Calculates the position and dimension of a draggable element. + * + * @param {goog.fx.DragDropItem} item Item that's being dragged. + * @param {Element} element The element to calculate the box. + * + * @return {!goog.math.Box} Box describing the position and dimension + * of element. + * @protected + */ +goog.fx.AbstractDragDrop.prototype.getElementBox = function(item, element) { + 'use strict'; + var pos = goog.style.getPageOffset(element); + var size = goog.style.getSize(element); + return new goog.math.Box( + pos.y, pos.x + size.width, pos.y + size.height, pos.x); +}; + + +/** + * Calculate the outer bounds (the region all targets are inside). + * + * @param {goog.math.Box} box Box describing the position and dimension + * of a drag target. + * @private + */ +goog.fx.AbstractDragDrop.prototype.calculateTargetBox_ = function(box) { + 'use strict'; + if (this.targetList_.length == 1) { + this.targetBox_ = + new goog.math.Box(box.top, box.right, box.bottom, box.left); + } else { + var tb = this.targetBox_; + tb.left = Math.min(box.left, tb.left); + tb.right = Math.max(box.right, tb.right); + tb.top = Math.min(box.top, tb.top); + tb.bottom = Math.max(box.bottom, tb.bottom); + } +}; + + +/** + * Creates a dummy target for the given cursor position. The assumption is to + * create as big dummy target box as possible, the only constraints are: + * - The dummy target box cannot overlap any of real target boxes. + * - The dummy target has to contain a point with current mouse coordinates. + * + * NOTE: For performance reasons the box construction algorithm is kept simple + * and it is not optimal (see example below). Currently it is O(n) in regard to + * the number of real drop target boxes, but its result depends on the order + * of those boxes being processed (the order in which they're added to the + * targetList_ collection). + * + * The algorithm. + * a) Assumptions + * - Mouse pointer is in the bounding box of real target boxes. + * - None of the boxes have negative coordinate values. + * - Mouse pointer is not contained by any of "real target" boxes. + * - For targets inside a scrollable container, the box used is the + * intersection of the scrollable container's box and the target's box. + * This is because the part of the target that extends outside the scrollable + * container should not be used in the clipping calculations. + * + * b) Outline + * - Initialize the fake target to the bounding box of real targets. + * - For each real target box - clip the fake target box so it does not contain + * that target box, but does contain the mouse pointer. + * -- Project the real target box, mouse pointer and fake target box onto + * both axes and calculate the clipping coordinates. + * -- Only one coordinate is used to clip the fake target box to keep the + * fake target as big as possible. + * -- If the projection of the real target box contains the mouse pointer, + * clipping for a given axis is not possible. + * -- If both clippings are possible, the clipping more distant from the + * mouse pointer is selected to keep bigger fake target area. + * - Save the created fake target only if it has a big enough area. + * + * + * c) Example + *
    + *        Input:           Algorithm created box:        Maximum box:
    + * +---------------------+ +---------------------+ +---------------------+
    + * | B1      |        B2 | | B1               B2 | | B1               B2 |
    + * |         |           | |   +-------------+   | |+-------------------+|
    + * |---------x-----------| |   |             |   | ||                   ||
    + * |         |           | |   |             |   | ||                   ||
    + * |         |           | |   |             |   | ||                   ||
    + * |         |           | |   |             |   | ||                   ||
    + * |         |           | |   |             |   | ||                   ||
    + * |         |           | |   +-------------+   | |+-------------------+|
    + * | B4      |        B3 | | B4               B3 | | B4               B3 |
    + * +---------------------+ +---------------------+ +---------------------+
    + * 
    + * + * @param {number} x Cursor position on the x-axis. + * @param {number} y Cursor position on the y-axis. + * @return {goog.fx.ActiveDropTarget_} Dummy drop target. + * @private + */ +goog.fx.AbstractDragDrop.prototype.maybeCreateDummyTargetForPosition_ = + function(x, y) { + 'use strict'; + if (!this.dummyTarget_) { + this.dummyTarget_ = new goog.fx.ActiveDropTarget_(this.targetBox_.clone()); + } + var fakeTargetBox = this.dummyTarget_.box_; + + // Initialize the fake target box to the bounding box of DnD targets. + fakeTargetBox.top = this.targetBox_.top; + fakeTargetBox.right = this.targetBox_.right; + fakeTargetBox.bottom = this.targetBox_.bottom; + fakeTargetBox.left = this.targetBox_.left; + + // Clip the fake target based on mouse position and DnD target boxes. + for (var i = 0, target; target = this.targetList_[i]; i++) { + var box = target.box_; + + if (target.scrollableContainer_) { + // If the target has a scrollable container, use the intersection of that + // container's box and the target's box. + var scrollBox = target.scrollableContainer_.box_; + + box = new goog.math.Box( + Math.max(box.top, scrollBox.top), + Math.min(box.right, scrollBox.right), + Math.min(box.bottom, scrollBox.bottom), + Math.max(box.left, scrollBox.left)); + } + + // Calculate clipping coordinates for horizontal and vertical axis. + // The clipping coordinate is calculated by projecting fake target box, + // the mouse pointer and DnD target box onto an axis and checking how + // box projections overlap and if the projected DnD target box contains + // mouse pointer. The clipping coordinate cannot be computed and is set to + // a negative value if the projected DnD target contains the mouse pointer. + + var horizontalClip = null; // Assume mouse is above or below the DnD box. + if (x >= box.right) { // Mouse is to the right of the DnD box. + // Clip the fake box only if the DnD box overlaps it. + horizontalClip = + box.right > fakeTargetBox.left ? box.right : fakeTargetBox.left; + } else if (x < box.left) { // Mouse is to the left of the DnD box. + // Clip the fake box only if the DnD box overlaps it. + horizontalClip = + box.left < fakeTargetBox.right ? box.left : fakeTargetBox.right; + } + var verticalClip = null; + if (y >= box.bottom) { + verticalClip = + box.bottom > fakeTargetBox.top ? box.bottom : fakeTargetBox.top; + } else if (y < box.top) { + verticalClip = + box.top < fakeTargetBox.bottom ? box.top : fakeTargetBox.bottom; + } + + // If both clippings are possible, choose one that gives us larger distance + // to mouse pointer (mark the shorter clipping as impossible, by setting it + // to null). + if (horizontalClip !== null && verticalClip !== null) { + if (Math.abs(horizontalClip - x) > Math.abs(verticalClip - y)) { + verticalClip = null; + } else { + horizontalClip = null; + } + } + + // Clip none or one of fake target box sides (at most one clipping + // coordinate can be active). + if (horizontalClip !== null) { + if (horizontalClip <= x) { + fakeTargetBox.left = horizontalClip; + } else { + fakeTargetBox.right = horizontalClip; + } + } else if (verticalClip !== null) { + if (verticalClip <= y) { + fakeTargetBox.top = verticalClip; + } else { + fakeTargetBox.bottom = verticalClip; + } + } + } + + // Only return the new fake target if it is big enough. + return (fakeTargetBox.right - fakeTargetBox.left) * + (fakeTargetBox.bottom - fakeTargetBox.top) >= + goog.fx.AbstractDragDrop.DUMMY_TARGET_MIN_SIZE_ ? + this.dummyTarget_ : + null; +}; + + +/** + * Returns the target for a given cursor position. + * + * @param {goog.math.Coordinate} position Cursor position. + * @return {goog.fx.ActiveDropTarget_} Target for position or null if no target + * was defined for the given position. + * @private + */ +goog.fx.AbstractDragDrop.prototype.getTargetFromPosition_ = function(position) { + 'use strict'; + for (var target, i = 0; target = this.targetList_[i]; i++) { + if (target.box_.contains(position)) { + if (target.scrollableContainer_) { + // If we have a scrollable container we will need to make sure + // we account for clipping of the scroll area + var box = target.scrollableContainer_.box_; + if (box.contains(position)) { + return target; + } + } else { + return target; + } + } + } + + return null; +}; + + +/** + * Checks whatever a given point is inside a given box. + * + * @param {number} x Cursor position on the x-axis. + * @param {number} y Cursor position on the y-axis. + * @param {goog.math.Box} box Box to check position against. + * @return {boolean} Whether the given point is inside `box`. + * @protected + * @deprecated Use goog.math.Box.contains. + */ +goog.fx.AbstractDragDrop.prototype.isInside = function(x, y, box) { + 'use strict'; + return x >= box.left && x < box.right && y >= box.top && y < box.bottom; +}; + + +/** + * Gets the scroll distance as a coordinate object, using + * the window of the current drag element's dom. + * @return {!goog.math.Coordinate} Object with scroll offsets 'x' and 'y'. + * @protected + */ +goog.fx.AbstractDragDrop.prototype.getScrollPos = function() { + 'use strict'; + return goog.dom.getDomHelper(this.dragEl_).getDocumentScroll(); +}; + + +/** + * Get the position of a drag event. + * @param {goog.fx.DragEvent} event Drag event. + * @return {!goog.math.Coordinate} Position of the event. + * @protected + */ +goog.fx.AbstractDragDrop.prototype.getEventPosition = function(event) { + 'use strict'; + var scroll = this.getScrollPos(); + return new goog.math.Coordinate( + event.clientX + scroll.x, event.clientY + scroll.y); +}; + + +/** + * @override + * @protected + */ +goog.fx.AbstractDragDrop.prototype.disposeInternal = function() { + 'use strict'; + goog.fx.AbstractDragDrop.base(this, 'disposeInternal'); + this.removeItems(); +}; + + + +/** + * Object representing a drag and drop event. + * + * @param {string} type Event type. + * @param {goog.fx.AbstractDragDrop} source Source drag drop object. + * @param {goog.fx.DragDropItem} sourceItem Source item. + * @param {goog.fx.AbstractDragDrop=} opt_target Target drag drop object. + * @param {goog.fx.DragDropItem=} opt_targetItem Target item. + * @param {Element=} opt_targetElement Target element. + * @param {number=} opt_clientX X-Position relative to the screen. + * @param {number=} opt_clientY Y-Position relative to the screen. + * @param {number=} opt_x X-Position relative to the viewport. + * @param {number=} opt_y Y-Position relative to the viewport. + * @param {Object=} opt_subtarget The currently active subtarget. + * @param {goog.events.BrowserEvent=} opt_browserEvent The browser event + * that caused this dragdrop event. + * @extends {goog.events.Event} + * @constructor + * @struct + */ +goog.fx.DragDropEvent = function( + type, source, sourceItem, opt_target, opt_targetItem, opt_targetElement, + opt_clientX, opt_clientY, opt_x, opt_y, opt_subtarget, opt_browserEvent) { + 'use strict'; + // TODO(eae): Get rid of all the optional parameters and have the caller set + // the fields directly instead. + goog.fx.DragDropEvent.base(this, 'constructor', type); + + /** + * Reference to the source goog.fx.AbstractDragDrop object. + * @type {goog.fx.AbstractDragDrop} + */ + this.dragSource = source; + + /** + * Reference to the source goog.fx.DragDropItem object. + * @type {goog.fx.DragDropItem} + */ + this.dragSourceItem = sourceItem; + + /** + * Reference to the target goog.fx.AbstractDragDrop object. + * @type {goog.fx.AbstractDragDrop|undefined} + */ + this.dropTarget = opt_target; + + /** + * Reference to the target goog.fx.DragDropItem object. + * @type {goog.fx.DragDropItem|undefined} + */ + this.dropTargetItem = opt_targetItem; + + /** + * The actual element of the drop target that is the target for this event. + * @type {Element|undefined} + */ + this.dropTargetElement = opt_targetElement; + + /** + * X-Position relative to the screen. + * @type {number|undefined} + */ + this.clientX = opt_clientX; + + /** + * Y-Position relative to the screen. + * @type {number|undefined} + */ + this.clientY = opt_clientY; + + /** + * X-Position relative to the viewport. + * @type {number|undefined} + */ + this.viewportX = opt_x; + + /** + * Y-Position relative to the viewport. + * @type {number|undefined} + */ + this.viewportY = opt_y; + + /** + * The subtarget that is currently active if a subtargeting function + * is supplied. + * @type {Object|undefined} + */ + this.subtarget = opt_subtarget; + + /** + * The browser event that caused this dragdrop event. + * @const + */ + this.browserEvent = opt_browserEvent; +}; +goog.inherits(goog.fx.DragDropEvent, goog.events.Event); + + + +/** + * Class representing a source or target element for drag and drop operations. + * + * @param {Element|string} element Dom Node, or string representation of node + * id, to be used as drag source/drop target. + * @param {DRAG_DROP_DATA=} opt_data Data associated with the source/target. + * @throws Error If no element argument is provided or if the type is invalid + * @extends {goog.events.EventTarget} + * @template DRAG_DROP_DATA + * @constructor + * @struct + */ +goog.fx.DragDropItem = function(element, opt_data) { + 'use strict'; + goog.fx.DragDropItem.base(this, 'constructor'); + + /** + * Reference to drag source/target element + * @type {Element} + */ + this.element = goog.dom.getElement(element); + + /** + * Data associated with element. + * @type {DRAG_DROP_DATA|undefined} + */ + this.data = opt_data; + + /** + * Drag object the item belongs to. + * @type {goog.fx.AbstractDragDrop?} + * @private + */ + this.parent_ = null; + + /** + * Event handler for listeners on events that can initiate a drag. + * @type {!goog.events.EventHandler} + * @private + */ + this.eventHandler_ = new goog.events.EventHandler(this); + this.registerDisposable(this.eventHandler_); + + /** + * The current element being dragged. This is needed because a DragDropItem + * can have multiple elements that can be dragged. + * @private {?Element} + */ + this.currentDragElement_ = null; + + /** @private {?goog.math.Coordinate} */ + this.startPosition_; + + if (!this.element) { + throw new Error('Invalid argument'); + } +}; +goog.inherits(goog.fx.DragDropItem, goog.events.EventTarget); + + +/** + * Get the data associated with the source/target. + * @return {DRAG_DROP_DATA|undefined} Data associated with the source/target. + */ +goog.fx.DragDropItem.prototype.getData = function() { + 'use strict'; + return this.data; +}; + + +/** + * Gets the element that is actually draggable given that the given target was + * attempted to be dragged. This should be overridden when the element that was + * given actually contains many items that can be dragged. From the target, you + * can determine what element should actually be dragged. + * + * @param {Element} target The target that was attempted to be dragged. + * @return {Element} The element that is draggable given the target. If + * none are draggable, this will return null. + */ +goog.fx.DragDropItem.prototype.getDraggableElement = function(target) { + 'use strict'; + return target; +}; + + +/** + * Gets the element that is currently being dragged. + * + * @return {Element} The element that is currently being dragged. + */ +goog.fx.DragDropItem.prototype.getCurrentDragElement = function() { + 'use strict'; + return this.currentDragElement_; +}; + + +/** + * Gets all the elements of this item that are potentially draggable/ + * + * @return {!Array} The draggable elements. + */ +goog.fx.DragDropItem.prototype.getDraggableElements = function() { + 'use strict'; + return [this.element]; +}; + + +/** + * Event handler for mouse down. + * + * @param {goog.events.BrowserEvent} event Mouse down event. + * @private + */ +goog.fx.DragDropItem.prototype.mouseDown_ = function(event) { + 'use strict'; + if (!event.isMouseActionButton()) { + return; + } + + // Get the draggable element for the target. + var element = this.getDraggableElement(/** @type {Element} */ (event.target)); + if (element) { + this.maybeStartDrag_(event, element); + } +}; + + +/** + * Sets the dragdrop to which this item belongs. + * @param {goog.fx.AbstractDragDrop} parent The parent dragdrop. + */ +goog.fx.DragDropItem.prototype.setParent = function(parent) { + 'use strict'; + this.parent_ = parent; +}; + + +/** + * Adds mouse move, mouse out and mouse up handlers. + * + * @param {goog.events.BrowserEvent} event Mouse down event. + * @param {Element} element Element. + * @private + */ +goog.fx.DragDropItem.prototype.maybeStartDrag_ = function(event, element) { + 'use strict'; + var eventType = goog.events.EventType; + this.eventHandler_ + .listen(element, eventType.MOUSEMOVE, this.mouseMove_, false) + .listen(element, eventType.MOUSEOUT, this.mouseMove_, false); + + // Capture the MOUSEUP on the document to ensure that we cancel the start + // drag handlers even if the mouse up occurs on some other element. This can + // happen for instance when the mouse down changes the geometry of the element + // clicked on (e.g. through changes in activation styling) such that the mouse + // up occurs outside the original element. + var doc = goog.dom.getOwnerDocument(element); + this.eventHandler_.listen(doc, eventType.MOUSEUP, this.mouseUp_, true); + + this.currentDragElement_ = element; + + this.startPosition_ = new goog.math.Coordinate(event.clientX, event.clientY); +}; + + +/** + * Event handler for mouse move. Starts drag operation if moved more than the + * threshold value. + * + * @param {goog.events.BrowserEvent} event Mouse move or mouse out event. + * @private + */ +goog.fx.DragDropItem.prototype.mouseMove_ = function(event) { + 'use strict'; + var distance = Math.abs(event.clientX - this.startPosition_.x) + + Math.abs(event.clientY - this.startPosition_.y); + // Fire dragStart event if the drag distance exceeds the threshold or if the + // mouse leave the dragged element. + // TODO(user): Consider using the goog.fx.Dragger to track the distance + // even after the mouse leaves the dragged element. + var currentDragElement = this.currentDragElement_; + var distanceAboveThreshold = + distance > goog.fx.AbstractDragDrop.initDragDistanceThreshold; + var mouseOutOnDragElement = event.type == goog.events.EventType.MOUSEOUT && + event.target == currentDragElement; + if (distanceAboveThreshold || mouseOutOnDragElement) { + this.eventHandler_.removeAll(); + this.parent_.startDrag(event, this); + } + + // Prevent text selection while dragging an element. + event.preventDefault(); +}; + + +/** + * Event handler for mouse up. Removes mouse move, mouse out and mouse up event + * handlers. + * + * @param {goog.events.BrowserEvent} event Mouse up event. + * @private + */ +goog.fx.DragDropItem.prototype.mouseUp_ = function(event) { + 'use strict'; + this.eventHandler_.removeAll(); + delete this.startPosition_; + this.currentDragElement_ = null; +}; + + + +/** + * Class representing an active drop target + * + * @param {goog.math.Box} box Box describing the position and dimension of the + * target item. + * @param {goog.fx.AbstractDragDrop=} opt_target Target that contains the item + associated with position. + * @param {goog.fx.DragDropItem=} opt_item Item associated with position. + * @param {Element=} opt_element Element of item associated with position. + * @constructor + * @struct + * @private + */ +goog.fx.ActiveDropTarget_ = function(box, opt_target, opt_item, opt_element) { + 'use strict'; + /** + * Box describing the position and dimension of the target item + * @type {goog.math.Box} + * @private + */ + this.box_ = box; + + /** + * Target that contains the item associated with position + * @type {goog.fx.AbstractDragDrop|undefined} + * @private + */ + this.target_ = opt_target; + + /** + * Item associated with position + * @type {goog.fx.DragDropItem|undefined} + * @private + */ + this.item_ = opt_item; + + /** + * The draggable element of the item associated with position. + * @type {Element} + * @private + */ + this.element_ = opt_element || null; + + /** + * If this target is in a scrollable container this is it. + * @private {?goog.fx.ScrollableContainer_} + */ + this.scrollableContainer_ = null; +}; + + + +/** + * Class for representing a scrollable container + * @param {Element} element the scrollable element. + * @constructor + * @private + */ +goog.fx.ScrollableContainer_ = function(element) { + 'use strict'; + /** + * The targets that lie within this container. + * @type {Array} + * @private + */ + this.containedTargets_ = []; + + /** + * The element that is this container + * @type {Element} + * @private + */ + this.element_ = element; + + /** + * The saved scroll left location for calculating deltas. + * @type {number} + * @private + */ + this.savedScrollLeft_ = 0; + + /** + * The saved scroll top location for calculating deltas. + * @type {number} + * @private + */ + this.savedScrollTop_ = 0; + + /** + * The space occupied by the container. + * @type {?goog.math.Box} + * @private + */ + this.box_ = null; +}; + + +/** + * Test-only exports. + * @const + */ +goog.fx.AbstractDragDrop.TEST_ONLY = { + ActiveDropTarget: goog.fx.ActiveDropTarget_, +}; diff --git a/closure/goog/fx/abstractdragdrop_test.js b/closure/goog/fx/abstractdragdrop_test.js new file mode 100644 index 0000000000..b03d679346 --- /dev/null +++ b/closure/goog/fx/abstractdragdrop_test.js @@ -0,0 +1,892 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.fx.AbstractDragDropTest'); +goog.setTestOnly('goog.fx.AbstractDragDropTest'); + +const AbstractDragDrop = goog.require('goog.fx.AbstractDragDrop'); +const Box = goog.require('goog.math.Box'); +const Coordinate = goog.require('goog.math.Coordinate'); +const DragDropItem = goog.require('goog.fx.DragDropItem'); +const EventType = goog.require('goog.events.EventType'); +const TagName = goog.require('goog.dom.TagName'); +const array = goog.require('goog.array'); +const dom = goog.require('goog.dom'); +const events = goog.require('goog.events'); +const functions = goog.require('goog.functions'); +const style = goog.require('goog.style'); +const testSuite = goog.require('goog.testing.testSuite'); +const testingEvents = goog.require('goog.testing.events'); + +const {ActiveDropTarget} = AbstractDragDrop.TEST_ONLY; + +const targets = [ + {box_: new Box(0, 3, 1, 1)}, {box_: new Box(0, 7, 2, 6)}, + {box_: new Box(2, 2, 3, 1)}, {box_: new Box(4, 1, 6, 1)}, + {box_: new Box(4, 9, 7, 6)}, {box_: new Box(9, 9, 10, 1)} +]; + +const targets2 = [ + {box_: new Box(10, 50, 20, 10)}, {box_: new Box(20, 50, 30, 10)}, + {box_: new Box(60, 50, 70, 10)}, {box_: new Box(70, 50, 80, 10)} +]; + +const targets3 = [ + {box_: new Box(0, 4, 1, 1)}, {box_: new Box(1, 6, 4, 5)}, + {box_: new Box(5, 5, 6, 2)}, {box_: new Box(2, 1, 5, 0)} +]; + + +/** + * An enum describing how two ranges overlap (non-symmetrical relation). + * @enum {number} + */ +const RangeOverlap = { + LEFT: 1, // First range is placed to the left of the second. + LEFT_IN: 2, // First range overlaps on the left side of the second. + IN: 3, // First range is completely contained in the second. + RIGHT_IN: 4, // First range overlaps on the right side of the second. + RIGHT: 5, // First range is placed to the right side of the second. + CONTAINS: 6 // First range contains the second. +}; + + +/** + * Computes how two one dimensional ranges overlap. + * + * @param {number} left1 Left inclusive bound of the first range. + * @param {number} right1 Right exclusive bound of the first range. + * @param {number} left2 Left inclusive bound of the second range. + * @param {number} right2 Right exclusive bound of the second range. + * @return {RangeOverlap} The enum value describing the type of the overlap. + */ +function rangeOverlap(left1, right1, left2, right2) { + if (right1 <= left2) return RangeOverlap.LEFT; + if (left1 >= right2) return RangeOverlap.RIGHT; + const leftIn = left1 >= left2; + const rightIn = right1 <= right2; + if (leftIn && rightIn) return RangeOverlap.IN; + if (leftIn) return RangeOverlap.RIGHT_IN; + if (rightIn) return RangeOverlap.LEFT_IN; + return RangeOverlap.CONTAINS; +} + + +/** + * Tells whether two boxes overlap. + * + * @param {!Box} box1 First box in question. + * @param {!Box} box2 Second box in question. + * @return {boolean} Whether boxes overlap in any way. + */ +function boxOverlaps(box1, box2) { + const horizontalOverlap = + rangeOverlap(box1.left, box1.right, box2.left, box2.right); + const verticalOverlap = + rangeOverlap(box1.top, box1.bottom, box2.top, box2.bottom); + return horizontalOverlap != RangeOverlap.LEFT && + horizontalOverlap != RangeOverlap.RIGHT && + verticalOverlap != RangeOverlap.LEFT && + verticalOverlap != RangeOverlap.RIGHT; +} + + + +/** + * Checks whether a given box overlaps any of given DnD target boxes. + * + * @param {!Box} box The box to check. + * @param {!Array} targets The array of targets with boxes to check + * if they overlap with the given box. + * @return {boolean} Whether the box overlaps any of the target boxes. + */ +function boxOverlapsTargets(box, targets) { + return array.some( + targets, /** + @suppress {strictMissingProperties} suppression added to + enable type checking + */ + function(target) { + return boxOverlaps(box, target.box_); + }); +} + + + +/** + * Helper function for manual debugging. + * @param {!Array<*>} targets + * @param {number} multiplier + */ +function drawTargets(targets, multiplier) { + const colors = ['green', 'blue', 'red', 'lime', 'pink', 'silver', 'orange']; + const cont = document.getElementById('cont'); + cont.innerHTML = ''; + for (let i = 0; i < targets.length; i++) { + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + const box = targets[i].box_; + const el = dom.createElement(TagName.DIV); + el.style.top = (box.top * multiplier) + 'px'; + el.style.left = (box.left * multiplier) + 'px'; + el.style.width = ((box.right - box.left) * multiplier) + 'px'; + el.style.height = ((box.bottom - box.top) * multiplier) + 'px'; + el.style.backgroundColor = colors[i]; + cont.appendChild(el); + } +} + + +testSuite({ + /** + * Test the utility function which tells how two one dimensional ranges + * overlap. + */ + testRangeOverlap() { + assertEquals(RangeOverlap.LEFT, rangeOverlap(1, 2, 3, 4)); + assertEquals(RangeOverlap.LEFT, rangeOverlap(2, 3, 3, 4)); + assertEquals(RangeOverlap.LEFT_IN, rangeOverlap(1, 3, 2, 4)); + assertEquals(RangeOverlap.IN, rangeOverlap(1, 3, 1, 4)); + assertEquals(RangeOverlap.IN, rangeOverlap(2, 3, 1, 4)); + assertEquals(RangeOverlap.IN, rangeOverlap(3, 4, 1, 4)); + assertEquals(RangeOverlap.RIGHT_IN, rangeOverlap(2, 4, 1, 3)); + assertEquals(RangeOverlap.RIGHT, rangeOverlap(2, 3, 1, 2)); + assertEquals(RangeOverlap.RIGHT, rangeOverlap(3, 4, 1, 2)); + assertEquals(RangeOverlap.CONTAINS, rangeOverlap(1, 4, 2, 3)); + }, + + + /** + * Tests if the utility function to compute box overlapping functions + * properly. + */ + testBoxOverlaps() { + // Overlapping tests. + let box2 = new Box(1, 4, 4, 1); + + // Corner overlaps. + assertTrue('NW overlap', boxOverlaps(new Box(0, 2, 2, 0), box2)); + assertTrue('NE overlap', boxOverlaps(new Box(0, 5, 2, 3), box2)); + assertTrue('SE overlap', boxOverlaps(new Box(3, 5, 5, 3), box2)); + assertTrue('SW overlap', boxOverlaps(new Box(3, 2, 5, 0), box2)); + + // Inside. + assertTrue('Inside overlap', boxOverlaps(new Box(2, 3, 3, 2), box2)); + + // Around. + assertTrue('Outside overlap', boxOverlaps(new Box(0, 5, 5, 0), box2)); + + // Edge overlaps. + assertTrue('N overlap', boxOverlaps(new Box(0, 3, 2, 2), box2)); + assertTrue('E overlap', boxOverlaps(new Box(2, 5, 3, 3), box2)); + assertTrue('S overlap', boxOverlaps(new Box(3, 3, 5, 2), box2)); + assertTrue('W overlap', boxOverlaps(new Box(2, 2, 3, 0), box2)); + + assertTrue('N-in overlap', boxOverlaps(new Box(0, 5, 2, 0), box2)); + assertTrue('E-in overlap', boxOverlaps(new Box(0, 5, 5, 3), box2)); + assertTrue('S-in overlap', boxOverlaps(new Box(3, 5, 5, 0), box2)); + assertTrue('W-in overlap', boxOverlaps(new Box(0, 2, 5, 0), box2)); + + // Does not overlap. + box2 = new Box(3, 6, 6, 3); + + // Along the edge - shorter. + assertFalse('N-in no overlap', boxOverlaps(new Box(1, 5, 2, 4), box2)); + assertFalse('E-in no overlap', boxOverlaps(new Box(4, 8, 5, 7), box2)); + assertFalse('S-in no overlap', boxOverlaps(new Box(7, 5, 8, 4), box2)); + assertFalse('N-in no overlap', boxOverlaps(new Box(4, 2, 5, 1), box2)); + + // By the corner. + assertFalse('NE no overlap', boxOverlaps(new Box(1, 8, 2, 7), box2)); + assertFalse('SE no overlap', boxOverlaps(new Box(7, 8, 8, 7), box2)); + assertFalse('SW no overlap', boxOverlaps(new Box(7, 2, 8, 1), box2)); + assertFalse('NW no overlap', boxOverlaps(new Box(1, 2, 2, 1), box2)); + + // Perpendicular to an edge. + assertFalse('NNE no overlap', boxOverlaps(new Box(1, 7, 2, 5), box2)); + assertFalse('NEE no overlap', boxOverlaps(new Box(2, 8, 4, 7), box2)); + assertFalse('SEE no overlap', boxOverlaps(new Box(5, 8, 7, 7), box2)); + assertFalse('SSE no overlap', boxOverlaps(new Box(7, 7, 8, 5), box2)); + assertFalse('SSW no overlap', boxOverlaps(new Box(7, 4, 8, 2), box2)); + assertFalse('SWW no overlap', boxOverlaps(new Box(5, 2, 7, 1), box2)); + assertFalse('NWW no overlap', boxOverlaps(new Box(2, 2, 4, 1), box2)); + assertFalse('NNW no overlap', boxOverlaps(new Box(1, 4, 2, 2), box2)); + + // Along the edge - longer. + assertFalse('N no overlap', boxOverlaps(new Box(0, 7, 1, 2), box2)); + assertFalse('E no overlap', boxOverlaps(new Box(2, 9, 7, 8), box2)); + assertFalse('S no overlap', boxOverlaps(new Box(8, 7, 9, 2), box2)); + assertFalse('W no overlap', boxOverlaps(new Box(2, 1, 7, 0), box2)); + }, + + + /** + @suppress {visibility,checkTypes} suppression added to enable type + checking + */ + testMaybeCreateDummyTargetForPosition() { + const testGroup = new AbstractDragDrop(); + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetList_ = targets; + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetBox_ = new Box(0, 9, 10, 1); + + /** @suppress {visibility} suppression added to enable type checking */ + let target = testGroup.maybeCreateDummyTargetForPosition_(3, 3); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + assertTrue(testGroup.isInside(3, 3, target.box_)); + + /** @suppress {visibility} suppression added to enable type checking */ + target = testGroup.maybeCreateDummyTargetForPosition_(2, 4); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + assertTrue(testGroup.isInside(2, 4, target.box_)); + + /** @suppress {visibility} suppression added to enable type checking */ + target = testGroup.maybeCreateDummyTargetForPosition_(2, 7); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + assertTrue(testGroup.isInside(2, 7, target.box_)); + + testGroup.targetList_.push({box_: new Box(5, 6, 6, 0)}); + + /** @suppress {visibility} suppression added to enable type checking */ + target = testGroup.maybeCreateDummyTargetForPosition_(3, 3); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + assertTrue(testGroup.isInside(3, 3, target.box_)); + + /** @suppress {visibility} suppression added to enable type checking */ + target = testGroup.maybeCreateDummyTargetForPosition_(2, 7); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + assertTrue(testGroup.isInside(2, 7, target.box_)); + + /** @suppress {visibility} suppression added to enable type checking */ + target = testGroup.maybeCreateDummyTargetForPosition_(6, 3); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + assertTrue(testGroup.isInside(6, 3, target.box_)); + + /** @suppress {visibility} suppression added to enable type checking */ + target = testGroup.maybeCreateDummyTargetForPosition_(0, 3); + assertNull(target); + /** @suppress {visibility} suppression added to enable type checking */ + target = testGroup.maybeCreateDummyTargetForPosition_(9, 0); + assertNull(target); + }, + + + /** + @suppress {visibility,checkTypes} suppression added to enable type + checking + */ + testMaybeCreateDummyTargetForPosition2() { + const testGroup = new AbstractDragDrop(); + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetList_ = targets2; + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetBox_ = new Box(10, 50, 80, 10); + + /** @suppress {visibility} suppression added to enable type checking */ + let target = testGroup.maybeCreateDummyTargetForPosition_(30, 40); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + assertTrue(testGroup.isInside(30, 40, target.box_)); + + /** @suppress {visibility} suppression added to enable type checking */ + target = testGroup.maybeCreateDummyTargetForPosition_(45, 40); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + assertTrue(testGroup.isInside(45, 40, target.box_)); + + testGroup.targetList_.push({box_: new Box(40, 50, 50, 40)}); + + /** @suppress {visibility} suppression added to enable type checking */ + target = testGroup.maybeCreateDummyTargetForPosition_(30, 40); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + /** @suppress {visibility} suppression added to enable type checking */ + target = testGroup.maybeCreateDummyTargetForPosition_(45, 35); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + }, + + + /** + @suppress {visibility,checkTypes} suppression added to enable type + checking + */ + testMaybeCreateDummyTargetForPosition3BoxHasDecentSize() { + const testGroup = new AbstractDragDrop(); + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetList_ = targets3; + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetBox_ = new Box(0, 6, 6, 0); + + /** @suppress {visibility} suppression added to enable type checking */ + const target = testGroup.maybeCreateDummyTargetForPosition_(3, 3); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + assertTrue(testGroup.isInside(3, 3, target.box_)); + assertEquals('(1t, 5r, 5b, 1l)', target.box_.toString()); + }, + + + /** + @suppress {visibility,checkTypes} suppression added to enable type + checking + */ + testMaybeCreateDummyTargetForPosition4() { + const testGroup = new AbstractDragDrop(); + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetList_ = targets; + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetBox_ = new Box(0, 9, 10, 1); + + for (/** @suppress {visibility} suppression added to enable type checking */ + let x = testGroup.targetBox_.left; x < testGroup.targetBox_.right; + x++) { + for (/** + @suppress {visibility} suppression added to enable type checking + */ + let y = testGroup.targetBox_.top; y < testGroup.targetBox_.bottom; + y++) { + let inRealTarget = false; + for (let i = 0; i < testGroup.targetList_.length; i++) { + if (testGroup.isInside(x, y, testGroup.targetList_[i].box_)) { + inRealTarget = true; + break; + } + } + if (!inRealTarget) { + /** + * @suppress {visibility} suppression added to enable type checking + */ + const target = testGroup.maybeCreateDummyTargetForPosition_(x, y); + if (target) { + assertFalse( + 'Fake target for point(' + x + ',' + y + ') should ' + + 'not overlap any real targets.', + boxOverlapsTargets(target.box_, testGroup.targetList_)); + assertTrue(testGroup.isInside(x, y, target.box_)); + } + } + } + } + }, + + /** + @suppress {visibility,checkTypes} suppression added to enable type + checking + */ + testMaybeCreateDummyTargetForPosition_NegativePositions() { + const negTargets = + [{box_: new Box(-20, 10, -5, 1)}, {box_: new Box(20, 10, 30, 1)}]; + + const testGroup = new AbstractDragDrop(); + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetList_ = negTargets; + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetBox_ = new Box(-20, 10, 30, 1); + + /** @suppress {visibility} suppression added to enable type checking */ + const target = testGroup.maybeCreateDummyTargetForPosition_(1, 5); + assertFalse(boxOverlapsTargets(target.box_, testGroup.targetList_)); + assertTrue(testGroup.isInside(1, 5, target.box_)); + }, + + /** + @suppress {visibility,checkTypes} suppression added to enable type + checking + */ + testMaybeCreateDummyTargetOutsideScrollableContainer() { + const targets = + [{box_: new Box(0, 3, 10, 1)}, {box_: new Box(20, 3, 30, 1)}]; + const target = targets[0]; + + const testGroup = new AbstractDragDrop(); + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetList_ = targets; + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetBox_ = new Box(0, 3, 30, 1); + + testGroup.addScrollableContainer(document.getElementById('container1')); + /** @suppress {visibility} suppression added to enable type checking */ + const container = testGroup.scrollableContainers_[0]; + container.containedTargets_.push(target); + /** @suppress {visibility} suppression added to enable type checking */ + container.box_ = new Box(0, 3, 5, 1); // shorter than target + target.scrollableContainer_ = container; + + // mouse cursor is below scrollable target but not the actual target + /** @suppress {visibility} suppression added to enable type checking */ + const dummyTarget = testGroup.maybeCreateDummyTargetForPosition_(2, 7); + // dummy target should not overlap the scrollable container + assertFalse(boxOverlaps(dummyTarget.box_, container.box_)); + // but should overlap the actual target, since not all of it is visible + assertTrue(boxOverlaps(dummyTarget.box_, target.box_)); + }, + + /** + @suppress {visibility,checkTypes} suppression added to enable type + checking + */ + testMaybeCreateDummyTargetInsideScrollableContainer() { + const targets = + [{box_: new Box(0, 3, 10, 1)}, {box_: new Box(20, 3, 30, 1)}]; + const target = targets[0]; + + const testGroup = new AbstractDragDrop(); + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetList_ = targets; + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetBox_ = new Box(0, 3, 30, 1); + + testGroup.addScrollableContainer(document.getElementById('container1')); + /** @suppress {visibility} suppression added to enable type checking */ + const container = testGroup.scrollableContainers_[0]; + container.containedTargets_.push(target); + /** @suppress {visibility} suppression added to enable type checking */ + container.box_ = new Box(0, 3, 20, 1); // longer than target + target.scrollableContainer_ = container; + + // mouse cursor is below both the scrollable and the actual target + /** @suppress {visibility} suppression added to enable type checking */ + const dummyTarget = testGroup.maybeCreateDummyTargetForPosition_(2, 15); + // dummy target should overlap the scrollable container + assertTrue(boxOverlaps(dummyTarget.box_, container.box_)); + // but not overlap the actual target + assertFalse(boxOverlaps(dummyTarget.box_, target.box_)); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testCalculateTargetBox() { + let testGroup = new AbstractDragDrop(); + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetList_ = []; + array.forEach( + targets, /** + @suppress {visibility} suppression added to enable type + checking + */ + function(target) { + testGroup.targetList_.push(target); + testGroup.calculateTargetBox_(target.box_); + }); + assertTrue(Box.equals(testGroup.targetBox_, new Box(0, 9, 10, 1))); + + testGroup = new AbstractDragDrop(); + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetList_ = []; + array.forEach( + targets2, /** + @suppress {visibility} suppression added to enable type + checking + */ + function(target) { + testGroup.targetList_.push(target); + testGroup.calculateTargetBox_(target.box_); + }); + assertTrue(Box.equals(testGroup.targetBox_, new Box(10, 50, 80, 10))); + + testGroup = new AbstractDragDrop(); + /** @suppress {visibility} suppression added to enable type checking */ + testGroup.targetList_ = []; + array.forEach( + targets3, /** + @suppress {visibility} suppression added to enable type + checking + */ + function(target) { + testGroup.targetList_.push(target); + testGroup.calculateTargetBox_(target.box_); + }); + assertTrue(Box.equals(testGroup.targetBox_, new Box(0, 6, 6, 0))); + }, + + + /** @suppress {visibility} suppression added to enable type checking */ + testIsInside() { + const add = new AbstractDragDrop(); + // The box in question. + // 10,20+++++20,20 + // + | + // 10,30-----20,30 + const box = new Box(20, 20, 30, 10); + + assertTrue( + 'A point somewhere in the middle of the box should be inside.', + add.isInside(15, 25, box)); + + assertTrue( + 'A point in top-left corner should be inside the box.', + add.isInside(10, 20, box)); + + assertTrue( + 'A point on top border should be inside the box.', + add.isInside(15, 20, box)); + + assertFalse( + 'A point in top-right corner should be outside the box.', + add.isInside(20, 20, box)); + + assertFalse( + 'A point on right border should be outside the box.', + add.isInside(20, 25, box)); + + assertFalse( + 'A point in bottom-right corner should be outside the box.', + add.isInside(20, 30, box)); + + assertFalse( + 'A point on bottom border should be outside the box.', + add.isInside(15, 30, box)); + + assertFalse( + 'A point in bottom-left corner should be outside the box.', + add.isInside(10, 30, box)); + + assertTrue( + 'A point on left border should be inside the box.', + add.isInside(10, 25, box)); + + add.dispose(); + }, + + + /** @suppress {visibility} suppression added to enable type checking */ + testAddingRemovingScrollableContainers() { + const group = new AbstractDragDrop(); + const el1 = dom.createElement(TagName.DIV); + const el2 = dom.createElement(dom.TagName.DIV); + + assertEquals(0, group.scrollableContainers_.length); + + group.addScrollableContainer(el1); + assertEquals(1, group.scrollableContainers_.length); + + group.addScrollableContainer(el2); + assertEquals(2, group.scrollableContainers_.length); + + group.removeAllScrollableContainers(); + assertEquals(0, group.scrollableContainers_.length); + }, + + + /** @suppress {visibility} suppression added to enable type checking */ + testScrollableContainersCalculation() { + const group = new AbstractDragDrop(); + const target = new AbstractDragDrop(); + + group.addTarget(target); + group.addScrollableContainer(document.getElementById('container1')); + /** @suppress {visibility} suppression added to enable type checking */ + const container = group.scrollableContainers_[0]; + + const item1 = new DragDropItem(document.getElementById('child1')); + const item2 = new DragDropItem(document.getElementById('child2')); + + target.items_.push(item1); + group.recalculateDragTargets(); + group.recalculateScrollableContainers(); + + assertEquals(1, container.containedTargets_.length); + assertEquals(container, group.targetList_[0].scrollableContainer_); + + target.items_.push(item2); + group.recalculateDragTargets(); + assertEquals(1, container.containedTargets_.length); + assertNull(group.targetList_[0].scrollableContainer_); + + group.recalculateScrollableContainers(); + assertEquals(2, container.containedTargets_.length); + assertEquals(container, group.targetList_[1].scrollableContainer_); + }, + + + /** @suppress {visibility} suppression added to enable type checking */ + testMouseDownEventDefaultAction() { + const group = new AbstractDragDrop(); + const target = new AbstractDragDrop(); + group.addTarget(target); + const item1 = new DragDropItem(document.getElementById('child1')); + group.items_.push(item1); + item1.setParent(group); + group.init(); + + const mousedownDefaultPrevented = + !testingEvents.fireMouseDownEvent(item1.element); + + assertFalse( + 'Default action of mousedown event should not be cancelled.', + mousedownDefaultPrevented); + }, + + + // See http://b/7494613. + /** + @suppress {visibility,checkTypes} suppression added to enable type + checking + */ + testMouseUpOutsideElement() { + const group = new AbstractDragDrop(); + const target = new AbstractDragDrop(); + group.addTarget(target); + const item1 = new DragDropItem(document.getElementById('child1')); + group.items_.push(item1); + item1.setParent(group); + group.init(); + + group.startDrag = functions.error('startDrag should not be called.'); + + testingEvents.fireMouseDownEvent(item1.element); + testingEvents.fireMouseUpEvent(item1.element.parentNode); + // This should have no effect (not start a drag) since the previous event + // should have cleared the listeners. + testingEvents.fireMouseOutEvent(item1.element); + + group.dispose(); + target.dispose(); + }, + + + /** + @suppress {visibility,checkTypes} suppression added to enable type + checking + */ + testScrollBeforeMoveDrag() { + const group = new AbstractDragDrop(); + const target = new AbstractDragDrop(); + + group.addTarget(target); + const container = document.getElementById('container1'); + group.addScrollableContainer(container); + + const childEl = document.getElementById('child1'); + const item = new DragDropItem(childEl); + /** @suppress {visibility} suppression added to enable type checking */ + item.currentDragElement_ = childEl; + + target.items_.push(item); + group.recalculateDragTargets(); + group.recalculateScrollableContainers(); + + // Simulare starting a drag. + const moveEvent = { + 'clientX': 8, + 'clientY': 10, + 'type': EventType.MOUSEMOVE, + 'relatedTarget': childEl, + 'preventDefault': function() {} + }; + group.startDrag(moveEvent, item); + + // Simulate scrolling before the first move drag event. + const scrollEvent = {'target': container}; + assertNotThrows( + goog.bind(group.containerScrollHandler_, group, scrollEvent)); + }, + + + /** + @suppress {visibility,checkTypes} suppression added to enable type + checking + */ + testMouseMove_mouseOutBeforeThreshold() { + // Setup dragdrop and item + const itemEl = dom.createElement(TagName.DIV); + const childEl = dom.createElement(TagName.DIV); + itemEl.appendChild(childEl); + const add = new AbstractDragDrop(); + const item = new DragDropItem(itemEl); + item.setParent(add); + add.items_.push(item); + + // Simulate maybeStartDrag + /** @suppress {visibility} suppression added to enable type checking */ + item.startPosition_ = new Coordinate(10, 10); + /** @suppress {visibility} suppression added to enable type checking */ + item.currentDragElement_ = itemEl; + + // Test + let draggedItem = null; + add.startDrag = function(event, item) { + draggedItem = item; + }; + + let event = new testingEvents.Event(EventType.MOUSEOUT, childEl); + // Drag distance is only 2. + event.clientX = 8; + event.clientY = 10; + item.mouseMove_(event); + assertEquals( + 'DragStart should not be fired for mouseout on child element.', null, + draggedItem); + + event = new testingEvents.Event(EventType.MOUSEOUT, itemEl); + // Drag distance is only 2. + event.clientX = 8; + event.clientY = 10; + item.mouseMove_(event); + assertEquals( + 'DragStart should be fired for mouseout on main element.', item, + draggedItem); + }, + + + testGetDragElementPosition() { + const testGroup = new AbstractDragDrop(); + const sourceEl = dom.createElement(TagName.DIV); + document.body.appendChild(sourceEl); + + let pageOffset = style.getPageOffset(sourceEl); + /** @suppress {checkTypes} suppression added to enable type checking */ + let pos = testGroup.getDragElementPosition(sourceEl); + assertEquals( + 'Drag element position should be source element page offset', + pageOffset.x, pos.x); + assertEquals( + 'Drag element position should be source element page offset', + pageOffset.y, pos.y); + + sourceEl.style.marginLeft = '5px'; + sourceEl.style.marginTop = '7px'; + pageOffset = style.getPageOffset(sourceEl); + /** @suppress {checkTypes} suppression added to enable type checking */ + pos = testGroup.getDragElementPosition(sourceEl); + assertEquals( + 'Drag element position should be adjusted for source element ' + + 'margins', + pageOffset.x - 10, pos.x); + assertEquals( + 'Drag element position should be adjusted for source element ' + + 'margins', + pageOffset.y - 14, pos.y); + }, + + + testDragEndEvent() { + /** + * @suppress {visibility,checkTypes} suppression added to enable type + * checking + */ + function testDragEndEventInternal(shouldContainItemData) { + const testGroup = new AbstractDragDrop(); + + const childEl = document.getElementById('child1'); + const item = new DragDropItem(childEl); + /** @suppress {visibility} suppression added to enable type checking */ + item.currentDragElement_ = childEl; + + testGroup.items_.push(item); + testGroup.recalculateDragTargets(); + + // Simulate starting a drag + const startEvent = { + 'clientX': 0, + 'clientY': 0, + 'type': EventType.MOUSEMOVE, + 'relatedTarget': childEl, + 'preventDefault': function() {} + }; + testGroup.startDrag(startEvent, item); + + /** + * @suppress {visibility,checkTypes} suppression added to enable type + * checking + */ + testGroup.activeTarget_ = + new ActiveDropTarget(new Box(0, 0, 0, 0), testGroup, item, childEl); + + events.listen( + testGroup, AbstractDragDrop.EventType.DRAGEND, function(event) { + if (shouldContainItemData) { + assertEquals( + 'The drag end event should contain a drop target', testGroup, + event.dropTarget); + assertEquals( + 'The drag end event should contain a drop target item', item, + event.dropTargetItem); + assertEquals( + 'The drag end event should contain a drop target element', + childEl, event.dropTargetElement); + } else { + assertUndefined( + 'The drag end event shouldn\'t contain a drop target', + event.dropTarget); + assertUndefined( + 'The drag end event shouldn\'t contain a drop target item', + event.dropTargetItem); + assertUndefined( + 'The drag end event shouldn\'t contain a drop target element', + event.dropTargetElement); + } + }); + + testGroup.endDrag( + {'clientX': 0, 'clientY': 0, 'dragCanceled': !shouldContainItemData}); + + testGroup.dispose(); + item.dispose(); + } + + testDragEndEventInternal(false); + testDragEndEventInternal(true); + }, + + + /** + @suppress {visibility,checkTypes} suppression added to enable type + checking + */ + testDropEventHasBrowserEvent() { + const testGroup = new AbstractDragDrop(); + + const childEl = document.getElementById('child1'); + const item = new DragDropItem(childEl); + /** @suppress {visibility} suppression added to enable type checking */ + item.currentDragElement_ = childEl; + + testGroup.items_.push(item); + testGroup.recalculateDragTargets(); + + // Simulate starting a drag + const startBrowserEvent = { + 'clientX': 0, + 'clientY': 0, + 'type': EventType.MOUSEMOVE, + 'relatedTarget': childEl, + 'preventDefault': function() {}, + }; + testGroup.startDrag(startBrowserEvent, item); + + /** + * @suppress {visibility,checkTypes} suppression added to enable type + * checking + */ + testGroup.activeTarget_ = + new ActiveDropTarget(new Box(0, 0, 0, 0), testGroup, item, childEl); + + const endBrowserEvent = { + 'clientX': 0, + 'clientY': 0, + 'type': EventType.MOUSEUP, + 'ctrlKey': false, + 'altKey': true + }; + + events.listen(testGroup, AbstractDragDrop.EventType.DROP, function(event) { + const browserEvent = event.browserEvent; + assertEquals( + 'The drop event should contain the browser event', endBrowserEvent, + browserEvent); + }); + + testGroup.endDrag({ + 'clientX': 0, + 'clientY': 0, + 'dragCanceled': false, + 'browserEvent': endBrowserEvent + }); + + testGroup.dispose(); + item.dispose(); + }, + +}); diff --git a/closure/goog/fx/abstractdragdrop_test_dom.html b/closure/goog/fx/abstractdragdrop_test_dom.html new file mode 100644 index 0000000000..dfd0c095b7 --- /dev/null +++ b/closure/goog/fx/abstractdragdrop_test_dom.html @@ -0,0 +1,37 @@ + + +
    +
    + +
    + +
    + +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/closure/goog/fx/anim/BUILD b/closure/goog/fx/anim/BUILD new file mode 100644 index 0000000000..af021bebb0 --- /dev/null +++ b/closure/goog/fx/anim/BUILD @@ -0,0 +1,17 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "anim", + srcs = ["anim.js"], + lenient = True, + deps = [ + "//closure/goog/async:animationdelay", + "//closure/goog/async:delay", + "//closure/goog/disposable", + "//closure/goog/object", + ], +) diff --git a/closure/goog/fx/anim/anim.js b/closure/goog/fx/anim/anim.js new file mode 100644 index 0000000000..e8973fd503 --- /dev/null +++ b/closure/goog/fx/anim/anim.js @@ -0,0 +1,212 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Basic animation controls. + */ +goog.provide('goog.fx.anim'); +goog.provide('goog.fx.anim.Animated'); + +goog.require('goog.async.AnimationDelay'); +goog.require('goog.async.Delay'); +goog.require('goog.dispose'); +goog.require('goog.object'); + + + +/** + * An interface for programatically animated objects. I.e. rendered in + * javascript frame by frame. + * + * @interface + */ +goog.fx.anim.Animated = function() {}; + + +/** + * Function called when a frame is requested for the animation. + * + * @param {number} now Current time in milliseconds. + */ +goog.fx.anim.Animated.prototype.onAnimationFrame; + + +/** + * Default wait timeout for animations (in milliseconds). Only used for timed + * animation, which uses a timer (setTimeout) to schedule animation. + * + * @type {number} + * @const + */ +goog.fx.anim.TIMEOUT = goog.async.AnimationDelay.TIMEOUT; + + +/** + * A map of animations which should be cycled on the global timer. + * + * @type {!Object} + * @private + */ +goog.fx.anim.activeAnimations_ = {}; + + +/** + * An optional animation window. + * @type {?Window} + * @private + */ +goog.fx.anim.animationWindow_ = null; + + +/** + * An interval ID for the global timer or event handler uid. + * @type {?goog.async.Delay|?goog.async.AnimationDelay} + * @private + */ +goog.fx.anim.animationDelay_ = null; + + +/** + * Registers an animation to be cycled on the global timer. + * @param {goog.fx.anim.Animated} animation The animation to register. + */ +goog.fx.anim.registerAnimation = function(animation) { + 'use strict'; + var uid = goog.getUid(animation); + if (!(uid in goog.fx.anim.activeAnimations_)) { + goog.fx.anim.activeAnimations_[uid] = animation; + } + + // If the timer is not already started, start it now. + goog.fx.anim.requestAnimationFrame_(); +}; + + +/** + * Removes an animation from the list of animations which are cycled on the + * global timer. + * @param {goog.fx.anim.Animated} animation The animation to unregister. + */ +goog.fx.anim.unregisterAnimation = function(animation) { + 'use strict'; + var uid = goog.getUid(animation); + delete goog.fx.anim.activeAnimations_[uid]; + + // If a timer is running and we no longer have any active timers we stop the + // timers. + if (goog.object.isEmpty(goog.fx.anim.activeAnimations_)) { + goog.fx.anim.cancelAnimationFrame_(); + } +}; + + +/** + * Tears down this module. Useful for testing. + */ +// TODO(nicksantos): Wow, this api is pretty broken. This should be fixed. +goog.fx.anim.tearDown = function() { + 'use strict'; + goog.fx.anim.animationWindow_ = null; + goog.dispose(goog.fx.anim.animationDelay_); + goog.fx.anim.animationDelay_ = null; + goog.fx.anim.activeAnimations_ = {}; +}; + + +/** + * Registers an animation window. This allows usage of the timing control API + * for animations. Note that this window must be visible, as non-visible + * windows can potentially stop animating. This window does not necessarily + * need to be the window inside which animation occurs, but must remain visible. + * See: https://developer.mozilla.org/en/DOM/window.mozRequestAnimationFrame. + * + * @param {Window} animationWindow The window in which to animate elements. + */ +goog.fx.anim.setAnimationWindow = function(animationWindow) { + 'use strict'; + // If a timer is currently running, reset it and restart with new functions + // after a timeout. This is to avoid mismatching timer UIDs if we change the + // animation window during a running animation. + // + // In practice this cannot happen before some animation window and timer + // control functions has already been set. + var hasTimer = + goog.fx.anim.animationDelay_ && goog.fx.anim.animationDelay_.isActive(); + + goog.dispose(goog.fx.anim.animationDelay_); + goog.fx.anim.animationDelay_ = null; + goog.fx.anim.animationWindow_ = animationWindow; + + // If the timer was running, start it again. + if (hasTimer) { + goog.fx.anim.requestAnimationFrame_(); + } +}; + + +/** + * Requests an animation frame based on the requestAnimationFrame and + * cancelRequestAnimationFrame function pair. + * @private + */ +goog.fx.anim.requestAnimationFrame_ = function() { + 'use strict'; + if (!goog.fx.anim.animationDelay_) { + // We cannot guarantee that the global window will be one that fires + // requestAnimationFrame events (consider off-screen chrome extension + // windows). Default to use goog.async.Delay, unless + // the client has explicitly set an animation window. + if (goog.fx.anim.animationWindow_) { + // requestAnimationFrame will call cycleAnimations_ with the current + // time in ms, as returned from goog.now(). + goog.fx.anim.animationDelay_ = + new goog.async.AnimationDelay(function(now) { + 'use strict'; + goog.fx.anim.cycleAnimations_(now); + }, goog.fx.anim.animationWindow_); + } else { + goog.fx.anim.animationDelay_ = new goog.async.Delay(function() { + 'use strict'; + goog.fx.anim.cycleAnimations_(goog.now()); + }, goog.fx.anim.TIMEOUT); + } + } + + var delay = goog.fx.anim.animationDelay_; + if (!delay.isActive()) { + delay.start(); + } +}; + + +/** + * Cancels an animation frame created by requestAnimationFrame_(). + * @private + */ +goog.fx.anim.cancelAnimationFrame_ = function() { + 'use strict'; + if (goog.fx.anim.animationDelay_) { + goog.fx.anim.animationDelay_.stop(); + } +}; + + +/** + * Cycles through all registered animations. + * @param {number} now Current time in milliseconds. + * @private + */ +goog.fx.anim.cycleAnimations_ = function(now) { + 'use strict'; + goog.object.forEach(goog.fx.anim.activeAnimations_, function(anim) { + 'use strict'; + anim.onAnimationFrame(now); + }); + + if (!goog.object.isEmpty(goog.fx.anim.activeAnimations_)) { + goog.fx.anim.requestAnimationFrame_(); + } +}; diff --git a/closure/goog/fx/anim/anim_test.js b/closure/goog/fx/anim/anim_test.js new file mode 100644 index 0000000000..37d69f3c75 --- /dev/null +++ b/closure/goog/fx/anim/anim_test.js @@ -0,0 +1,220 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.fx.animTest'); +goog.setTestOnly(); + +const Animation = goog.require('goog.fx.Animation'); +const AnimationDelay = goog.require('goog.async.AnimationDelay'); +const Delay = goog.require('goog.async.Delay'); +const MockClock = goog.require('goog.testing.MockClock'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const events = goog.require('goog.events'); +const functions = goog.require('goog.functions'); +const fxAnim = goog.require('goog.fx.anim'); +const googObject = goog.require('goog.object'); +const recordFunction = goog.require('goog.testing.recordFunction'); +const testSuite = goog.require('goog.testing.testSuite'); +const userAgent = goog.require('goog.userAgent'); + +let clock; +let replacer; + +/** + * @param {!Function} delayType The constructor for Delay or AnimationDelay. + * The methods will be mocked out. + * @suppress {checkTypes,visibility} suppression added to enable type checking + */ +function registerAndUnregisterAnimationWithMocks(delayType) { + let timerCount = 0; + + replacer.set(delayType.prototype, 'start', () => { + timerCount++; + }); + replacer.set(delayType.prototype, 'stop', () => { + timerCount--; + }); + replacer.set(delayType.prototype, 'isActive', () => timerCount > 0); + + const forbiddenDelayType = + delayType == AnimationDelay ? Delay : AnimationDelay; + replacer.set(forbiddenDelayType.prototype, 'start', functions.error()); + replacer.set(forbiddenDelayType.prototype, 'stop', functions.error()); + replacer.set(forbiddenDelayType.prototype, 'isActive', functions.error()); + + const anim = new Animation([0], [1], 1000); + const anim2 = new Animation([0], [1], 1000); + + fxAnim.registerAnimation(anim); + + assertTrue( + 'Should contain the animation', + googObject.containsValue(fxAnim.activeAnimations_, anim)); + assertEquals('Should have called start once', 1, timerCount); + + fxAnim.registerAnimation(anim2); + + assertEquals('Should not have called start again', 1, timerCount); + + // Add anim again. + fxAnim.registerAnimation(anim); + assertTrue( + 'Should contain the animation', + googObject.containsValue(fxAnim.activeAnimations_, anim)); + assertEquals('Should not have called start again', 1, timerCount); + + fxAnim.unregisterAnimation(anim); + assertFalse( + 'Should not contain the animation', + googObject.containsValue(fxAnim.activeAnimations_, anim)); + assertEquals('clearTimeout should not have been called', 1, timerCount); + + fxAnim.unregisterAnimation(anim2); + assertEquals('There should be no remaining timers', 0, timerCount); + + // Make sure we don't trigger setTimeout or setInterval. + clock.tick(1000); + fxAnim.cycleAnimations_(Date.now()); + + assertEquals('There should be no remaining timers', 0, timerCount); + + anim.dispose(); + anim2.dispose(); +} + +testSuite({ + setUpPage() { + clock = new MockClock(true); + }, + + tearDownPage() { + clock.dispose(); + }, + + setUp() { + replacer = new PropertyReplacer(); + }, + + tearDown() { + replacer.reset(); + fxAnim.tearDown(); + }, + + testDelayWithMocks() { + fxAnim.setAnimationWindow(null); + registerAndUnregisterAnimationWithMocks(Delay); + }, + + testAnimationDelayWithMocks() { + fxAnim.setAnimationWindow(window); + registerAndUnregisterAnimationWithMocks(AnimationDelay); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testRegisterAndUnregisterAnimationWithRequestAnimationFrameGecko() { + // Only FF4 onwards support requestAnimationFrame. + if (!userAgent.GECKO || userAgent.isVersionOrHigher('17')) { + return; + } + + fxAnim.setAnimationWindow(window); + + const anim = new Animation([0], [1], 1000); + const anim2 = new Animation([0], [1], 1000); + + fxAnim.registerAnimation(anim); + + assertTrue( + 'Should contain the animation', + googObject.containsValue(fxAnim.activeAnimations_, anim)); + + assertEquals( + 'Should have listen to MozBeforePaint once', 1, + events.getListeners(window, 'MozBeforePaint', false).length); + + fxAnim.registerAnimation(anim2); + + assertEquals( + 'Should not add more listener for MozBeforePaint', 1, + events.getListeners(window, 'MozBeforePaint', false).length); + + // Add anim again. + fxAnim.registerAnimation(anim); + assertTrue( + 'Should contain the animation', + googObject.containsValue(fxAnim.activeAnimations_, anim)); + assertEquals( + 'Should not add more listener for MozBeforePaint', 1, + events.getListeners(window, 'MozBeforePaint', false).length); + + fxAnim.unregisterAnimation(anim); + assertFalse( + 'Should not contain the animation', + googObject.containsValue(fxAnim.activeAnimations_, anim)); + assertEquals( + 'Should not clear listener for MozBeforePaint yet', 1, + events.getListeners(window, 'MozBeforePaint', false).length); + + fxAnim.unregisterAnimation(anim2); + assertEquals( + 'There should be no more listener for MozBeforePaint', 0, + events.getListeners(window, 'MozBeforePaint', false).length); + + anim.dispose(); + anim2.dispose(); + + fxAnim.setAnimationWindow(null); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testRegisterUnregisterAnimation() { + const anim = new Animation([0], [1], 1000); + + fxAnim.registerAnimation(anim); + + assertTrue( + 'There should be an active timer', + fxAnim.animationDelay_ && fxAnim.animationDelay_.isActive()); + assertEquals( + 'There should be an active animations', 1, + googObject.getCount(fxAnim.activeAnimations_)); + + fxAnim.unregisterAnimation(anim); + + assertTrue( + 'There should be no active animations', + googObject.isEmpty(fxAnim.activeAnimations_)); + assertFalse( + 'There should be no active timer', + fxAnim.animationDelay_ && fxAnim.animationDelay_.isActive()); + + anim.dispose(); + }, + + /** @suppress {missingProperties} suppression added to enable type checking */ + testCycleWithMockClock() { + fxAnim.setAnimationWindow(null); + const anim = new Animation([0], [1], 1000); + anim.onAnimationFrame = recordFunction(); + + fxAnim.registerAnimation(anim); + clock.tick(fxAnim.TIMEOUT); + + assertEquals(1, anim.onAnimationFrame.getCallCount()); + }, + + /** @suppress {missingProperties} suppression added to enable type checking */ + testCycleWithMockClockAndAnimationWindow() { + fxAnim.setAnimationWindow(window); + const anim = new Animation([0], [1], 1000); + anim.onAnimationFrame = recordFunction(); + + fxAnim.registerAnimation(anim); + clock.tick(fxAnim.TIMEOUT); + + assertEquals(1, anim.onAnimationFrame.getCallCount()); + }, +}); diff --git a/closure/goog/fx/anim/anim_test_dom.html b/closure/goog/fx/anim/anim_test_dom.html new file mode 100644 index 0000000000..239a62fdbc --- /dev/null +++ b/closure/goog/fx/anim/anim_test_dom.html @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/closure/goog/fx/animation.js b/closure/goog/fx/animation.js new file mode 100644 index 0000000000..d6b36fc336 --- /dev/null +++ b/closure/goog/fx/animation.js @@ -0,0 +1,553 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Classes for doing animations and visual effects. + * + * (Based loosly on my animation code for 13thparallel.org, with extra + * inspiration from the DojoToolkit's modifications to my code) + */ + +goog.provide('goog.fx.Animation'); +goog.provide('goog.fx.Animation.EventType'); +goog.provide('goog.fx.Animation.State'); +goog.provide('goog.fx.AnimationEvent'); + +goog.require('goog.asserts'); +goog.require('goog.events.Event'); +goog.require('goog.fx.Transition'); +goog.require('goog.fx.TransitionBase'); +goog.require('goog.fx.anim'); +goog.require('goog.fx.anim.Animated'); + + + +/** + * Constructor for an animation object. + * @param {Array} start Array for start coordinates. + * @param {Array} end Array for end coordinates. + * @param {number} duration Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @constructor + * @struct + * @implements {goog.fx.anim.Animated} + * @implements {goog.fx.Transition} + * @extends {goog.fx.TransitionBase} + */ +goog.fx.Animation = function(start, end, duration, opt_acc) { + 'use strict'; + goog.fx.Animation.base(this, 'constructor'); + + if (!Array.isArray(start) || !Array.isArray(end)) { + throw new Error('Start and end parameters must be arrays'); + } + + if (start.length != end.length) { + throw new Error('Start and end points must be the same length'); + } + + /** + * Start point. + * @type {Array} + * @protected + */ + this.startPoint = start; + + /** + * End point. + * @type {Array} + * @protected + */ + this.endPoint = end; + + /** + * Duration of animation in milliseconds. + * @type {number} + * @protected + */ + this.duration = duration; + + /** + * Acceleration function, which must return a number between 0 and 1 for + * inputs between 0 and 1. + * @type {Function|undefined} + * @private + */ + this.accel_ = opt_acc; + + /** + * Current coordinate for animation. + * @type {Array} + * @protected + */ + this.coords = []; + + /** + * Whether the animation should use "right" rather than "left" to position + * elements in RTL. This is a temporary flag to allow clients to transition + * to the new behavior at their convenience. At some point it will be the + * default. + * @type {boolean} + * @private + */ + this.useRightPositioningForRtl_ = false; + + /** + * Current frame rate. + * @private {number} + */ + this.fps_ = 0; + + /** + * Percent of the way through the animation. + * @protected {number} + */ + this.progress = 0; + + /** + * Timestamp for when last frame was run. + * @protected {?number} + */ + this.lastFrame = null; +}; +goog.inherits(goog.fx.Animation, goog.fx.TransitionBase); + + +/** + * @return {number} The duration of this animation in milliseconds. + */ +goog.fx.Animation.prototype.getDuration = function() { + 'use strict'; + return this.duration; +}; + + +/** + * Sets whether the animation should use "right" rather than "left" to position + * elements. This is a temporary flag to allow clients to transition + * to the new component at their convenience. At some point "right" will be + * used for RTL elements by default. + * @param {boolean} useRightPositioningForRtl True if "right" should be used for + * positioning, false if "left" should be used for positioning. + */ +goog.fx.Animation.prototype.enableRightPositioningForRtl = function( + useRightPositioningForRtl) { + 'use strict'; + this.useRightPositioningForRtl_ = useRightPositioningForRtl; +}; + + +/** + * Whether the animation should use "right" rather than "left" to position + * elements. This is a temporary flag to allow clients to transition + * to the new component at their convenience. At some point "right" will be + * used for RTL elements by default. + * @return {boolean} True if "right" should be used for positioning, false if + * "left" should be used for positioning. + */ +goog.fx.Animation.prototype.isRightPositioningForRtlEnabled = function() { + 'use strict'; + return this.useRightPositioningForRtl_; +}; + + +/** + * Events fired by the animation. + * @enum {string} + */ +goog.fx.Animation.EventType = { + /** + * Dispatched when played for the first time OR when it is resumed. + * @deprecated Use goog.fx.Transition.EventType.PLAY. + */ + PLAY: goog.fx.Transition.EventType.PLAY, + + /** + * Dispatched only when the animation starts from the beginning. + * @deprecated Use goog.fx.Transition.EventType.BEGIN. + */ + BEGIN: goog.fx.Transition.EventType.BEGIN, + + /** + * Dispatched only when animation is restarted after a pause. + * @deprecated Use goog.fx.Transition.EventType.RESUME. + */ + RESUME: goog.fx.Transition.EventType.RESUME, + + /** + * Dispatched when animation comes to the end of its duration OR stop + * is called. + * @deprecated Use goog.fx.Transition.EventType.END. + */ + END: goog.fx.Transition.EventType.END, + + /** + * Dispatched only when stop is called. + * @deprecated Use goog.fx.Transition.EventType.STOP. + */ + STOP: goog.fx.Transition.EventType.STOP, + + /** + * Dispatched only when animation comes to its end naturally. + * @deprecated Use goog.fx.Transition.EventType.FINISH. + */ + FINISH: goog.fx.Transition.EventType.FINISH, + + /** + * Dispatched when an animation is paused. + * @deprecated Use goog.fx.Transition.EventType.PAUSE. + */ + PAUSE: goog.fx.Transition.EventType.PAUSE, + + /** + * Dispatched each frame of the animation. This is where the actual animator + * will listen. + */ + ANIMATE: 'animate', + + /** + * Dispatched when the animation is destroyed. + */ + DESTROY: 'destroy' +}; + + +/** + * @deprecated Use goog.fx.anim.TIMEOUT. + */ +goog.fx.Animation.TIMEOUT = goog.fx.anim.TIMEOUT; + + +/** + * Enum for the possible states of an animation. + * @deprecated Use goog.fx.Transition.State instead. + * @enum {number} + */ +goog.fx.Animation.State = goog.fx.TransitionBase.State; + + +/** + * @deprecated Use goog.fx.anim.setAnimationWindow. + * @param {Window} animationWindow The window in which to animate elements. + */ +goog.fx.Animation.setAnimationWindow = function(animationWindow) { + 'use strict'; + goog.fx.anim.setAnimationWindow(animationWindow); +}; + + +/** + * Starts or resumes an animation. + * @param {boolean=} opt_restart Whether to restart the + * animation from the beginning if it has been paused. + * @return {boolean} Whether animation was started. + * @override + */ +goog.fx.Animation.prototype.play = function(opt_restart) { + 'use strict'; + if (opt_restart || this.isStopped()) { + this.progress = 0; + this.coords = this.startPoint; + } else if (this.isPlaying()) { + return false; + } + + goog.fx.anim.unregisterAnimation(this); + + var now = /** @type {number} */ (goog.now()); + + this.startTime = now; + if (this.isPaused()) { + this.startTime -= this.duration * this.progress; + } + + this.endTime = this.startTime + this.duration; + this.lastFrame = this.startTime; + + if (!this.progress) { + this.onBegin(); + } + + this.onPlay(); + + if (this.isPaused()) { + this.onResume(); + } + + this.setStatePlaying(); + + goog.fx.anim.registerAnimation(this); + this.cycle(now); + + return true; +}; + + +/** + * Stops the animation. + * @param {boolean=} opt_gotoEnd If true the animation will move to the + * end coords. + * @override + */ +goog.fx.Animation.prototype.stop = function(opt_gotoEnd) { + 'use strict'; + goog.fx.anim.unregisterAnimation(this); + this.setStateStopped(); + + if (opt_gotoEnd) { + this.progress = 1; + } + + this.updateCoords_(this.progress); + + this.onStop(); + this.onEnd(); +}; + + +/** + * Pauses the animation (iff it's playing). + * @override + */ +goog.fx.Animation.prototype.pause = function() { + 'use strict'; + if (this.isPlaying()) { + goog.fx.anim.unregisterAnimation(this); + this.setStatePaused(); + this.onPause(); + } +}; + + +/** + * @return {number} The current progress of the animation, the number + * is between 0 and 1 inclusive. + */ +goog.fx.Animation.prototype.getProgress = function() { + 'use strict'; + return this.progress; +}; + + +/** + * Sets the progress of the animation. + * @param {number} progress The new progress of the animation. + */ +goog.fx.Animation.prototype.setProgress = function(progress) { + 'use strict'; + this.progress = progress; + if (this.isPlaying()) { + var now = goog.now(); + // If the animation is already playing, we recompute startTime and endTime + // such that the animation plays consistently, that is: + // now = startTime + progress * duration. + this.startTime = now - this.duration * this.progress; + this.endTime = this.startTime + this.duration; + } +}; + + +/** + * Disposes of the animation. Stops an animation, fires a 'destroy' event and + * then removes all the event handlers to clean up memory. + * @override + * @protected + */ +goog.fx.Animation.prototype.disposeInternal = function() { + 'use strict'; + if (!this.isStopped()) { + this.stop(false); + } + this.onDestroy(); + goog.fx.Animation.base(this, 'disposeInternal'); +}; + + +/** + * Stops an animation, fires a 'destroy' event and then removes all the event + * handlers to clean up memory. + * @deprecated Use dispose() instead. + */ +goog.fx.Animation.prototype.destroy = function() { + 'use strict'; + this.dispose(); +}; + + +/** @override */ +goog.fx.Animation.prototype.onAnimationFrame = function(now) { + 'use strict'; + this.cycle(now); +}; + + +/** + * Handles the actual iteration of the animation in a timeout + * @param {number} now The current time. + */ +goog.fx.Animation.prototype.cycle = function(now) { + 'use strict'; + goog.asserts.assertNumber(this.startTime); + goog.asserts.assertNumber(this.endTime); + goog.asserts.assertNumber(this.lastFrame); + // Happens in rare system clock reset. + if (now < this.startTime) { + this.endTime = now + this.endTime - this.startTime; + this.startTime = now; + } + this.progress = (now - this.startTime) / (this.endTime - this.startTime); + + if (this.progress > 1) { + this.progress = 1; + } + + this.fps_ = 1000 / (now - this.lastFrame); + this.lastFrame = now; + + this.updateCoords_(this.progress); + + // Animation has finished. + if (this.progress == 1) { + this.setStateStopped(); + goog.fx.anim.unregisterAnimation(this); + + this.onFinish(); + this.onEnd(); + + // Animation is still under way. + } else if (this.isPlaying()) { + this.onAnimate(); + } +}; + + +/** + * Calculates current coordinates, based on the current state. Applies + * the acceleration function if it exists. + * @param {number} t Percentage of the way through the animation as a decimal. + * @private + */ +goog.fx.Animation.prototype.updateCoords_ = function(t) { + 'use strict'; + if (typeof this.accel_ === 'function') { + t = this.accel_(t); + } + this.coords = new Array(this.startPoint.length); + for (var i = 0; i < this.startPoint.length; i++) { + this.coords[i] = + (this.endPoint[i] - this.startPoint[i]) * t + this.startPoint[i]; + } +}; + + +/** + * Dispatches the ANIMATE event. Sub classes should override this instead + * of listening to the event. + * @protected + */ +goog.fx.Animation.prototype.onAnimate = function() { + 'use strict'; + this.dispatchAnimationEvent(goog.fx.Animation.EventType.ANIMATE); +}; + + +/** + * Dispatches the DESTROY event. Sub classes should override this instead + * of listening to the event. + * @protected + */ +goog.fx.Animation.prototype.onDestroy = function() { + 'use strict'; + this.dispatchAnimationEvent(goog.fx.Animation.EventType.DESTROY); +}; + + +/** @override */ +goog.fx.Animation.prototype.dispatchAnimationEvent = function(type) { + 'use strict'; + this.dispatchEvent(new goog.fx.AnimationEvent(type, this)); +}; + + + +/** + * Class for an animation event object. + * @param {string} type Event type. + * @param {goog.fx.Animation} anim An animation object. + * @constructor + * @struct + * @extends {goog.events.Event} + */ +goog.fx.AnimationEvent = function(type, anim) { + 'use strict'; + goog.fx.AnimationEvent.base(this, 'constructor', type); + + /** + * The current coordinates. + * @type {Array} + */ + this.coords = anim.coords; + + /** + * The x coordinate. + * @type {number} + */ + this.x = anim.coords[0]; + + /** + * The y coordinate. + * @type {number} + */ + this.y = anim.coords[1]; + + /** + * The z coordinate. + * @type {number} + */ + this.z = anim.coords[2]; + + /** + * The current duration. + * @type {number} + */ + this.duration = anim.duration; + + /** + * The current progress. + * @type {number} + */ + this.progress = anim.getProgress(); + + /** + * Frames per second so far. + */ + this.fps = anim.fps_; + + /** + * The state of the animation. + * @type {number} + */ + this.state = anim.getStateInternal(); + + /** + * The animation object. + * @type {goog.fx.Animation} + */ + // TODO(arv): This can be removed as this is the same as the target + this.anim = anim; +}; +goog.inherits(goog.fx.AnimationEvent, goog.events.Event); + + +/** + * Returns the coordinates as integers (rounded to nearest integer). + * @return {!Array} An array of the coordinates rounded to + * the nearest integer. + */ +goog.fx.AnimationEvent.prototype.coordsAsInts = function() { + 'use strict'; + return this.coords.map(Math.round); +}; diff --git a/closure/goog/fx/animation_test.js b/closure/goog/fx/animation_test.js new file mode 100644 index 0000000000..fcba4c5689 --- /dev/null +++ b/closure/goog/fx/animation_test.js @@ -0,0 +1,138 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.fx.AnimationTest'); +goog.setTestOnly(); + +const Animation = goog.require('goog.fx.Animation'); +const MockClock = goog.require('goog.testing.MockClock'); +const events = goog.require('goog.events'); +const testSuite = goog.require('goog.testing.testSuite'); + +let clock; + +testSuite({ + setUpPage() { + clock = new MockClock(true); + }, + + tearDownPage() { + clock.dispose(); + }, + + testPauseLogic() { + const anim = new Animation([], [], 3000); + let nFrames = 0; + let progress = 0; + events.listen(anim, Animation.EventType.ANIMATE, (e) => { + assertRoughlyEquals(e.progress, progress, 1e-6); + nFrames++; + }); + events.listen(anim, Animation.EventType.END, (e) => { + nFrames++; + }); + const nSteps = 10; + for (let i = 0; i < nSteps; i++) { + progress = i / (nSteps - 1); + anim.setProgress(progress); + anim.play(); + anim.pause(); + } + assertEquals(nSteps, nFrames); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testPauseOffset() { + const anim = new Animation([0], [1000], 1000); + anim.play(); + + assertEquals(0, anim.coords[0]); + assertRoughlyEquals(0, anim.progress, 1e-4); + + clock.tick(300); + + assertEquals(300, anim.coords[0]); + assertRoughlyEquals(0.3, anim.progress, 1e-4); + + anim.pause(); + + clock.tick(400); + + assertEquals(300, anim.coords[0]); + assertRoughlyEquals(0.3, anim.progress, 1e-4); + + anim.play(); + + assertEquals(300, anim.coords[0]); + assertRoughlyEquals(0.3, anim.progress, 1e-4); + + clock.tick(400); + + assertEquals(700, anim.coords[0]); + assertRoughlyEquals(0.7, anim.progress, 1e-4); + + anim.pause(); + + clock.tick(300); + + assertEquals(700, anim.coords[0]); + assertRoughlyEquals(0.7, anim.progress, 1e-4); + + anim.play(); + + const lastPlay = Date.now(); + + assertEquals(700, anim.coords[0]); + assertRoughlyEquals(0.7, anim.progress, 1e-4); + + clock.tick(300); + + assertEquals(1000, anim.coords[0]); + assertRoughlyEquals(1, anim.progress, 1e-4); + assertEquals(Animation.State.STOPPED, anim.getStateInternal()); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testClockReset() { + const anim = new Animation([0], [1000], 1000); + anim.play(); + + assertEquals(0, anim.coords[0]); + assertRoughlyEquals(0, anim.progress, 1e-4); + + // Possible when clock is reset. + /** @suppress {visibility} suppression added to enable type checking */ + clock.nowMillis_ -= 200000; + anim.pause(); + anim.play(); + + assertEquals(0, anim.coords[0]); + assertRoughlyEquals(0, anim.progress, 1e-4); + + // Animation shoud still only last a second. + clock.tick(900); + anim.pause(); + anim.play(); + + assertEquals(900, anim.coords[0]); + assertRoughlyEquals(0.9, anim.progress, 1e-4); + }, + + testSetProgress() { + const anim = new Animation([0], [1000], 3000); + let nFrames = 0; + anim.play(); + anim.setProgress(0.5); + events.listen(anim, Animation.EventType.ANIMATE, (e) => { + assertEquals(500, e.coords[0]); + assertRoughlyEquals(0.5, e.progress, 1e-4); + nFrames++; + }); + anim.cycle(Date.now()); + anim.stop(); + assertEquals(1, nFrames); + }, +}); diff --git a/closure/goog/fx/animationqueue.js b/closure/goog/fx/animationqueue.js new file mode 100644 index 0000000000..f77bcf54fc --- /dev/null +++ b/closure/goog/fx/animationqueue.js @@ -0,0 +1,325 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A class which automatically plays through a queue of + * animations. AnimationParallelQueue and AnimationSerialQueue provide + * specific implementations of the abstract class AnimationQueue. + * + * @see ../demos/animationqueue.html + */ + +goog.provide('goog.fx.AnimationParallelQueue'); +goog.provide('goog.fx.AnimationQueue'); +goog.provide('goog.fx.AnimationSerialQueue'); + +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.events'); +goog.require('goog.fx.Animation'); +goog.require('goog.fx.Transition'); +goog.require('goog.fx.TransitionBase'); +goog.requireType('goog.events.Event'); + + + +/** + * Constructor for AnimationQueue object. + * + * @constructor + * @struct + * @extends {goog.fx.TransitionBase} + */ +goog.fx.AnimationQueue = function() { + 'use strict'; + goog.fx.AnimationQueue.base(this, 'constructor'); + + /** + * An array holding all animations in the queue. + * @type {Array} + * @protected + */ + this.queue = []; +}; +goog.inherits(goog.fx.AnimationQueue, goog.fx.TransitionBase); + + +/** + * Pushes an Animation to the end of the queue. + * @param {goog.fx.TransitionBase} animation The animation to add to the queue. + */ +goog.fx.AnimationQueue.prototype.add = function(animation) { + 'use strict'; + goog.asserts.assert( + this.isStopped(), + 'Not allowed to add animations to a running animation queue.'); + + if (goog.array.contains(this.queue, animation)) { + return; + } + + this.queue.push(animation); + goog.events.listen( + animation, goog.fx.Transition.EventType.FINISH, this.onAnimationFinish, + false, this); +}; + + +/** + * Removes an Animation from the queue. + * @param {goog.fx.Animation} animation The animation to remove. + */ +goog.fx.AnimationQueue.prototype.remove = function(animation) { + 'use strict'; + goog.asserts.assert( + this.isStopped(), + 'Not allowed to remove animations from a running animation queue.'); + + if (goog.array.remove(this.queue, animation)) { + goog.events.unlisten( + animation, goog.fx.Transition.EventType.FINISH, this.onAnimationFinish, + false, this); + } +}; + + +/** + * Handles the event that an animation has finished. + * @param {goog.events.Event} e The finishing event. + * @protected + */ +goog.fx.AnimationQueue.prototype.onAnimationFinish = goog.abstractMethod; + + +/** + * Disposes of the animations. + * @override + */ +goog.fx.AnimationQueue.prototype.disposeInternal = function() { + 'use strict'; + this.queue.forEach(function(animation) { + 'use strict'; + animation.dispose(); + }); + this.queue.length = 0; + + goog.fx.AnimationQueue.base(this, 'disposeInternal'); +}; + + + +/** + * Constructor for AnimationParallelQueue object. + * @constructor + * @struct + * @extends {goog.fx.AnimationQueue} + */ +goog.fx.AnimationParallelQueue = function() { + 'use strict'; + goog.fx.AnimationParallelQueue.base(this, 'constructor'); + + /** + * Number of finished animations. + * @type {number} + * @private + */ + this.finishedCounter_ = 0; +}; +goog.inherits(goog.fx.AnimationParallelQueue, goog.fx.AnimationQueue); + + +/** @override */ +goog.fx.AnimationParallelQueue.prototype.play = function(opt_restart) { + 'use strict'; + if (this.queue.length == 0) { + return false; + } + + if (opt_restart || this.isStopped()) { + this.finishedCounter_ = 0; + this.onBegin(); + } else if (this.isPlaying()) { + return false; + } + + this.onPlay(); + if (this.isPaused()) { + this.onResume(); + } + var resuming = this.isPaused() && !opt_restart; + + this.startTime = goog.now(); + this.endTime = null; + this.setStatePlaying(); + + this.queue.forEach(function(anim) { + 'use strict'; + if (!resuming || anim.isPaused()) { + anim.play(opt_restart); + } + }); + + return true; +}; + + +/** @override */ +goog.fx.AnimationParallelQueue.prototype.pause = function() { + 'use strict'; + if (this.isPlaying()) { + this.queue.forEach(function(anim) { + 'use strict'; + if (anim.isPlaying()) { + anim.pause(); + } + }); + + this.setStatePaused(); + this.onPause(); + } +}; + + +/** @override */ +goog.fx.AnimationParallelQueue.prototype.stop = function(opt_gotoEnd) { + 'use strict'; + this.queue.forEach(function(anim) { + 'use strict'; + if (!anim.isStopped()) { + anim.stop(opt_gotoEnd); + } + }); + + this.setStateStopped(); + this.endTime = goog.now(); + + this.onStop(); + this.onEnd(); +}; + + +/** @override */ +goog.fx.AnimationParallelQueue.prototype.onAnimationFinish = function(e) { + 'use strict'; + this.finishedCounter_++; + if (this.finishedCounter_ == this.queue.length) { + this.endTime = goog.now(); + + this.setStateStopped(); + + this.onFinish(); + this.onEnd(); + } +}; + + + +/** + * Constructor for AnimationSerialQueue object. + * @constructor + * @struct + * @extends {goog.fx.AnimationQueue} + */ +goog.fx.AnimationSerialQueue = function() { + 'use strict'; + goog.fx.AnimationSerialQueue.base(this, 'constructor'); + + /** + * Current animation in queue currently active. + * @type {number} + * @private + */ + this.current_ = 0; +}; +goog.inherits(goog.fx.AnimationSerialQueue, goog.fx.AnimationQueue); + + +/** @override */ +goog.fx.AnimationSerialQueue.prototype.play = function(opt_restart) { + 'use strict'; + if (this.queue.length == 0) { + return false; + } + + if (opt_restart || this.isStopped()) { + if (this.current_ < this.queue.length && + !this.queue[this.current_].isStopped()) { + this.queue[this.current_].stop(false); + } + + this.current_ = 0; + this.onBegin(); + } else if (this.isPlaying()) { + return false; + } + + this.onPlay(); + if (this.isPaused()) { + this.onResume(); + } + + this.startTime = goog.now(); + this.endTime = null; + this.setStatePlaying(); + + this.queue[this.current_].play(opt_restart); + + return true; +}; + + +/** @override */ +goog.fx.AnimationSerialQueue.prototype.pause = function() { + 'use strict'; + if (this.isPlaying()) { + this.queue[this.current_].pause(); + this.setStatePaused(); + this.onPause(); + } +}; + + +/** @override */ +goog.fx.AnimationSerialQueue.prototype.stop = function(opt_gotoEnd) { + 'use strict'; + this.setStateStopped(); + this.endTime = goog.now(); + + if (opt_gotoEnd) { + for (var i = this.current_; i < this.queue.length; ++i) { + var anim = this.queue[i]; + // If the animation is stopped, start it to initiate rendering. This + // might be needed to make the next line work. + if (anim.isStopped()) anim.play(); + // If the animation is not done, stop it and go to the end state of the + // animation. + if (!anim.isStopped()) anim.stop(true); + } + } else if (this.current_ < this.queue.length) { + this.queue[this.current_].stop(false); + } + + this.onStop(); + this.onEnd(); +}; + + +/** @override */ +goog.fx.AnimationSerialQueue.prototype.onAnimationFinish = function(e) { + 'use strict'; + if (this.isPlaying()) { + this.current_++; + if (this.current_ < this.queue.length) { + this.queue[this.current_].play(); + } else { + this.endTime = goog.now(); + this.setStateStopped(); + + this.onFinish(); + this.onEnd(); + } + } +}; diff --git a/closure/goog/fx/animationqueue_test.js b/closure/goog/fx/animationqueue_test.js new file mode 100644 index 0000000000..21c969c060 --- /dev/null +++ b/closure/goog/fx/animationqueue_test.js @@ -0,0 +1,339 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.fx.AnimationQueueTest'); +goog.setTestOnly(); + +const Animation = goog.require('goog.fx.Animation'); +const AnimationParallelQueue = goog.require('goog.fx.AnimationParallelQueue'); +const AnimationSerialQueue = goog.require('goog.fx.AnimationSerialQueue'); +const MockClock = goog.require('goog.testing.MockClock'); +const Transition = goog.require('goog.fx.Transition'); +const events = goog.require('goog.events'); +const fxAnim = goog.require('goog.fx.anim'); +const testSuite = goog.require('goog.testing.testSuite'); + +let clock; + +testSuite({ + setUpPage() { + clock = new MockClock(true); + fxAnim.setAnimationWindow(null); + }, + + tearDownPage() { + clock.dispose(); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testParallelEvents() { + const anim = new AnimationParallelQueue(); + anim.add(new Animation([0], [100], 200)); + anim.add(new Animation([0], [100], 400)); + anim.add(new Animation([0], [100], 600)); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isStopped()); + assertTrue(anim.isStopped()); + + let beginEvents = 0; + let pauseEvents = 0; + let playEvents = 0; + let resumeEvents = 0; + + let endEvents = 0; + let finishEvents = 0; + let stopEvents = 0; + + events.listen(anim, Transition.EventType.PLAY, () => { + ++playEvents; + }); + events.listen(anim, Transition.EventType.BEGIN, () => { + ++beginEvents; + }); + events.listen(anim, Transition.EventType.RESUME, () => { + ++resumeEvents; + }); + events.listen(anim, Transition.EventType.PAUSE, () => { + ++pauseEvents; + }); + events.listen(anim, Transition.EventType.END, () => { + ++endEvents; + }); + events.listen(anim, Transition.EventType.STOP, () => { + ++stopEvents; + }); + events.listen(anim, Transition.EventType.FINISH, () => { + ++finishEvents; + }); + + // PLAY, BEGIN + anim.play(); + // No queue events. + clock.tick(100); + // PAUSE + anim.pause(); + // No queue events + clock.tick(200); + // PLAY, RESUME + anim.play(); + // No queue events. + clock.tick(400); + // END, STOP + anim.stop(); + // PLAY, BEGIN + anim.play(); + // No queue events. + clock.tick(400); + // END, FINISH + clock.tick(200); + + // Make sure the event counts are right. + assertEquals(3, playEvents); + assertEquals(2, beginEvents); + assertEquals(1, resumeEvents); + assertEquals(1, pauseEvents); + assertEquals(2, endEvents); + assertEquals(1, stopEvents); + assertEquals(1, finishEvents); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testSerialEvents() { + const anim = new AnimationSerialQueue(); + anim.add(new Animation([0], [100], 100)); + anim.add(new Animation([0], [100], 200)); + anim.add(new Animation([0], [100], 300)); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isStopped()); + assertTrue(anim.isStopped()); + + let beginEvents = 0; + let pauseEvents = 0; + let playEvents = 0; + let resumeEvents = 0; + + let endEvents = 0; + let finishEvents = 0; + let stopEvents = 0; + + events.listen(anim, Transition.EventType.PLAY, () => { + ++playEvents; + }); + events.listen(anim, Transition.EventType.BEGIN, () => { + ++beginEvents; + }); + events.listen(anim, Transition.EventType.RESUME, () => { + ++resumeEvents; + }); + events.listen(anim, Transition.EventType.PAUSE, () => { + ++pauseEvents; + }); + events.listen(anim, Transition.EventType.END, () => { + ++endEvents; + }); + events.listen(anim, Transition.EventType.STOP, () => { + ++stopEvents; + }); + events.listen(anim, Transition.EventType.FINISH, () => { + ++finishEvents; + }); + + // PLAY, BEGIN + anim.play(); + // No queue events. + clock.tick(100); + // PAUSE + anim.pause(); + // No queue events + clock.tick(200); + // PLAY, RESUME + anim.play(); + // No queue events. + clock.tick(400); + // END, STOP + anim.stop(); + // PLAY, BEGIN + anim.play(true); + // No queue events. + clock.tick(400); + // END, FINISH + clock.tick(200); + + // Make sure the event counts are right. + assertEquals(3, playEvents); + assertEquals(2, beginEvents); + assertEquals(1, resumeEvents); + assertEquals(1, pauseEvents); + assertEquals(2, endEvents); + assertEquals(1, stopEvents); + assertEquals(1, finishEvents); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testParallelPause() { + const anim = new AnimationParallelQueue(); + anim.add(new Animation([0], [100], 100)); + anim.add(new Animation([0], [100], 200)); + anim.add(new Animation([0], [100], 300)); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isStopped()); + assertTrue(anim.isStopped()); + + anim.play(); + + assertTrue(anim.queue[0].isPlaying()); + assertTrue(anim.queue[1].isPlaying()); + assertTrue(anim.queue[2].isPlaying()); + assertTrue(anim.isPlaying()); + + clock.tick(100); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isPlaying()); + assertTrue(anim.queue[2].isPlaying()); + assertTrue(anim.isPlaying()); + + anim.pause(); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isPaused()); + assertTrue(anim.queue[2].isPaused()); + assertTrue(anim.isPaused()); + + clock.tick(200); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isPaused()); + assertTrue(anim.queue[2].isPaused()); + assertTrue(anim.isPaused()); + + anim.play(); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isPlaying()); + assertTrue(anim.queue[2].isPlaying()); + assertTrue(anim.isPlaying()); + + clock.tick(100); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isPlaying()); + assertTrue(anim.isPlaying()); + + anim.pause(); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isPaused()); + assertTrue(anim.isPaused()); + + clock.tick(200); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isPaused()); + assertTrue(anim.isPaused()); + + anim.play(); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isPlaying()); + assertTrue(anim.isPlaying()); + + clock.tick(100); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isStopped()); + assertTrue(anim.isStopped()); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testSerialPause() { + const anim = new AnimationSerialQueue(); + anim.add(new Animation([0], [100], 100)); + anim.add(new Animation([0], [100], 200)); + anim.add(new Animation([0], [100], 300)); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isStopped()); + assertTrue(anim.isStopped()); + + anim.play(); + + assertTrue(anim.queue[0].isPlaying()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isStopped()); + assertTrue(anim.isPlaying()); + + clock.tick(100); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isPlaying()); + assertTrue(anim.queue[2].isStopped()); + assertTrue(anim.isPlaying()); + + anim.pause(); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isPaused()); + assertTrue(anim.queue[2].isStopped()); + assertTrue(anim.isPaused()); + + clock.tick(400); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isPaused()); + assertTrue(anim.queue[2].isStopped()); + assertTrue(anim.isPaused()); + + anim.play(); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isPlaying()); + assertTrue(anim.queue[2].isStopped()); + assertTrue(anim.isPlaying()); + + clock.tick(200); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isPlaying()); + assertTrue(anim.isPlaying()); + + anim.pause(); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isPaused()); + assertTrue(anim.isPaused()); + + clock.tick(300); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isPaused()); + assertTrue(anim.isPaused()); + + anim.play(); + + clock.tick(300); + + assertTrue(anim.queue[0].isStopped()); + assertTrue(anim.queue[1].isStopped()); + assertTrue(anim.queue[2].isStopped()); + assertTrue(anim.isStopped()); + }, +}); diff --git a/closure/goog/fx/css3/BUILD b/closure/goog/fx/css3/BUILD new file mode 100644 index 0000000000..59feb6f50b --- /dev/null +++ b/closure/goog/fx/css3/BUILD @@ -0,0 +1,25 @@ +load("//closure:defs.bzl", "closure_js_library") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +closure_js_library( + name = "fx", + srcs = ["fx.js"], + lenient = True, + deps = [":transition"], +) + +closure_js_library( + name = "transition", + srcs = ["transition.js"], + lenient = True, + deps = [ + "//closure/goog/asserts", + "//closure/goog/fx:transitionbase", + "//closure/goog/style", + "//closure/goog/style:transition", + "//closure/goog/timer", + ], +) diff --git a/closure/goog/fx/css3/fx.js b/closure/goog/fx/css3/fx.js new file mode 100644 index 0000000000..907b9fd36a --- /dev/null +++ b/closure/goog/fx/css3/fx.js @@ -0,0 +1,56 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A collection of CSS3 targeted animation, based on + * `goog.fx.css3.Transition`. + */ + +goog.provide('goog.fx.css3'); + +goog.require('goog.fx.css3.Transition'); + + +/** + * Creates a transition to fade the element. + * @param {Element} element The element to fade. + * @param {number} duration Duration in seconds. + * @param {string} timing The CSS3 timing function. + * @param {number} startOpacity Starting opacity. + * @param {number} endOpacity Ending opacity. + * @return {!goog.fx.css3.Transition} The transition object. + */ +goog.fx.css3.fade = function( + element, duration, timing, startOpacity, endOpacity) { + 'use strict'; + return new goog.fx.css3.Transition( + element, duration, {'opacity': startOpacity}, {'opacity': endOpacity}, + {property: 'opacity', duration: duration, timing: timing, delay: 0}); +}; + + +/** + * Creates a transition to fade in the element. + * @param {Element} element The element to fade in. + * @param {number} duration Duration in seconds. + * @return {!goog.fx.css3.Transition} The transition object. + */ +goog.fx.css3.fadeIn = function(element, duration) { + 'use strict'; + return goog.fx.css3.fade(element, duration, 'ease-out', 0, 1); +}; + + +/** + * Creates a transition to fade out the element. + * @param {Element} element The element to fade out. + * @param {number} duration Duration in seconds. + * @return {!goog.fx.css3.Transition} The transition object. + */ +goog.fx.css3.fadeOut = function(element, duration) { + 'use strict'; + return goog.fx.css3.fade(element, duration, 'ease-in', 1, 0); +}; diff --git a/closure/goog/fx/css3/transition.js b/closure/goog/fx/css3/transition.js new file mode 100644 index 0000000000..2780d9c9bb --- /dev/null +++ b/closure/goog/fx/css3/transition.js @@ -0,0 +1,201 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview CSS3 transition base library. + */ + +goog.provide('goog.fx.css3.Transition'); + +goog.require('goog.Timer'); +goog.require('goog.asserts'); +goog.require('goog.fx.TransitionBase'); +goog.require('goog.style'); +goog.require('goog.style.transition'); + + + +/** + * A class to handle targeted CSS3 transition. This class + * handles common features required for targeted CSS3 transition. + * + * Browser that does not support CSS3 transition will still receive all + * the events fired by the transition object, but will not have any transition + * played. If the browser supports the final state as set in setFinalState + * method, the element will ends in the final state. + * + * Transitioning multiple properties with the same setting is possible + * by setting Css3Property's property to 'all'. Performing multiple + * transitions can be done via setting multiple initialStyle, + * finalStyle and transitions. Css3Property's delay can be used to + * delay one of the transition. Here is an example for a transition + * that expands on the width and then followed by the height: + * + *
    + *   var animation = new goog.fx.css3.Transition(
    + *     element,
    + *     duration,
    + *     {width: 10px, height: 10px},
    + *     {width: 100px, height: 100px},
    + *     [
    + *       {property: width, duration: 1, timing: 'ease-in', delay: 0},
    + *       {property: height, duration: 1, timing: 'ease-in', delay: 1}
    + *     ]
    + *   );
    + * 
    + * + * @param {Element} element The element to be transitioned. + * @param {number} duration The duration of the transition in seconds. + * This should be the longest of all transitions, including any delay. + * @param {Object} initialStyle Initial style properties of the element before + * animating. Set using `goog.style.setStyle`. + * @param {Object} finalStyle Final style properties of the element after + * animating. Set using `goog.style.setStyle`. + * @param {goog.style.transition.Css3Property| + * Array} transitions A single CSS3 + * transition property or an array of it. + * @extends {goog.fx.TransitionBase} + * @constructor + * @struct + */ +goog.fx.css3.Transition = function( + element, duration, initialStyle, finalStyle, transitions) { + 'use strict'; + goog.fx.css3.Transition.base(this, 'constructor'); + + /** + * Timer id to be used to cancel animation part-way. + * @private {number} + */ + this.timerId_; + + /** + * @type {Element} + * @private + */ + this.element_ = element; + + /** + * @type {number} + * @private + */ + this.duration_ = duration; + + /** + * @type {Object} + * @private + */ + this.initialStyle_ = initialStyle; + + /** + * @type {Object} + * @private + */ + this.finalStyle_ = finalStyle; + + /** + * @type {Array} + * @private + */ + this.transitions_ = Array.isArray(transitions) ? transitions : [transitions]; +}; +goog.inherits(goog.fx.css3.Transition, goog.fx.TransitionBase); + + +/** @override */ +goog.fx.css3.Transition.prototype.play = function() { + 'use strict'; + if (this.isPlaying()) { + return false; + } + + this.onBegin(); + this.onPlay(); + + this.startTime = goog.now(); + this.setStatePlaying(); + + if (goog.style.transition.isSupported()) { + goog.style.setStyle(this.element_, this.initialStyle_); + // Allow element to get updated to its initial state before installing + // CSS3 transition. + this.timerId_ = goog.Timer.callOnce(this.play_, undefined, this); + return true; + } else { + this.stop_(false); + return false; + } +}; + + +/** + * Helper method for play method. This needs to be executed on a timer. + * @private + */ +goog.fx.css3.Transition.prototype.play_ = function() { + 'use strict'; + // This measurement of the DOM element causes the browser to recalculate its + // initial state before the transition starts. + goog.style.getSize(this.element_); + goog.style.transition.set(this.element_, this.transitions_); + goog.style.setStyle(this.element_, this.finalStyle_); + this.timerId_ = goog.Timer.callOnce( + goog.bind(this.stop_, this, false), this.duration_ * 1000); +}; + + +/** @override */ +goog.fx.css3.Transition.prototype.stop = function() { + 'use strict'; + if (!this.isPlaying()) return; + + this.stop_(true); +}; + + +/** + * Helper method for stop method. + * @param {boolean} stopped If the transition was stopped. + * @private + */ +goog.fx.css3.Transition.prototype.stop_ = function(stopped) { + 'use strict'; + goog.style.transition.removeAll(this.element_); + + // Clear the timer. + goog.Timer.clear(this.timerId_); + + // Make sure that we have reached the final style. + goog.style.setStyle(this.element_, this.finalStyle_); + + this.endTime = goog.now(); + this.setStateStopped(); + + if (stopped) { + this.onStop(); + } else { + this.onFinish(); + } + this.onEnd(); +}; + + +/** @override */ +goog.fx.css3.Transition.prototype.disposeInternal = function() { + 'use strict'; + this.stop(); + goog.fx.css3.Transition.base(this, 'disposeInternal'); +}; + + +/** + * Pausing CSS3 Transitions in not supported. + * @override + */ +goog.fx.css3.Transition.prototype.pause = function() { + 'use strict'; + goog.asserts.assert(false, 'Css3 transitions does not support pause action.'); +}; diff --git a/closure/goog/fx/css3/transition_test.js b/closure/goog/fx/css3/transition_test.js new file mode 100644 index 0000000000..5fe8b6ba68 --- /dev/null +++ b/closure/goog/fx/css3/transition_test.js @@ -0,0 +1,191 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.fx.css3.TransitionTest'); +goog.setTestOnly(); + +const Css3Transition = goog.require('goog.fx.css3.Transition'); +const MockClock = goog.require('goog.testing.MockClock'); +const TagName = goog.require('goog.dom.TagName'); +const Transition = goog.require('goog.fx.Transition'); +const dispose = goog.require('goog.dispose'); +const dom = goog.require('goog.dom'); +const events = goog.require('goog.events'); +const recordFunction = goog.require('goog.testing.recordFunction'); +const styleTransition = goog.require('goog.style.transition'); +const testSuite = goog.require('goog.testing.testSuite'); + +let transition; +let element; +let mockClock; + +function createTransition(element, duration) { + return new Css3Transition( + element, duration, {'opacity': 0}, {'opacity': 1}, + {property: 'opacity', duration: duration, timing: 'ease-in', delay: 0}); +} + +testSuite({ + setUp() { + mockClock = new MockClock(true); + element = dom.createElement(TagName.DIV); + document.body.appendChild(element); + }, + + tearDown() { + dispose(transition); + dispose(mockClock); + dom.removeNode(element); + }, + + testPlayEventFiredOnPlay() { + if (!styleTransition.isSupported()) return; + + transition = createTransition(element, 10); + let handlerCalled = false; + events.listen(transition, Transition.EventType.PLAY, () => { + handlerCalled = true; + }); + + transition.play(); + assertTrue(handlerCalled); + }, + + testBeginEventFiredOnPlay() { + if (!styleTransition.isSupported()) return; + + transition = createTransition(element, 10); + let handlerCalled = false; + events.listen(transition, Transition.EventType.BEGIN, () => { + handlerCalled = true; + }); + + transition.play(); + assertTrue(handlerCalled); + }, + + testFinishEventsFiredAfterFinish() { + if (!styleTransition.isSupported()) return; + + transition = createTransition(element, 10); + let finishHandlerCalled = false; + let endHandlerCalled = false; + events.listen(transition, Transition.EventType.FINISH, () => { + finishHandlerCalled = true; + }); + events.listen(transition, Transition.EventType.END, () => { + endHandlerCalled = true; + }); + + transition.play(); + + mockClock.tick(10000); + + assertTrue(finishHandlerCalled); + assertTrue(endHandlerCalled); + }, + + testEventsWhenTransitionIsUnsupported() { + if (styleTransition.isSupported()) return; + + transition = createTransition(element, 10); + + let stopHandlerCalled = false; + let endHandlerCalled = false; + let finishHandlerCalled = false; + + let beginHandlerCalled = false; + let playHandlerCalled = false; + + events.listen(transition, Transition.EventType.BEGIN, () => { + beginHandlerCalled = true; + }); + events.listen(transition, Transition.EventType.PLAY, () => { + playHandlerCalled = true; + }); + events.listen(transition, Transition.EventType.FINISH, () => { + finishHandlerCalled = true; + }); + events.listen(transition, Transition.EventType.END, () => { + endHandlerCalled = true; + }); + events.listen(transition, Transition.EventType.STOP, () => { + stopHandlerCalled = true; + }); + + assertFalse(transition.play()); + + assertTrue(beginHandlerCalled); + assertTrue(playHandlerCalled); + assertTrue(endHandlerCalled); + assertTrue(finishHandlerCalled); + + transition.stop(); + + assertFalse(stopHandlerCalled); + }, + + testCallingStopDuringAnimationWorks() { + if (!styleTransition.isSupported()) return; + + transition = createTransition(element, 10); + + const stopHandler = recordFunction(); + const endHandler = recordFunction(); + const finishHandler = recordFunction(); + events.listen(transition, Transition.EventType.STOP, stopHandler); + events.listen(transition, Transition.EventType.END, endHandler); + events.listen(transition, Transition.EventType.FINISH, finishHandler); + + transition.play(); + mockClock.tick(1); + transition.stop(); + assertEquals(1, stopHandler.getCallCount()); + assertEquals(1, endHandler.getCallCount()); + mockClock.tick(10000); + assertEquals(0, finishHandler.getCallCount()); + }, + + testCallingStopImmediatelyWorks() { + if (!styleTransition.isSupported()) return; + + transition = createTransition(element, 10); + + const stopHandler = recordFunction(); + const endHandler = recordFunction(); + const finishHandler = recordFunction(); + events.listen(transition, Transition.EventType.STOP, stopHandler); + events.listen(transition, Transition.EventType.END, endHandler); + events.listen(transition, Transition.EventType.FINISH, finishHandler); + + transition.play(); + transition.stop(); + assertEquals(1, stopHandler.getCallCount()); + assertEquals(1, endHandler.getCallCount()); + mockClock.tick(10000); + assertEquals(0, finishHandler.getCallCount()); + }, + + testCallingStopAfterAnimationDoesNothing() { + if (!styleTransition.isSupported()) return; + + transition = createTransition(element, 10); + + const stopHandler = recordFunction(); + const endHandler = recordFunction(); + const finishHandler = recordFunction(); + events.listen(transition, Transition.EventType.STOP, stopHandler); + events.listen(transition, Transition.EventType.END, endHandler); + events.listen(transition, Transition.EventType.FINISH, finishHandler); + + transition.play(); + mockClock.tick(10000); + transition.stop(); + assertEquals(0, stopHandler.getCallCount()); + assertEquals(1, endHandler.getCallCount()); + assertEquals(1, finishHandler.getCallCount()); + }, +}); diff --git a/closure/goog/fx/cssspriteanimation.js b/closure/goog/fx/cssspriteanimation.js new file mode 100644 index 0000000000..3d4bba1373 --- /dev/null +++ b/closure/goog/fx/cssspriteanimation.js @@ -0,0 +1,129 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview An animation class that animates CSS sprites by changing the + * CSS background-position. + * + * @see ../demos/cssspriteanimation.html + */ + +goog.provide('goog.fx.CssSpriteAnimation'); + +goog.require('goog.fx.Animation'); +goog.requireType('goog.math.Box'); +goog.requireType('goog.math.Size'); + + + +/** + * This animation class is used to animate a CSS sprite (moving a background + * image). This moves through a series of images in a single image sprite. By + * default, the animation loops when done. Looping can be disabled by setting + * `opt_disableLoop` and results in the animation stopping on the last + * image in the image sprite. You should set up the {@code background-image} + * and size in a CSS rule for the relevant element. + * + * @param {Element} element The HTML element to animate the background for. + * @param {goog.math.Size} size The size of one image in the image sprite. + * @param {goog.math.Box} box The box describing the layout of the sprites to + * use in the large image. The sprites can be position horizontally or + * vertically and using a box here allows the implementation to know which + * way to go. + * @param {number} time The duration in milliseconds for one iteration of the + * animation. For example, if the sprite contains 4 images and the duration + * is set to 400ms then each sprite will be displayed for 100ms. + * @param {function(number) : number=} opt_acc Acceleration function, + * returns 0-1 for inputs 0-1. This can be used to make certain frames be + * shown for a longer period of time. + * @param {boolean=} opt_disableLoop Whether the animation should be halted + * after a single loop of the images in the sprite. + * + * @constructor + * @struct + * @extends {goog.fx.Animation} + * @final + */ +goog.fx.CssSpriteAnimation = function( + element, size, box, time, opt_acc, opt_disableLoop) { + 'use strict'; + var start = [box.left, box.top]; + // We never draw for the end so we do not need to subtract for the size + var end = [box.right, box.bottom]; + goog.fx.CssSpriteAnimation.base( + this, 'constructor', start, end, time, opt_acc); + + /** + * HTML element that will be used in the animation. + * @type {Element} + * @private + */ + this.element_ = element; + + /** + * The size of an individual sprite in the image sprite. + * @type {goog.math.Size} + * @private + */ + this.size_ = size; + + /** + * Whether the animation should be halted after a single loop of the images + * in the sprite. + * @type {boolean} + * @private + */ + this.disableLoop_ = !!opt_disableLoop; +}; +goog.inherits(goog.fx.CssSpriteAnimation, goog.fx.Animation); + + +/** @override */ +goog.fx.CssSpriteAnimation.prototype.onAnimate = function() { + 'use strict'; + // Round to nearest sprite. + var x = -Math.floor(this.coords[0] / this.size_.width) * this.size_.width; + var y = -Math.floor(this.coords[1] / this.size_.height) * this.size_.height; + this.element_.style.backgroundPosition = x + 'px ' + y + 'px'; + + goog.fx.CssSpriteAnimation.base(this, 'onAnimate'); +}; + + +/** @override */ +goog.fx.CssSpriteAnimation.prototype.onFinish = function() { + 'use strict'; + if (!this.disableLoop_) { + this.play(true); + } + goog.fx.CssSpriteAnimation.base(this, 'onFinish'); +}; + + +/** + * Clears the background position style set directly on the element + * by the animation. Allows to apply CSS styling for background position on the + * same element when the sprite animation is not runniing. + */ +goog.fx.CssSpriteAnimation.prototype.clearSpritePosition = function() { + 'use strict'; + var style = this.element_.style; + style.backgroundPosition = ''; + + if (typeof style.backgroundPositionX != 'undefined') { + // IE needs to clear x and y to actually clear the position + style.backgroundPositionX = ''; + style.backgroundPositionY = ''; + } +}; + + +/** @override */ +goog.fx.CssSpriteAnimation.prototype.disposeInternal = function() { + 'use strict'; + goog.fx.CssSpriteAnimation.superClass_.disposeInternal.call(this); + this.element_ = null; +}; diff --git a/closure/goog/fx/cssspriteanimation_test.js b/closure/goog/fx/cssspriteanimation_test.js new file mode 100644 index 0000000000..fd17111da5 --- /dev/null +++ b/closure/goog/fx/cssspriteanimation_test.js @@ -0,0 +1,145 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.fx.CssSpriteAnimationTest'); +goog.setTestOnly(); + +const Box = goog.require('goog.math.Box'); +const CssSpriteAnimation = goog.require('goog.fx.CssSpriteAnimation'); +const MockClock = goog.require('goog.testing.MockClock'); +const Size = goog.require('goog.math.Size'); +const testSuite = goog.require('goog.testing.testSuite'); + +let el; +let size; +let box; +const time = 1000; +let anim; +let clock; + +function assertBackgroundPosition(x, y) { + if (typeof el.style.backgroundPositionX != 'undefined') { + assertEquals(`${x}px`, el.style.backgroundPositionX); + assertEquals(`${y}px`, el.style.backgroundPositionY); + } else { + const bgPos = el.style.backgroundPosition; + const message = `Expected <${x}px ${y}px>, found <${bgPos}>`; + if (x == y) { + // when x and y are the same the browser sometimes collapse the prop + assertTrue( + message, + bgPos == x || // in case of 0 without a unit + bgPos == `${x}px` || bgPos == `${x} ${y}` || + bgPos == `${x}px ${y}px`); + } else { + assertTrue( + message, + bgPos == `${x} ${y}` || bgPos == `${x}px ${y}` || + bgPos == `${x} ${y}px` || bgPos == `${x}px ${y}px`); + } + } +} + +testSuite({ + setUpPage() { + clock = new MockClock(true); + el = document.getElementById('test'); + size = new Size(10, 10); + box = new Box(0, 10, 100, 0); + }, + + tearDownPage() { + clock.dispose(); + }, + + tearDown() { + anim.clearSpritePosition(); + anim.dispose(); + }, + + testAnimation() { + anim = new CssSpriteAnimation(el, size, box, time); + anim.play(); + + assertBackgroundPosition(0, 0); + + clock.tick(5); + assertBackgroundPosition(0, 0); + + clock.tick(95); + assertBackgroundPosition(0, -10); + + clock.tick(100); + assertBackgroundPosition(0, -20); + + clock.tick(300); + assertBackgroundPosition(0, -50); + + clock.tick(400); + assertBackgroundPosition(0, -90); + + // loop around to starting position + clock.tick(100); + assertBackgroundPosition(0, 0); + + assertTrue(anim.isPlaying()); + assertFalse(anim.isStopped()); + + clock.tick(100); + assertBackgroundPosition(0, -10); + }, + + testAnimation_disableLoop() { + anim = new CssSpriteAnimation( + el, size, box, time, undefined, true /* opt_disableLoop */); + anim.play(); + + assertBackgroundPosition(0, 0); + + clock.tick(5); + assertBackgroundPosition(0, 0); + + clock.tick(95); + assertBackgroundPosition(0, -10); + + clock.tick(100); + assertBackgroundPosition(0, -20); + + clock.tick(300); + assertBackgroundPosition(0, -50); + + clock.tick(400); + assertBackgroundPosition(0, -90); + + // loop around to starting position + clock.tick(100); + assertBackgroundPosition(0, -90); + + assertTrue(anim.isStopped()); + assertFalse(anim.isPlaying()); + + clock.tick(100); + assertBackgroundPosition(0, -90); + }, + + testClearSpritePosition() { + anim = new CssSpriteAnimation(el, size, box, time); + anim.play(); + + assertBackgroundPosition(0, 0); + + clock.tick(100); + assertBackgroundPosition(0, -10); + anim.clearSpritePosition(); + + if (typeof el.style.backgroundPositionX != 'undefined') { + assertEquals('', el.style.backgroundPositionX); + assertEquals('', el.style.backgroundPositionY); + } + + assertEquals('', el.style.backgroundPosition); + }, +}); diff --git a/closure/goog/fx/cssspriteanimation_test_dom.html b/closure/goog/fx/cssspriteanimation_test_dom.html new file mode 100644 index 0000000000..6ae8ce4c44 --- /dev/null +++ b/closure/goog/fx/cssspriteanimation_test_dom.html @@ -0,0 +1,14 @@ + + +
    +
    \ No newline at end of file diff --git a/closure/goog/fx/dom.js b/closure/goog/fx/dom.js new file mode 100644 index 0000000000..c666c16fa1 --- /dev/null +++ b/closure/goog/fx/dom.js @@ -0,0 +1,749 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Predefined DHTML animations such as slide, resize and fade. + * + * @see ../demos/effects.html + */ + +goog.provide('goog.fx.dom'); +goog.provide('goog.fx.dom.BgColorTransform'); +goog.provide('goog.fx.dom.ColorTransform'); +goog.provide('goog.fx.dom.Fade'); +goog.provide('goog.fx.dom.FadeIn'); +goog.provide('goog.fx.dom.FadeInAndShow'); +goog.provide('goog.fx.dom.FadeOut'); +goog.provide('goog.fx.dom.FadeOutAndHide'); +goog.provide('goog.fx.dom.PredefinedEffect'); +goog.provide('goog.fx.dom.Resize'); +goog.provide('goog.fx.dom.ResizeHeight'); +goog.provide('goog.fx.dom.ResizeWidth'); +goog.provide('goog.fx.dom.Scroll'); +goog.provide('goog.fx.dom.Slide'); +goog.provide('goog.fx.dom.SlideFrom'); +goog.provide('goog.fx.dom.Swipe'); + +goog.require('goog.color'); +goog.require('goog.events'); +goog.require('goog.fx.Animation'); +goog.require('goog.fx.Transition'); +goog.require('goog.style'); +goog.require('goog.style.bidi'); +goog.requireType('goog.events.EventHandler'); + + + +/** + * Abstract class that provides reusable functionality for predefined animations + * that manipulate a single DOM element + * + * @param {Element} element Dom Node to be used in the animation. + * @param {Array} start Array for start coordinates. + * @param {Array} end Array for end coordinates. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.Animation} + * @constructor + * @struct + */ +goog.fx.dom.PredefinedEffect = function(element, start, end, time, opt_acc) { + 'use strict'; + goog.fx.dom.PredefinedEffect.base( + this, 'constructor', start, end, time, opt_acc); + + /** + * DOM Node that will be used in the animation + * @type {Element} + */ + this.element = element; + + /** + * Whether the element is rendered right-to-left. We cache this here for + * efficiency. + * @private {boolean|undefined} + */ + this.rightToLeft_; +}; +goog.inherits(goog.fx.dom.PredefinedEffect, goog.fx.Animation); + + +/** + * Called to update the style of the element. + * @protected + */ +goog.fx.dom.PredefinedEffect.prototype.updateStyle = function() {}; + + +/** + * Whether the DOM element being manipulated is rendered right-to-left. + * @return {boolean} True if the DOM element is rendered right-to-left, false + * otherwise. + */ +goog.fx.dom.PredefinedEffect.prototype.isRightToLeft = function() { + 'use strict'; + if (this.rightToLeft_ === undefined) { + this.rightToLeft_ = goog.style.isRightToLeft(this.element); + } + return this.rightToLeft_; +}; + + +/** @override */ +goog.fx.dom.PredefinedEffect.prototype.onAnimate = function() { + 'use strict'; + this.updateStyle(); + goog.fx.dom.PredefinedEffect.superClass_.onAnimate.call(this); +}; + + +/** @override */ +goog.fx.dom.PredefinedEffect.prototype.onEnd = function() { + 'use strict'; + this.updateStyle(); + goog.fx.dom.PredefinedEffect.superClass_.onEnd.call(this); +}; + + +/** @override */ +goog.fx.dom.PredefinedEffect.prototype.onBegin = function() { + 'use strict'; + this.updateStyle(); + goog.fx.dom.PredefinedEffect.superClass_.onBegin.call(this); +}; + + + +/** + * Creates an animation object that will slide an element from A to B. (This + * in effect automatically sets up the onanimate event for an Animation object) + * + * Start and End should be 2 dimensional arrays + * + * @param {Element} element Dom Node to be used in the animation. + * @param {Array} start 2D array for start coordinates (X, Y). + * @param {Array} end 2D array for end coordinates (X, Y). + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.PredefinedEffect} + * @constructor + * @struct + */ +goog.fx.dom.Slide = function(element, start, end, time, opt_acc) { + 'use strict'; + if (start.length != 2 || end.length != 2) { + throw new Error('Start and end points must be 2D'); + } + goog.fx.dom.Slide.base( + this, 'constructor', element, start, end, time, opt_acc); +}; +goog.inherits(goog.fx.dom.Slide, goog.fx.dom.PredefinedEffect); + + +/** @override */ +goog.fx.dom.Slide.prototype.updateStyle = function() { + 'use strict'; + var pos = (this.isRightPositioningForRtlEnabled() && this.isRightToLeft()) ? + 'right' : + 'left'; + this.element.style[pos] = Math.round(this.coords[0]) + 'px'; + this.element.style.top = Math.round(this.coords[1]) + 'px'; +}; + + + +/** + * Slides an element from its current position. + * + * @param {Element} element DOM node to be used in the animation. + * @param {Array} end 2D array for end coordinates (X, Y). + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.Slide} + * @constructor + * @struct + */ +goog.fx.dom.SlideFrom = function(element, end, time, opt_acc) { + 'use strict'; + var offsetLeft = /** @type {!HTMLElement} */ (element).offsetLeft; + var start = [offsetLeft, /** @type {!HTMLElement} */ (element).offsetTop]; + goog.fx.dom.SlideFrom.base( + this, 'constructor', element, start, end, time, opt_acc); + /** @type {?Array} */ + this.startPoint; +}; +goog.inherits(goog.fx.dom.SlideFrom, goog.fx.dom.Slide); + + +/** @override */ +goog.fx.dom.SlideFrom.prototype.onBegin = function() { + 'use strict'; + var offsetLeft = this.isRightPositioningForRtlEnabled() ? + goog.style.bidi.getOffsetStart(this.element) : + /** @type {!HTMLElement} */ (this.element).offsetLeft; + this.startPoint = [ + offsetLeft, + /** @type {!HTMLElement} */ (this.element).offsetTop + ]; + goog.fx.dom.SlideFrom.superClass_.onBegin.call(this); +}; + + + +/** + * Creates an animation object that will slide an element into its final size. + * Requires that the element is absolutely positioned. + * + * @param {Element} element Dom Node to be used in the animation. + * @param {Array} start 2D array for start size (W, H). + * @param {Array} end 2D array for end size (W, H). + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.PredefinedEffect} + * @constructor + * @struct + */ +goog.fx.dom.Swipe = function(element, start, end, time, opt_acc) { + 'use strict'; + if (start.length != 2 || end.length != 2) { + throw new Error('Start and end points must be 2D'); + } + goog.fx.dom.Swipe.base( + this, 'constructor', element, start, end, time, opt_acc); + + /** + * Maximum width for element. + * @type {number} + * @private + */ + this.maxWidth_ = Math.max(this.endPoint[0], this.startPoint[0]); + + /** + * Maximum height for element. + * @type {number} + * @private + */ + this.maxHeight_ = Math.max(this.endPoint[1], this.startPoint[1]); +}; +goog.inherits(goog.fx.dom.Swipe, goog.fx.dom.PredefinedEffect); + + +/** + * Animation event handler that will resize an element by setting its width, + * height and clipping. + * @protected + * @override + */ +goog.fx.dom.Swipe.prototype.updateStyle = function() { + 'use strict'; + var x = this.coords[0]; + var y = this.coords[1]; + this.clip_(Math.round(x), Math.round(y), this.maxWidth_, this.maxHeight_); + this.element.style.width = Math.round(x) + 'px'; + var marginX = + (this.isRightPositioningForRtlEnabled() && this.isRightToLeft()) ? + 'marginRight' : + 'marginLeft'; + + this.element.style[marginX] = Math.round(x) - this.maxWidth_ + 'px'; + this.element.style.marginTop = Math.round(y) - this.maxHeight_ + 'px'; +}; + + +/** + * Helper function for setting element clipping. + * @param {number} x Current element width. + * @param {number} y Current element height. + * @param {number} w Maximum element width. + * @param {number} h Maximum element height. + * @private + */ +goog.fx.dom.Swipe.prototype.clip_ = function(x, y, w, h) { + 'use strict'; + this.element.style.clip = + 'rect(' + (h - y) + 'px ' + w + 'px ' + h + 'px ' + (w - x) + 'px)'; +}; + + + +/** + * Creates an animation object that will scroll an element from A to B. + * + * Start and End should be 2 dimensional arrays + * + * @param {Element} element Dom Node to be used in the animation. + * @param {Array} start 2D array for start scroll left and top. + * @param {Array} end 2D array for end scroll left and top. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.PredefinedEffect} + * @constructor + * @struct + */ +goog.fx.dom.Scroll = function(element, start, end, time, opt_acc) { + 'use strict'; + if (start.length != 2 || end.length != 2) { + throw new Error('Start and end points must be 2D'); + } + goog.fx.dom.Scroll.base( + this, 'constructor', element, start, end, time, opt_acc); +}; +goog.inherits(goog.fx.dom.Scroll, goog.fx.dom.PredefinedEffect); + + +/** + * Animation event handler that will set the scroll position of an element. + * @protected + * @override + */ +goog.fx.dom.Scroll.prototype.updateStyle = function() { + 'use strict'; + if (this.isRightPositioningForRtlEnabled()) { + goog.style.bidi.setScrollOffset(this.element, Math.round(this.coords[0])); + } else { + this.element.scrollLeft = Math.round(this.coords[0]); + } + this.element.scrollTop = Math.round(this.coords[1]); +}; + + + +/** + * Creates an animation object that will resize an element between two widths + * and heights. + * + * Start and End should be 2 dimensional arrays + * + * @param {Element} element Dom Node to be used in the animation. + * @param {Array} start 2D array for start width and height. + * @param {Array} end 2D array for end width and height. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.PredefinedEffect} + * @constructor + * @struct + */ +goog.fx.dom.Resize = function(element, start, end, time, opt_acc) { + 'use strict'; + if (start.length != 2 || end.length != 2) { + throw new Error('Start and end points must be 2D'); + } + goog.fx.dom.Resize.base( + this, 'constructor', element, start, end, time, opt_acc); +}; +goog.inherits(goog.fx.dom.Resize, goog.fx.dom.PredefinedEffect); + + +/** + * Animation event handler that will resize an element by setting its width and + * height. + * @protected + * @override + */ +goog.fx.dom.Resize.prototype.updateStyle = function() { + 'use strict'; + this.element.style.width = Math.round(this.coords[0]) + 'px'; + this.element.style.height = Math.round(this.coords[1]) + 'px'; +}; + + + +/** + * Creates an animation object that will resize an element between two widths + * + * Start and End should be numbers + * + * @param {Element} element Dom Node to be used in the animation. + * @param {number} start Start width. + * @param {number} end End width. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.PredefinedEffect} + * @constructor + * @struct + */ +goog.fx.dom.ResizeWidth = function(element, start, end, time, opt_acc) { + 'use strict'; + goog.fx.dom.ResizeWidth.base( + this, 'constructor', element, [start], [end], time, opt_acc); +}; +goog.inherits(goog.fx.dom.ResizeWidth, goog.fx.dom.PredefinedEffect); + + +/** + * Animation event handler that will resize an element by setting its width. + * @protected + * @override + */ +goog.fx.dom.ResizeWidth.prototype.updateStyle = function() { + 'use strict'; + this.element.style.width = Math.round(this.coords[0]) + 'px'; +}; + + + +/** + * Creates an animation object that will resize an element between two heights + * + * Start and End should be numbers + * + * @param {Element} element Dom Node to be used in the animation. + * @param {number} start Start height. + * @param {number} end End height. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.PredefinedEffect} + * @constructor + * @struct + */ +goog.fx.dom.ResizeHeight = function(element, start, end, time, opt_acc) { + 'use strict'; + goog.fx.dom.ResizeHeight.base( + this, 'constructor', element, [start], [end], time, opt_acc); +}; +goog.inherits(goog.fx.dom.ResizeHeight, goog.fx.dom.PredefinedEffect); + + +/** + * Animation event handler that will resize an element by setting its height. + * @protected + * @override + */ +goog.fx.dom.ResizeHeight.prototype.updateStyle = function() { + 'use strict'; + this.element.style.height = Math.round(this.coords[0]) + 'px'; +}; + + + +/** + * Creates an animation object that fades the opacity of an element between two + * limits. + * + * Start and End should be floats between 0 and 1 + * + * @param {Element} element Dom Node to be used in the animation. + * @param {Array|number} start 1D Array or Number with start opacity. + * @param {Array|number} end 1D Array or Number for end opacity. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.PredefinedEffect} + * @constructor + * @struct + */ +goog.fx.dom.Fade = function(element, start, end, time, opt_acc) { + 'use strict'; + if (typeof start === 'number') start = [start]; + if (typeof end === 'number') end = [end]; + + goog.fx.dom.Fade.base( + this, 'constructor', element, start, end, time, opt_acc); + + if (start.length != 1 || end.length != 1) { + throw new Error('Start and end points must be 1D'); + } + + /** + * The last opacity we set, or -1 for not set. + * @private {number} + */ + this.lastOpacityUpdate_ = goog.fx.dom.Fade.OPACITY_UNSET_; +}; +goog.inherits(goog.fx.dom.Fade, goog.fx.dom.PredefinedEffect); + + +/** + * The quantization of opacity values to use. + * @private {number} + */ +goog.fx.dom.Fade.TOLERANCE_ = 1.0 / 0x400; // 10-bit color + + +/** + * Value indicating that the opacity must be set on next update. + * @private {number} + */ +goog.fx.dom.Fade.OPACITY_UNSET_ = -1; + + +/** + * Animation event handler that will set the opacity of an element. + * @protected + * @override + */ +goog.fx.dom.Fade.prototype.updateStyle = function() { + 'use strict'; + var opacity = this.coords[0]; + var delta = Math.abs(opacity - this.lastOpacityUpdate_); + // In order to keep eager browsers from over-rendering, only update + // on a potentially visible change in opacity. + if (delta >= goog.fx.dom.Fade.TOLERANCE_) { + goog.style.setOpacity(this.element, opacity); + this.lastOpacityUpdate_ = opacity; + } +}; + + +/** @override */ +goog.fx.dom.Fade.prototype.onBegin = function() { + 'use strict'; + this.lastOpacityUpdate_ = goog.fx.dom.Fade.OPACITY_UNSET_; + goog.fx.dom.Fade.base(this, 'onBegin'); +}; + + +/** @override */ +goog.fx.dom.Fade.prototype.onEnd = function() { + 'use strict'; + this.lastOpacityUpdate_ = goog.fx.dom.Fade.OPACITY_UNSET_; + goog.fx.dom.Fade.base(this, 'onEnd'); +}; + + +/** + * Animation event handler that will show the element. + */ +goog.fx.dom.Fade.prototype.show = function() { + 'use strict'; + this.element.style.display = ''; +}; + + +/** + * Animation event handler that will hide the element + */ +goog.fx.dom.Fade.prototype.hide = function() { + 'use strict'; + this.element.style.display = 'none'; +}; + + + +/** + * Fades an element out from full opacity to completely transparent. + * + * @param {Element} element Dom Node to be used in the animation. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.Fade} + * @constructor + * @struct + */ +goog.fx.dom.FadeOut = function(element, time, opt_acc) { + 'use strict'; + goog.fx.dom.FadeOut.base(this, 'constructor', element, 1, 0, time, opt_acc); +}; +goog.inherits(goog.fx.dom.FadeOut, goog.fx.dom.Fade); + + + +/** + * Fades an element in from completely transparent to fully opacity. + * + * @param {Element} element Dom Node to be used in the animation. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.Fade} + * @constructor + * @struct + */ +goog.fx.dom.FadeIn = function(element, time, opt_acc) { + 'use strict'; + goog.fx.dom.FadeIn.base(this, 'constructor', element, 0, 1, time, opt_acc); +}; +goog.inherits(goog.fx.dom.FadeIn, goog.fx.dom.Fade); + + + +/** + * Fades an element out from full opacity to completely transparent and then + * sets the display to 'none' + * + * @param {Element} element Dom Node to be used in the animation. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.Fade} + * @constructor + * @struct + */ +goog.fx.dom.FadeOutAndHide = function(element, time, opt_acc) { + 'use strict'; + goog.fx.dom.FadeOutAndHide.base( + this, 'constructor', element, 1, 0, time, opt_acc); +}; +goog.inherits(goog.fx.dom.FadeOutAndHide, goog.fx.dom.Fade); + + +/** @override */ +goog.fx.dom.FadeOutAndHide.prototype.onBegin = function() { + 'use strict'; + this.show(); + goog.fx.dom.FadeOutAndHide.superClass_.onBegin.call(this); +}; + + +/** @override */ +goog.fx.dom.FadeOutAndHide.prototype.onEnd = function() { + 'use strict'; + this.hide(); + goog.fx.dom.FadeOutAndHide.superClass_.onEnd.call(this); +}; + + + +/** + * Sets an element's display to be visible and then fades an element in from + * completely transparent to fully opaque. + * + * @param {Element} element Dom Node to be used in the animation. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.Fade} + * @constructor + * @struct + */ +goog.fx.dom.FadeInAndShow = function(element, time, opt_acc) { + 'use strict'; + goog.fx.dom.FadeInAndShow.base( + this, 'constructor', element, 0, 1, time, opt_acc); +}; +goog.inherits(goog.fx.dom.FadeInAndShow, goog.fx.dom.Fade); + + +/** @override */ +goog.fx.dom.FadeInAndShow.prototype.onBegin = function() { + 'use strict'; + this.show(); + goog.fx.dom.FadeInAndShow.superClass_.onBegin.call(this); +}; + + + +/** + * Provides a transformation of an elements background-color. + * + * Start and End should be 3D arrays representing R,G,B + * + * @param {Element} element Dom Node to be used in the animation. + * @param {Array} start 3D Array for RGB of start color. + * @param {Array} end 3D Array for RGB of end color. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @extends {goog.fx.dom.PredefinedEffect} + * @constructor + * @struct + */ +goog.fx.dom.BgColorTransform = function(element, start, end, time, opt_acc) { + 'use strict'; + if (start.length != 3 || end.length != 3) { + throw new Error('Start and end points must be 3D'); + } + goog.fx.dom.BgColorTransform.base( + this, 'constructor', element, start, end, time, opt_acc); +}; +goog.inherits(goog.fx.dom.BgColorTransform, goog.fx.dom.PredefinedEffect); + + +/** + * Animation event handler that will set the background-color of an element + */ +goog.fx.dom.BgColorTransform.prototype.setColor = function() { + 'use strict'; + var coordsAsInts = []; + for (var i = 0; i < this.coords.length; i++) { + coordsAsInts[i] = Math.round(this.coords[i]); + } + var color = 'rgb(' + coordsAsInts.join(',') + ')'; + this.element.style.backgroundColor = color; +}; + + +/** @override */ +goog.fx.dom.BgColorTransform.prototype.updateStyle = function() { + 'use strict'; + this.setColor(); +}; + + +/** + * Fade elements background color from start color to the element's current + * background color. + * + * Start should be a 3D array representing R,G,B + * + * @param {Element} element Dom Node to be used in the animation. + * @param {Array} start 3D Array for RGB of start color. + * @param {number} time Length of animation in milliseconds. + * @param {goog.events.EventHandler=} opt_eventHandler Optional event handler + * to use when listening for events. + */ +goog.fx.dom.bgColorFadeIn = function(element, start, time, opt_eventHandler) { + 'use strict'; + var initialBgColor = element.style.backgroundColor || ''; + var computedBgColor = goog.style.getBackgroundColor(element); + var end; + + if (computedBgColor && computedBgColor != 'transparent' && + computedBgColor != 'rgba(0, 0, 0, 0)') { + end = goog.color.hexToRgb(goog.color.parse(computedBgColor).hex); + } else { + end = [255, 255, 255]; + } + + var anim = new goog.fx.dom.BgColorTransform(element, start, end, time); + + function setBgColor() { + element.style.backgroundColor = initialBgColor; + } + + if (opt_eventHandler) { + opt_eventHandler.listen(anim, goog.fx.Transition.EventType.END, setBgColor); + } else { + goog.events.listen(anim, goog.fx.Transition.EventType.END, setBgColor); + } + + anim.play(); +}; + + + +/** + * Provides a transformation of an elements color. + * + * @param {Element} element Dom Node to be used in the animation. + * @param {Array} start 3D Array representing R,G,B. + * @param {Array} end 3D Array representing R,G,B. + * @param {number} time Length of animation in milliseconds. + * @param {Function=} opt_acc Acceleration function, returns 0-1 for inputs 0-1. + * @constructor + * @struct + * @extends {goog.fx.dom.PredefinedEffect} + */ +goog.fx.dom.ColorTransform = function(element, start, end, time, opt_acc) { + 'use strict'; + if (start.length != 3 || end.length != 3) { + throw new Error('Start and end points must be 3D'); + } + goog.fx.dom.ColorTransform.base( + this, 'constructor', element, start, end, time, opt_acc); +}; +goog.inherits(goog.fx.dom.ColorTransform, goog.fx.dom.PredefinedEffect); + + +/** + * Animation event handler that will set the color of an element. + * @protected + * @override + */ +goog.fx.dom.ColorTransform.prototype.updateStyle = function() { + 'use strict'; + var coordsAsInts = []; + for (var i = 0; i < this.coords.length; i++) { + coordsAsInts[i] = Math.round(this.coords[i]); + } + var color = 'rgb(' + coordsAsInts.join(',') + ')'; + this.element.style.color = color; +}; diff --git a/closure/goog/fx/dragdrop.js b/closure/goog/fx/dragdrop.js new file mode 100644 index 0000000000..8097767028 --- /dev/null +++ b/closure/goog/fx/dragdrop.js @@ -0,0 +1,44 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Single Element Drag and Drop. + * + * Drag and drop implementation for sources/targets consisting of a single + * element. + * + * @see ../demos/dragdrop.html + */ + +goog.provide('goog.fx.DragDrop'); + +goog.require('goog.fx.AbstractDragDrop'); +goog.require('goog.fx.DragDropItem'); + + + +/** + * Drag/drop implementation for creating drag sources/drop targets consisting of + * a single HTML Element. + * + * @param {Element|string} element Dom Node, or string representation of node + * id, to be used as drag source/drop target. + * @param {DRAG_DROP_DATA=} opt_data Data associated with the source/target. + * @throws Error If no element argument is provided or if the type is invalid + * @extends {goog.fx.AbstractDragDrop} + * @template DRAG_DROP_DATA + * @constructor + * @struct + */ +goog.fx.DragDrop = function(element, opt_data) { + 'use strict'; + goog.fx.AbstractDragDrop.call(this); + + var item = new goog.fx.DragDropItem(element, opt_data); + item.setParent(this); + this.items_.push(item); +}; +goog.inherits(goog.fx.DragDrop, goog.fx.AbstractDragDrop); diff --git a/closure/goog/fx/dragdropgroup.js b/closure/goog/fx/dragdropgroup.js new file mode 100644 index 0000000000..ff31fc3ae7 --- /dev/null +++ b/closure/goog/fx/dragdropgroup.js @@ -0,0 +1,106 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Multiple Element Drag and Drop. + * + * Drag and drop implementation for sources/targets consisting of multiple + * elements. + * + * @see ../demos/dragdrop.html + */ + +goog.provide('goog.fx.DragDropGroup'); + +goog.require('goog.dom'); +goog.require('goog.fx.AbstractDragDrop'); +goog.require('goog.fx.DragDropItem'); + + + +/** + * Drag/drop implementation for creating drag sources/drop targets consisting of + * multiple HTML Elements (items). All items share the same drop target(s) but + * can be dragged individually. + * + * @extends {goog.fx.AbstractDragDrop} + * @constructor + * @struct + */ +goog.fx.DragDropGroup = function() { + 'use strict'; + goog.fx.AbstractDragDrop.call(this); +}; +goog.inherits(goog.fx.DragDropGroup, goog.fx.AbstractDragDrop); + + +/** + * Add item to drag object. + * + * @param {Element|string} element Dom Node, or string representation of node + * id, to be used as drag source/drop target. + * @param {DRAG_DROP_DATA=} opt_data Data associated with the source/target. + * @throws Error If no element argument is provided or if the type is + * invalid + * @template DRAG_DROP_DATA + * @override + */ +goog.fx.DragDropGroup.prototype.addItem = function(element, opt_data) { + 'use strict'; + var item = new goog.fx.DragDropItem(element, opt_data); + this.addDragDropItem(item); +}; + + +/** + * Add DragDropItem to drag object. + * + * @param {goog.fx.DragDropItem} item DragDropItem being added to the + * drag object. + * @throws Error If no element argument is provided or if the type is + * invalid + */ +goog.fx.DragDropGroup.prototype.addDragDropItem = function(item) { + 'use strict'; + item.setParent(this); + this.items_.push(item); + if (this.isInitialized()) { + this.initItem(item); + } +}; + + +/** + * Remove item from drag object. + * + * @param {Element|string} element Dom Node, or string representation of node + * id, that was previously added with addItem(). + */ +goog.fx.DragDropGroup.prototype.removeItem = function(element) { + 'use strict'; + element = goog.dom.getElement(element); + for (var item, i = 0; item = this.items_[i]; i++) { + if (item.element == element) { + this.items_.splice(i, 1); + this.disposeItem(item); + break; + } + } +}; + + +/** + * Marks the supplied list of items as selected. A drag operation for any of the + * selected items will affect all of them. + * + * @param {Array} list List of items to select or null to + * clear selection. + * + * TODO(eae): Not yet implemented. + */ +goog.fx.DragDropGroup.prototype.setSelection = function(list) { + +}; diff --git a/closure/goog/fx/dragdropgroup_test.js b/closure/goog/fx/dragdropgroup_test.js new file mode 100644 index 0000000000..1a55cd0eb3 --- /dev/null +++ b/closure/goog/fx/dragdropgroup_test.js @@ -0,0 +1,222 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.fx.DragDropGroupTest'); +goog.setTestOnly(); + +const DragDropGroup = goog.require('goog.fx.DragDropGroup'); +const events = goog.require('goog.events'); +const testSuite = goog.require('goog.testing.testSuite'); + +let s1; +let s2; +let t1; +let t2; + +let source = null; +let target = null; + +function addElementsToGroups() { + source.addItem(s1); + source.addItem(s2); + target.addItem(t1); + target.addItem(t2); +} + +testSuite({ + setUpPage() { + s1 = document.getElementById('s1'); + s2 = document.getElementById('s2'); + t1 = document.getElementById('t1'); + t2 = document.getElementById('t2'); + }, + + setUp() { + source = new DragDropGroup(); + source.setSourceClass('ss'); + source.setTargetClass('st'); + + target = new DragDropGroup(); + target.setSourceClass('ts'); + target.setTargetClass('tt'); + + source.addTarget(target); + }, + + tearDown() { + source.removeItems(); + target.removeItems(); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testAddItemsBeforeInit() { + addElementsToGroups(); + source.init(); + target.init(); + + assertEquals(2, source.items_.length); + assertEquals(2, target.items_.length); + + assertEquals('s ss', s1.className); + assertEquals('s ss', s2.className); + assertEquals('t tt', t1.className); + assertEquals('t tt', t2.className); + + assertTrue(events.hasListener(s1)); + assertTrue(events.hasListener(s2)); + assertFalse(events.hasListener(t1)); + assertFalse(events.hasListener(t2)); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testAddItemsAfterInit() { + source.init(); + target.init(); + addElementsToGroups(); + + assertEquals(2, source.items_.length); + assertEquals(2, target.items_.length); + + assertEquals('s ss', s1.className); + assertEquals('s ss', s2.className); + assertEquals('t tt', t1.className); + assertEquals('t tt', t2.className); + + assertTrue(events.hasListener(s1)); + assertTrue(events.hasListener(s2)); + assertFalse(events.hasListener(t1)); + assertFalse(events.hasListener(t2)); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testRemoveItems() { + source.init(); + target.init(); + addElementsToGroups(); + + assertEquals(2, source.items_.length); + assertEquals(s1, source.items_[0].element); + assertEquals(s2, source.items_[1].element); + + assertEquals('s ss', s1.className); + assertEquals('s ss', s2.className); + assertTrue(events.hasListener(s1)); + assertTrue(events.hasListener(s2)); + + source.removeItems(); + + assertEquals(0, source.items_.length); + + assertEquals('s', s1.className); + assertEquals('s', s2.className); + assertFalse(events.hasListener(s1)); + assertFalse(events.hasListener(s2)); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testRemoveSourceItem1() { + source.init(); + target.init(); + addElementsToGroups(); + + assertEquals(2, source.items_.length); + assertEquals(s1, source.items_[0].element); + assertEquals(s2, source.items_[1].element); + + assertEquals('s ss', s1.className); + assertEquals('s ss', s2.className); + assertTrue(events.hasListener(s1)); + assertTrue(events.hasListener(s2)); + + source.removeItem(s1); + + assertEquals(1, source.items_.length); + assertEquals(s2, source.items_[0].element); + + assertEquals('s', s1.className); + assertEquals('s ss', s2.className); + assertFalse(events.hasListener(s1)); + assertTrue(events.hasListener(s2)); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testRemoveSourceItem2() { + source.init(); + target.init(); + addElementsToGroups(); + + assertEquals(2, source.items_.length); + assertEquals(s1, source.items_[0].element); + assertEquals(s2, source.items_[1].element); + + assertEquals('s ss', s1.className); + assertEquals('s ss', s2.className); + assertTrue(events.hasListener(s1)); + assertTrue(events.hasListener(s2)); + + source.removeItem(s2); + + assertEquals(1, source.items_.length); + assertEquals(s1, source.items_[0].element); + + assertEquals('s ss', s1.className); + assertEquals('s', s2.className); + assertTrue(events.hasListener(s1)); + assertFalse(events.hasListener(s2)); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testRemoveTargetItem1() { + source.init(); + target.init(); + addElementsToGroups(); + + assertEquals(2, target.items_.length); + assertEquals(t1, target.items_[0].element); + assertEquals(t2, target.items_[1].element); + + assertEquals('t tt', t1.className); + assertEquals('t tt', t2.className); + assertFalse(events.hasListener(t1)); + assertFalse(events.hasListener(t2)); + + target.removeItem(t1); + + assertEquals(1, target.items_.length); + assertEquals(t2, target.items_[0].element); + + assertEquals('t', t1.className); + assertEquals('t tt', t2.className); + assertFalse(events.hasListener(t1)); + assertFalse(events.hasListener(t2)); + }, + + /** @suppress {visibility} suppression added to enable type checking */ + testRemoveTargetItem2() { + source.init(); + target.init(); + addElementsToGroups(); + + assertEquals(2, target.items_.length); + assertEquals(t1, target.items_[0].element); + assertEquals(t2, target.items_[1].element); + + assertEquals('t tt', t1.className); + assertEquals('t tt', t2.className); + assertFalse(events.hasListener(t1)); + assertFalse(events.hasListener(t2)); + + target.removeItem(t2); + + assertEquals(1, target.items_.length); + assertEquals(t1, target.items_[0].element); + + assertEquals('t tt', t1.className); + assertEquals('t', t2.className); + assertFalse(events.hasListener(t1)); + assertFalse(events.hasListener(t2)); + }, +}); diff --git a/closure/goog/fx/dragdropgroup_test_dom.html b/closure/goog/fx/dragdropgroup_test_dom.html new file mode 100644 index 0000000000..3f38b39e74 --- /dev/null +++ b/closure/goog/fx/dragdropgroup_test_dom.html @@ -0,0 +1,14 @@ + +
    +
    +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/closure/goog/fx/dragger.js b/closure/goog/fx/dragger.js new file mode 100644 index 0000000000..00e5982d91 --- /dev/null +++ b/closure/goog/fx/dragger.js @@ -0,0 +1,855 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Drag Utilities. + * + * Provides extensible functionality for drag & drop behaviour. + * + * @see ../demos/drag.html + * @see ../demos/dragger.html + */ + + +goog.provide('goog.fx.DragEvent'); +goog.provide('goog.fx.Dragger'); +goog.provide('goog.fx.Dragger.EventType'); + +goog.require('goog.dom'); +goog.require('goog.dom.TagName'); +goog.require('goog.events'); +goog.require('goog.events.Event'); +goog.require('goog.events.EventHandler'); +goog.require('goog.events.EventTarget'); +goog.require('goog.events.EventType'); +goog.require('goog.math.Coordinate'); +goog.require('goog.math.Rect'); +goog.require('goog.style'); +goog.require('goog.style.bidi'); +goog.require('goog.userAgent'); +goog.requireType('goog.events.BrowserEvent'); + + + +/** + * A class that allows mouse or touch-based dragging (moving) of an element + * + * @param {Element} target The element that will be dragged. + * @param {Element=} opt_handle An optional handle to control the drag, if null + * the target is used. + * @param {goog.math.Rect=} opt_limits Object containing left, top, width, + * and height. + * + * @extends {goog.events.EventTarget} + * @constructor + * @struct + */ +goog.fx.Dragger = function(target, opt_handle, opt_limits) { + 'use strict'; + goog.fx.Dragger.base(this, 'constructor'); + + /** + * Reference to drag target element. + * @type {?Element} + */ + this.target = target; + + /** + * Reference to the handler that initiates the drag. + * @type {?Element} + */ + this.handle = opt_handle || target; + + /** + * Object representing the limits of the drag region. + * @type {goog.math.Rect} + */ + this.limits = opt_limits || new goog.math.Rect(NaN, NaN, NaN, NaN); + + /** + * Reference to a document object to use for the events. + * @private {Document} + */ + this.document_ = goog.dom.getOwnerDocument(target); + + /** @private {!goog.events.EventHandler} */ + this.eventHandler_ = new goog.events.EventHandler(this); + this.registerDisposable(this.eventHandler_); + + /** + * Whether the element is rendered right-to-left. We initialize this lazily. + * @private {boolean|undefined}} + */ + this.rightToLeft_; + + /** + * Current x position of mouse or touch relative to viewport. + * @type {number} + */ + this.clientX = 0; + + /** + * Current y position of mouse or touch relative to viewport. + * @type {number} + */ + this.clientY = 0; + + /** + * Current x position of mouse or touch relative to screen. Deprecated because + * it doesn't take into affect zoom level or pixel density. + * @type {number} + * @deprecated Consider switching to clientX instead. + */ + this.screenX = 0; + + /** + * Current y position of mouse or touch relative to screen. Deprecated because + * it doesn't take into affect zoom level or pixel density. + * @type {number} + * @deprecated Consider switching to clientY instead. + */ + this.screenY = 0; + + /** + * The x position where the first mousedown or touchstart occurred. + * @type {number} + */ + this.startX = 0; + + /** + * The y position where the first mousedown or touchstart occurred. + * @type {number} + */ + this.startY = 0; + + /** + * Current x position of drag relative to target's parent. + * @type {number} + */ + this.deltaX = 0; + + /** + * Current y position of drag relative to target's parent. + * @type {number} + */ + this.deltaY = 0; + + /** + * The current page scroll value. + * @type {?goog.math.Coordinate} + */ + this.pageScroll; + + /** + * Whether dragging is currently enabled. + * @private {boolean} + */ + this.enabled_ = true; + + /** + * Whether object is currently being dragged. + * @private {boolean} + */ + this.dragging_ = false; + + /** + * Whether mousedown should be default prevented. + * @private {boolean} + **/ + this.preventMouseDown_ = true; + + /** + * The amount of distance, in pixels, after which a mousedown or touchstart is + * considered a drag. + * @private {number} + */ + this.hysteresisDistanceSquared_ = 0; + + /** + * The SCROLL event target used to make drag element follow scrolling. + * @private {?EventTarget} + */ + this.scrollTarget_; + + /** + * Whether IE drag events cancelling is on. + * @private {boolean} + */ + this.ieDragStartCancellingOn_ = false; + + /** + * Whether the dragger implements the changes described in http://b/6324964, + * making it truly RTL. This is a temporary flag to allow clients to + * transition to the new behavior at their convenience. At some point it will + * be the default. + * @private {boolean} + */ + this.useRightPositioningForRtl_ = false; + + // Add listener. Do not use the event handler here since the event handler is + // used for listeners added and removed during the drag operation. + goog.events.listen( + this.handle, + [goog.events.EventType.TOUCHSTART, goog.events.EventType.MOUSEDOWN], + this.startDrag, false, this); + + /** @private {boolean} Avoids setCapture() calls to fix click handlers. */ + this.useSetCapture_ = goog.fx.Dragger.HAS_SET_CAPTURE_; +}; +goog.inherits(goog.fx.Dragger, goog.events.EventTarget); +// Dragger is meant to be extended, but defines most properties on its +// prototype, thus making it unsuitable for sealing. + + +/** + * Whether setCapture is supported by the browser. + * IE and Gecko after 1.9.3 have setCapture. MS Edge and WebKit + * (https://bugs.webkit.org/show_bug.cgi?id=27330) don't. + * @type {boolean} + * @private + */ +goog.fx.Dragger.HAS_SET_CAPTURE_ = goog.global.document && + goog.global.document.documentElement && + !!goog.global.document.documentElement.setCapture && + !!goog.global.document.releaseCapture; + + +/** + * Creates copy of node being dragged. This is a utility function to be used + * wherever it is inappropriate for the original source to follow the mouse + * cursor itself. + * + * @param {Element} sourceEl Element to copy. + * @return {!Element} The clone of `sourceEl`. + */ +goog.fx.Dragger.cloneNode = function(sourceEl) { + 'use strict'; + var clonedEl = sourceEl.cloneNode(true), + origTexts = + goog.dom.getElementsByTagName(goog.dom.TagName.TEXTAREA, sourceEl), + dragTexts = + goog.dom.getElementsByTagName(goog.dom.TagName.TEXTAREA, clonedEl); + // Cloning does not copy the current value of textarea elements, so correct + // this manually. + for (var i = 0; i < origTexts.length; i++) { + dragTexts[i].value = origTexts[i].value; + } + switch (sourceEl.tagName) { + case String(goog.dom.TagName.TR): + return goog.dom.createDom( + goog.dom.TagName.TABLE, null, + goog.dom.createDom(goog.dom.TagName.TBODY, null, clonedEl)); + case String(goog.dom.TagName.TD): + case String(goog.dom.TagName.TH): + return goog.dom.createDom( + goog.dom.TagName.TABLE, null, + goog.dom.createDom( + goog.dom.TagName.TBODY, null, + goog.dom.createDom(goog.dom.TagName.TR, null, clonedEl))); + case String(goog.dom.TagName.TEXTAREA): + /** + * @suppress {strictMissingProperties} Added to tighten compiler checks + */ + clonedEl.value = sourceEl.value; + default: + return clonedEl; + } +}; + + +/** + * Constants for event names. + * @enum {string} + */ +goog.fx.Dragger.EventType = { + // The drag action was canceled before the START event. Possible reasons: + // disabled dragger, dragging with the right mouse button or releasing the + // button before reaching the hysteresis distance. + EARLY_CANCEL: 'earlycancel', + START: 'start', + BEFOREDRAG: 'beforedrag', + DRAG: 'drag', + END: 'end' +}; + + +/** + * Prevents the dragger from calling setCapture(), even in browsers that support + * it. If the draggable item has click handlers, setCapture() can break them. + * @param {boolean} allow True to use setCapture if the browser supports it. + */ +goog.fx.Dragger.prototype.setAllowSetCapture = function(allow) { + 'use strict'; + this.useSetCapture_ = allow && goog.fx.Dragger.HAS_SET_CAPTURE_; +}; + + +/** + * Turns on/off true RTL behavior. This should be called immediately after + * construction. This is a temporary flag to allow clients to transition + * to the new component at their convenience. At some point true will be the + * default. + * @param {boolean} useRightPositioningForRtl True if "right" should be used for + * positioning, false if "left" should be used for positioning. + */ +goog.fx.Dragger.prototype.enableRightPositioningForRtl = function( + useRightPositioningForRtl) { + 'use strict'; + this.useRightPositioningForRtl_ = useRightPositioningForRtl; +}; + + +/** + * Returns the event handler, intended for subclass use. + * @return {!goog.events.EventHandler} The event handler. + * @this {T} + * @template T + */ +goog.fx.Dragger.prototype.getHandler = function() { + 'use strict'; + // TODO(user): templated "this" values currently result in "this" being + // "unknown" in the body of the function. + var self = /** @type {goog.fx.Dragger} */ (this); + return self.eventHandler_; +}; + + +/** + * Sets (or reset) the Drag limits after a Dragger is created. + * @param {goog.math.Rect?} limits Object containing left, top, width, + * height for new Dragger limits. If target is right-to-left and + * enableRightPositioningForRtl(true) is called, then rect is interpreted as + * right, top, width, and height. + */ +goog.fx.Dragger.prototype.setLimits = function(limits) { + 'use strict'; + this.limits = limits || new goog.math.Rect(NaN, NaN, NaN, NaN); +}; + + +/** + * Sets the distance the user has to drag the element before a drag operation is + * started. + * @param {number} distance The number of pixels after which a mousedown and + * move is considered a drag. + */ +goog.fx.Dragger.prototype.setHysteresis = function(distance) { + 'use strict'; + this.hysteresisDistanceSquared_ = Math.pow(distance, 2); +}; + + +/** + * Gets the distance the user has to drag the element before a drag operation is + * started. + * @return {number} distance The number of pixels after which a mousedown and + * move is considered a drag. + */ +goog.fx.Dragger.prototype.getHysteresis = function() { + 'use strict'; + return Math.sqrt(this.hysteresisDistanceSquared_); +}; + + +/** + * Sets the SCROLL event target to make drag element follow scrolling. + * + * @param {EventTarget} scrollTarget The event target that dispatches SCROLL + * events. + */ +goog.fx.Dragger.prototype.setScrollTarget = function(scrollTarget) { + 'use strict'; + this.scrollTarget_ = scrollTarget; +}; + + +/** + * Enables cancelling of built-in IE drag events. + * @param {boolean} cancelIeDragStart Whether to enable cancelling of IE + * dragstart event. + */ +goog.fx.Dragger.prototype.setCancelIeDragStart = function(cancelIeDragStart) { + 'use strict'; + this.ieDragStartCancellingOn_ = cancelIeDragStart; +}; + + +/** + * @return {boolean} Whether the dragger is enabled. + */ +goog.fx.Dragger.prototype.getEnabled = function() { + 'use strict'; + return this.enabled_; +}; + + +/** + * Set whether dragger is enabled + * @param {boolean} enabled Whether dragger is enabled. + */ +goog.fx.Dragger.prototype.setEnabled = function(enabled) { + 'use strict'; + this.enabled_ = enabled; +}; + + +/** + * Set whether mousedown should be default prevented. + * @param {boolean} preventMouseDown Whether mousedown should be default + * prevented. + */ +goog.fx.Dragger.prototype.setPreventMouseDown = function(preventMouseDown) { + 'use strict'; + this.preventMouseDown_ = preventMouseDown; +}; + + +/** @override */ +goog.fx.Dragger.prototype.disposeInternal = function() { + 'use strict'; + goog.fx.Dragger.superClass_.disposeInternal.call(this); + goog.events.unlisten( + this.handle, + [goog.events.EventType.TOUCHSTART, goog.events.EventType.MOUSEDOWN], + this.startDrag, false, this); + this.cleanUpAfterDragging_(); + + this.target = null; + this.handle = null; +}; + + +/** + * Whether the DOM element being manipulated is rendered right-to-left. + * @return {boolean} True if the DOM element is rendered right-to-left, false + * otherwise. + * @private + */ +goog.fx.Dragger.prototype.isRightToLeft_ = function() { + 'use strict'; + if (this.rightToLeft_ === undefined) { + this.rightToLeft_ = goog.style.isRightToLeft(this.target); + } + return this.rightToLeft_; +}; + + +/** + * Event handler that is used to start the drag + * @param {goog.events.BrowserEvent} e Event object. + */ +goog.fx.Dragger.prototype.startDrag = function(e) { + 'use strict'; + var isMouseDown = e.type == goog.events.EventType.MOUSEDOWN; + + // Dragger.startDrag() can be called by AbstractDragDrop with a mousemove + // event and IE does not report pressed mouse buttons on mousemove. Also, + // it does not make sense to check for the button if the user is already + // dragging. + + if (this.enabled_ && !this.dragging_ && + (!isMouseDown || e.isMouseActionButton())) { + if (this.hysteresisDistanceSquared_ == 0) { + if (this.fireDragStart_(e)) { + this.dragging_ = true; + if (this.preventMouseDown_ && isMouseDown) { + e.preventDefault(); + } + } else { + // If the start drag is cancelled, don't setup for a drag. + return; + } + } else if (this.preventMouseDown_ && isMouseDown) { + // Need to preventDefault for hysteresis to prevent page getting selected. + e.preventDefault(); + } + this.setupDragHandlers(); + + this.clientX = this.startX = e.clientX; + this.clientY = this.startY = e.clientY; + this.screenX = e.screenX; + this.screenY = e.screenY; + this.computeInitialPosition(); + this.pageScroll = goog.dom.getDomHelper(this.document_).getDocumentScroll(); + } else { + this.dispatchEvent(goog.fx.Dragger.EventType.EARLY_CANCEL); + } +}; + + +/** + * Sets up event handlers when dragging starts. + * @protected + */ +goog.fx.Dragger.prototype.setupDragHandlers = function() { + 'use strict'; + var doc = this.document_; + var docEl = doc.documentElement; + // Use bubbling when we have setCapture since we got reports that IE has + // problems with the capturing events in combination with setCapture. + var useCapture = !this.useSetCapture_; + + this.eventHandler_.listen( + doc, [goog.events.EventType.TOUCHMOVE, goog.events.EventType.MOUSEMOVE], + this.handleMove_, {capture: useCapture, passive: false}); + this.eventHandler_.listen( + doc, [goog.events.EventType.TOUCHEND, goog.events.EventType.MOUSEUP], + this.endDrag, useCapture); + + if (this.useSetCapture_) { + docEl.setCapture(false); + this.eventHandler_.listen( + docEl, goog.events.EventType.LOSECAPTURE, this.endDrag); + } else { + // Make sure we stop the dragging if the window loses focus. + // Don't use capture in this listener because we only want to end the drag + // if the actual window loses focus. Since blur events do not bubble we use + // a bubbling listener on the window. + this.eventHandler_.listen( + goog.dom.getWindow(doc), goog.events.EventType.BLUR, this.endDrag); + } + + if (goog.userAgent.IE && this.ieDragStartCancellingOn_) { + // Cancel IE's 'ondragstart' event. + this.eventHandler_.listen( + doc, goog.events.EventType.DRAGSTART, goog.events.Event.preventDefault); + } + + if (this.scrollTarget_) { + this.eventHandler_.listen( + this.scrollTarget_, goog.events.EventType.SCROLL, this.onScroll_, + useCapture); + } +}; + + +/** + * Fires a goog.fx.Dragger.EventType.START event. + * @param {goog.events.BrowserEvent} e Browser event that triggered the drag. + * @return {boolean} False iff preventDefault was called on the DragEvent. + * @private + */ +goog.fx.Dragger.prototype.fireDragStart_ = function(e) { + 'use strict'; + return this.dispatchEvent(new goog.fx.DragEvent( + goog.fx.Dragger.EventType.START, this, e.clientX, e.clientY, e)); +}; + + +/** + * Unregisters the event handlers that are only active during dragging, and + * releases mouse capture. + * @private + */ +goog.fx.Dragger.prototype.cleanUpAfterDragging_ = function() { + 'use strict'; + this.eventHandler_.removeAll(); + if (this.useSetCapture_) { + this.document_.releaseCapture(); + } +}; + + +/** + * Event handler that is used to end the drag. + * @param {goog.events.BrowserEvent} e Event object. + * @param {boolean=} opt_dragCanceled Whether the drag has been canceled. + */ +goog.fx.Dragger.prototype.endDrag = function(e, opt_dragCanceled) { + 'use strict'; + this.cleanUpAfterDragging_(); + + if (this.dragging_) { + this.dragging_ = false; + + var x = this.limitX(this.deltaX); + var y = this.limitY(this.deltaY); + var dragCanceled = + opt_dragCanceled || e.type == goog.events.EventType.TOUCHCANCEL; + this.dispatchEvent( + new goog.fx.DragEvent( + goog.fx.Dragger.EventType.END, this, e.clientX, e.clientY, e, x, y, + dragCanceled)); + } else { + this.dispatchEvent(goog.fx.Dragger.EventType.EARLY_CANCEL); + } +}; + + +/** + * Event handler that is used to end the drag by cancelling it. + * @param {goog.events.BrowserEvent} e Event object. + */ +goog.fx.Dragger.prototype.endDragCancel = function(e) { + 'use strict'; + this.endDrag(e, true); +}; + + +/** + * Event handler that is used on mouse / touch move to update the drag + * @param {goog.events.BrowserEvent} e Event object. + * @private + */ +goog.fx.Dragger.prototype.handleMove_ = function(e) { + 'use strict'; + if (this.enabled_) { + // dx in right-to-left cases is relative to the right. + var sign = + this.useRightPositioningForRtl_ && this.isRightToLeft_() ? -1 : 1; + var dx = sign * (e.clientX - this.clientX); + var dy = e.clientY - this.clientY; + this.clientX = e.clientX; + this.clientY = e.clientY; + this.screenX = e.screenX; + this.screenY = e.screenY; + + if (!this.dragging_) { + var diffX = this.startX - this.clientX; + var diffY = this.startY - this.clientY; + var distance = diffX * diffX + diffY * diffY; + if (distance > this.hysteresisDistanceSquared_) { + if (this.fireDragStart_(e)) { + this.dragging_ = true; + } else { + // DragListGroup disposes of the dragger if BEFOREDRAGSTART is + // canceled. + if (!this.isDisposed()) { + this.endDrag(e); + } + return; + } + } + } + + var pos = this.calculatePosition_(dx, dy); + var x = pos.x; + var y = pos.y; + + if (this.dragging_) { + var rv = this.dispatchEvent( + new goog.fx.DragEvent( + goog.fx.Dragger.EventType.BEFOREDRAG, this, e.clientX, e.clientY, + e, x, y)); + + // Only do the defaultAction and dispatch drag event if predrag didn't + // prevent default + if (rv) { + this.doDrag(e, x, y, false); + e.preventDefault(); + } + } + } +}; + + +/** + * Calculates the drag position. + * + * @param {number} dx The horizontal movement delta. + * @param {number} dy The vertical movement delta. + * @return {!goog.math.Coordinate} The newly calculated drag element position. + * @private + */ +goog.fx.Dragger.prototype.calculatePosition_ = function(dx, dy) { + 'use strict'; + // Update the position for any change in body scrolling + var pageScroll = goog.dom.getDomHelper(this.document_).getDocumentScroll(); + dx += pageScroll.x - this.pageScroll.x; + dy += pageScroll.y - this.pageScroll.y; + this.pageScroll = pageScroll; + + this.deltaX += dx; + this.deltaY += dy; + + var x = this.limitX(this.deltaX); + var y = this.limitY(this.deltaY); + return new goog.math.Coordinate(x, y); +}; + + +/** + * Event handler for scroll target scrolling. + * @param {goog.events.BrowserEvent} e The event. + * @private + */ +goog.fx.Dragger.prototype.onScroll_ = function(e) { + 'use strict'; + var pos = this.calculatePosition_(0, 0); + e.clientX = this.clientX; + e.clientY = this.clientY; + this.doDrag(e, pos.x, pos.y, true); +}; + + +/** + * @param {goog.events.BrowserEvent} e The closure object + * representing the browser event that caused a drag event. + * @param {number} x The new horizontal position for the drag element. + * @param {number} y The new vertical position for the drag element. + * @param {boolean} dragFromScroll Whether dragging was caused by scrolling + * the associated scroll target. + * @protected + */ +goog.fx.Dragger.prototype.doDrag = function(e, x, y, dragFromScroll) { + 'use strict'; + this.defaultAction(x, y); + this.dispatchEvent( + new goog.fx.DragEvent( + goog.fx.Dragger.EventType.DRAG, this, e.clientX, e.clientY, e, x, y)); +}; + + +/** + * Returns the 'real' x after limits are applied (allows for some + * limits to be undefined). + * @param {number} x X-coordinate to limit. + * @return {number} The 'real' X-coordinate after limits are applied. + */ +goog.fx.Dragger.prototype.limitX = function(x) { + 'use strict'; + var rect = this.limits; + var left = !isNaN(rect.left) ? rect.left : null; + var width = !isNaN(rect.width) ? rect.width : 0; + var maxX = left != null ? left + width : Infinity; + var minX = left != null ? left : -Infinity; + return Math.min(maxX, Math.max(minX, x)); +}; + + +/** + * Returns the 'real' y after limits are applied (allows for some + * limits to be undefined). + * @param {number} y Y-coordinate to limit. + * @return {number} The 'real' Y-coordinate after limits are applied. + */ +goog.fx.Dragger.prototype.limitY = function(y) { + 'use strict'; + var rect = this.limits; + var top = !isNaN(rect.top) ? rect.top : null; + var height = !isNaN(rect.height) ? rect.height : 0; + var maxY = top != null ? top + height : Infinity; + var minY = top != null ? top : -Infinity; + return Math.min(maxY, Math.max(minY, y)); +}; + + +/** + * Overridable function for computing the initial position of the target + * before dragging begins. + * @protected + */ +goog.fx.Dragger.prototype.computeInitialPosition = function() { + 'use strict'; + this.deltaX = this.useRightPositioningForRtl_ ? + goog.style.bidi.getOffsetStart(this.target) : + /** @type {!HTMLElement} */ (this.target).offsetLeft; + this.deltaY = /** @type {!HTMLElement} */ (this.target).offsetTop; +}; + + +/** + * Overridable function for handling the default action of the drag behaviour. + * Normally this is simply moving the element to x,y though in some cases it + * might be used to resize the layer. This is basically a shortcut to + * implementing a default ondrag event handler. + * @param {number} x X-coordinate for target element. In right-to-left, x this + * is the number of pixels the target should be moved to from the right. + * @param {number} y Y-coordinate for target element. + */ +goog.fx.Dragger.prototype.defaultAction = function(x, y) { + 'use strict'; + if (this.useRightPositioningForRtl_ && this.isRightToLeft_()) { + this.target.style.right = x + 'px'; + } else { + this.target.style.left = x + 'px'; + } + this.target.style.top = y + 'px'; +}; + + +/** + * @return {boolean} Whether the dragger is currently in the midst of a drag. + */ +goog.fx.Dragger.prototype.isDragging = function() { + 'use strict'; + return this.dragging_; +}; + + + +/** + * Object representing a drag event + * @param {string} type Event type. + * @param {goog.fx.Dragger} dragobj Drag object initiating event. + * @param {number} clientX X-coordinate relative to the viewport. + * @param {number} clientY Y-coordinate relative to the viewport. + * @param {goog.events.BrowserEvent} browserEvent The closure object + * representing the browser event that caused this drag event. + * @param {number=} opt_actX Optional actual x for drag if it has been limited. + * @param {number=} opt_actY Optional actual y for drag if it has been limited. + * @param {boolean=} opt_dragCanceled Whether the drag has been canceled. + * @constructor + * @struct + * @extends {goog.events.Event} + */ +goog.fx.DragEvent = function( + type, dragobj, clientX, clientY, browserEvent, opt_actX, opt_actY, + opt_dragCanceled) { + 'use strict'; + goog.events.Event.call(this, type); + + /** + * X-coordinate relative to the viewport + * @type {number} + */ + this.clientX = clientX; + + /** + * Y-coordinate relative to the viewport + * @type {number} + */ + this.clientY = clientY; + + /** + * The closure object representing the browser event that caused this drag + * event. + * @type {goog.events.BrowserEvent} + */ + this.browserEvent = browserEvent; + + /** + * The real x-position of the drag if it has been limited + * @type {number} + */ + this.left = (opt_actX !== undefined) ? opt_actX : dragobj.deltaX; + + /** + * The real y-position of the drag if it has been limited + * @type {number} + */ + this.top = (opt_actY !== undefined) ? opt_actY : dragobj.deltaY; + + /** + * Reference to the drag object for this event + * @type {goog.fx.Dragger} + */ + this.dragger = dragobj; + + /** + * Whether drag was canceled with this event. Used to differentiate between + * a legitimate drag END that can result in an action and a drag END which is + * a result of a drag cancelation. For now it can happen 1) with drag END + * event on FireFox when user drags the mouse out of the window, 2) with + * drag END event on IE7 which is generated on MOUSEMOVE event when user + * moves the mouse into the document after the mouse button has been + * released, 3) when TOUCHCANCEL is raised instead of TOUCHEND (on touch + * events). + * @type {boolean} + */ + this.dragCanceled = !!opt_dragCanceled; +}; +goog.inherits(goog.fx.DragEvent, goog.events.Event); diff --git a/closure/goog/fx/dragger_test.js b/closure/goog/fx/dragger_test.js new file mode 100644 index 0000000000..705ac2e328 --- /dev/null +++ b/closure/goog/fx/dragger_test.js @@ -0,0 +1,578 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.fx.DraggerTest'); +goog.setTestOnly(); + +const BrowserEvent = goog.require('goog.events.BrowserEvent'); +const Dragger = goog.require('goog.fx.Dragger'); +const EventType = goog.require('goog.events.EventType'); +const GoogEvent = goog.require('goog.events.Event'); +const GoogRect = goog.require('goog.math.Rect'); +const StrictMock = goog.require('goog.testing.StrictMock'); +const TagName = goog.require('goog.dom.TagName'); +const bidi = goog.require('goog.style.bidi'); +const dom = goog.require('goog.dom'); +const events = goog.require('goog.events'); +const testSuite = goog.require('goog.testing.testSuite'); +const testingEvents = goog.require('goog.testing.events'); + +/** @suppress {visibility} suppression added to enable type checking */ +const HAS_SET_CAPTURE = Dragger.HAS_SET_CAPTURE_; + +let target; +let targetRtl; + +/** + * @suppress {missingProperties,checkTypes} suppression added to enable type + * checking + */ +function runStartDragTest(handleId, targetElement) { + let dragger = new Dragger(targetElement, dom.getElement(handleId)); + if (handleId == 'handle_rtl') { + dragger.enableRightPositioningForRtl(true); + } + const e = new StrictMock(BrowserEvent); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.type = EventType.MOUSEDOWN; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientX = 1; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientY = 2; + e.isMouseActionButton().$returns(true); + e.preventDefault(); + e.isMouseActionButton().$returns(true); + e.preventDefault(); + e.$replay(); + + events.listen(dragger, Dragger.EventType.START, () => { + targetElement.style.display = 'block'; + }); + + dragger.startDrag(e); + + assertTrue( + 'Start drag with no hysteresis must actually start the drag.', + dragger.isDragging()); + if (handleId == 'handle_rtl') { + assertEquals(10, bidi.getOffsetStart(targetElement)); + } + assertEquals( + 'Dragger startX must match event\'s clientX.', 1, dragger.startX); + assertEquals( + 'Dragger clientX must match event\'s clientX', 1, dragger.clientX); + assertEquals( + 'Dragger startY must match event\'s clientY.', 2, dragger.startY); + assertEquals( + 'Dragger clientY must match event\'s clientY', 2, dragger.clientY); + assertEquals( + 'Dragger deltaX must match target\'s offsetLeft', 10, dragger.deltaX); + assertEquals( + 'Dragger deltaY must match target\'s offsetTop', 15, dragger.deltaY); + + dragger = new Dragger(targetElement, dom.getElement(handleId)); + dragger.setHysteresis(1); + dragger.startDrag(e); + assertFalse( + 'Start drag with a valid non-zero hysteresis should not start ' + + 'the drag.', + dragger.isDragging()); + e.$verify(); +} + +testSuite({ + setUp() { + const sandbox = dom.getElement('sandbox'); + target = dom.createDom(TagName.DIV, { + 'id': 'target', + 'style': 'display:none;position:absolute;top:15px;left:10px', + }); + sandbox.appendChild(target); + sandbox.appendChild(dom.createDom(TagName.DIV, {id: 'handle'})); + + const sandboxRtl = dom.getElement('sandbox_rtl'); + targetRtl = dom.createDom(TagName.DIV, { + 'id': 'target_rtl', + 'style': 'position:absolute; top:15px; right:10px; width:10px; ' + + 'height: 10px; background: green;', + }); + sandboxRtl.appendChild(targetRtl); + sandboxRtl.appendChild(dom.createDom(TagName.DIV, { + 'id': 'background_rtl', + 'style': 'width: 10000px;height:50px;position:absolute;color:blue;', + })); + sandboxRtl.appendChild(dom.createDom(TagName.DIV, {id: 'handle_rtl'})); + }, + + tearDown() { + dom.removeChildren(dom.getElement('sandbox')); + dom.removeChildren(dom.getElement('sandbox_rtl')); + events.removeAll(document); + }, + + testStartDrag() { + runStartDragTest('handle', target); + }, + + testStartDrag_rtl() { + runStartDragTest('handle_rtl', targetRtl); + }, + + /** + @bug 1381317 Cancelling start drag didn't end the attempt to drag. + @suppress {missingProperties,checkTypes,visibility} suppression added to + enable type checking + */ + testStartDrag_Cancel() { + const dragger = new Dragger(target); + + const e = new StrictMock(BrowserEvent); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.type = EventType.MOUSEDOWN; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientX = 1; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientY = 2; + e.isMouseActionButton().$returns(true); + e.$replay(); + + events.listen(dragger, Dragger.EventType.START, (e) => { + // Cancel drag. + e.preventDefault(); + }); + + dragger.startDrag(e); + + assertFalse('Start drag must have been cancelled.', dragger.isDragging()); + assertFalse( + 'Dragger must not have registered mousemove handlers.', + events.hasListener( + dragger.document_, EventType.MOUSEMOVE, !HAS_SET_CAPTURE)); + assertFalse( + 'Dragger must not have registered mouseup handlers.', + events.hasListener( + dragger.document_, EventType.MOUSEUP, !HAS_SET_CAPTURE)); + e.$verify(); + }, + + /** + Tests that start drag happens on left mousedown. + @suppress {missingProperties,checkTypes,visibility} suppression added to + enable type checking + */ + testStartDrag_LeftMouseDownOnly() { + const dragger = new Dragger(target); + + const e = new StrictMock(BrowserEvent); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.type = EventType.MOUSEDOWN; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientX = 1; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientY = 2; + e.isMouseActionButton().$returns(false); + e.$replay(); + + events.listen(dragger, Dragger.EventType.START, (e) => { + fail('No drag START event should have been dispatched'); + }); + + dragger.startDrag(e); + + assertFalse('Start drag must have been cancelled.', dragger.isDragging()); + assertFalse( + 'Dragger must not have registered mousemove handlers.', + events.hasListener(dragger.document_, EventType.MOUSEMOVE, true)); + assertFalse( + 'Dragger must not have registered mouseup handlers.', + events.hasListener(dragger.document_, EventType.MOUSEUP, true)); + e.$verify(); + }, + + /** + Tests that start drag happens on other event type than MOUSEDOWN. + @suppress {checkTypes,visibility} suppression added to enable type checking + */ + testStartDrag_MouseMove() { + const dragger = new Dragger(target); + + const e = new StrictMock(BrowserEvent); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.type = EventType.MOUSEMOVE; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientX = 1; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientY = 2; + // preventDefault is not called. + e.$replay(); + + let startDragFired = false; + events.listen(dragger, Dragger.EventType.START, (e) => { + startDragFired = true; + }); + + dragger.startDrag(e); + + assertTrue('Dragging should be in progress.', dragger.isDragging()); + assertTrue('Start drag event should have fired.', startDragFired); + assertTrue( + 'Dragger must have registered mousemove handlers.', + events.hasListener( + dragger.document_, EventType.MOUSEMOVE, !HAS_SET_CAPTURE)); + assertTrue( + 'Dragger must have registered mouseup handlers.', + events.hasListener( + dragger.document_, EventType.MOUSEUP, !HAS_SET_CAPTURE)); + e.$verify(); + }, + + /** + Tests that preventDefault is not called for TOUCHSTART event. + @suppress {checkTypes} suppression added to enable type checking + */ + testStartDrag_TouchStart() { + const dragger = new Dragger(target); + + const e = new StrictMock(BrowserEvent); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.type = EventType.TOUCHSTART; + // preventDefault is not called. + e.$replay(); + + let startDragFired = false; + events.listen(dragger, Dragger.EventType.START, (e) => { + startDragFired = true; + }); + + dragger.startDrag(e); + + assertTrue('Dragging should be in progress.', dragger.isDragging()); + assertTrue('Start drag event should have fired.', startDragFired); + assertTrue( + 'Dragger must have registered touchstart listener.', + events.hasListener( + dragger.handle, EventType.TOUCHSTART, false /*opt_cap*/)); + e.$verify(); + }, + + /** + * Tests that preventDefault is not called for TOUCHSTART event when + * hysteresis is set to be greater than zero. + * @suppress {checkTypes} suppression added to enable type checking + */ + testStartDrag_TouchStart_NonZeroHysteresis() { + const dragger = new Dragger(target); + dragger.setHysteresis(5); + const e = new StrictMock(BrowserEvent); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.type = EventType.TOUCHSTART; + // preventDefault is not called. + e.$replay(); + + let startDragFired = false; + events.listen(dragger, Dragger.EventType.START, (e) => { + startDragFired = true; + }); + + dragger.startDrag(e); + + assertFalse( + 'Start drag must not start drag because of hysterisis.', + dragger.isDragging()); + assertTrue( + 'Dragger must have registered touchstart listener.', + events.hasListener( + dragger.handle, EventType.TOUCHSTART, false /*opt_cap*/)); + e.$verify(); + }, + + /** + @bug 1381317 Cancelling start drag didn't end the attempt to drag. + @suppress {missingProperties,checkTypes,visibility} suppression added to + enable type checking + */ + testHandleMove_Cancel() { + const dragger = new Dragger(target); + dragger.setHysteresis(5); + + events.listen(dragger, Dragger.EventType.START, (e) => { + // Cancel drag. + e.preventDefault(); + }); + + const e = new StrictMock(BrowserEvent); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientX = 1; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientY = 2; + e.isMouseActionButton().$returns(true).$anyTimes(); + // preventDefault is not called. + e.$replay(); + dragger.startDrag(e); + assertFalse( + 'Start drag must not start drag because of hysterisis.', + dragger.isDragging()); + assertTrue( + 'Dragger must have registered mousemove handlers.', + events.hasListener( + dragger.document_, EventType.MOUSEMOVE, !HAS_SET_CAPTURE)); + assertTrue( + 'Dragger must have registered mouseup handlers.', + events.hasListener( + dragger.document_, EventType.MOUSEUP, !HAS_SET_CAPTURE)); + + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientX = 10; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientY = 10; + dragger.handleMove_(e); + assertFalse('Drag must be cancelled.', dragger.isDragging()); + assertFalse( + 'Dragger must unregistered mousemove handlers.', + events.hasListener(dragger.document_, EventType.MOUSEMOVE, true)); + assertFalse( + 'Dragger must unregistered mouseup handlers.', + events.hasListener(dragger.document_, EventType.MOUSEUP, true)); + e.$verify(); + }, + + /** + @suppress {missingProperties,checkTypes} suppression added to enable type + checking + */ + testPreventMouseDown() { + const dragger = new Dragger(target); + dragger.setPreventMouseDown(false); + + const e = new StrictMock(BrowserEvent); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.type = EventType.MOUSEDOWN; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientX = 1; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientY = 2; + e.isMouseActionButton().$returns(true); + // preventDefault is not called. + e.$replay(); + + dragger.startDrag(e); + + assertTrue('Dragging should be in progess.', dragger.isDragging()); + e.$verify(); + }, + + testLimits() { + const dragger = new Dragger(target); + + assertEquals(100, dragger.limitX(100)); + assertEquals(100, dragger.limitY(100)); + + dragger.setLimits(new GoogRect(10, 20, 30, 40)); + + assertEquals(10, dragger.limitX(0)); + assertEquals(40, dragger.limitX(100)); + assertEquals(20, dragger.limitY(0)); + assertEquals(60, dragger.limitY(100)); + }, + + /** + @suppress {missingProperties,checkTypes} suppression added to enable type + checking + */ + testWindowBlur() { + const dragger = new Dragger(target); + dragger.setAllowSetCapture(false); + + let dragEnded = false; + events.listen(dragger, Dragger.EventType.END, (e) => { + dragEnded = true; + }); + + let e = new StrictMock(BrowserEvent); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.type = EventType.MOUSEDOWN; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientX = 1; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientY = 2; + e.isMouseActionButton().$returns(true); + e.preventDefault(); + e.$replay(); + dragger.startDrag(e); + e.$verify(); + + assertTrue(dragger.isDragging()); + + e = new BrowserEvent(); + e.type = EventType.BLUR; + /** @suppress {checkTypes} suppression added to enable type checking */ + e.target = window; + /** @suppress {checkTypes} suppression added to enable type checking */ + e.currentTarget = window; + testingEvents.fireBrowserEvent(e); + + assertTrue(dragEnded); + }, + + /** + @suppress {missingProperties,checkTypes} suppression added to enable type + checking + */ + testBlur() { + const dragger = new Dragger(target); + dragger.setAllowSetCapture(false); + + let dragEnded = false; + events.listen(dragger, Dragger.EventType.END, (e) => { + dragEnded = true; + }); + + let e = new StrictMock(BrowserEvent); + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.type = EventType.MOUSEDOWN; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientX = 1; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + e.clientY = 2; + e.isMouseActionButton().$returns(true); + e.preventDefault(); + e.$replay(); + dragger.startDrag(e); + e.$verify(); + + assertTrue(dragger.isDragging()); + + e = new BrowserEvent(); + e.type = EventType.BLUR; + e.target = document.body; + e.currentTarget = document.body; + // Blur events do not bubble but the test event system does not emulate that + // part so we add a capturing listener on the target and stops the + // propagation at the target, preventing any event from bubbling. + events.listen(document.body, EventType.BLUR, (e) => { + e.propagationStopped_ = true; + }, true); + testingEvents.fireBrowserEvent(e); + + assertFalse(dragEnded); + }, + + /** + @suppress {strictMissingProperties} suppression added to enable type + checking + */ + testCloneNode() { + const element = dom.createDom(TagName.DIV); + element.innerHTML = '' + + '' + + ''; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + element.childNodes[0].value = '\'new\'\n"value"'; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + element.childNodes[1].value = '<' + + '/textarea><3'; + /** + * @suppress {strictMissingProperties} suppression added to enable type + * checking + */ + element.childNodes[2].value = ''); + }); + + // Can set content. + assertSameHtml( + '', + SafeHtml.createIframe(null, null, {'sandbox': null}, '<')); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testSafeHtmlCreateIframe_withMonkeypatchedObjectPrototype() { + stubs.set(Object.prototype, 'foo', 'bar'); + const url = TrustedResourceUrl.fromConstant( + Const.from('https://google.com/trusted<')); + assertSameHtml( + '', + SafeHtml.createIframe(url, null, {'sandbox': null})); + }, + + /** @suppress {checkTypes} */ + testSafeHtmlcreateSandboxIframe() { + function assertSameHtmlIfSupportsSandbox( + referenceHtml, testedHtmlFunction) { + if (!SafeHtml.canUseSandboxIframe()) { + assertThrows(testedHtmlFunction); + } else { + assertSameHtml(referenceHtml, testedHtmlFunction()); + } + } + + // Setting src and srcdoc. + const url = SafeUrl.fromConstant(Const.from('https://google.com/trusted<')); + assertSameHtmlIfSupportsSandbox( + '', + () => SafeHtml.createSandboxIframe(url, null)); + + // If set with a string, src is sanitized. + assertSameHtmlIfSupportsSandbox( + '', + () => SafeHtml.createSandboxIframe('javascript:evil();', null)); + + const srcdoc = '
    '; + assertSameHtmlIfSupportsSandbox( + '', + () => SafeHtml.createSandboxIframe(null, srcdoc)); + + // Cannot override src, srcdoc. + assertThrows(() => { + SafeHtml.createSandboxIframe(null, null, {'Src': url}); + }); + assertThrows(() => { + SafeHtml.createSandboxIframe(null, null, {'Srcdoc': url}); + }); + + // Sandboxed by default, and can't be overriden. + assertSameHtmlIfSupportsSandbox( + '', () => SafeHtml.createSandboxIframe()); + + assertThrows(() => { + SafeHtml.createSandboxIframe(null, null, {'sandbox': ''}); + }); + assertThrows(() => { + SafeHtml.createSandboxIframe(null, null, {'SaNdBoX': 'allow-scripts'}); + }); + assertThrows(() => { + SafeHtml.createSandboxIframe( + null, null, {'sandbox': 'allow-same-origin allow-top-navigation'}); + }); + + // Can set content. + assertSameHtmlIfSupportsSandbox( + '', + () => SafeHtml.createSandboxIframe(null, null, null, '<')); + }, + + /** + @suppress {strictPrimitiveOperators} suppression added to enable type + checking + */ + testSafeHtmlCanUseIframeSandbox() { + // We know that the IE < 10 do not support the sandbox attribute, so use + // them as a reference. + if (browser.isIE() && browser.getVersion() < 10) { + assertEquals(false, SafeHtml.canUseSandboxIframe()); + } else { + assertEquals(true, SafeHtml.canUseSandboxIframe()); + } + }, + + testSafeHtmlCreateScript() { + const script = SafeScript.fromConstant(Const.from('function1();')); + let scriptHtml = SafeHtml.createScript(script); + assertSameHtml('', scriptHtml); + + // Two pieces of script. + const otherScript = SafeScript.fromConstant(Const.from('function2();')); + scriptHtml = SafeHtml.createScript([script, otherScript]); + assertSameHtml('', scriptHtml); + + // Set attribute. + scriptHtml = SafeHtml.createScript(script, {'id': 'test'}); + assertContains('id="test"', SafeHtml.unwrap(scriptHtml)); + + // Set attribute to null. + scriptHtml = SafeHtml.createScript(SafeScript.EMPTY, {'id': null}); + assertSameHtml('', scriptHtml); + + // Can create JSON scripts by setting the type attribute + const jsonScript = SafeScript.fromJson({ + '@context': 'https://schema.org/', + '@type': 'Test', + 'name': 'JSON Script', + }); + scriptHtml = + SafeHtml.createScript(jsonScript, {type: 'application/ld+json'}); + assertSameHtml( + [ + '', + ].join(''), + scriptHtml); + + // Set attribute to invalid value. + let exception = assertThrows(() => { + SafeHtml.createScript(SafeScript.EMPTY, {'invalid.': 'cantdothis'}); + }); + assertContains('Invalid attribute name', exception.message); + + // Cannot set src attribute. + exception = assertThrows(() => { + SafeHtml.createScript(SafeScript.EMPTY, {'src': 'cantdothis'}); + }); + assertContains('Cannot set "src"', exception.message); + }, + + /** @suppress {checkTypes} suppression added to enable type checking */ + testSafeHtmlCreateScript_withMonkeypatchedObjectPrototype() { + stubs.set(Object.prototype, 'foo', 'bar'); + stubs.set(Object.prototype, 'type', 'baz'); + const scriptHtml = SafeHtml.createScript(SafeScript.EMPTY, {'id': null}); + assertSameHtml('', scriptHtml); + }, + + /** @suppress {checkTypes} */ + testSafeHtmlCreateScriptSrc() { + const url = TrustedResourceUrl.fromConstant( + Const.from('https://google.com/trusted<')); + + assertSameHtml( + '', + SafeHtml.createScriptSrc(url)); + + assertSameHtml( + '', + SafeHtml.createScriptSrc(url, {'defer': 'defer'})); + + // Unsafe src. + assertThrows(() => { + SafeHtml.createScriptSrc('http://example.com'); + }); + + // Unsafe attribute. + assertThrows(() => { + SafeHtml.createScriptSrc(url, {'onerror': 'alert(1)'}); + }); + + // Cannot override src. + assertThrows(() => { + SafeHtml.createScriptSrc(url, {'Src': url}); + }); + }, + + testSafeHtmlCreateMeta() { + const url = SafeUrl.fromConstant(Const.from('https://google.com/trusted<')); + + // SafeUrl with no timeout gets properly escaped. + assertSameHtml( + '', + SafeHtml.createMetaRefresh(url)); + + // SafeUrl with 0 timeout also gets properly escaped. + assertSameHtml( + '', + SafeHtml.createMetaRefresh(url, 0)); + + // Positive timeouts are supported. + assertSameHtml( + '', + SafeHtml.createMetaRefresh(url, 1337)); + + // Negative timeouts are also kept, though they're not correct HTML. + assertSameHtml( + '', + SafeHtml.createMetaRefresh(url, -1337)); + + // String-based URLs work out of the box. + assertSameHtml( + '', + SafeHtml.createMetaRefresh('https://google.com/trusted<')); + + // Sanitization happens. + assertSameHtml( + '', + SafeHtml.createMetaRefresh('javascript:alert(1)')); + }, + + testSafeHtmlCreateStyle() { + const styleSheet = + SafeStyleSheet.fromConstant(Const.from('P.special { color:"red" ; }')); + let styleHtml = SafeHtml.createStyle(styleSheet); + assertSameHtml( + '', + styleHtml); + + // Two stylesheets. + const otherStyleSheet = + SafeStyleSheet.fromConstant(Const.from('P.regular { color:blue ; }')); + styleHtml = SafeHtml.createStyle([styleSheet, otherStyleSheet]); + assertSameHtml( + '', + styleHtml); + + // Set attribute. + styleHtml = SafeHtml.createStyle(styleSheet, {'id': 'test'}); + const styleHtmlString = SafeHtml.unwrap(styleHtml); + assertContains('id="test"', styleHtmlString); + assertContains('type="text/css"', styleHtmlString); + + // Set attribute to null. + styleHtml = SafeHtml.createStyle(SafeStyleSheet.EMPTY, {'id': null}); + assertSameHtml('', styleHtml); + + // Set attribute to invalid value. + let exception = assertThrows(() => { + SafeHtml.createStyle(SafeStyleSheet.EMPTY, {'invalid.': 'cantdothis'}); + }); + assertContains('Invalid attribute name', exception.message); + + // Cannot override type attribute. + exception = assertThrows(() => { + SafeHtml.createStyle(SafeStyleSheet.EMPTY, {'Type': 'cantdothis'}); + }); + assertContains('Cannot override "type"', exception.message); + }, + + testSafeHtmlJoin() { + const br = SafeHtml.BR; + assertSameHtml('Hello
    World', SafeHtml.join(br, ['Hello', 'World'])); + assertSameHtml('Hello
    World', SafeHtml.join(br, ['Hello', ['World']])); + assertSameHtml('Hello
    ', SafeHtml.join('Hello', ['', br])); + }, + + testSafeHtmlConcat() { + const br = testing.newSafeHtmlForTest('
    '); + + const html = SafeHtml.htmlEscape('Hello'); + assertSameHtml('Hello
    ', SafeHtml.concat(html, br)); + + assertSameHtml('', SafeHtml.concat()); + assertSameHtml('', SafeHtml.concat([])); + + assertSameHtml('a
    c', SafeHtml.concat('a', br, 'c')); + assertSameHtml('a
    c', SafeHtml.concat(['a', br, 'c'])); + assertSameHtml('a
    c', SafeHtml.concat('a', [br, 'c'])); + assertSameHtml('a
    c', SafeHtml.concat(['a'], br, ['c'])); + }, + + testHtmlEscapePreservingNewlines() { + // goog.html.SafeHtml passes through unchanged. + const safeHtmlIn = SafeHtml.htmlEscapePreservingNewlines('in'); + assertTrue( + safeHtmlIn === SafeHtml.htmlEscapePreservingNewlines(safeHtmlIn)); + + assertSameHtml('a
    c', SafeHtml.htmlEscapePreservingNewlines('a\nc')); + assertSameHtml('<
    ', SafeHtml.htmlEscapePreservingNewlines('<\n')); + assertSameHtml('
    ', SafeHtml.htmlEscapePreservingNewlines('\r\n')); + assertSameHtml('
    ', SafeHtml.htmlEscapePreservingNewlines('\r')); + assertSameHtml('', SafeHtml.htmlEscapePreservingNewlines('')); + }, + + testHtmlEscapePreservingNewlinesAndSpaces() { + // goog.html.SafeHtml passes through unchanged. + const safeHtmlIn = + SafeHtml.htmlEscapePreservingNewlinesAndSpaces('in'); + assertTrue( + safeHtmlIn === + SafeHtml.htmlEscapePreservingNewlinesAndSpaces(safeHtmlIn)); + + assertSameHtml( + 'a
    c', SafeHtml.htmlEscapePreservingNewlinesAndSpaces('a\nc')); + assertSameHtml( + '<
    ', SafeHtml.htmlEscapePreservingNewlinesAndSpaces('<\n')); + assertSameHtml( + '
    ', SafeHtml.htmlEscapePreservingNewlinesAndSpaces('\r\n')); + assertSameHtml( + '
    ', SafeHtml.htmlEscapePreservingNewlinesAndSpaces('\r')); + assertSameHtml('', SafeHtml.htmlEscapePreservingNewlinesAndSpaces('')); + + assertSameHtml( + 'a  b', SafeHtml.htmlEscapePreservingNewlinesAndSpaces('a b')); + }, + + testComment() { + assertSameHtml('', SafeHtml.comment(''; + let expected = ''; + assertSanitizedCssEquals(expected, input); + + input = 'a { } '); + }); + }, + + testInlineStyleRules_empty() { + assertInlinedStyles('', ''); + }, + + testInlineStyleRules_basic() { + const input = 'foo'; + const expected = 'foo'; + assertInlinedStyles(expected, input); + }, + + testInlineStyleRules_onlyStyle() { + const input = ''; + assertInlinedStyles('', input); + }, + + testInlineStyleRules_noStyle() { + const input = 'hi'; + assertInlinedStyles(input, input); + }, + + testInlineStyleRules_onlyText() { + const input = 'hello'; + assertInlinedStyles(input, input); + }, + + testInlineStyleRules_specificity() { + // Assert that the #foo style is applied over the style (ID selectors + // have a higher specificity). + const input = '' + + 'foo'; + const expected = 'foo'; + assertInlinedStyles(expected, input); + }, + + testInlineStyleRules_specificity_reverse() { + // Assert that the style rule with greater specificity (#foo) wins + // regardless of the order of appearance. + const input = '' + + 'foo'; + const expected = 'foo'; + assertInlinedStyles(expected, input); + }, + + testInlineStyleRules_specificityTie_lastRuleWins() { + // In case of a specificity tie, assert that the last style rule defined + // wins. + const input = + 'foo'; + const expected = 'foo'; + assertInlinedStyles(expected, input); + }, + + testInlineStyleRules_media() { + const input = + '' + + 'foo'; + const expected = 'foo'; + assertInlinedStyles(expected, input); + }, + + testInlineStyleRules_background() { + const input = 'foo'; + const expected = product.SAFARI ? + // Safari will expand multi-value properties such as background, border, + // etc into multiple properties. The result is more verbose but it + // should not affect the effective style. + ('foo') : + 'foo'; + assertInlinedStyles(expected, input); + }, +}); diff --git a/closure/goog/html/sanitizer/elementweakmap.js b/closure/goog/html/sanitizer/elementweakmap.js new file mode 100644 index 0000000000..9bee986c46 --- /dev/null +++ b/closure/goog/html/sanitizer/elementweakmap.js @@ -0,0 +1,101 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @package + * @supported IE 10+ and other browsers. IE 8 and IE 9 could be supported by + * by making anti-clobbering support optional. + */ + +goog.module('goog.html.sanitizer.ElementWeakMap'); +goog.module.declareLegacyNamespace(); + +var noclobber = goog.require('goog.html.sanitizer.noclobber'); + +// We also need to check if WeakMap has been polyfilled, because we want to use +// ElementWeakMap instead of the polyfill. +/** @const {boolean} */ +var NATIVE_WEAKMAP_SUPPORTED = typeof WeakMap != 'undefined' && + WeakMap.toString().indexOf('[native code]') != -1; + +/** @const {string} */ +var DATA_ATTRIBUTE_NAME_PREFIX = 'data-elementweakmap-index-'; + +// Increased every time a new ElementWeakMap is constructed, to guarantee +// that each weakmap uses a different attribute name. +var weakMapCount = 0; + +/** + * A weakmap-like implementation for browsers that don't support native WeakMap. + * It uses a data attribute on the key element for O(1) lookups. + * @template T + * @constructor + */ +var ElementWeakMap = function() { + /** @private {!Array} */ + this.keys_ = []; + + /** @private {!Array} */ + this.values_ = []; + + /** @private @const {string} */ + this.dataAttributeName_ = DATA_ATTRIBUTE_NAME_PREFIX + weakMapCount++; +}; + +/** + * Stores a `elementKey` -> `value` mapping. + * @param {!Element} elementKey + * @param {!T} value + * @return {!ElementWeakMap} + */ +ElementWeakMap.prototype.set = function(elementKey, value) { + if (noclobber.hasElementAttribute(elementKey, this.dataAttributeName_)) { + var itemIndex = parseInt( + noclobber.getElementAttribute(elementKey, this.dataAttributeName_), 10); + this.values_[itemIndex] = value; + } else { + var itemIndex = this.values_.push(value) - 1; + noclobber.setElementAttribute( + elementKey, this.dataAttributeName_, itemIndex.toString()); + this.keys_.push(elementKey); + } + return this; +}; + +/** + * Gets the value previously stored for `elementKey`, or undefined if no + * value was stored for such key. + * @param {!Element} elementKey + * @return {!Element|undefined} + */ +ElementWeakMap.prototype.get = function(elementKey) { + if (!noclobber.hasElementAttribute(elementKey, this.dataAttributeName_)) { + return undefined; + } + var itemIndex = parseInt( + noclobber.getElementAttribute(elementKey, this.dataAttributeName_), 10); + return this.values_[itemIndex]; +}; + +/** Clears the map. */ +ElementWeakMap.prototype.clear = function() { + this.keys_.forEach(function(el) { + noclobber.removeElementAttribute(el, this.dataAttributeName_); + }, this); + this.keys_ = []; + this.values_ = []; +}; + +/** + * Returns either this weakmap adapter or the native weakmap implmentation, if + * available. + * @return {!ElementWeakMap|!WeakMap} + */ +ElementWeakMap.newWeakMap = function() { + return NATIVE_WEAKMAP_SUPPORTED ? new WeakMap() : new ElementWeakMap(); +}; + +exports = ElementWeakMap; diff --git a/closure/goog/html/sanitizer/elementweakmap_test.js b/closure/goog/html/sanitizer/elementweakmap_test.js new file mode 100644 index 0000000000..e28158c9ac --- /dev/null +++ b/closure/goog/html/sanitizer/elementweakmap_test.js @@ -0,0 +1,63 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @fileoverview Tests for {@link goog.html.sanitizer.ElementWeakMap} */ + +goog.module('goog.html.sanitizer.ElementWeakMapTest'); +goog.setTestOnly(); + +const ElementWeakMap = goog.require('goog.html.sanitizer.ElementWeakMap'); +const testSuite = goog.require('goog.testing.testSuite'); +const userAgent = goog.require('goog.userAgent'); + +/** @const {boolean} */ +const ELEMENTWEAKMAP_SUPPORTED = !userAgent.IE || document.documentMode >= 10; + +testSuite({ + testBasic() { + if (!ELEMENTWEAKMAP_SUPPORTED) { + return; + } + const el1 = document.createElement('a'); + const el2 = document.createElement('b'); + const el3 = document.createElement('a'); + const weakMap = ElementWeakMap.newWeakMap(); + weakMap.set(el1, 1); + weakMap.set(el2, 2); + + assertEquals(1, weakMap.get(el1)); + assertEquals(2, weakMap.get(el2)); + assertUndefined(weakMap.get(el3)); + }, + + testDuplicates() { + if (!ELEMENTWEAKMAP_SUPPORTED) { + return; + } + const el1 = document.createElement('a'); + const el2 = document.createElement('a'); + const weakMap = ElementWeakMap.newWeakMap(); + weakMap.set(el1, 1); + weakMap.set(el1, 2); + + assertEquals(2, weakMap.get(el1)); + assertUndefined(weakMap.get(el2)); + }, + + testClear() { + if (!ELEMENTWEAKMAP_SUPPORTED) { + return; + } + const el = document.createElement('a'); + const weakMap = ElementWeakMap.newWeakMap(); + weakMap.set(el, 1); + weakMap.set(el, 2); + + if (weakMap.clear) { + weakMap.clear(); + } + } +}); diff --git a/closure/goog/html/sanitizer/html_test_vectors.js b/closure/goog/html/sanitizer/html_test_vectors.js new file mode 100644 index 0000000000..6a71d1f5ca --- /dev/null +++ b/closure/goog/html/sanitizer/html_test_vectors.js @@ -0,0 +1,13713 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +// AUTOGENERATED. DO NOT EDIT. +// clang-format off + +goog.provide('goog.html.htmlTestVectors'); +goog.setTestOnly(); + +goog.html.htmlTestVectors.HTML_TEST_VECTORS = [ + {input: "foo", + acceptable: [ + "foo", + "foo", + "foo", + "foo", + "foo", + "foo", + "foo", + ], + name: "a"}, + {input: "foo", + acceptable: [ + "foo", + "foo", + "foo", + "foo", + "foo", + "foo", + ], + name: "a_quot"}, + {input: "foo", + acceptable: [ + "foo", + "foo", + "foo", + "foo", + "foo", + "foo", + "foo", + ], + name: "a_tab"}, + {input: "", + acceptable: [ + "", + ], + name: "body_onload"}, + {input: "
    ", + acceptable: [ + "", + "
    ", + "
    ", + ], + name: "clobbering_children"}, + {input: "
    ", + acceptable: [ + "", + "
    ", + "
    ", + ], + name: "clobbering_firstchild"}, + {input: "
    ", + acceptable: [ + "", + "
    ", + "
    ", + ], + name: "clobbering_proto"}, + {input: "
    ", + acceptable: [ + "", + "
    ", + ], + name: "clobbering_tagname"}, + {input: "
    ", + acceptable: [ + "", + "
    ", + "
    ", + "
    ", + "
    ", + "
    ", + ], + name: "details"}, + {input: "", + "", + "", + "", + acceptable: [ + "", + "", + "", + ], + name: "contract_iframe_plain"}, + {input: "", + acceptable: [ + "", + "", + "", + ], + name: "contract_iframe_scriptinside"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "", + "", + "", + ], + name: "contract_iframe_formaction"}, + {input: "", + "
    ", + "", + "", + "", + "", + ], + name: "contract_iframe_formmethod"}, + {input: "", + "
    ", + "", + "", + "", + "", + ], + name: "contract_iframe_pattern"}, + {input: "", + "
    ", + "", + "", + "", + "", + ], + name: "contract_iframe_defer"}, + {input: "", + acceptable: [ + "", + "", + "", + ], + name: "contract_embed_plain"}, + {input: "", + acceptable: [ + "", + "", + "", + ], + name: "contract_embed_scriptinside"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_embed_srcdoc"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_embed_formaction"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_embed_formmethod"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_embed_pattern"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_embed_defer"}, + {input: "", + acceptable: [ + "", + "", + "", + ], + name: "contract_object_plain"}, + {input: "", + acceptable: [ + "", + "", + "", + ], + name: "contract_object_scriptinside"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_object_srcdoc"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_object_formaction"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_object_formmethod"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_object_pattern"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_object_defer"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "", + "", + "
    ", + "", + "", + "", + ], + name: "contract_param_plain"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "", + "", + "
    ", + "", + "", + "", + "
    ", + ], + name: "contract_param_scriptinside"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_param_srcdoc"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_param_formaction"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_param_formmethod"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_param_pattern"}, + {input: "", + acceptable: [ + "", + "", + "", + "", + "
    ", + "
    ", + "", + "", + "", + "", + "", + ], + name: "contract_param_defer"}, + {input: "", + acceptable: [ + "