diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 5131e620167..00000000000 --- a/.eslintignore +++ /dev/null @@ -1,10 +0,0 @@ -*_compressed*.js -*_uncompressed*.js -/msg/* -/core/css.js -/tests/jsunit/* -/tests/generators/* -/generators/* -/demos/* -/accessible/* -/appengine/* \ No newline at end of file diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index bba9e1ba20e..00000000000 --- a/.eslintrc +++ /dev/null @@ -1,28 +0,0 @@ -{ - "rules": { - "curly": ["error", "multi-line"], - "eol-last": ["error"], - "indent": ["error", 2, {"SwitchCase": 1}], # Blockly/Google use 2-space indents - "linebreak-style": ["error", "unix"], - "max-len": ["error", 120, 4], - "no-trailing-spaces": ["error", { "skipBlankLines": true }], - "no-unused-vars": ["error", {"args": "after-used", "varsIgnorePattern": "^_"}], - "no-use-before-define": ["error"], - "quotes": ["off"], # Blockly mixes single and double quotes - "semi": ["error", "always"], - "space-before-function-paren": ["error", "never"], # Blockly doesn't have space before function paren - "strict": ["off"], # Blockly uses 'use strict' in files - "no-cond-assign": ["off"], # Blockly often uses cond-assignment in loops - "no-redeclare": ["off"], # Closure style allows redeclarations - "valid-jsdoc": ["error", {"requireReturn": false}], - "no-console": ["off"] - }, - "env": { - "browser": true - }, - "globals": { - "Blockly": true, # Blockly global - "goog": true # goog closure libraries/includes - }, - "extends": "eslint:recommended" -} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..176a458f94e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..d52d27df155 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @google/blockly-eng \ No newline at end of file diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 87% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md index fcd866043f5..634b59bfad4 100644 --- a/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,6 +1,7 @@ # Contributing to Blockly Want to contribute? Great! + - First, read this page (including the small print at the end). - Second, please make pull requests against develop, not master. If your patch needs to go into master immediately, include a note in your PR. @@ -8,6 +9,7 @@ Want to contribute? Great! For more information on style guide and other details, head over to the [Blockly Developers site](https://developers.google.com/blockly/guides/modify/contributing). ### Before you contribute + Before we can use your code, you must sign the [Google Individual Contributor License Agreement](https://cla.developers.google.com/about/google-individual) (CLA), which you can do online. The CLA is necessary mainly because you own the @@ -19,22 +21,26 @@ the CLA until after you've submitted your code for review and a member has approved it, but you must do it before we can put your code into our codebase. ### Larger changes + Before you start working on a larger contribution, you should get in touch with us first through the issue tracker with your idea so that we can help out and possibly guide you. Coordinating up front makes it much easier to avoid frustration later on. ### Code reviews + All submissions, including submissions by project members, require review. We use Github pull requests for this purpose. ### Browser compatibility -We care strongly about making Blockly work on all browsers. As of 2017 we -support IE 10 and 11, Edge, Chrome, Safari, and Firefox. We will not accept -changes that only work on a subset of those browsers. You can check [caniuse.com](https://caniuse.com/) + +We care strongly about making Blockly work on all browsers. As of 2022 we +support Edge, Chrome, Safari, and Firefox. We will not accept changes that only +work on a subset of those browsers. You can check [caniuse.com](https://caniuse.com/) for compatibility information. ### The small print + Contributions made by corporations are covered by a different agreement than the one above, the [Software Grant and Corporate Contributor License Agreement](https://cla.developers.google.com/about/google-corporate). diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 00000000000..5d92329ee27 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,67 @@ +name: Report a bug 🐛 +description: Report bugs in Blockly, so we can fix them. +labels: 'issue: bug, issue: triage' +body: + - type: markdown + attributes: + value: > + Thank you for taking the time to fill out a bug report! + If you have a question about how to use Blockly in your application, + please ask on the [forum](https://groups.google.com/forum/#!forum/blockly) instead of filing an issue. + - type: checkboxes + id: duplicates + attributes: + label: Check for duplicates + options: + - label: I have searched for similar issues before opening a new one. + - type: textarea + id: description + attributes: + label: Description + description: Please provide a clear and concise description of the bug. + placeholder: What happened? What did you expect to happen? + validations: + required: true + - type: textarea + id: repro + attributes: + label: Reproduction steps + description: What steps should we take to reproduce the issue? + value: | + 1. + 2. + 3. + - type: textarea + id: priority + attributes: + label: Priority + description: Please help us understand the priority for this issue. The more information provided will help the team better assess urgency and complexity. + placeholder: | + Work effort: Is this a quick fix or will it require a significant amount of work? Is there a clear path to a solution or is further investigation required? + Impact: Which part of the service is impacted? How many users are experencing this issue? + Is there a known workaround for this issue? If so, please describe. If not, does it prevent a user from doing a core task? + - type: textarea + id: stack-trace + attributes: + label: Stack trace + description: If you saw an error message or stack trace, please include it here. + placeholder: The text in this section will be formatted automatically; no need to include backticks. + render: shell + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Screenshots can help us see the behavior you're describing. Please add a screenshot or gif, especially if you are describing a rendering or visual bug. + placeholder: Paste or drag-and-drop an image to upload it. + - type: dropdown + id: browsers + attributes: + label: Browsers + description: Please select all browsers you've observed this behavior on. If the bug isn't browser-specific, you can leave this blank. + multiple: true + options: + - Chrome desktop + - Safari desktop + - Firefox desktop + - Android mobile + - iOS mobile diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..aa4bd749d99 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: Ask a question ❓ + url: https://groups.google.com/forum/#!forum/blockly + about: Go to the Blockly developer forum, where you can ask and answer questions. + - name: Report issues with plugins and examples 🧩 + url: https://github.com/google/blockly-samples/issues/new/choose + about: File bugs or feature requests about plugins and samples in our blockly-samples repository. diff --git a/.github/ISSUE_TEMPLATE/documentation.yaml b/.github/ISSUE_TEMPLATE/documentation.yaml new file mode 100644 index 00000000000..e3a5b118821 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yaml @@ -0,0 +1,38 @@ +name: Report a documentation problem 📖 +description: Could our documentation be better? Tell us how. +labels: 'issue: docs, issue: triage' +body: + - type: markdown + attributes: + value: > + Thanks for helping us improve our developer site documentation! + Use this template to describe issues with the content on our + [developer site](https://developers.google.com/blockly/guides). + - type: input + id: link + attributes: + label: Location + description: > + A link to the page with the documentation you want us to be updated. + If no page exists, describe what the page should be, and where. + - type: checkboxes + id: type + attributes: + label: Type + description: What kind of content is it? + options: + - label: Text + - label: Image or Gif + - label: Other + - type: textarea + id: content + attributes: + label: Suggested content + description: Your suggestion for improved documentation. If it's helpful, also include the old content for comparison. + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context about the problem. If this is related to a specific pull request, link to it. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 00000000000..04c3fdef6e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,39 @@ +name: Make a feature request ✨ +description: Suggest an idea to make Blockly better. +labels: 'issue: feature request, issue: triage' +body: + - type: markdown + attributes: + value: > + Thank you for taking the time to fill out a feature request! + If you have a question about how to use Blockly in your application, + please ask on the [forum](https://groups.google.com/forum/#!forum/blockly) instead of filing an issue. + - type: checkboxes + id: duplicates + attributes: + label: Check for duplicates + options: + - label: I have searched for similar issues before opening a new one. + - type: textarea + id: problem + attributes: + label: Problem + description: Is your feature request related to a problem? Please describe. + placeholder: I'm always frustrated when... + - type: textarea + id: request + attributes: + label: Request + description: Describe your feature request and how it solves your problem. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Describe any alternative solutions or features you've considered. + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..6a62a4d9d92 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,42 @@ + + +## The basics + + + +- [ ] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) + +## The details +### Resolves + + +Fixes + +### Proposed Changes + + + +### Reason for Changes + + + +### Test Coverage + + + +### Documentation + + + +### Additional Information + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..a1882a5298a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: 'npm' # See documentation for possible values + directory: '/' # Location of package manifests + target-branch: 'develop' + schedule: + interval: 'weekly' + commit-message: + prefix: 'chore(deps)' + labels: + - 'PR: chore' + - 'PR: dependencies' + - package-ecosystem: 'github-actions' # See documentation for possible values + directory: '/' + target-branch: 'develop' + schedule: + interval: 'weekly' + ignore: + # See notes in welcome_new_contributors.yml for details on this. + - dependency-name: 'actions/first-interaction' + versions: ['*'] + commit-message: + prefix: 'chore(deps)' + labels: + - 'PR: chore' + - 'PR: dependencies' diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000000..a6b8dc3ef36 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,32 @@ +# release.yml + +changelog: + exclude: + labels: + - ignore-for-release + - 'PR: chore' + authors: + - dependabot + categories: + - title: Breaking changes 🛠 + labels: + - breaking change + - title: Deprecations 🧹 - APIs that may be removed in future releases + labels: + - deprecation + - title: New features ✨ + labels: + - 'PR: feature' + - title: Bug fixes 🐛 + labels: + - 'PR: fix' + - title: Cleanup ♻️ + labels: + - 'PR: docs' + - 'PR: refactor' + - title: Reverted changes ⎌ + labels: + - 'PR: revert' + - title: Other changes + labels: + - '*' diff --git a/.github/workflows/appengine_deploy.yml b/.github/workflows/appengine_deploy.yml new file mode 100644 index 00000000000..621918329d1 --- /dev/null +++ b/.github/workflows/appengine_deploy.yml @@ -0,0 +1,54 @@ +# Workflow that prepares files and deploys to appengine + +name: Deploy to App Engine + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + prepare: + name: Prepare + runs-on: ubuntu-latest + + steps: + # Checks-out the repository under $GITHUB_WORKSPACE. + # When running manually this checks out the master branch. + - uses: actions/checkout@v5 + + - name: Prepare demo files + # Install all dependencies, then copy all the files needed for demos. + run: | + npm install + npm run prepareDemos + + - name: Upload + uses: actions/upload-artifact@v5 + with: + name: appengine_files + path: _deploy/ + + deploy: + name: Deploy + runs-on: ubuntu-latest + # The prepare step must succeed for this step to run. + needs: prepare + steps: + - name: Download prepared files + uses: actions/download-artifact@v6 + with: + name: appengine_files + path: _deploy/ + + - name: Deploy to App Engine + uses: google-github-actions/deploy-appengine@v3.0.1 + # For parameters see: + # https://github.com/google-github-actions/deploy-appengine#inputs + with: + working_directory: _deploy/ + deliverables: app.yaml + project_id: ${{ secrets.GCP_PROJECT }} + credentials: ${{ secrets.GCP_SA_KEY }} + promote: false + version: vtest diff --git a/.github/workflows/assign_reviewers.yml b/.github/workflows/assign_reviewers.yml new file mode 100644 index 00000000000..9382d1a570b --- /dev/null +++ b/.github/workflows/assign_reviewers.yml @@ -0,0 +1,43 @@ +name: Assign requested reviewers + +# This workflow adds requested reviewers as assignees. If you remove a +# requested reviewer, it will not remove them as an assignee. +# +# See https://github.com/google/blockly/issues/5643 for more +# information on why this was added. +# +# N.B.: Runs with a read-write repo token. Do not check out the +# submitted branch! +on: + pull_request_target: + types: [review_requested] + +jobs: + requested-reviewer: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Assign requested reviewer + uses: actions/github-script@v8 + with: + script: | + try { + if (context.payload.pull_request === undefined) { + throw new Error("Can't get pull_request payload. " + + 'Check a request reviewer event was triggered.'); + } + const reviewers = context.payload.pull_request.requested_reviewers; + // Assignees takes in a list of logins rather than the + // reviewer object. + const reviewerNames = reviewers.map(reviewer => reviewer.login); + const {number:issue_number} = context.payload.pull_request; + github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue_number, + assignees: reviewerNames + }); + } catch (error) { + core.setFailed(error.message); + } diff --git a/.github/workflows/browser_test.yml b/.github/workflows/browser_test.yml new file mode 100644 index 00000000000..7b7fc4c107f --- /dev/null +++ b/.github/workflows/browser_test.yml @@ -0,0 +1,57 @@ +# This workflow will do a clean install, start the selenium server, and run +# all of our browser based tests + +name: Run browser manually + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * 1' # Runs every Monday at 06:00 UTC + +permissions: + contents: read + +jobs: + build: + timeout-minutes: 120 + runs-on: ${{ matrix.os }} + + strategy: + matrix: + # TODO (#2114): re-enable osx build. + # os: [ubuntu-latest, macos-latest] + os: [macos-latest] + node-version: [18.x, 20.x] + # See supported Node.js release schedule at + # https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Reconfigure git to use HTTP authentication + run: > + git config --global url."https://github.com/".insteadOf + ssh://git@github.com/ + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node-version }} + + - name: Npm Install + run: npm install + + - name: Linux Test Setup + if: runner.os == 'Linux' + run: source ./tests/scripts/setup_linux_env.sh + + - name: Run Build + run: npm run build + + - name: Run Test + run: npm run test:browser + + env: + CI: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000000..c67fe9831b3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,85 @@ +# This workflow will do a clean install, start the selenium server, and run +# all of our tests. + +name: Node.js CI + +on: [pull_request] + +permissions: + contents: read + +jobs: + build: + timeout-minutes: 10 + runs-on: ${{ matrix.os }} + + strategy: + matrix: + # TODO (#2114): re-enable osx build. + # os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest] + node-version: [18.x, 20.x, 22.x, 24.x] + # See supported Node.js release schedule at + # https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Reconfigure git to use HTTP authentication + run: > + git config --global url."https://github.com/".insteadOf + ssh://git@github.com/ + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node-version }} + + - name: Npm Clean Install + run: npm ci + + - name: Linux Test Setup + if: runner.os == 'Linux' + run: source ./tests/scripts/setup_linux_env.sh + + - name: Run + run: npm run test + + env: + CI: true + + lint: + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Use Node.js 20.x + uses: actions/setup-node@v5 + with: + node-version: 20.x + + - name: Npm Install + run: npm install + + - name: Lint + run: npm run lint + + format: + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Use Node.js 20.x + uses: actions/setup-node@v5 + with: + node-version: 20.x + + - name: Npm Install + run: npm install + + - name: Check Format + run: npm run format:check diff --git a/.github/workflows/conventional-label.yml b/.github/workflows/conventional-label.yml new file mode 100644 index 00000000000..69e5035f757 --- /dev/null +++ b/.github/workflows/conventional-label.yml @@ -0,0 +1,42 @@ +on: + pull_request_target: + types: + - opened + - edited +name: commit lint & label +jobs: + lint: + runs-on: ubuntu-latest + env: + PR_TITLE: ${{ github.event.pull_request.title }} + permissions: + pull-requests: read + contents: read + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Setup node + uses: actions/setup-node@v5 + with: + node-version: lts/* + cache: npm + - name: Install dependencies + run: npm ci + - name: Check PR title + id: check-pr-title + run: echo "$PR_TITLE" | npx commitlint --verbose + + label: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: bcoe/conventional-release-labels@v1 + with: + type_labels: + '{"feat": "PR: feature", "fix": "PR: fix", "breaking": "breaking + change", "chore": "PR: chore", "docs": "PR: docs", "refactor": "PR: + refactor", "revert": "PR: revert", "deprecate": "deprecation"}' + ignored_types: '[]' diff --git a/.github/workflows/develop_freeze.yml b/.github/workflows/develop_freeze.yml new file mode 100644 index 00000000000..395a34434dd --- /dev/null +++ b/.github/workflows/develop_freeze.yml @@ -0,0 +1,26 @@ +# This workflow will comment on pull requests that are submitted while develop +# is frozen during the week of release. Skips any pull requests that have the +# label 'ignore-freeze'. +# This workflow should be enabled only while develop is frozen. + +name: Develop Freeze PR Comment + +on: + # Trigger the workflow on pull request on develop branch + pull_request: + types: + - opened + - reopened + branches: + - develop + +jobs: + freeze-comment: + if: ${{ !contains(github.event.pull_request.labels.*.name, 'ignore-freeze') }} + runs-on: ubuntu-latest + steps: + - name: PR Comment + uses: github-actions-up-and-running/pr-comment@f1f8ab2bf00dce6880a369ce08758a60c61d6c0b + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + message: 'Thanks for the PR! The develop branch is currently frozen in preparation for the release so it may not be addressed until after release week.' diff --git a/.github/workflows/keyboard_plugin_test.yml b/.github/workflows/keyboard_plugin_test.yml new file mode 100644 index 00000000000..efb4494e73d --- /dev/null +++ b/.github/workflows/keyboard_plugin_test.yml @@ -0,0 +1,66 @@ +# Workflow for running the keyboard navigation plugin's automated tests. + +name: Keyboard Navigation Automated Tests + +on: + workflow_dispatch: + pull_request: + push: + branches: + - develop + +permissions: + contents: read + +jobs: + webdriverio_tests: + name: WebdriverIO tests + timeout-minutes: 10 + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - name: Checkout core Blockly + uses: actions/checkout@v5 + with: + path: core-blockly + + - name: Checkout keyboard navigation plugin + uses: actions/checkout@v5 + with: + repository: 'google/blockly-keyboard-experimentation' + ref: 'main' + path: blockly-keyboard-experimentation + + - name: Use Node.js 20.x + uses: actions/setup-node@v5 + with: + node-version: 20.x + + - name: NPM install + run: | + cd core-blockly + npm install + cd .. + cd blockly-keyboard-experimentation + npm install + cd .. + + - name: Link latest core develop with plugin + run: | + cd core-blockly + npm run package + cd dist + npm link + cd ../../blockly-keyboard-experimentation + npm link blockly + cd .. + + - name: Run keyboard navigation plugin tests + run: | + cd blockly-keyboard-experimentation + npm run test diff --git a/.github/workflows/tag_module_cleanup.yml b/.github/workflows/tag_module_cleanup.yml new file mode 100644 index 00000000000..83e581b4182 --- /dev/null +++ b/.github/workflows/tag_module_cleanup.yml @@ -0,0 +1,37 @@ +# For new pull requests against the goog_module branch, adds the 'type: cleanup' +# label and sets the milestone to q3 2021 release. + +name: Tag module cleanup + +# Trigger on pull requests against goog_module branch only +# Uses pull_request_target to get write permissions so that it can write labels. +on: + pull_request_target: + branches: + - goog_module + +jobs: + tag-module-cleanup: + # Add the type: cleanup label + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v8 + with: + script: | + // Note that pull requests are considered issues and "shared" + // actions for both features, like manipulating labels and + // milestones are provided within the issues API. + await github.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + // 2021 q3 release milestone. + // https://github.com/google/blockly/milestone/18 + milestone: 18 + }) + await github.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['type: cleanup'] + }) diff --git a/.github/workflows/welcome_new_contributors.yml b/.github/workflows/welcome_new_contributors.yml new file mode 100644 index 00000000000..0f1f05c17ae --- /dev/null +++ b/.github/workflows/welcome_new_contributors.yml @@ -0,0 +1,41 @@ +on: + pull_request_target: + types: + - opened +name: Welcome new contributors +jobs: + welcome: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + # NOTE TO DEVELOPER: Per #9447 this is pinned to v1.3.0 and all updates + # have been disabled for it. There are some largely incompatibilities on + # v2 and v3 of the action that, without resolution, will break the first + # interaction experience for new contributors. This dependency should not + # be upgraded until those issues are resolved. + - uses: actions/first-interaction@v1.3.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + pr-message: > + Welcome! It looks like this is your first pull request in Blockly, + so here are a couple of tips: + + - You can find tips about contributing to Blockly and how to + validate your changes on our + [developer site](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change). + + - All contributors must sign the Google Contributor License + Agreement (CLA). If the google-cla bot leaves a comment on this + PR, make sure you follow the instructions. + + - We use conventional commits to make versioning the package easier. Make sure your commit + message is in the [proper format](https://developers.google.com/blockly/guides/contribute/get-started/commits) + or [learn how to fix it](https://developers.google.com/blockly/guides/contribute/get-started/commits#fixing_non-conventional_commits). + + - If any of the other checks on this PR fail, you can click on + them to learn why. It might be that your change caused a test + failure, or that you need to double-check the + [style guide](https://developers.google.com/blockly/guides/contribute/core/style_guide). + + Thank you for opening this PR! A member of the Blockly team will review it soon. diff --git a/.gitignore b/.gitignore index 53eebc859d0..3c1938f17d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,22 @@ node_modules npm-debug.log +build-debug.log .DS_Store .settings .project +*.gz *.pyc *.komodoproject -/nbproject/private/ \ No newline at end of file +/nbproject/private/ +tsdoc-metadata.json + +tests/compile/main_compressed.js +tests/compile/main_compressed.js.map +tests/compile/*compiler*.jar +tests/screenshot/outputs/* +local_build/*compiler*.jar +local_build/local_*_compressed.js +chromedriver +build/ +dist/ +temp/ diff --git a/.jshintignore b/.jshintignore deleted file mode 100644 index 9cc962747a8..00000000000 --- a/.jshintignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules/ -tests/ -demos/ -**/*_compressed.js -**/*_uncompressed.js -**/*_test.js \ No newline at end of file diff --git a/.npmrc b/.npmrc index 214c29d1395..a4af34998fa 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ registry=https://registry.npmjs.org/ +legacy-peer-deps=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000000..9d52f19fe6a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,30 @@ +# Build Artifacts +/msg/* +/build/* +/dist/* +/typings/* +/docs/* + +# Tests other than mocha unit tests +/tests/blocks/* +/tests/themes/* +/tests/compile/* +/tests/jsunit/* +/tests/generators/* +/tests/mocha/webdriver.js +/tests/screenshot/* +/tests/test_runner.js +/tests/workspace_svg/* + +# Demos, scripts, misc +/node_modules/* +/demos/* +/appengine/* +/externs/* +/closure/* +/scripts/gulpfiles/* +CHANGELOG.md +PULL_REQUEST_TEMPLATE.md + +# Don't bother formatting JavaScript files we're about to migrate: +/generators/**/*.js diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000000..84a85c1159e --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,15 @@ +// This config attempts to match google-style code. + +module.exports = { + // Prefer single quotes, but minimize escaping. + singleQuote: true, + // Some properties must be quoted to preserve closure compiler behavior. + // Don't ever change whether properties are quoted. + quoteProps: 'preserve', + // Don't add spaces around braces for object literals. + bracketSpacing: false, + // Put HTML tag closing brackets on same line as last attribute. + bracketSameLine: true, + // Organise imports using a plugin. + 'plugins': ['prettier-plugin-organize-imports'], +}; diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index bbc6cfcb887..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -language: node_js -matrix: - include: - - os: linux - dist: trusty - node_js: stable - sudo: required - addons: - apt: - packages: - - google-chrome-stable - - os: osx - node_js: stable - osx_image: xcode8.3 - -before_install: -- npm install google-closure-library -- npm install webdriverio -# Symlink closure library -- ln -s $(npm root)/google-closure-library ../closure-library - -before_script: - - export DISPLAY=:99.0 - - if [ "${TRAVIS_OS_NAME}" == "linux" ]; then ( scripts/setup_linux_env.sh ) fi - - if [ "${TRAVIS_OS_NAME}" == "osx" ]; then ( scripts/setup_osx_env.sh ) fi - - sleep 2 - -script: - - set -x - - npm test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000000..cbe1c7ee290 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,77 @@ +# Changelog + +## [8.0.0](https://github.com/google/blockly/compare/blockly-v7.20211209.0...blockly-v8.0.0) (2022-03-31) + + +### ⚠ BREAKING CHANGES + +* change paste to return the pasted thing to support keyboard nav (#5996) +* **blocks:** ...and rename Blockly.blocks.all (blocks/all.js) to Blockly.libraryBlocks (blocks/blocks.js +* * refactor(blocks): Make loopTypes a Set +* allows previously internal constants to be configurable (#5897) +* * refactor(blocks): Make loopTypes a Set +* remove unused constants from internalConstants (#5889) + +### Features + +* add mocha failure messages to console output ([#5984](https://github.com/google/blockly/issues/5984)) ([7d250fa](https://github.com/google/blockly/commit/7d250fa9cfb30f95e7af523720b66c8b001df15c)) +* Allow developers to set a custom tooltip rendering function. ([#5956](https://github.com/google/blockly/issues/5956)) ([6841ccc](https://github.com/google/blockly/commit/6841ccc99fdbcc5f6d5a63bb36cb3b6ebd2be246)) +* **blocks:** Export block definitions ([#5908](https://github.com/google/blockly/issues/5908)) ([ffb8907](https://github.com/google/blockly/commit/ffb8907db8d0f11609c1fe14b2a450d3e639a871)) +* make mocha fail if it encounters 0 tests ([#5981](https://github.com/google/blockly/issues/5981)) ([0b2bf3a](https://github.com/google/blockly/commit/0b2bf3ae9d0c777f4d13d47692f5ae224dff1ec8)) +* **tests:** Add a test to validate `scripts/migration/renamings.js` ([#5980](https://github.com/google/blockly/issues/5980)) ([3c723f0](https://github.com/google/blockly/commit/3c723f0199b1f3b5eaac58f064b02d52b60d0ddb)) +* **tests:** Use official semver.org RegExp ([#5990](https://github.com/google/blockly/issues/5990)) ([afc4088](https://github.com/google/blockly/commit/afc4088ce278f97585f9ff5e65a921f7c4c65531)) + + +### Bug Fixes + +* Adds check for changedTouches ([#5869](https://github.com/google/blockly/issues/5869)) ([3f4f505](https://github.com/google/blockly/commit/3f4f5057919fdb4a329e9d2b15378c5c5831ae3b)) +* advanced playground and playground to work when hosted ([#6021](https://github.com/google/blockly/issues/6021)) ([364bf14](https://github.com/google/blockly/commit/364bf14ce6932f426591e3f53c1d066771ddcb8e)) +* always rename caller to legal name ([#6014](https://github.com/google/blockly/issues/6014)) ([c430800](https://github.com/google/blockly/commit/c4308007bc4b58d51adf1fda7b51ffa9f1d3f093)) +* **blocks:** correct the callType_ of procedures_defreturn ([#5974](https://github.com/google/blockly/issues/5974)) ([b34db5b](https://github.com/google/blockly/commit/b34db5bd01f7b532ebabc80264ca9fc804a76c75)) +* **build:** Correctly handle deep export paths in UMD wrapper ([#5945](https://github.com/google/blockly/issues/5945)) ([71ab146](https://github.com/google/blockly/commit/71ab146bc21aef9bdd6b2385c1df5f51e3ff5b58)) +* bumping a block after duplicate breaking undo ([#5844](https://github.com/google/blockly/issues/5844)) ([5204569](https://github.com/google/blockly/commit/5204569cff58c1ead7c15165a1351fa6a2ba2ad3)) +* change getCandidate_ and showInsertionMarker_ to be more dynamic ([#5722](https://github.com/google/blockly/issues/5722)) ([68d8113](https://github.com/google/blockly/commit/68d81132b851d20884ee9da41719fa62cdfce0ee)) +* change paste to return the pasted thing to support keyboard nav ([#5996](https://github.com/google/blockly/issues/5996)) ([20f1475](https://github.com/google/blockly/commit/20f1475afc1abf4b5e600219c2981150fc621ba5)) +* Change the truthy tests of width and height in WorkspaceSvg.setCachedParentSvgSize to actual comparisons with null so that zero value can be saved into the cache ([#5997](https://github.com/google/blockly/issues/5997)) ([fec44d9](https://github.com/google/blockly/commit/fec44d917e4b8475beba28e4769a50982425e887)) +* comments not being restored when dragging ([#6011](https://github.com/google/blockly/issues/6011)) ([85ce3b8](https://github.com/google/blockly/commit/85ce3b82c6c32e8a2a1608c6d604262ea0e5c38d)) +* convert the common renderer to an ES6 class ([#5978](https://github.com/google/blockly/issues/5978)) ([c1004be](https://github.com/google/blockly/commit/c1004be1f24debe1df1566e6067cf2f6769d51aa)) +* convert the Workspace class to an ES6 class ([#5977](https://github.com/google/blockly/issues/5977)) ([e2eaebe](https://github.com/google/blockly/commit/e2eaebec47b08a83eb36d0d04cefa254d1c5d666)) +* custom block context menus ([#5976](https://github.com/google/blockly/issues/5976)) ([8058df2](https://github.com/google/blockly/commit/8058df2a71dcecdc1190ae1d6f5dcccfafc980e8)) +* Don't throw if drag surface is empty. ([#5695](https://github.com/google/blockly/issues/5695)) ([769a25f](https://github.com/google/blockly/commit/769a25f4badffd2409ce19535344c98f5d8b01c9)) +* export Blockly.Names.NameType and Blockly.Input.Align correctly ([#6030](https://github.com/google/blockly/issues/6030)) ([2c15d00](https://github.com/google/blockly/commit/2c15d002ababcba7f34c526c05f231735e3e0169)) +* Export loopTypes from Blockly.blocks.loops ([#5900](https://github.com/google/blockly/issues/5900)) ([4f74210](https://github.com/google/blockly/commit/4f74210e74ef0b06216ab0f288268192674d69c9)) +* Export loopTypes from Blockly.blocks.loops ([#5900](https://github.com/google/blockly/issues/5900)) ([74ef1cb](https://github.com/google/blockly/commit/74ef1cbf521f7c6447ea9672ddbfe861d2292e5f)) +* Fix bug where workspace comments could not be created. ([#6024](https://github.com/google/blockly/issues/6024)) ([2cf8eb8](https://github.com/google/blockly/commit/2cf8eb87dcb029ba152b63b01ee7e4df431d1bb6)) +* Fix downloading screenshots on the playground. ([#6025](https://github.com/google/blockly/issues/6025)) ([ca6e590](https://github.com/google/blockly/commit/ca6e590101d511a8d98a5c5438af32ff6749e020)) +* fix keycodes type ([#5805](https://github.com/google/blockly/issues/5805)) ([0a96543](https://github.com/google/blockly/commit/0a96543a1179636e4efeb3c654c075952aca0c9f)) +* Fixed the label closure on demo/blockfactory ([#5833](https://github.com/google/blockly/issues/5833)) ([e8ea2e9](https://github.com/google/blockly/commit/e8ea2e9902fb9f642459e7341c3d59e19f359fca)) +* **generators:** Fix an operator precedence issue in the math_number_property generators to remove extra parentheses ([#5685](https://github.com/google/blockly/issues/5685)) ([a31003f](https://github.com/google/blockly/commit/a31003fab964e529152389029ec3126a3802851b)) +* incorrect module for event data in renamings database ([#6012](https://github.com/google/blockly/issues/6012)) ([e502eaa](https://github.com/google/blockly/commit/e502eaa6e1c88b2bb34e9a87917a15098b81cfa3)) +* Move [@alias](https://github.com/alias) onto classes instead of constructors ([#6003](https://github.com/google/blockly/issues/6003)) ([1647a32](https://github.com/google/blockly/commit/1647a3299ac48b5924f987015d8f3c47593922af)) +* move test helpers from samples into core ([#5969](https://github.com/google/blockly/issues/5969)) ([2edd228](https://github.com/google/blockly/commit/2edd22811752f05e16c68d593e5d1b809e24ed25)) +* move the dropdown div to a namespace instead of a class with only static properties ([#5979](https://github.com/google/blockly/issues/5979)) ([543cb8e](https://github.com/google/blockly/commit/543cb8e1b1c1a7fca5a1629f42f71c9b18e1a255)) +* msg imports in type definitions ([#5858](https://github.com/google/blockly/issues/5858)) ([07a75de](https://github.com/google/blockly/commit/07a75dee8de13b6c5a02959325a0155d413d6712)) +* opening/closing the mutators ([#6000](https://github.com/google/blockly/issues/6000)) ([243fc52](https://github.com/google/blockly/commit/243fc52a96e1089aad89ff6b642c6605d8f71afd)) +* playground access to Blockly ([9e1cda8](https://github.com/google/blockly/commit/9e1cda8f45cea1707c5a228d5ce79b4cd81566f8)) +* playground test blocks, text area listeners, and show/hide buttons ([#6015](https://github.com/google/blockly/issues/6015)) ([7abf3de](https://github.com/google/blockly/commit/7abf3de910a35e1a6086a3243570627a41e73339)) +* procedure param edits breaking undo ([#5845](https://github.com/google/blockly/issues/5845)) ([8a71f87](https://github.com/google/blockly/commit/8a71f879504503f4aec1140fe653d93602c664df)) +* re-expose HSV_VALUE and HSV_SATURATION as settable properties on Blockly ([#5821](https://github.com/google/blockly/issues/5821)) ([0e5f3ce](https://github.com/google/blockly/commit/0e5f3ce6074fbbb2923e9a62bffefeae0a813be8)) +* re-expose HSV_VALUE and HSV_SATURATION as settable properties on Blockly ([#5821](https://github.com/google/blockly/issues/5821)) ([6fc3316](https://github.com/google/blockly/commit/6fc3316309534270106050f0e1fecb7a09b8e62c)) +* revert "Delete events should animate when played ([#5919](https://github.com/google/blockly/issues/5919))" ([#6031](https://github.com/google/blockly/issues/6031)) ([c4a25eb](https://github.com/google/blockly/commit/c4a25eb3c432b0e8a9a18aae42839d163a177c48)) +* revert converting test helpers to es modules ([#5982](https://github.com/google/blockly/issues/5982)) ([01d4597](https://github.com/google/blockly/commit/01d45972d4df8b5e4afa4a19d93defb8961fea57)) +* setting null for a font style on a theme ([#5831](https://github.com/google/blockly/issues/5831)) ([835fb02](https://github.com/google/blockly/commit/835fb02343df0a4b9dab7704a4b3d8be8e9a497c)) +* **tests:** Enable --debug for test:compile:advanced; fix some errors ([#5959](https://github.com/google/blockly/issues/5959)) ([88334be](https://github.com/google/blockly/commit/88334bea80aa26c08705f16aba5e79dd708158f9)) +* **tests:** Enable `--debug` for `test:compile:advanced`; fix some errors (and demote the rest to warnings) ([#5983](https://github.com/google/blockly/issues/5983)) ([e11b583](https://github.com/google/blockly/commit/e11b5834e5e4e8fe991be32afb08eafa7c8adffd)) +* TypeScript exporting of the serialization functions ([#5890](https://github.com/google/blockly/issues/5890)) ([5d7c890](https://github.com/google/blockly/commit/5d7c890243ba7d0501514ba48778715097ce5a3b)) +* undo/redo for auto disabling if-return blocks ([#6018](https://github.com/google/blockly/issues/6018)) ([c7a359a](https://github.com/google/blockly/commit/c7a359a8424287f139752573a27a8a6eb97cb7b3)) +* update the playground to load compressed when hosted ([#5835](https://github.com/google/blockly/issues/5835)) ([2adf326](https://github.com/google/blockly/commit/2adf326c230589800880faa9599eca2ecc94d283)) +* Update typings for q1 2022 release ([#6051](https://github.com/google/blockly/issues/6051)) ([69f3d4a](https://github.com/google/blockly/commit/69f3d4ae89ce16a558443dd0a772e35b62c096d3)) +* Use correct namespace for svgMath functions ([#5813](https://github.com/google/blockly/issues/5813)) ([b8cc983](https://github.com/google/blockly/commit/b8cc983324338b2cbd536425c93ff3e7d512751e)) +* Use correct namespace for svgMath functions ([#5813](https://github.com/google/blockly/issues/5813)) ([025bab6](https://github.com/google/blockly/commit/025bab656669f99ebdb8b95bea39ebae296f1495)) + + +### Code Refactoring + +* allows previously internal constants to be configurable ([#5897](https://github.com/google/blockly/issues/5897)) ([4b5733e](https://github.com/google/blockly/commit/4b5733e7c85f2e196719550a3cfdcbcbd61739df)) +* **blocks:** Rename Blockly.blocks.* modules to Blockly.libraryBlocks.* ([#5953](https://github.com/google/blockly/issues/5953)) ([5078dcb](https://github.com/google/blockly/commit/5078dcbc6d4d48422313732e87191b29569b5eed)) +* remove unused constants from internalConstants ([#5889](https://github.com/google/blockly/issues/5889)) ([f0b1077](https://github.com/google/blockly/commit/f0b10776eb0657a5446adcfc62ad13f419c14271)) diff --git a/LICENSE b/LICENSE index 6a1992987fe..d6456956733 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ Apache License - Version 2.0, January 2011 + Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION @@ -175,3 +175,28 @@ of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README.md b/README.md index 457fb810f72..8094c4576d9 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,78 @@ -# Blockly [![Build Status]( https://travis-ci.org/google/blockly.svg?branch=master)](https://travis-ci.org/google/blockly) +# Blockly +Google's Blockly is a library that adds a visual code editor to web and mobile apps. The Blockly editor uses interlocking, graphical blocks to represent code concepts like variables, logical expressions, loops, and more. It allows users to apply programming principles without having to worry about syntax or the intimidation of a blinking cursor on the command line. All code is [free and open source](https://github.com/google/blockly/blob/develop/LICENSE). -Google's Blockly is a web-based, visual programming editor. Users can drag -blocks together to build programs. All code is free and open source. +![Sample](./sample.svg) -**The project page is https://developers.google.com/blockly/** +## Getting Started with Blockly -![](https://developers.google.com/blockly/images/sample.png) +Blockly has many resources for learning how to use the library. Start at our [Google Developers Site](https://developers.google.com/blockly) to read the documentation on how to get started, configure Blockly, and integrate it into your application. The developers site also contains links to: -Blockly has an active [developer forum](https://groups.google.com/forum/#!forum/blockly). Please drop by and say hello. Show us your prototypes early; collectively we have a lot of experience and can offer hints which will save you time. +- [Getting Started article](https://developers.google.com/blockly/guides/get-started/web) +- [Getting Started codelab](https://blocklycodelabs.dev/codelabs/getting-started/index.html#0) +- [More codelabs](https://blocklycodelabs.dev/) +- [Demos and plugins](https://google.github.io/blockly-samples/) -Help us focus our development efforts by telling us [what you are doing with -Blockly](https://developers.google.com/blockly/registration). The questionnaire only takes -a few minutes and will help us better support the Blockly community. +Help us focus our development efforts by telling us [what you are doing with Blockly](https://developers.google.com/blockly/registration). The questionnaire only takes a few minutes and will help us better support the Blockly community. -Want to contribute? Great! First, read [our guidelines for contributors](https://developers.google.com/blockly/guides/modify/contributing). +### Installing Blockly + +Blockly is [available on npm](https://www.npmjs.com/package/blockly): + +```bash +npm install blockly +``` + +For more information on installing and using Blockly, see the [Getting Started article](https://developers.google.com/blockly/guides/get-started/web). + +### Getting Help + +- [Report a bug](https://developers.google.com/blockly/guides/modify/contribute/write_a_good_issue) or file a feature request on GitHub +- Ask a question, or search others' questions, on our [developer forum](https://groups.google.com/forum/#!forum/blockly). You can also drop by to say hello and show us your prototypes; collectively we have a lot of experience and can offer hints which will save you time. We actively monitor the forums and typically respond to questions within 2 working days. + +### blockly-samples + +We have a number of resources such as [examples](https://github.com/google/blockly-samples/tree/master/examples), [codelabs](https://github.com/google/blockly-samples/tree/master/codelabs), and [plugins](https://github.com/google/blockly-samples/tree/master/plugins) in another repository called [blockly-samples](https://github.com/google/blockly-samples). A plugin is a self-contained piece of code that adds functionality to Blockly. Plugins can add fields, define themes, create renderers, and much more. For more information, see the [Plugins documentation](https://developers.google.com/blockly/guides/programming/plugin_overview). + +## Contributing to Blockly + +Want to make Blockly better? We welcome contributions to Blockly in the form of pull requests, bug reports, documentation, answers on the forum, and more! Check out our [Contributing Guidelines](https://developers.google.com/blockly/guides/modify/contributing) for more information. You might also want to look for issues tagged "[Help Wanted](https://github.com/google/blockly/labels/help%20wanted)" which are issues we think would be great for external contributors to help with. + +## Releases + +We release by pushing the latest code to the master branch, followed by updating the npm package, our [docs](https://developers.google.com/blockly), and [demo pages](https://google.github.io/blockly-samples/). If there are breaking bugs, such as a crash when performing a standard action or a rendering issue that makes Blockly unusable, we will cherry-pick fixes to master between releases to fix them. The [releases page](https://github.com/google/blockly/releases) has a list of all releases. + +We use [semantic versioning](https://semver.org/). Releases that have breaking changes or are otherwise not backwards compatible will have a new major version. Patch versions are reserved for bug-fix patches between scheduled releases. + +We now have a [beta release on npm](https://www.npmjs.com/package/blockly?activeTab=versions). If you'd like to test the upcoming release, or try out a not-yet-released new API, you can use the beta channel with: + +```bash +npm install blockly@beta +``` + +As it is a beta channel, it may be less stable, and the APIs there are subject to change. + +### Branches + +There are two main branches for Blockly. + +**[master](https://github.com/google/blockly)** - This is the (mostly) stable current release of Blockly. + +**[develop](https://github.com/google/blockly/tree/develop)** - This is where most of our work happens. Pull requests should always be made against develop. This branch will generally be usable, but may be less stable than the master branch. Once something is in develop we expect it to merge to master in the next release. + +**other branches:** - Larger changes may have their own branches until they are good enough for people to try out. These will be developed separately until we think they are almost ready for release. These branches typically get merged into develop immediately after a release to allow extra time for testing. + +### New APIs + +Once a new API is merged into master it is considered beta until the following release. We generally try to avoid changing an API after it has been merged to master, but sometimes we need to make changes after seeing how an API is used. If an API has been around for at least two releases we'll do our best to avoid breaking it. + +Unreleased APIs may change radically. Anything that is in `develop` but not `master` is subject to change without warning. + +## Issues and Milestones + +We typically triage all bugs within 1 week, which includes adding any appropriate labels and assigning it to a milestone. Please keep in mind, we are a small team so even feature requests that everyone agrees on may not be prioritized. + +## Good to Know + +- Cross-browser Testing Platform and Open Source <3 Provided by [Sauce Labs](https://saucelabs.com) +- We test browsers using [BrowserStack](https://browserstack.com) diff --git a/_config.yml b/_config.yml new file mode 100644 index 00000000000..bd053b36b40 --- /dev/null +++ b/_config.yml @@ -0,0 +1 @@ +exclude: [] diff --git a/accessible/README.md b/accessible/README.md deleted file mode 100644 index a69fa6667cf..00000000000 --- a/accessible/README.md +++ /dev/null @@ -1,54 +0,0 @@ -Accessible Blockly -================== - -Google's Blockly is a web-based, visual programming editor that is accessible -to blind users. - -The code in this directory renders a version of the Blockly toolbox and -workspace that is fully keyboard-navigable, and compatible with most screen -readers. It is optimized for NVDA on Firefox. - -In the future, Accessible Blockly may be modified to suit accessibility needs -other than visual impairments. Note that deaf users are expected to continue -using Blockly over Accessible Blockly. - - -Using Accessible Blockly in Your Web App ----------------------------------------- -The demo at blockly/demos/accessible covers the absolute minimum required to -import Accessible Blockly into your web app. You will need to import the files -in the same order as in the demo: utils.service.js will need to be the first -Angular file imported. - -When the DOMContentLoaded event fires, call ng.platform.browser.bootstrap() on -the main component to be loaded. This will usually be blocklyApp.AppComponent, -but if you have another component that wraps it, use that one instead. - - -Customizing the Sidebar and Audio ---------------------------------- -The Accessible Blockly workspace comes with a customizable sidebar. - -To customize the sidebar, you will need to declare an ACCESSIBLE_GLOBALS object -in the global scope that looks like this: - - var ACCESSIBLE_GLOBALS = { - mediaPathPrefix: null, - customSidebarButtons: [] - }; - -The value of mediaPathPrefix should be the location of the accessible/media -folder. - -The value of 'customSidebarButtons' should be a list of objects, each -representing buttons on the sidebar. Each of these objects should have the -following keys: - - 'text' (the text to display on the button) - - 'action' (the function that gets run when the button is clicked) - - 'id' (optional; the id of the button) - - -Limitations ------------ -- We do not support having multiple Accessible Blockly apps in a single webpage. -- Accessible Blockly does not support the use of shadow blocks. diff --git a/accessible/app.component.js b/accessible/app.component.js deleted file mode 100644 index c31fd4259bb..00000000000 --- a/accessible/app.component.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Top-level component for the Accessible Blockly application. - * @author madeeha@google.com (Madeeha Ghori) - */ - -goog.provide('blocklyApp.AppComponent'); - -goog.require('Blockly'); - -goog.require('blocklyApp.AudioService'); -goog.require('blocklyApp.BlockConnectionService'); -goog.require('blocklyApp.BlockOptionsModalComponent'); -goog.require('blocklyApp.BlockOptionsModalService'); -goog.require('blocklyApp.KeyboardInputService'); -goog.require('blocklyApp.NotificationsService'); -goog.require('blocklyApp.SidebarComponent'); -goog.require('blocklyApp.ToolboxModalComponent'); -goog.require('blocklyApp.ToolboxModalService'); -goog.require('blocklyApp.TranslatePipe'); -goog.require('blocklyApp.TreeService'); -goog.require('blocklyApp.UtilsService'); -goog.require('blocklyApp.VariableAddModalComponent'); -goog.require('blocklyApp.VariableModalService'); -goog.require('blocklyApp.VariableRenameModalComponent'); -goog.require('blocklyApp.VariableRemoveModalComponent'); -goog.require('blocklyApp.WorkspaceComponent'); - - -blocklyApp.workspace = new Blockly.Workspace(); - -blocklyApp.AppComponent = ng.core.Component({ - selector: 'blockly-app', - template: ` - - - -
- {{getAriaLiveReadout()}} -
- - - - - - - - - - `, - directives: [ - blocklyApp.BlockOptionsModalComponent, - blocklyApp.SidebarComponent, - blocklyApp.ToolboxModalComponent, - blocklyApp.VariableAddModalComponent, - blocklyApp.VariableRenameModalComponent, - blocklyApp.VariableRemoveModalComponent, - blocklyApp.WorkspaceComponent - ], - pipes: [blocklyApp.TranslatePipe], - // All services are declared here, so that all components in the application - // use the same instance of the service. - // https://www.sitepoint.com/angular-2-components-providers-classes-factories-values/ - providers: [ - blocklyApp.AudioService, - blocklyApp.BlockConnectionService, - blocklyApp.BlockOptionsModalService, - blocklyApp.KeyboardInputService, - blocklyApp.NotificationsService, - blocklyApp.ToolboxModalService, - blocklyApp.TreeService, - blocklyApp.UtilsService, - blocklyApp.VariableModalService - ] -}) -.Class({ - constructor: [ - blocklyApp.NotificationsService, function(notificationsService) { - this.notificationsService = notificationsService; - } - ], - getAriaLiveReadout: function() { - return this.notificationsService.getDisplayedMessage(); - } -}); diff --git a/accessible/audio.service.js b/accessible/audio.service.js deleted file mode 100644 index 4f7eb4f0866..00000000000 --- a/accessible/audio.service.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Service for playing audio files. - * @author sll@google.com (Sean Lip) - */ - -goog.provide('blocklyApp.AudioService'); - -goog.require('blocklyApp.NotificationsService'); - - -blocklyApp.AudioService = ng.core.Class({ - constructor: [ - blocklyApp.NotificationsService, function(notificationsService) { - this.notificationsService = notificationsService; - - // We do not play any audio unless a media path prefix is specified. - this.canPlayAudio = false; - - if (ACCESSIBLE_GLOBALS.hasOwnProperty('mediaPathPrefix')) { - this.canPlayAudio = true; - var mediaPathPrefix = ACCESSIBLE_GLOBALS['mediaPathPrefix']; - this.AUDIO_PATHS_ = { - 'connect': mediaPathPrefix + 'click.mp3', - 'delete': mediaPathPrefix + 'delete.mp3', - 'oops': mediaPathPrefix + 'oops.mp3' - }; - } - - this.cachedAudioFiles_ = {}; - // Store callback references here so that they can be removed if a new - // call to this.play_() comes in. - this.onEndedCallbacks_ = { - 'connect': [], - 'delete': [], - 'oops': [] - }; - } - ], - play_: function(audioId, onEndedCallback) { - if (this.canPlayAudio) { - if (!this.cachedAudioFiles_.hasOwnProperty(audioId)) { - this.cachedAudioFiles_[audioId] = new Audio(this.AUDIO_PATHS_[audioId]); - } - - if (onEndedCallback) { - this.onEndedCallbacks_[audioId].push(onEndedCallback); - this.cachedAudioFiles_[audioId].addEventListener( - 'ended', onEndedCallback); - } else { - var that = this; - this.onEndedCallbacks_[audioId].forEach(function(callback) { - that.cachedAudioFiles_[audioId].removeEventListener( - 'ended', callback); - }); - this.onEndedCallbacks_[audioId].length = 0; - } - - this.cachedAudioFiles_[audioId].play(); - } - }, - playConnectSound: function() { - this.play_('connect'); - }, - playDeleteSound: function() { - this.play_('delete'); - }, - playOopsSound: function(optionalStatusMessage) { - if (optionalStatusMessage) { - var that = this; - this.play_('oops', function() { - that.notificationsService.speak(optionalStatusMessage); - }); - } else { - this.play_('oops'); - } - } -}); diff --git a/accessible/block-connection.service.js b/accessible/block-connection.service.js deleted file mode 100644 index c1ee03aaccd..00000000000 --- a/accessible/block-connection.service.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Service for handling the mechanics of how blocks - * get connected to each other. - * @author sll@google.com (Sean Lip) - */ - -goog.provide('blocklyApp.BlockConnectionService'); - -goog.require('blocklyApp.AudioService'); -goog.require('blocklyApp.NotificationsService'); - - -blocklyApp.BlockConnectionService = ng.core.Class({ - constructor: [ - blocklyApp.NotificationsService, blocklyApp.AudioService, - function(_notificationsService, _audioService) { - this.notificationsService = _notificationsService; - this.audioService = _audioService; - - // When a user "adds a link" to a block, the connection representing this - // link is stored here. - this.markedConnection_ = null; - }], - findCompatibleConnection_: function(block, targetConnection) { - // Locates and returns a connection on the given block that is compatible - // with the target connection, if one exists. Returns null if no such - // connection exists. - // Note: the targetConnection is assumed to be the markedConnection_, or - // possibly its counterpart (in the case where the marked connection is - // currently attached to another connection). This method therefore ignores - // input connections on the given block, since one doesn't usually mark an - // output connection and attach a block to it. - if (!targetConnection || !targetConnection.getSourceBlock().workspace) { - return null; - } - - var desiredType = Blockly.OPPOSITE_TYPE[targetConnection.type]; - var potentialConnection = ( - desiredType == Blockly.OUTPUT_VALUE ? block.outputConnection : - desiredType == Blockly.PREVIOUS_STATEMENT ? block.previousConnection : - desiredType == Blockly.NEXT_STATEMENT ? block.nextConnection : - null); - - if (potentialConnection && - potentialConnection.checkType_(targetConnection)) { - return potentialConnection; - } else { - return null; - } - }, - isAnyConnectionMarked: function() { - return Boolean(this.markedConnection_); - }, - getMarkedConnectionSourceBlock: function() { - return this.markedConnection_ ? - this.markedConnection_.getSourceBlock() : null; - }, - canBeAttachedToMarkedConnection: function(block) { - return Boolean( - this.findCompatibleConnection_(block, this.markedConnection_)); - }, - canBeMovedToMarkedConnection: function(block) { - if (!this.markedConnection_) { - return false; - } - - // It should not be possible to move any ancestor of the block containing - // the marked connection to the marked connection. - var ancestorBlock = this.getMarkedConnectionSourceBlock(); - while (ancestorBlock) { - if (ancestorBlock.id == block.id) { - return false; - } - ancestorBlock = ancestorBlock.getParent(); - } - - return this.canBeAttachedToMarkedConnection(block); - }, - markConnection: function(connection) { - this.markedConnection_ = connection; - this.notificationsService.speak(Blockly.Msg.ADDED_LINK_MSG); - }, - attachToMarkedConnection: function(block) { - var xml = Blockly.Xml.blockToDom(block); - var reconstitutedBlock = Blockly.Xml.domToBlock(blocklyApp.workspace, xml); - - var targetConnection = null; - if (this.markedConnection_.targetBlock() && - this.markedConnection_.type == Blockly.PREVIOUS_STATEMENT) { - // Is the marked connection a 'previous' connection that is already - // connected? If so, find the block that's currently connected to it, and - // use that block's 'next' connection as the new marked connection. - // Otherwise, splicing does not happen correctly, and inserting a block - // in the middle of a group of two linked blocks will split the group. - targetConnection = this.markedConnection_.targetConnection; - } else { - targetConnection = this.markedConnection_; - } - - var connection = this.findCompatibleConnection_( - reconstitutedBlock, targetConnection); - if (connection) { - targetConnection.connect(connection); - - this.markedConnection_ = null; - this.audioService.playConnectSound(); - return reconstitutedBlock.id; - } else { - // We throw an error here, because we expect any UI controls that would - // result in a non-connection to be disabled or hidden. - throw Error( - 'Unable to connect block to marked connection. This should not ' + - 'happen.'); - } - } -}); diff --git a/accessible/block-options-modal.component.js b/accessible/block-options-modal.component.js deleted file mode 100644 index 6ac7975b5de..00000000000 --- a/accessible/block-options-modal.component.js +++ /dev/null @@ -1,146 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Component that represents the block options modal. - * - * @author sll@google.com (Sean Lip) - */ - -goog.provide('blocklyApp.BlockOptionsModalComponent'); - -goog.require('blocklyApp.AudioService'); -goog.require('blocklyApp.BlockOptionsModalService'); -goog.require('blocklyApp.KeyboardInputService'); -goog.require('blocklyApp.TranslatePipe'); - -goog.require('Blockly.CommonModal'); - - -blocklyApp.BlockOptionsModalComponent = ng.core.Component({ - selector: 'blockly-block-options-modal', - template: ` -
- -
-

{{'BLOCK_OPTIONS'|translate}}

-
-
- -
-
- -
- -
-
-
- `, - pipes: [blocklyApp.TranslatePipe] -}) -.Class({ - constructor: [ - blocklyApp.BlockOptionsModalService, blocklyApp.KeyboardInputService, - blocklyApp.AudioService, - function(blockOptionsModalService_, keyboardInputService_, audioService_) { - this.blockOptionsModalService = blockOptionsModalService_; - this.keyboardInputService = keyboardInputService_; - this.audioService = audioService_; - - this.modalIsVisible = false; - this.actionButtonsInfo = []; - this.activeButtonIndex = -1; - this.onDismissCallback = null; - - var that = this; - this.blockOptionsModalService.registerPreShowHook( - function(newActionButtonsInfo, onDismissCallback) { - that.modalIsVisible = true; - that.actionButtonsInfo = newActionButtonsInfo; - that.activeActionButtonIndex = -1; - that.onDismissCallback = onDismissCallback; - - Blockly.CommonModal.setupKeyboardOverrides(that); - that.keyboardInputService.addOverride('13', function(evt) { - evt.preventDefault(); - evt.stopPropagation(); - - if (that.activeButtonIndex == -1) { - return; - } - - var button = document.getElementById( - that.getOptionId(that.activeButtonIndex)); - if (that.activeButtonIndex < - that.actionButtonsInfo.length) { - that.actionButtonsInfo[that.activeButtonIndex].action(); - } else { - that.dismissModal(); - } - - that.hideModal(); - }); - - setTimeout(function() { - document.getElementById('blockOptionsModal').focus(); - }, 150); - } - ); - } - ], - focusOnOption: function(index) { - var button = document.getElementById(this.getOptionId(index)); - button.focus(); - }, - // Counts the number of interactive elements for the modal. - numInteractiveElements: function() { - return this.actionButtonsInfo.length + 1; - }, - // Returns the ID for the corresponding option button. - getOptionId: function(index) { - return 'block-options-modal-option-' + index; - }, - // Returns the ID for the "cancel" option button. - getCancelOptionId: function() { - return this.getOptionId(this.actionButtonsInfo.length); - }, - dismissModal: function() { - this.onDismissCallback(); - this.hideModal(); - }, - // Closes the modal. - hideModal: function() { - this.modalIsVisible = false; - this.keyboardInputService.clearOverride(); - this.blockOptionsModalService.hideModal(); - } -}); diff --git a/accessible/block-options-modal.service.js b/accessible/block-options-modal.service.js deleted file mode 100644 index ad775c6d47c..00000000000 --- a/accessible/block-options-modal.service.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Service for the block options modal. - * - * @author sll@google.com (Sean Lip) - */ - -goog.provide('blocklyApp.BlockOptionsModalService'); - - -blocklyApp.BlockOptionsModalService = ng.core.Class({ - constructor: [function() { - this.actionButtonsInfo = []; - // The aim of the pre-show hook is to populate the modal component with the - // information it needs to display the modal (e.g., which action buttons to - // display). - this.preShowHook = function() { - throw Error( - 'A pre-show hook must be defined for the block options modal ' + - 'before it can be shown.'); - }; - this.modalIsShown = false; - this.onDismissCallback = null; - }], - registerPreShowHook: function(preShowHook) { - var that = this; - this.preShowHook = function() { - preShowHook(that.actionButtonsInfo, that.onDismissCallback); - }; - }, - isModalShown: function() { - return this.modalIsShown; - }, - showModal: function(actionButtonsInfo, onDismissCallback) { - this.actionButtonsInfo = actionButtonsInfo; - this.onDismissCallback = onDismissCallback; - - this.preShowHook(); - this.modalIsShown = true; - }, - hideModal: function() { - this.modalIsShown = false; - } -}); diff --git a/accessible/commonModal.js b/accessible/commonModal.js deleted file mode 100644 index 57e88b529ee..00000000000 --- a/accessible/commonModal.js +++ /dev/null @@ -1,77 +0,0 @@ -goog.provide('Blockly.CommonModal'); - - -Blockly.CommonModal = function() {}; - -Blockly.CommonModal.setupKeyboardOverrides = function(component) { - component.keyboardInputService.setOverride({ - // Tab key: navigates to the previous or next item in the list. - '9': function(evt) { - evt.preventDefault(); - evt.stopPropagation(); - - if (evt.shiftKey) { - // Move to the previous item in the list. - if (component.activeButtonIndex <= 0) { - component.activeActionButtonIndex = 0; - component.audioService.playOopsSound(); - } else { - component.activeButtonIndex--; - } - } else { - // Move to the next item in the list. - if (component.activeButtonIndex == component.numInteractiveElements(component) - 1) { - component.audioService.playOopsSound(); - } else { - component.activeButtonIndex++; - } - } - - component.focusOnOption(component.activeButtonIndex, component); - }, - // Escape key: closes the modal. - '27': function() { - component.dismissModal(); - }, - // Up key: no-op. - '38': function(evt) { - evt.preventDefault(); - }, - // Down key: no-op. - '40': function(evt) { - evt.preventDefault(); - } - }); -} - -Blockly.CommonModal.getInteractiveElements = function(component) { - return Array.prototype.filter.call( - component.getInteractiveContainer().elements, function(element) { - if (element.type === 'hidden') { - return false; - } - if (element.disabled) { - return false; - } - if (element.tabIndex < 0) { - return false; - } - return true; - }); -}; - -Blockly.CommonModal.numInteractiveElements = function(component) { - var elements = this.getInteractiveElements(component); - return elements.length; -}; - -Blockly.CommonModal.focusOnOption = function(index, component) { - var elements = this.getInteractiveElements(component); - var button = elements[index]; - button.focus(); -}; - -Blockly.CommonModal.hideModal = function() { - this.modalIsVisible = false; - this.keyboardInputService.clearOverride(); -}; \ No newline at end of file diff --git a/accessible/field-segment.component.js b/accessible/field-segment.component.js deleted file mode 100644 index 8dda20d71d3..00000000000 --- a/accessible/field-segment.component.js +++ /dev/null @@ -1,206 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Component that renders a "field segment" (a group - * of non-editable Blockly.Field followed by 0 or 1 editable Blockly.Field) - * in a block. Also handles any interactions with the field. - * @author madeeha@google.com (Madeeha Ghori) - */ - -goog.provide('blocklyApp.FieldSegmentComponent'); - -goog.require('blocklyApp.NotificationsService'); -goog.require('blocklyApp.TranslatePipe'); -goog.require('blocklyApp.VariableModalService'); - - -blocklyApp.FieldSegmentComponent = ng.core.Component({ - selector: 'blockly-field-segment', - template: ` - - - - `, - inputs: ['prefixFields', 'mainField', 'mainFieldId', 'level'], - pipes: [blocklyApp.TranslatePipe] -}) -.Class({ - constructor: [ - blocklyApp.NotificationsService, - blocklyApp.VariableModalService, - function(notificationsService, variableModalService) { - this.notificationsService = notificationsService; - this.variableModalService = variableModalService; - this.dropdownOptions = []; - this.rawOptions = []; - }], - // Angular2 hook - called after initialization. - ngAfterContentInit: function() { - if (this.mainField) { - this.mainField.initModel(); - } - }, - // Angular2 hook - called to check if the cached component needs an update. - ngDoCheck: function() { - if (this.isDropdown() && this.shouldBreakCache()) { - this.optionValue = this.mainField.getValue(); - this.fieldValue = this.mainField.getValue(); - this.rawOptions = this.mainField.getOptions(); - this.dropdownOptions = this.rawOptions.map(function(valueAndText) { - return { - text: valueAndText[0], - value: valueAndText[1] - }; - }); - - // Set the currently selected value to the variable on the field. - for (var i = 0; i < this.dropdownOptions.length; i++) { - if (this.dropdownOptions[i].text === this.fieldValue) { - this.selectedOption = this.dropdownOptions[i].value; - } - } - } - }, - // Returns whether the mutable, cached information needs to be refreshed. - shouldBreakCache: function() { - var newOptions = this.mainField.getOptions(); - if (newOptions.length != this.rawOptions.length) { - return true; - } - - for (var i = 0; i < this.rawOptions.length; i++) { - // Compare the value of the cached options with the values in the field. - if (newOptions[i][0] != this.rawOptions[i][0]) { - return true; - } - } - - if (this.fieldValue != this.mainField.getValue()) { - return true; - } - - return false; - }, - // Gets the prefix text, to be printed before a field. - getPrefixText: function() { - var prefixTexts = this.prefixFields.map(function(prefixField) { - return prefixField.getText(); - }); - return prefixTexts.join(' '); - }, - // Gets the description, for labeling a field. - getFieldDescription: function() { - var description = this.mainField.getText(); - if (this.prefixFields.length > 0) { - description = this.getPrefixText() + ': ' + description; - } - return description; - }, - // Returns true if the field is text input, false otherwise. - isTextInput: function() { - return this.mainField instanceof Blockly.FieldTextInput && - !(this.mainField instanceof Blockly.FieldNumber); - }, - // Returns true if the field is number input, false otherwise. - isNumberInput: function() { - return this.mainField instanceof Blockly.FieldNumber; - }, - // Returns true if the field is a dropdown, false otherwise. - isDropdown: function() { - return this.mainField instanceof Blockly.FieldDropdown; - }, - // Sets the text value on the underlying field. - setTextValue: function(newValue) { - this.mainField.setValue(newValue); - }, - // Sets the number value on the underlying field. - setNumberValue: function(newValue) { - // Do not permit a residual value of NaN after a backspace event. - this.mainField.setValue(newValue || 0); - }, - // Confirm a selection for dropdown fields. - selectOption: function() { - if (this.optionValue != Blockly.RENAME_VARIABLE_ID && this.optionValue != - Blockly.DELETE_VARIABLE_ID) { - this.mainField.setValue(this.optionValue); - } - - if (this.optionValue == Blockly.RENAME_VARIABLE_ID) { - this.variableModalService.showRenameModal_(this.mainField.getValue()); - } - - if (this.optionValue == Blockly.DELETE_VARIABLE_ID) { - this.variableModalService.showRemoveModal_(this.mainField.getValue()); - } - }, - // Sets the value on a dropdown input. - setDropdownValue: function(optionValue) { - this.optionValue = optionValue - if (this.optionValue == 'NO_ACTION') { - return; - } - - var optionText = undefined; - for (var i = 0; i < this.dropdownOptions.length; i++) { - if (this.dropdownOptions[i].value == optionValue) { - optionText = this.dropdownOptions[i].text; - break; - } - } - - if (!optionText) { - throw Error( - 'There is no option text corresponding to the value: ' + - this.optionValue); - } - - this.notificationsService.speak('Selected option ' + optionText); - } -}); diff --git a/accessible/keyboard-input.service.js b/accessible/keyboard-input.service.js deleted file mode 100644 index 63827563719..00000000000 --- a/accessible/keyboard-input.service.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Service for handling keyboard input. - * - * @author sll@google.com (Sean Lip) - */ - -goog.provide('blocklyApp.KeyboardInputService'); - - -blocklyApp.KeyboardInputService = ng.core.Class({ - constructor: [function() { - // Default custom actions for global keystrokes. The keys of this object - // are string representations of the key codes. - this.keysToActions = {}; - // Override for the default keysToActions mapping (e.g. in a modal - // context). - this.keysToActionsOverride = null; - - // Attach a keydown handler to the entire window. - var that = this; - document.addEventListener('keydown', function(evt) { - var stringifiedKeycode = String(evt.keyCode); - var actionsObject = that.keysToActionsOverride || that.keysToActions; - - if (actionsObject.hasOwnProperty(stringifiedKeycode)) { - actionsObject[stringifiedKeycode](evt); - } - }); - }], - setOverride: function(newKeysToActions) { - this.keysToActionsOverride = newKeysToActions; - }, - addOverride: function(keyCode, action) { - this.keysToActionsOverride[keyCode] = action; - }, - clearOverride: function() { - this.keysToActionsOverride = null; - } -}); diff --git a/accessible/libs/README b/accessible/libs/README deleted file mode 100644 index ebf4d96ba7c..00000000000 --- a/accessible/libs/README +++ /dev/null @@ -1,15 +0,0 @@ -This folder contains the following dependencies for accessible Blockly: - -* Angular2 (angular2-all.umd.min.js, angular2-polyfills.min.js) -* RxJava (Rx.umd.min) - -Used for data binding between the core Blockly workspace and accessible Blockly. -RxJava is required by Angular2. -Fetched from https://code.angularjs.org/ -The current version is 2.0.0-beta.16. - -* ES6 Shim - -Required by Angular2, for Javascript files. -Fetched from https://github.com/paulmillr/es6-shim -The current version is 0.35.1. diff --git a/accessible/libs/Rx.umd.min.js b/accessible/libs/Rx.umd.min.js deleted file mode 100644 index 38c0666b4b3..00000000000 --- a/accessible/libs/Rx.umd.min.js +++ /dev/null @@ -1,748 +0,0 @@ -/** - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2015-2016 Netflix, Inc. - - 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. - -**/ -/** - @license - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2015-2016 Netflix, Inc. - - 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. - - **/ -(function(w){"object"===typeof exports&&"undefined"!==typeof module?module.exports=w():"function"===typeof define&&define.amd?define([],w):("undefined"!==typeof window?window:"undefined"!==typeof global?global:"undefined"!==typeof self?self:this).Rx=w()})(function(){return function a(b,f,g){function k(e,d){if(!f[e]){if(!b[e]){var c="function"==typeof require&&require;if(!d&&c)return c(e,!0);if(l)return l(e,!0);c=Error("Cannot find module '"+e+"'");throw c.code="MODULE_NOT_FOUND",c;}c=f[e]={exports:{}}; -b[e][0].call(c.exports,function(a){var c=b[e][1][a];return k(c?c:a)},c,c.exports,a,b,f,g)}return f[e].exports}for(var l="function"==typeof require&&require,h=0;h=n?h.complete():(c=b?b(c[e],e):c[e],h.next(c),a.index=e+1,this.schedule(a)))};e.prototype._subscribe=function(a){var c=this.arrayLike,m=this.mapFn, -n=this.scheduler,b=c.length;if(n)return n.schedule(e.dispatch,0,{arrayLike:c,index:0,length:b,mapFn:m,subscriber:a});for(n=0;n=a.count?b.complete():(b.next(d[e]),b.isUnsubscribed||(a.index=e+1,this.schedule(a)))};d.prototype._subscribe=function(a){var e=this.array,n=e.length,b=this.scheduler;if(b)return b.schedule(d.dispatch,0,{array:e,index:0,count:n,subscriber:a});for(b=0;bd)this.period=0;c&&"function"===typeof c.schedule||(this.scheduler=l.asap)}g(e,a);e.create=function(a,c){void 0===a&&(a=0);void 0===c&&(c=l.asap);return new e(a,c)};e.dispatch=function(a){var c=a.subscriber,e=a.period;c.next(a.index);c.isUnsubscribed||(a.index+=1,this.schedule(a,e))};e.prototype._subscribe=function(a){var c=this.period;a.add(this.scheduler.schedule(e.dispatch,c,{index:0,subscriber:a,period:c}))};return e}(b.Observable);f.IntervalObservable=a},{"../Observable":3,"../scheduler/asap":224, -"../util/isNumeric":243}],128:[function(a,b,f){var g=this&&this.__extends||function(a,c){function d(){this.constructor=a}for(var e in c)c.hasOwnProperty(e)&&(a[e]=c[e]);a.prototype=null===c?Object.create(c):(d.prototype=c.prototype,new d)},k=a("../util/root"),l=a("../util/isObject"),h=a("../util/tryCatch");b=a("../Observable");var e=a("../util/isFunction"),d=a("../util/SymbolShim"),c=a("../util/errorObject");a=function(a){function b(c,h,f,q){a.call(this);if(null==c)throw Error("iterator cannot be null."); -if(l.isObject(h))this.thisArg=h,this.scheduler=f;else if(e.isFunction(h))this.project=h,this.thisArg=f,this.scheduler=q;else if(null!=h)throw Error("When provided, `project` must be a function.");if((h=c[d.SymbolShim.iterator])||"string"!==typeof c)if(h||void 0===c.length){if(!h)throw new TypeError("Object is not iterable");c=c[d.SymbolShim.iterator]()}else c=new n(c);else c=new m(c);this.iterator=c}g(b,a);b.create=function(a,c,d,e){return new b(a,c,d,e)};b.dispatch=function(a){var d=a.index,e=a.thisArg, -m=a.project,b=a.iterator,n=a.subscriber;a.hasError?n.error(a.error):(b=b.next(),b.done?n.complete():(m?(b=h.tryCatch(m).call(e,b.value,d),b===c.errorObject?(a.error=c.errorObject.e,a.hasError=!0):(n.next(b),a.index=d+1)):(n.next(b.value),a.index=d+1),n.isUnsubscribed||this.schedule(a)))};b.prototype._subscribe=function(a){var d=0,e=this.iterator,m=this.project,n=this.thisArg,f=this.scheduler;if(f)return f.schedule(b.dispatch,0,{index:d,thisArg:n,project:m,iterator:e,subscriber:a});do{f=e.next();if(f.done){a.complete(); -break}else if(m){f=h.tryCatch(m).call(n,f.value,d++);if(f===c.errorObject){a.error(c.errorObject.e);break}a.next(f)}else a.next(f.value);if(a.isUnsubscribed)break}while(1)};return b}(b.Observable);f.IteratorObservable=a;var m=function(){function a(c,d,e){void 0===d&&(d=0);void 0===e&&(e=c.length);this.str=c;this.idx=d;this.len=e}a.prototype[d.SymbolShim.iterator]=function(){return this};a.prototype.next=function(){return this.idxm?-1:1;e=m*Math.floor(Math.abs(e));e=0>=e?0:e>q?q:e}this.arr=c;this.idx=d;this.len=e}a.prototype[d.SymbolShim.iterator]=function(){return this};a.prototype.next=function(){return this.idx=a.end?c.complete():(c.next(e),c.isUnsubscribed||(a.index=d+1,a.start=e+1,this.schedule(a)))};b.prototype._subscribe=function(a){var e=0,d=this.start,c=this.end,m=this.scheduler;if(m)return m.schedule(b.dispatch, -0,{index:e,end:c,start:d,subscriber:a});do{if(e++>=c){a.complete();break}a.next(d++);if(a.isUnsubscribed)break}while(1)};return b}(a("../Observable").Observable);f.RangeObservable=a},{"../Observable":3}],132:[function(a,b,f){var g=this&&this.__extends||function(a,b){function h(){this.constructor=a}for(var e in b)b.hasOwnProperty(e)&&(a[e]=b[e]);a.prototype=null===b?Object.create(b):(h.prototype=b.prototype,new h)};a=function(a){function b(h,e){a.call(this);this.value=h;this.scheduler=e;this._isScalar= -!0}g(b,a);b.create=function(a,e){return new b(a,e)};b.dispatch=function(a){var e=a.value,d=a.subscriber;a.done?d.complete():(d.next(e),d.isUnsubscribed||(a.done=!0,this.schedule(a)))};b.prototype._subscribe=function(a){var e=this.value,d=this.scheduler;if(d)return d.schedule(b.dispatch,0,{done:!1,value:e,subscriber:a});a.next(e);a.isUnsubscribed||a.complete()};return b}(a("../Observable").Observable);f.ScalarObservable=a},{"../Observable":3}],133:[function(a,b,f){var g=this&&this.__extends||function(a, -e){function d(){this.constructor=a}for(var c in e)e.hasOwnProperty(c)&&(a[c]=e[c]);a.prototype=null===e?Object.create(e):(d.prototype=e.prototype,new d)};b=a("../Observable");var k=a("../scheduler/asap"),l=a("../util/isNumeric");a=function(a){function e(d,c,e){void 0===c&&(c=0);void 0===e&&(e=k.asap);a.call(this);this.source=d;this.delayTime=c;this.scheduler=e;if(!l.isNumeric(c)||0>c)this.delayTime=0;e&&"function"===typeof e.schedule||(this.scheduler=k.asap)}g(e,a);e.create=function(a,c,m){void 0=== -c&&(c=0);void 0===m&&(m=k.asap);return new e(a,c,m)};e.dispatch=function(a){return a.source.subscribe(a.subscriber)};e.prototype._subscribe=function(a){return this.scheduler.schedule(e.dispatch,this.delayTime,{source:this.source,subscriber:a})};return e}(b.Observable);f.SubscribeOnObservable=a},{"../Observable":3,"../scheduler/asap":224,"../util/isNumeric":243}],134:[function(a,b,f){var g=this&&this.__extends||function(a,c){function e(){this.constructor=a}for(var b in c)c.hasOwnProperty(b)&&(a[b]= -c[b]);a.prototype=null===c?Object.create(c):(e.prototype=c.prototype,new e)},k=a("../util/isNumeric");b=a("../Observable");var l=a("../scheduler/asap"),h=a("../util/isScheduler"),e=a("../util/isDate");a=function(a){function c(c,b,f){void 0===c&&(c=0);a.call(this);this.period=-1;this.dueTime=0;k.isNumeric(b)?this.period=1>+b&&1||+b:h.isScheduler(b)&&(f=b);h.isScheduler(f)||(f=l.asap);this.scheduler=f;this.dueTime=e.isDate(c)?+c-this.scheduler.now():c}g(c,a);c.create=function(a,d,e){void 0===a&&(a= -0);return new c(a,d,e)};c.dispatch=function(a){var c=a.index,d=a.period,e=a.subscriber;e.next(c);if(!e.isUnsubscribed){if(-1===d)return e.complete();a.index=c+1;this.schedule(a,d)}};c.prototype._subscribe=function(a){return this.scheduler.schedule(c.dispatch,this.dueTime,{index:0,period:this.period,subscriber:a})};return c}(b.Observable);f.TimerObservable=a},{"../Observable":3,"../scheduler/asap":224,"../util/isDate":241,"../util/isNumeric":243,"../util/isScheduler":246}],135:[function(a,b,f){var g= -this&&this.__extends||function(a,d){function c(){this.constructor=a}for(var b in d)d.hasOwnProperty(b)&&(a[b]=d[b]);a.prototype=null===d?Object.create(d):(c.prototype=d.prototype,new c)};b=a("../OuterSubscriber");var k=a("../util/subscribeToResult");f.buffer=function(a){return this.lift(new l(a))};var l=function(){function a(d){this.closingNotifier=d}a.prototype.call=function(a){return new h(a,this.closingNotifier)};return a}(),h=function(a){function d(c,d){a.call(this,c);this.buffer=[];this.add(k.subscribeToResult(this, -d))}g(d,a);d.prototype._next=function(a){this.buffer.push(a)};d.prototype.notifyNext=function(a,d,e,b,h){a=this.buffer;this.buffer=[];this.destination.next(a)};return d}(b.OuterSubscriber)},{"../OuterSubscriber":6,"../util/subscribeToResult":250}],136:[function(a,b,f){var g=this&&this.__extends||function(a,e){function d(){this.constructor=a}for(var c in e)e.hasOwnProperty(c)&&(a[c]=e[c]);a.prototype=null===e?Object.create(e):(d.prototype=e.prototype,new d)};a=a("../Subscriber");f.bufferCount=function(a, -e){void 0===e&&(e=null);return this.lift(new k(a,e))};var k=function(){function a(e,d){this.bufferSize=e;this.startBufferEvery=d}a.prototype.call=function(a){return new l(a,this.bufferSize,this.startBufferEvery)};return a}(),l=function(a){function e(d,c,e){a.call(this,d);this.bufferSize=c;this.startBufferEvery=e;this.buffers=[[]];this.count=0}g(e,a);e.prototype._next=function(a){var c=this.count+=1,e=this.destination,b=this.bufferSize,h=this.buffers,f=h.length,k=-1;0===c%(null==this.startBufferEvery? -b:this.startBufferEvery)&&h.push([]);for(c=0;c=d[0].time-e.now();)d.shift().notification.observe(b);0(d||0)?Number.POSITIVE_INFINITY:d;return this.lift(new e(a,d,b))};var e=function(){function a(c,d,e){this.project=c;this.concurrent=d;this.scheduler=e}a.prototype.call=function(a){return new d(a,this.project,this.concurrent,this.scheduler)};return a}();f.ExpandOperator=e;var d=function(a){function d(e,b,m,h){a.call(this, -e);this.project=b;this.concurrent=m;this.scheduler=h;this.active=this.index=0;this.hasCompleted=!1;ma?this.lift(new l(-1,this)):this.lift(new l(a-1,this))};var l=function(){function a(d,c){this.count=d;this.source=c}a.prototype.call=function(a){return new h(a, -this.count,this.source)};return a}(),h=function(a){function d(c,d,b){a.call(this,c);this.count=d;this.source=b}g(d,a);d.prototype.complete=function(){if(!this.isStopped){var c=this.source,d=this.count;if(0===d)return a.prototype.complete.call(this);-1this.total&&this.destination.next(a)};return b}(a.Subscriber)},{"../Subscriber":9}],194:[function(a,b,f){var g=this&&this.__extends||function(a,d){function c(){this.constructor=a}for(var b in d)d.hasOwnProperty(b)&&(a[b]=d[b]);a.prototype=null===d?Object.create(d):(c.prototype=d.prototype,new c)};b=a("../OuterSubscriber"); -var k=a("../util/subscribeToResult");f.skipUntil=function(a){return this.lift(new l(a))};var l=function(){function a(d){this.notifier=d}a.prototype.call=function(a){return new h(a,this.notifier)};return a}(),h=function(a){function d(c,d){a.call(this,c);this.isInnerStopped=this.hasValue=!1;this.add(k.subscribeToResult(this,d))}g(d,a);d.prototype._next=function(c){this.hasValue&&a.prototype._next.call(this,c)};d.prototype._complete=function(){this.isInnerStopped?a.prototype._complete.call(this):this.unsubscribe()}; -d.prototype.notifyNext=function(a,d,b,e,f){this.hasValue=!0};d.prototype.notifyComplete=function(){this.isInnerStopped=!0;this.isStopped&&a.prototype._complete.call(this)};return d}(b.OuterSubscriber)},{"../OuterSubscriber":6,"../util/subscribeToResult":250}],195:[function(a,b,f){var g=this&&this.__extends||function(a,b){function d(){this.constructor=a}for(var c in b)b.hasOwnProperty(c)&&(a[c]=b[c]);a.prototype=null===b?Object.create(b):(d.prototype=b.prototype,new d)};a=a("../Subscriber");f.skipWhile= -function(a){return this.lift(new k(a))};var k=function(){function a(b){this.predicate=b}a.prototype.call=function(a){return new l(a,this.predicate)};return a}(),l=function(a){function b(d,c){a.call(this,d);this.predicate=c;this.skipping=!0;this.index=0}g(b,a);b.prototype._next=function(a){var c=this.destination;this.skipping&&this.tryCallPredicate(a);this.skipping||c.next(a)};b.prototype.tryCallPredicate=function(a){try{this.skipping=!!this.predicate(a,this.index++)}catch(c){this.destination.error(c)}}; -return b}(a.Subscriber)},{"../Subscriber":9}],196:[function(a,b,f){var g=a("../observable/ArrayObservable"),k=a("../observable/ScalarObservable"),l=a("../observable/EmptyObservable"),h=a("./concat"),e=a("../util/isScheduler");f.startWith=function(){for(var a=[],c=0;cthis.total)throw new k.ArgumentOutOfRangeError;}a.prototype.call= -function(a){return new e(a,this.total)};return a}(),e=function(a){function c(c,b){a.call(this,c);this.total=b;this.count=0}g(c,a);c.prototype._next=function(a){var c=this.total;++this.count<=c&&(this.destination.next(a),this.count===c&&this.destination.complete())};return c}(b.Subscriber)},{"../Subscriber":9,"../observable/EmptyObservable":121,"../util/ArgumentOutOfRangeError":231}],202:[function(a,b,f){var g=this&&this.__extends||function(a,c){function b(){this.constructor=a}for(var e in c)c.hasOwnProperty(e)&& -(a[e]=c[e]);a.prototype=null===c?Object.create(c):(b.prototype=c.prototype,new b)};b=a("../Subscriber");var k=a("../util/ArgumentOutOfRangeError"),l=a("../observable/EmptyObservable");f.takeLast=function(a){return 0===a?new l.EmptyObservable:this.lift(new h(a))};var h=function(){function a(c){this.total=c;if(0>this.total)throw new k.ArgumentOutOfRangeError;}a.prototype.call=function(a){return new e(a,this.total)};return a}(),e=function(a){function c(c,b){a.call(this,c);this.total=b;this.index=this.count= -0;this.ring=Array(b)}g(c,a);c.prototype._next=function(a){var c=this.index,d=this.ring,b=this.total,e=this.count;1this.index};a.prototype.hasCompleted=function(){return this.array.length===this.index};return a}(),u=function(a){function b(c,d,e,f){a.call(this,c);this.parent=d;this.observable=e;this.index=f;this.stillUnsubscribed=!0;this.buffer=[];this.isComplete=!1}k(b,a);b.prototype[c.SymbolShim.iterator]=function(){return this};b.prototype.next= -function(){var a=this.buffer;return 0===a.length&&this.isComplete?{done:!0}:{value:a.shift(),done:!1}};b.prototype.hasValue=function(){return 0=b?this.scheduleNow(a,d):this.scheduleLater(a,b,d)};a.prototype.scheduleNow=function(a,b){return(new g.QueueAction(this,a)).schedule(b)};a.prototype.scheduleLater=function(a,b,d){return(new k.FutureAction(this,a)).schedule(d,b)};return a}(); -f.QueueScheduler=a},{"./FutureAction":221,"./QueueAction":222}],224:[function(a,b,f){a=a("./AsapScheduler");f.asap=new a.AsapScheduler},{"./AsapScheduler":220}],225:[function(a,b,f){a=a("./QueueScheduler");f.queue=new a.QueueScheduler},{"./QueueScheduler":223}],226:[function(a,b,f){var g=this&&this.__extends||function(a,b){function f(){this.constructor=a}for(var e in b)b.hasOwnProperty(e)&&(a[e]=b[e]);a.prototype=null===b?Object.create(b):(f.prototype=b.prototype,new f)};a=function(a){function b(){a.apply(this, -arguments);this.value=null;this.hasNext=!1}g(b,a);b.prototype._subscribe=function(b){this.hasCompleted&&this.hasNext&&b.next(this.value);return a.prototype._subscribe.call(this,b)};b.prototype._next=function(a){this.value=a;this.hasNext=!0};b.prototype._complete=function(){var a=-1,b=this.observers,d=b.length;this.isUnsubscribed=!0;if(this.hasNext)for(;++ac?1:c; -this._windowTime=1>d?1:d}g(b,a);b.prototype._next=function(b){var d=this._getNow();this.events.push(new h(d,b));this._trimBufferThenGetEvents(d);a.prototype._next.call(this,b)};b.prototype._subscribe=function(b){var d=this._trimBufferThenGetEvents(this._getNow()),f=this.scheduler;f&&b.add(b=new l.ObserveOnSubscriber(b,f));for(var f=-1,g=d.length;++fb&&(g=Math.max(g,f-b));0o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(5),a=n(6),u=n(7),c=function(t){function e(e){t.call(this),this.attributeName=e}return r(e,t),Object.defineProperty(e.prototype,"token",{get:function(){return this},enumerable:!0,configurable:!0}),e.prototype.toString=function(){return"@Attribute("+s.stringify(this.attributeName)+")"},e=i([s.CONST(),o("design:paramtypes",[String])],e)}(u.DependencyMetadata);e.AttributeMetadata=c;var p=function(t){function e(e,n){var r=void 0===n?{}:n,i=r.descendants,o=void 0===i?!1:i,s=r.first,a=void 0===s?!1:s,u=r.read,c=void 0===u?null:u;t.call(this),this._selector=e,this.descendants=o,this.first=a,this.read=c}return r(e,t),Object.defineProperty(e.prototype,"isViewQuery",{get:function(){return!1},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"selector",{get:function(){return a.resolveForwardRef(this._selector)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"isVarBindingQuery",{get:function(){return s.isString(this.selector)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"varBindings",{get:function(){return this.selector.split(",")},enumerable:!0,configurable:!0}),e.prototype.toString=function(){return"@Query("+s.stringify(this.selector)+")"},e=i([s.CONST(),o("design:paramtypes",[Object,Object])],e)}(u.DependencyMetadata);e.QueryMetadata=p;var l=function(t){function e(e,n){var r=void 0===n?{}:n,i=r.descendants,o=void 0===i?!1:i,s=r.read,a=void 0===s?null:s;t.call(this,e,{descendants:o,read:a})}return r(e,t),e=i([s.CONST(),o("design:paramtypes",[Object,Object])],e)}(p);e.ContentChildrenMetadata=l;var h=function(t){function e(e,n){var r=(void 0===n?{}:n).read,i=void 0===r?null:r;t.call(this,e,{descendants:!0,first:!0,read:i})}return r(e,t),e=i([s.CONST(),o("design:paramtypes",[Object,Object])],e)}(p);e.ContentChildMetadata=h;var f=function(t){function e(e,n){var r=void 0===n?{}:n,i=r.descendants,o=void 0===i?!1:i,s=r.first,a=void 0===s?!1:s,u=r.read,c=void 0===u?null:u;t.call(this,e,{descendants:o,first:a,read:c})}return r(e,t),Object.defineProperty(e.prototype,"isViewQuery",{get:function(){return!0},enumerable:!0,configurable:!0}),e.prototype.toString=function(){return"@ViewQuery("+s.stringify(this.selector)+")"},e=i([s.CONST(),o("design:paramtypes",[Object,Object])],e)}(p);e.ViewQueryMetadata=f;var d=function(t){function e(e,n){var r=(void 0===n?{}:n).read,i=void 0===r?null:r;t.call(this,e,{descendants:!0,read:i})}return r(e,t),e=i([s.CONST(),o("design:paramtypes",[Object,Object])],e)}(f);e.ViewChildrenMetadata=d;var v=function(t){function e(e,n){var r=(void 0===n?{}:n).read,i=void 0===r?null:r;t.call(this,e,{descendants:!0,first:!0,read:i})}return r(e,t),e=i([s.CONST(),o("design:paramtypes",[Object,Object])],e)}(f);e.ViewChildMetadata=v},function(t,e){(function(t){"use strict";function n(t){Zone.current.scheduleMicroTask("scheduleMicrotask",t)}function r(t){return t.name?t.name:typeof t}function i(){H=!0}function o(){if(H)throw"Cannot enable prod mode after platform setup.";W=!1}function s(){return W}function a(t){return t}function u(){return function(t){return t}}function c(t){return void 0!==t&&null!==t}function p(t){return void 0===t||null===t}function l(t){return"boolean"==typeof t}function h(t){return"number"==typeof t}function f(t){return"string"==typeof t}function d(t){return"function"==typeof t}function v(t){return d(t)}function y(t){return"object"==typeof t&&null!==t}function m(t){return t instanceof U.Promise}function g(t){return Array.isArray(t)}function _(t){return t instanceof e.Date&&!isNaN(t.valueOf())}function b(){}function P(t){if("string"==typeof t)return t;if(void 0===t||null===t)return""+t;if(t.name)return t.name;if(t.overriddenName)return t.overriddenName;var e=t.toString(),n=e.indexOf("\n");return-1===n?e:e.substring(0,n)}function E(t){return t}function w(t,e){return t}function C(t,e){return t[e]}function R(t,e){return t===e||"number"==typeof t&&"number"==typeof e&&isNaN(t)&&isNaN(e)}function S(t){return t}function O(t){return p(t)?null:t}function T(t){return p(t)?!1:t}function x(t){return null!==t&&("function"==typeof t||"object"==typeof t)}function A(t){console.log(t)}function I(t,e,n){for(var r=e.split("."),i=t;r.length>1;){var o=r.shift();i=i.hasOwnProperty(o)&&c(i[o])?i[o]:i[o]={}}(void 0===i||null===i)&&(i={}),i[r.shift()]=n}function M(){if(p(Y))if(c(Symbol)&&c(Symbol.iterator))Y=Symbol.iterator;else for(var t=Object.getOwnPropertyNames(Map.prototype),e=0;e=0&&t[r]==e;r--)n--;t=t.substring(0,n)}return t},t.replace=function(t,e,n){return t.replace(e,n)},t.replaceAll=function(t,e,n){return t.replace(e,n)},t.slice=function(t,e,n){return void 0===e&&(e=0),void 0===n&&(n=null),t.slice(e,null===n?void 0:n)},t.replaceAllMapped=function(t,e,n){return t.replace(e,function(){for(var t=[],e=0;et?-1:t>e?1:0},t}();e.StringWrapper=X;var q=function(){function t(t){void 0===t&&(t=[]),this.parts=t}return t.prototype.add=function(t){this.parts.push(t)},t.prototype.toString=function(){return this.parts.join("")},t}();e.StringJoiner=q;var G=function(t){function e(e){t.call(this),this.message=e}return F(e,t),e.prototype.toString=function(){return this.message},e}(Error);e.NumberParseError=G;var z=function(){function t(){}return t.toFixed=function(t,e){return t.toFixed(e)},t.equal=function(t,e){return t===e},t.parseIntAutoRadix=function(t){var e=parseInt(t);if(isNaN(e))throw new G("Invalid integer literal when parsing "+t);return e},t.parseInt=function(t,e){if(10==e){if(/^(\-|\+)?[0-9]+$/.test(t))return parseInt(t,e)}else if(16==e){if(/^(\-|\+)?[0-9ABCDEFabcdef]+$/.test(t))return parseInt(t,e)}else{var n=parseInt(t,e);if(!isNaN(n))return n}throw new G("Invalid integer literal when parsing "+t+" in base "+e)},t.parseFloat=function(t){return parseFloat(t)},Object.defineProperty(t,"NaN",{get:function(){return NaN},enumerable:!0,configurable:!0}),t.isNaN=function(t){return isNaN(t)},t.isInteger=function(t){return Number.isInteger(t)},t}();e.NumberWrapper=z,e.RegExp=U.RegExp;var K=function(){function t(){}return t.create=function(t,e){return void 0===e&&(e=""),e=e.replace(/g/g,""),new U.RegExp(t,e+"g")},t.firstMatch=function(t,e){return t.lastIndex=0,t.exec(e)},t.test=function(t,e){return t.lastIndex=0,t.test(e)},t.matcher=function(t,e){return t.lastIndex=0,{re:t,input:e}},t.replaceAll=function(t,e,n){var r=t.exec(e),i="";t.lastIndex=0;for(var o=0;r;)i+=e.substring(o,r.index),i+=n(r),o=r.index+r[0].length,t.lastIndex=o,r=t.exec(e);return i+=e.substring(o)},t}();e.RegExpWrapper=K;var $=function(){function t(){}return t.next=function(t){return t.re.exec(t.input)},t}();e.RegExpMatcherWrapper=$;var Q=function(){function t(){}return t.apply=function(t,e){return t.apply(null,e)},t}();e.FunctionWrapper=Q,e.looseIdentical=R,e.getMapKey=S,e.normalizeBlank=O,e.normalizeBool=T,e.isJsObject=x,e.print=A;var J=function(){function t(){}return t.parse=function(t){return U.JSON.parse(t)},t.stringify=function(t){return U.JSON.stringify(t,null,2)},t}();e.Json=J;var Z=function(){function t(){}return t.create=function(t,n,r,i,o,s,a){return void 0===n&&(n=1),void 0===r&&(r=1),void 0===i&&(i=0),void 0===o&&(o=0),void 0===s&&(s=0),void 0===a&&(a=0),new e.Date(t,n-1,r,i,o,s,a)},t.fromISOString=function(t){return new e.Date(t)},t.fromMillis=function(t){return new e.Date(t)},t.toMillis=function(t){return t.getTime()},t.now=function(){return new e.Date},t.toJson=function(t){return t.toJSON()},t}();e.DateWrapper=Z,e.setValueOnPath=I;var Y=null;e.getSymbolIterator=M,e.evalExpression=k,e.isPrimitive=N,e.hasConstructor=D,e.bitWiseOr=V,e.bitWiseAnd=j,e.escape=L}).call(e,function(){return this}())},function(t,e,n){"use strict";function r(t){for(var n in t)e.hasOwnProperty(n)||(e[n]=t[n])}var i=n(7);e.InjectMetadata=i.InjectMetadata,e.OptionalMetadata=i.OptionalMetadata,e.InjectableMetadata=i.InjectableMetadata,e.SelfMetadata=i.SelfMetadata,e.HostMetadata=i.HostMetadata,e.SkipSelfMetadata=i.SkipSelfMetadata,e.DependencyMetadata=i.DependencyMetadata,r(n(8));var o=n(10);e.forwardRef=o.forwardRef,e.resolveForwardRef=o.resolveForwardRef;var s=n(11);e.Injector=s.Injector;var a=n(16);e.ReflectiveInjector=a.ReflectiveInjector;var u=n(24);e.Binding=u.Binding,e.ProviderBuilder=u.ProviderBuilder,e.bind=u.bind,e.Provider=u.Provider,e.provide=u.provide;var c=n(17);e.ResolvedReflectiveFactory=c.ResolvedReflectiveFactory,e.ReflectiveDependency=c.ReflectiveDependency;var p=n(22);e.ReflectiveKey=p.ReflectiveKey;var l=n(23);e.NoProviderError=l.NoProviderError,e.AbstractProviderError=l.AbstractProviderError,e.CyclicDependencyError=l.CyclicDependencyError,e.InstantiationError=l.InstantiationError,e.InvalidProviderError=l.InvalidProviderError,e.NoAnnotationError=l.NoAnnotationError,e.OutOfBoundsError=l.OutOfBoundsError;var h=n(25);e.OpaqueToken=h.OpaqueToken},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=function(){function t(t){this.token=t}return t.prototype.toString=function(){return"@Inject("+o.stringify(this.token)+")"},t=r([o.CONST(),i("design:paramtypes",[Object])],t)}();e.InjectMetadata=s;var a=function(){function t(){}return t.prototype.toString=function(){return"@Optional()"},t=r([o.CONST(),i("design:paramtypes",[])],t)}();e.OptionalMetadata=a;var u=function(){function t(){}return Object.defineProperty(t.prototype,"token",{get:function(){return null},enumerable:!0,configurable:!0}),t=r([o.CONST(),i("design:paramtypes",[])],t)}();e.DependencyMetadata=u;var c=function(){function t(){}return t=r([o.CONST(),i("design:paramtypes",[])],t)}();e.InjectableMetadata=c;var p=function(){function t(){}return t.prototype.toString=function(){return"@Self()"},t=r([o.CONST(),i("design:paramtypes",[])],t)}();e.SelfMetadata=p;var l=function(){function t(){}return t.prototype.toString=function(){return"@SkipSelf()"},t=r([o.CONST(),i("design:paramtypes",[])],t)}();e.SkipSelfMetadata=l;var h=function(){function t(){}return t.prototype.toString=function(){return"@Host()"},t=r([o.CONST(),i("design:paramtypes",[])],t)}();e.HostMetadata=h},function(t,e,n){"use strict";var r=n(7),i=n(9);e.Inject=i.makeParamDecorator(r.InjectMetadata),e.Optional=i.makeParamDecorator(r.OptionalMetadata),e.Injectable=i.makeDecorator(r.InjectableMetadata),e.Self=i.makeParamDecorator(r.SelfMetadata),e.Host=i.makeParamDecorator(r.HostMetadata),e.SkipSelf=i.makeParamDecorator(r.SkipSelfMetadata)},function(t,e,n){"use strict";function r(t){return c.isFunction(t)&&t.hasOwnProperty("annotation")&&(t=t.annotation),t}function i(t,e){if(t===Object||t===String||t===Function||t===Number||t===Array)throw new Error("Can not use native "+c.stringify(t)+" as constructor");if(c.isFunction(t))return t;if(t instanceof Array){var n=t,i=t[t.length-1];if(!c.isFunction(i))throw new Error("Last position of Class method array must be Function in key "+e+" was '"+c.stringify(i)+"'");var o=n.length-1;if(o!=i.length)throw new Error("Number of annotations ("+o+") does not match number of arguments ("+i.length+") in the function: "+c.stringify(i));for(var s=[],a=0,u=n.length-1;u>a;a++){var p=[];s.push(p);var h=n[a];if(h instanceof Array)for(var f=0;f-1?(t.splice(n,1),!0):!1},t.clear=function(t){t.length=0},t.isEmpty=function(t){return 0==t.length},t.fill=function(t,e,n,r){void 0===n&&(n=0),void 0===r&&(r=null),t.fill(e,n,null===r?t.length:r)},t.equals=function(t,e){if(t.length!=e.length)return!1;for(var n=0;nr&&(n=o,r=s)}}return n},t.flatten=function(t){var e=[];return r(t,e),e},t.addAll=function(t,e){for(var n=0;n0&&(this.provider0=e[0],this.keyId0=e[0].key.id),n>1&&(this.provider1=e[1],this.keyId1=e[1].key.id),n>2&&(this.provider2=e[2],this.keyId2=e[2].key.id),n>3&&(this.provider3=e[3],this.keyId3=e[3].key.id),n>4&&(this.provider4=e[4],this.keyId4=e[4].key.id),n>5&&(this.provider5=e[5],this.keyId5=e[5].key.id),n>6&&(this.provider6=e[6],this.keyId6=e[6].key.id),n>7&&(this.provider7=e[7],this.keyId7=e[7].key.id),n>8&&(this.provider8=e[8],this.keyId8=e[8].key.id),n>9&&(this.provider9=e[9],this.keyId9=e[9].key.id)}return t.prototype.getProviderAtIndex=function(t){if(0==t)return this.provider0;if(1==t)return this.provider1;if(2==t)return this.provider2;if(3==t)return this.provider3;if(4==t)return this.provider4;if(5==t)return this.provider5;if(6==t)return this.provider6;if(7==t)return this.provider7;if(8==t)return this.provider8;if(9==t)return this.provider9;throw new s.OutOfBoundsError(t)},t.prototype.createInjectorStrategy=function(t){return new m(t,this)},t}();e.ReflectiveProtoInjectorInlineStrategy=d;var v=function(){function t(t,e){this.providers=e;var n=e.length;this.keyIds=i.ListWrapper.createFixedSize(n);for(var r=0;n>r;r++)this.keyIds[r]=e[r].key.id}return t.prototype.getProviderAtIndex=function(t){if(0>t||t>=this.providers.length)throw new s.OutOfBoundsError(t);return this.providers[t]},t.prototype.createInjectorStrategy=function(t){return new g(this,t)},t}();e.ReflectiveProtoInjectorDynamicStrategy=v;var y=function(){function t(t){this.numberOfProviders=t.length,this._strategy=t.length>h?new v(this,t):new d(this,t)}return t.fromResolvedProviders=function(e){return new t(e)},t.prototype.getProviderAtIndex=function(t){return this._strategy.getProviderAtIndex(t); -},t}();e.ReflectiveProtoInjector=y;var m=function(){function t(t,e){this.injector=t,this.protoStrategy=e,this.obj0=f,this.obj1=f,this.obj2=f,this.obj3=f,this.obj4=f,this.obj5=f,this.obj6=f,this.obj7=f,this.obj8=f,this.obj9=f}return t.prototype.resetConstructionCounter=function(){this.injector._constructionCounter=0},t.prototype.instantiateProvider=function(t){return this.injector._new(t)},t.prototype.getObjByKeyId=function(t){var e=this.protoStrategy,n=this.injector;return e.keyId0===t?(this.obj0===f&&(this.obj0=n._new(e.provider0)),this.obj0):e.keyId1===t?(this.obj1===f&&(this.obj1=n._new(e.provider1)),this.obj1):e.keyId2===t?(this.obj2===f&&(this.obj2=n._new(e.provider2)),this.obj2):e.keyId3===t?(this.obj3===f&&(this.obj3=n._new(e.provider3)),this.obj3):e.keyId4===t?(this.obj4===f&&(this.obj4=n._new(e.provider4)),this.obj4):e.keyId5===t?(this.obj5===f&&(this.obj5=n._new(e.provider5)),this.obj5):e.keyId6===t?(this.obj6===f&&(this.obj6=n._new(e.provider6)),this.obj6):e.keyId7===t?(this.obj7===f&&(this.obj7=n._new(e.provider7)),this.obj7):e.keyId8===t?(this.obj8===f&&(this.obj8=n._new(e.provider8)),this.obj8):e.keyId9===t?(this.obj9===f&&(this.obj9=n._new(e.provider9)),this.obj9):f},t.prototype.getObjAtIndex=function(t){if(0==t)return this.obj0;if(1==t)return this.obj1;if(2==t)return this.obj2;if(3==t)return this.obj3;if(4==t)return this.obj4;if(5==t)return this.obj5;if(6==t)return this.obj6;if(7==t)return this.obj7;if(8==t)return this.obj8;if(9==t)return this.obj9;throw new s.OutOfBoundsError(t)},t.prototype.getMaxNumberOfObjects=function(){return h},t}();e.ReflectiveInjectorInlineStrategy=m;var g=function(){function t(t,e){this.protoStrategy=t,this.injector=e,this.objs=i.ListWrapper.createFixedSize(t.providers.length),i.ListWrapper.fill(this.objs,f)}return t.prototype.resetConstructionCounter=function(){this.injector._constructionCounter=0},t.prototype.instantiateProvider=function(t){return this.injector._new(t)},t.prototype.getObjByKeyId=function(t){for(var e=this.protoStrategy,n=0;nt||t>=this.objs.length)throw new s.OutOfBoundsError(t);return this.objs[t]},t.prototype.getMaxNumberOfObjects=function(){return this.objs.length},t}();e.ReflectiveInjectorDynamicStrategy=g;var _=function(){function t(){}return t.resolve=function(t){return o.resolveReflectiveProviders(t)},t.resolveAndCreate=function(e,n){void 0===n&&(n=null);var r=t.resolve(e);return t.fromResolvedProviders(r,n)},t.fromResolvedProviders=function(t,e){return void 0===e&&(e=null),new b(y.fromResolvedProviders(t),e)},t.fromResolvedBindings=function(e){return t.fromResolvedProviders(e)},Object.defineProperty(t.prototype,"parent",{get:function(){return u.unimplemented()},enumerable:!0,configurable:!0}),t.prototype.debugContext=function(){return null},t.prototype.resolveAndCreateChild=function(t){return u.unimplemented()},t.prototype.createChildFromResolved=function(t){return u.unimplemented()},t.prototype.resolveAndInstantiate=function(t){return u.unimplemented()},t.prototype.instantiateResolved=function(t){return u.unimplemented()},t}();e.ReflectiveInjector=_;var b=function(){function t(t,e,n){void 0===e&&(e=null),void 0===n&&(n=null),this._debugContext=n,this._constructionCounter=0,this._proto=t,this._parent=e,this._strategy=t._strategy.createInjectorStrategy(this)}return t.prototype.debugContext=function(){return this._debugContext()},t.prototype.get=function(t,e){return void 0===e&&(e=l.THROW_IF_NOT_FOUND),this._getByKey(c.ReflectiveKey.get(t),null,null,e)},t.prototype.getAt=function(t){return this._strategy.getObjAtIndex(t)},Object.defineProperty(t.prototype,"parent",{get:function(){return this._parent},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"internalStrategy",{get:function(){return this._strategy},enumerable:!0,configurable:!0}),t.prototype.resolveAndCreateChild=function(t){var e=_.resolve(t);return this.createChildFromResolved(e)},t.prototype.createChildFromResolved=function(e){var n=new y(e),r=new t(n);return r._parent=this,r},t.prototype.resolveAndInstantiate=function(t){return this.instantiateResolved(_.resolve([t])[0])},t.prototype.instantiateResolved=function(t){return this._instantiateProvider(t)},t.prototype._new=function(t){if(this._constructionCounter++>this._strategy.getMaxNumberOfObjects())throw new s.CyclicDependencyError(this,t.key);return this._instantiateProvider(t)},t.prototype._instantiateProvider=function(t){if(t.multiProvider){for(var e=i.ListWrapper.createFixedSize(t.resolvedFactories.length),n=0;n0?this._getByReflectiveDependency(t,R[0]):null,r=S>1?this._getByReflectiveDependency(t,R[1]):null,i=S>2?this._getByReflectiveDependency(t,R[2]):null,o=S>3?this._getByReflectiveDependency(t,R[3]):null,a=S>4?this._getByReflectiveDependency(t,R[4]):null,c=S>5?this._getByReflectiveDependency(t,R[5]):null,p=S>6?this._getByReflectiveDependency(t,R[6]):null,l=S>7?this._getByReflectiveDependency(t,R[7]):null,h=S>8?this._getByReflectiveDependency(t,R[8]):null,f=S>9?this._getByReflectiveDependency(t,R[9]):null,d=S>10?this._getByReflectiveDependency(t,R[10]):null,v=S>11?this._getByReflectiveDependency(t,R[11]):null,y=S>12?this._getByReflectiveDependency(t,R[12]):null,m=S>13?this._getByReflectiveDependency(t,R[13]):null,g=S>14?this._getByReflectiveDependency(t,R[14]):null,_=S>15?this._getByReflectiveDependency(t,R[15]):null,b=S>16?this._getByReflectiveDependency(t,R[16]):null,P=S>17?this._getByReflectiveDependency(t,R[17]):null,E=S>18?this._getByReflectiveDependency(t,R[18]):null,w=S>19?this._getByReflectiveDependency(t,R[19]):null}catch(O){throw(O instanceof s.AbstractProviderError||O instanceof s.InstantiationError)&&O.addKey(this,t.key),O}var T;try{switch(S){case 0:T=C();break;case 1:T=C(n);break;case 2:T=C(n,r);break;case 3:T=C(n,r,i);break;case 4:T=C(n,r,i,o);break;case 5:T=C(n,r,i,o,a);break;case 6:T=C(n,r,i,o,a,c);break;case 7:T=C(n,r,i,o,a,c,p);break;case 8:T=C(n,r,i,o,a,c,p,l);break;case 9:T=C(n,r,i,o,a,c,p,l,h);break;case 10:T=C(n,r,i,o,a,c,p,l,h,f);break;case 11:T=C(n,r,i,o,a,c,p,l,h,f,d);break;case 12:T=C(n,r,i,o,a,c,p,l,h,f,d,v);break;case 13:T=C(n,r,i,o,a,c,p,l,h,f,d,v,y);break;case 14:T=C(n,r,i,o,a,c,p,l,h,f,d,v,y,m);break;case 15:T=C(n,r,i,o,a,c,p,l,h,f,d,v,y,m,g);break;case 16:T=C(n,r,i,o,a,c,p,l,h,f,d,v,y,m,g,_);break;case 17:T=C(n,r,i,o,a,c,p,l,h,f,d,v,y,m,g,_,b);break;case 18:T=C(n,r,i,o,a,c,p,l,h,f,d,v,y,m,g,_,b,P);break;case 19:T=C(n,r,i,o,a,c,p,l,h,f,d,v,y,m,g,_,b,P,E);break;case 20:T=C(n,r,i,o,a,c,p,l,h,f,d,v,y,m,g,_,b,P,E,w);break;default:throw new u.BaseException("Cannot instantiate '"+t.key.displayName+"' because it has more than 20 dependencies")}}catch(O){throw new s.InstantiationError(this,O,O.stack,t.key)}return T},t.prototype._getByReflectiveDependency=function(t,e){return this._getByKey(e.key,e.lowerBoundVisibility,e.upperBoundVisibility,e.optional?null:l.THROW_IF_NOT_FOUND)},t.prototype._getByKey=function(t,e,n,r){return t===P?this:n instanceof p.SelfMetadata?this._getByKeySelf(t,r):this._getByKeyDefault(t,r,e)},t.prototype._throwOrNull=function(t,e){if(e!==l.THROW_IF_NOT_FOUND)return e;throw new s.NoProviderError(this,t)},t.prototype._getByKeySelf=function(t,e){var n=this._strategy.getObjByKeyId(t.id);return n!==f?n:this._throwOrNull(t,e)},t.prototype._getByKeyDefault=function(e,n,r){var i;for(i=r instanceof p.SkipSelfMetadata?this._parent:this;i instanceof t;){var o=i,s=o._strategy.getObjByKeyId(e.id);if(s!==f)return s;i=o._parent}return null!==i?i.get(e.token,n):this._throwOrNull(e,n)},Object.defineProperty(t.prototype,"displayName",{get:function(){return"ReflectiveInjector(providers: ["+r(this,function(t){return' "'+t.key.displayName+'" '}).join(", ")+"])"},enumerable:!0,configurable:!0}),t.prototype.toString=function(){return this.displayName},t}();e.ReflectiveInjector_=b;var P=c.ReflectiveKey.get(l.Injector)},function(t,e,n){"use strict";function r(t){var e,n;if(h.isPresent(t.useClass)){var r=g.resolveForwardRef(t.useClass);e=d.reflector.factory(r),n=c(r)}else h.isPresent(t.useExisting)?(e=function(t){return t},n=[b.fromKey(v.ReflectiveKey.get(t.useExisting))]):h.isPresent(t.useFactory)?(e=t.useFactory,n=u(t.useFactory,t.dependencies)):(e=function(){return t.useValue},n=P);return new w(e,n)}function i(t){return new E(v.ReflectiveKey.get(t.token),[r(t)],t.multi)}function o(t){var e=a(t,[]),n=e.map(i);return f.MapWrapper.values(s(n,new Map))}function s(t,e){for(var n=0;n1){var e=r(s.ListWrapper.reversed(t)),n=e.map(function(t){return a.stringify(t.token)});return" ("+n.join(" -> ")+")"}return""}var o=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},s=n(15),a=n(5),u=n(12),c=function(t){function e(e,n,r){t.call(this,"DI Exception"),this.keys=[n],this.injectors=[e],this.constructResolvingMessage=r,this.message=this.constructResolvingMessage(this.keys)}return o(e,t),e.prototype.addKey=function(t,e){this.injectors.push(t),this.keys.push(e),this.message=this.constructResolvingMessage(this.keys)},Object.defineProperty(e.prototype,"context",{get:function(){return this.injectors[this.injectors.length-1].debugContext()},enumerable:!0,configurable:!0}),e}(u.BaseException);e.AbstractProviderError=c;var p=function(t){function e(e,n){t.call(this,e,n,function(t){var e=a.stringify(s.ListWrapper.first(t).token);return"No provider for "+e+"!"+i(t)})}return o(e,t),e}(c);e.NoProviderError=p;var l=function(t){function e(e,n){t.call(this,e,n,function(t){return"Cannot instantiate cyclic dependency!"+i(t)})}return o(e,t),e}(c);e.CyclicDependencyError=l;var h=function(t){function e(e,n,r,i){t.call(this,"DI Exception",n,r,null),this.keys=[i],this.injectors=[e]}return o(e,t),e.prototype.addKey=function(t,e){this.injectors.push(t),this.keys.push(e)},Object.defineProperty(e.prototype,"wrapperMessage",{get:function(){var t=a.stringify(s.ListWrapper.first(this.keys).token);return"Error during instantiation of "+t+"!"+i(this.keys)+"."},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"causeKey",{get:function(){return this.keys[0]},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"context",{get:function(){return this.injectors[this.injectors.length-1].debugContext()},enumerable:!0,configurable:!0}),e}(u.WrappedException);e.InstantiationError=h;var f=function(t){function e(e){t.call(this,"Invalid provider - only instances of Provider and Type are allowed, got: "+e.toString())}return o(e,t),e}(u.BaseException);e.InvalidProviderError=f;var d=function(t){function e(n,r){t.call(this,e._genMessage(n,r))}return o(e,t),e._genMessage=function(t,e){for(var n=[],r=0,i=e.length;i>r;r++){var o=e[r];a.isBlank(o)||0==o.length?n.push("?"):n.push(o.map(a.stringify).join(" "))}return"Cannot resolve all parameters for '"+a.stringify(t)+"'("+n.join(", ")+"). Make sure that all the parameters are decorated with Inject or have valid type annotations and that '"+a.stringify(t)+"' is decorated with Injectable."},e}(u.BaseException);e.NoAnnotationError=d;var v=function(t){function e(e){t.call(this,"Index "+e+" is out-of-bounds.")}return o(e,t),e}(u.BaseException);e.OutOfBoundsError=v;var y=function(t){function e(e,n){t.call(this,"Cannot mix multi providers and regular providers, got: "+e.toString()+" "+n.toString())}return o(e,t),e}(u.BaseException);e.MixingMultiProvidersWithRegularProvidersError=y},function(t,e,n){"use strict";function r(t){return new h(t)}function i(t,e){var n=e.useClass,r=e.useValue,i=e.useExisting,o=e.useFactory,s=e.deps,a=e.multi;return new p(t,{useClass:n,useValue:r,useExisting:i,useFactory:o,deps:s,multi:a})}var o=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},s=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},a=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},u=n(5),c=n(12),p=function(){function t(t,e){var n=e.useClass,r=e.useValue,i=e.useExisting,o=e.useFactory,s=e.deps,a=e.multi;this.token=t,this.useClass=n,this.useValue=r,this.useExisting=i,this.useFactory=o,this.dependencies=s,this._multi=a}return Object.defineProperty(t.prototype,"multi",{get:function(){return u.normalizeBool(this._multi)},enumerable:!0,configurable:!0}),t=s([u.CONST(),a("design:paramtypes",[Object,Object])],t)}();e.Provider=p;var l=function(t){function e(e,n){var r=n.toClass,i=n.toValue,o=n.toAlias,s=n.toFactory,a=n.deps,u=n.multi;t.call(this,e,{useClass:r,useValue:i,useExisting:o,useFactory:s,deps:a,multi:u})}return o(e,t),Object.defineProperty(e.prototype,"toClass",{get:function(){return this.useClass},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"toAlias",{get:function(){return this.useExisting},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"toFactory",{get:function(){return this.useFactory},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"toValue",{get:function(){return this.useValue},enumerable:!0,configurable:!0}),e=s([u.CONST(),a("design:paramtypes",[Object,Object])],e)}(p);e.Binding=l,e.bind=r;var h=function(){function t(t){this.token=t}return t.prototype.toClass=function(t){if(!u.isType(t))throw new c.BaseException('Trying to create a class provider but "'+u.stringify(t)+'" is not a class!');return new p(this.token,{useClass:t})},t.prototype.toValue=function(t){return new p(this.token,{useValue:t})},t.prototype.toAlias=function(t){if(u.isBlank(t))throw new c.BaseException("Can not alias "+u.stringify(this.token)+" to a blank value!");return new p(this.token,{useExisting:t})},t.prototype.toFactory=function(t,e){if(!u.isFunction(t))throw new c.BaseException('Trying to create a factory provider but "'+u.stringify(t)+'" is not a function!');return new p(this.token,{useFactory:t,deps:e})},t}();e.ProviderBuilder=h,e.provide=i},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=function(){function t(t){this._desc=t}return t.prototype.toString=function(){return"Token "+this._desc},t=r([o.CONST(),i("design:paramtypes",[String])],t)}();e.OpaqueToken=s},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(5),a=n(7),u=n(27),c=function(t){function e(e){var n=void 0===e?{}:e,r=n.selector,i=n.inputs,o=n.outputs,s=n.properties,a=n.events,u=n.host,c=n.bindings,p=n.providers,l=n.exportAs,h=n.queries;t.call(this),this.selector=r,this._inputs=i,this._properties=s,this._outputs=o,this._events=a,this.host=u,this.exportAs=l,this.queries=h,this._providers=p,this._bindings=c}return r(e,t),Object.defineProperty(e.prototype,"inputs",{get:function(){return s.isPresent(this._properties)&&this._properties.length>0?this._properties:this._inputs},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"properties",{get:function(){return this.inputs},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"outputs",{get:function(){return s.isPresent(this._events)&&this._events.length>0?this._events:this._outputs},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"events",{get:function(){return this.outputs},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"providers",{get:function(){return s.isPresent(this._bindings)&&this._bindings.length>0?this._bindings:this._providers},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"bindings",{get:function(){return this.providers},enumerable:!0,configurable:!0}),e=i([s.CONST(),o("design:paramtypes",[Object])],e)}(a.InjectableMetadata);e.DirectiveMetadata=c;var p=function(t){function e(e){var n=void 0===e?{}:e,r=n.selector,i=n.inputs,o=n.outputs,s=n.properties,a=n.events,c=n.host,p=n.exportAs,l=n.moduleId,h=n.bindings,f=n.providers,d=n.viewBindings,v=n.viewProviders,y=n.changeDetection,m=void 0===y?u.ChangeDetectionStrategy.Default:y,g=n.queries,_=n.templateUrl,b=n.template,P=n.styleUrls,E=n.styles,w=n.directives,C=n.pipes,R=n.encapsulation;t.call(this,{selector:r,inputs:i,outputs:o,properties:s,events:a,host:c,exportAs:p,bindings:h,providers:f,queries:g}),this.changeDetection=m,this._viewProviders=v,this._viewBindings=d,this.templateUrl=_,this.template=b,this.styleUrls=P,this.styles=E,this.directives=w,this.pipes=C,this.encapsulation=R,this.moduleId=l}return r(e,t),Object.defineProperty(e.prototype,"viewProviders",{get:function(){return s.isPresent(this._viewBindings)&&this._viewBindings.length>0?this._viewBindings:this._viewProviders},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"viewBindings",{get:function(){return this.viewProviders},enumerable:!0,configurable:!0}),e=i([s.CONST(),o("design:paramtypes",[Object])],e)}(c);e.ComponentMetadata=p;var l=function(t){function e(e){var n=e.name,r=e.pure;t.call(this),this.name=n,this._pure=r}return r(e,t),Object.defineProperty(e.prototype,"pure",{get:function(){return s.isPresent(this._pure)?this._pure:!0},enumerable:!0,configurable:!0}),e=i([s.CONST(),o("design:paramtypes",[Object])],e)}(a.InjectableMetadata);e.PipeMetadata=l;var h=function(){function t(t){this.bindingPropertyName=t}return t=i([s.CONST(),o("design:paramtypes",[String])],t)}();e.InputMetadata=h;var f=function(){function t(t){this.bindingPropertyName=t}return t=i([s.CONST(),o("design:paramtypes",[String])],t)}();e.OutputMetadata=f;var d=function(){function t(t){this.hostPropertyName=t}return t=i([s.CONST(),o("design:paramtypes",[String])],t)}();e.HostBindingMetadata=d;var v=function(){function t(t,e){this.eventName=t,this.args=e}return t=i([s.CONST(),o("design:paramtypes",[String,Array])],t)}();e.HostListenerMetadata=v},function(t,e,n){"use strict";var r=n(28);e.ChangeDetectionStrategy=r.ChangeDetectionStrategy,e.ChangeDetectorRef=r.ChangeDetectorRef,e.WrappedValue=r.WrappedValue,e.SimpleChange=r.SimpleChange,e.IterableDiffers=r.IterableDiffers,e.KeyValueDiffers=r.KeyValueDiffers,e.CollectionChangeRecord=r.CollectionChangeRecord,e.KeyValueChangeRecord=r.KeyValueChangeRecord},function(t,e,n){"use strict";var r=n(29),i=n(30),o=n(31),s=n(32),a=n(5),u=n(32);e.DefaultKeyValueDifferFactory=u.DefaultKeyValueDifferFactory,e.KeyValueChangeRecord=u.KeyValueChangeRecord;var c=n(30);e.DefaultIterableDifferFactory=c.DefaultIterableDifferFactory,e.CollectionChangeRecord=c.CollectionChangeRecord;var p=n(33);e.ChangeDetectionStrategy=p.ChangeDetectionStrategy,e.CHANGE_DETECTION_STRATEGY_VALUES=p.CHANGE_DETECTION_STRATEGY_VALUES,e.ChangeDetectorState=p.ChangeDetectorState,e.CHANGE_DETECTOR_STATE_VALUES=p.CHANGE_DETECTOR_STATE_VALUES,e.isDefaultChangeDetectionStrategy=p.isDefaultChangeDetectionStrategy;var l=n(34);e.ChangeDetectorRef=l.ChangeDetectorRef;var h=n(29);e.IterableDiffers=h.IterableDiffers;var f=n(31);e.KeyValueDiffers=f.KeyValueDiffers;var d=n(35);e.WrappedValue=d.WrappedValue,e.ValueUnwrapper=d.ValueUnwrapper,e.SimpleChange=d.SimpleChange,e.devModeEqual=d.devModeEqual,e.looseIdentical=d.looseIdentical,e.uninitialized=d.uninitialized,e.keyValDiff=a.CONST_EXPR([a.CONST_EXPR(new s.DefaultKeyValueDifferFactory)]),e.iterableDiff=a.CONST_EXPR([a.CONST_EXPR(new i.DefaultIterableDifferFactory)]),e.defaultIterableDiffers=a.CONST_EXPR(new r.IterableDiffers(e.iterableDiff)),e.defaultKeyValueDiffers=a.CONST_EXPR(new o.KeyValueDiffers(e.keyValDiff))},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s); -return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(12),a=n(15),u=n(6),c=function(){function t(t){this.factories=t}return t.create=function(e,n){if(o.isPresent(n)){var r=a.ListWrapper.clone(n.factories);return e=e.concat(r),new t(e)}return new t(e)},t.extend=function(e){return new u.Provider(t,{useFactory:function(n){if(o.isBlank(n))throw new s.BaseException("Cannot extend IterableDiffers without a parent injector");return t.create(e,n)},deps:[[t,new u.SkipSelfMetadata,new u.OptionalMetadata]]})},t.prototype.find=function(t){var e=this.factories.find(function(e){return e.supports(t)});if(o.isPresent(e))return e;throw new s.BaseException("Cannot find a differ supporting object '"+t+"' of type '"+o.getTypeNameForDebugging(t)+"'")},t=r([o.CONST(),i("design:paramtypes",[Array])],t)}();e.IterableDiffers=c},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(12),a=n(15),u=n(5),c=function(){function t(){}return t.prototype.supports=function(t){return a.isListLikeIterable(t)},t.prototype.create=function(t,e){return new l(e)},t=r([o.CONST(),i("design:paramtypes",[])],t)}();e.DefaultIterableDifferFactory=c;var p=function(t,e){return e},l=function(){function t(t){this._trackByFn=t,this._length=null,this._collection=null,this._linkedRecords=null,this._unlinkedRecords=null,this._previousItHead=null,this._itHead=null,this._itTail=null,this._additionsHead=null,this._additionsTail=null,this._movesHead=null,this._movesTail=null,this._removalsHead=null,this._removalsTail=null,this._identityChangesHead=null,this._identityChangesTail=null,this._trackByFn=u.isPresent(this._trackByFn)?this._trackByFn:p}return Object.defineProperty(t.prototype,"collection",{get:function(){return this._collection},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"length",{get:function(){return this._length},enumerable:!0,configurable:!0}),t.prototype.forEachItem=function(t){var e;for(e=this._itHead;null!==e;e=e._next)t(e)},t.prototype.forEachPreviousItem=function(t){var e;for(e=this._previousItHead;null!==e;e=e._nextPrevious)t(e)},t.prototype.forEachAddedItem=function(t){var e;for(e=this._additionsHead;null!==e;e=e._nextAdded)t(e)},t.prototype.forEachMovedItem=function(t){var e;for(e=this._movesHead;null!==e;e=e._nextMoved)t(e)},t.prototype.forEachRemovedItem=function(t){var e;for(e=this._removalsHead;null!==e;e=e._nextRemoved)t(e)},t.prototype.forEachIdentityChange=function(t){var e;for(e=this._identityChangesHead;null!==e;e=e._nextIdentityChange)t(e)},t.prototype.diff=function(t){if(u.isBlank(t)&&(t=[]),!a.isListLikeIterable(t))throw new s.BaseException("Error trying to diff '"+t+"'");return this.check(t)?this:null},t.prototype.onDestroy=function(){},t.prototype.check=function(t){var e=this;this._reset();var n,r,i,o=this._itHead,s=!1;if(u.isArray(t)){var c=t;for(this._length=t.length,n=0;n"+u.stringify(this.currentIndex)+"]"},t}();e.CollectionChangeRecord=h;var f=function(){function t(){this._head=null,this._tail=null}return t.prototype.add=function(t){null===this._head?(this._head=this._tail=t,t._nextDup=null,t._prevDup=null):(this._tail._nextDup=t,t._prevDup=this._tail,t._nextDup=null,this._tail=t)},t.prototype.get=function(t,e){var n;for(n=this._head;null!==n;n=n._nextDup)if((null===e||eo?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(12),a=n(15),u=n(6),c=function(){function t(t){this.factories=t}return t.create=function(e,n){if(o.isPresent(n)){var r=a.ListWrapper.clone(n.factories);return e=e.concat(r),new t(e)}return new t(e)},t.extend=function(e){return new u.Provider(t,{useFactory:function(n){if(o.isBlank(n))throw new s.BaseException("Cannot extend KeyValueDiffers without a parent injector");return t.create(e,n)},deps:[[t,new u.SkipSelfMetadata,new u.OptionalMetadata]]})},t.prototype.find=function(t){var e=this.factories.find(function(e){return e.supports(t)});if(o.isPresent(e))return e;throw new s.BaseException("Cannot find a differ supporting object '"+t+"'")},t=r([o.CONST(),i("design:paramtypes",[Array])],t)}();e.KeyValueDiffers=c},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(15),s=n(5),a=n(12),u=function(){function t(){}return t.prototype.supports=function(t){return t instanceof Map||s.isJsObject(t)},t.prototype.create=function(t){return new c},t=r([s.CONST(),i("design:paramtypes",[])],t)}();e.DefaultKeyValueDifferFactory=u;var c=function(){function t(){this._records=new Map,this._mapHead=null,this._previousMapHead=null,this._changesHead=null,this._changesTail=null,this._additionsHead=null,this._additionsTail=null,this._removalsHead=null,this._removalsTail=null}return Object.defineProperty(t.prototype,"isDirty",{get:function(){return null!==this._additionsHead||null!==this._changesHead||null!==this._removalsHead},enumerable:!0,configurable:!0}),t.prototype.forEachItem=function(t){var e;for(e=this._mapHead;null!==e;e=e._next)t(e)},t.prototype.forEachPreviousItem=function(t){var e;for(e=this._previousMapHead;null!==e;e=e._nextPrevious)t(e)},t.prototype.forEachChangedItem=function(t){var e;for(e=this._changesHead;null!==e;e=e._nextChanged)t(e)},t.prototype.forEachAddedItem=function(t){var e;for(e=this._additionsHead;null!==e;e=e._nextAdded)t(e)},t.prototype.forEachRemovedItem=function(t){var e;for(e=this._removalsHead;null!==e;e=e._nextRemoved)t(e)},t.prototype.diff=function(t){if(s.isBlank(t)&&(t=o.MapWrapper.createFromPairs([])),!(t instanceof Map||s.isJsObject(t)))throw new a.BaseException("Error trying to diff '"+t+"'");return this.check(t)?this:null},t.prototype.onDestroy=function(){},t.prototype.check=function(t){var e=this;this._reset();var n=this._records,r=this._mapHead,i=null,o=null,a=!1;return this._forEach(t,function(t,u){var c;null!==r&&u===r.key?(c=r,s.looseIdentical(t,r.currentValue)||(r.previousValue=r.currentValue,r.currentValue=t,e._addToChanges(r))):(a=!0,null!==r&&(r._next=null,e._removeFromSeq(i,r),e._addToRemovals(r)),n.has(u)?c=n.get(u):(c=new p(u),n.set(u,c),c.currentValue=t,e._addToAdditions(c))),a&&(e._isInRemovals(c)&&e._removeFromRemovals(c),null==o?e._mapHead=c:o._next=c),i=r,o=c,r=null===r?null:r._next}),this._truncate(i,r),this.isDirty},t.prototype._reset=function(){if(this.isDirty){var t;for(t=this._previousMapHead=this._mapHead;null!==t;t=t._next)t._nextPrevious=t._next;for(t=this._changesHead;null!==t;t=t._nextChanged)t.previousValue=t.currentValue;for(t=this._additionsHead;null!=t;t=t._nextAdded)t.previousValue=t.currentValue;this._changesHead=this._changesTail=null,this._additionsHead=this._additionsTail=null,this._removalsHead=this._removalsTail=null}},t.prototype._truncate=function(t,e){for(;null!==e;){null===t?this._mapHead=null:t._next=null;var n=e._next;this._addToRemovals(e),t=e,e=n}for(var r=this._removalsHead;null!==r;r=r._nextRemoved)r.previousValue=r.currentValue,r.currentValue=null,this._records["delete"](r.key)},t.prototype._isInRemovals=function(t){return t===this._removalsHead||null!==t._nextRemoved||null!==t._prevRemoved},t.prototype._addToRemovals=function(t){null===this._removalsHead?this._removalsHead=this._removalsTail=t:(this._removalsTail._nextRemoved=t,t._prevRemoved=this._removalsTail,this._removalsTail=t)},t.prototype._removeFromSeq=function(t,e){var n=e._next;null===t?this._mapHead=n:t._next=n},t.prototype._removeFromRemovals=function(t){var e=t._prevRemoved,n=t._nextRemoved;null===e?this._removalsHead=n:e._nextRemoved=n,null===n?this._removalsTail=e:n._prevRemoved=e,t._prevRemoved=t._nextRemoved=null},t.prototype._addToAdditions=function(t){null===this._additionsHead?this._additionsHead=this._additionsTail=t:(this._additionsTail._nextAdded=t,this._additionsTail=t)},t.prototype._addToChanges=function(t){null===this._changesHead?this._changesHead=this._changesTail=t:(this._changesTail._nextChanged=t,this._changesTail=t)},t.prototype.toString=function(){var t,e=[],n=[],r=[],i=[],o=[];for(t=this._mapHead;null!==t;t=t._next)e.push(s.stringify(t));for(t=this._previousMapHead;null!==t;t=t._nextPrevious)n.push(s.stringify(t));for(t=this._changesHead;null!==t;t=t._nextChanged)r.push(s.stringify(t));for(t=this._additionsHead;null!==t;t=t._nextAdded)i.push(s.stringify(t));for(t=this._removalsHead;null!==t;t=t._nextRemoved)o.push(s.stringify(t));return"map: "+e.join(", ")+"\nprevious: "+n.join(", ")+"\nadditions: "+i.join(", ")+"\nchanges: "+r.join(", ")+"\nremovals: "+o.join(", ")+"\n"},t.prototype._forEach=function(t,e){t instanceof Map?t.forEach(e):o.StringMapWrapper.forEach(t,e)},t}();e.DefaultKeyValueDiffer=c;var p=function(){function t(t){this.key=t,this.previousValue=null,this.currentValue=null,this._nextPrevious=null,this._next=null,this._nextAdded=null,this._nextRemoved=null,this._prevRemoved=null,this._nextChanged=null}return t.prototype.toString=function(){return s.looseIdentical(this.previousValue,this.currentValue)?s.stringify(this.key):s.stringify(this.key)+"["+s.stringify(this.previousValue)+"->"+s.stringify(this.currentValue)+"]"},t}();e.KeyValueChangeRecord=p},function(t,e,n){"use strict";function r(t){return i.isBlank(t)||t===s.Default}var i=n(5);!function(t){t[t.NeverChecked=0]="NeverChecked",t[t.CheckedBefore=1]="CheckedBefore",t[t.Errored=2]="Errored"}(e.ChangeDetectorState||(e.ChangeDetectorState={}));var o=e.ChangeDetectorState;!function(t){t[t.CheckOnce=0]="CheckOnce",t[t.Checked=1]="Checked",t[t.CheckAlways=2]="CheckAlways",t[t.Detached=3]="Detached",t[t.OnPush=4]="OnPush",t[t.Default=5]="Default"}(e.ChangeDetectionStrategy||(e.ChangeDetectionStrategy={}));var s=e.ChangeDetectionStrategy;e.CHANGE_DETECTION_STRATEGY_VALUES=[s.CheckOnce,s.Checked,s.CheckAlways,s.Detached,s.OnPush,s.Default],e.CHANGE_DETECTOR_STATE_VALUES=[o.NeverChecked,o.CheckedBefore,o.Errored],e.isDefaultChangeDetectionStrategy=r},function(t,e){"use strict";var n=function(){function t(){}return t}();e.ChangeDetectorRef=n},function(t,e,n){"use strict";function r(t,e){return o.isListLikeIterable(t)&&o.isListLikeIterable(e)?o.areIterablesEqual(t,e,r):o.isListLikeIterable(t)||i.isPrimitive(t)||o.isListLikeIterable(e)||i.isPrimitive(e)?i.looseIdentical(t,e):!0}var i=n(5),o=n(15),s=n(5);e.looseIdentical=s.looseIdentical,e.uninitialized=i.CONST_EXPR(new Object),e.devModeEqual=r;var a=function(){function t(t){this.wrapped=t}return t.wrap=function(e){return new t(e)},t}();e.WrappedValue=a;var u=function(){function t(){this.hasWrappedValue=!1}return t.prototype.unwrap=function(t){return t instanceof a?(this.hasWrappedValue=!0,t.wrapped):t},t.prototype.reset=function(){this.hasWrappedValue=!1},t}();e.ValueUnwrapper=u;var c=function(){function t(t,e){this.previousValue=t,this.currentValue=e}return t.prototype.isFirstChange=function(){return this.previousValue===e.uninitialized},t}();e.SimpleChange=c},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5);!function(t){t[t.Emulated=0]="Emulated",t[t.Native=1]="Native",t[t.None=2]="None"}(e.ViewEncapsulation||(e.ViewEncapsulation={}));var s=e.ViewEncapsulation;e.VIEW_ENCAPSULATION_VALUES=[s.Emulated,s.Native,s.None];var a=function(){function t(t){var e=void 0===t?{}:t,n=e.templateUrl,r=e.template,i=e.directives,o=e.pipes,s=e.encapsulation,a=e.styles,u=e.styleUrls;this.templateUrl=n,this.template=r,this.styleUrls=u,this.styles=a,this.directives=i,this.pipes=o,this.encapsulation=s}return t=r([o.CONST(),i("design:paramtypes",[Object])],t)}();e.ViewMetadata=a},function(t,e,n){"use strict";var r=n(9);e.Class=r.Class},function(t,e,n){"use strict";var r=n(5);e.enableProdMode=r.enableProdMode},function(t,e,n){"use strict";var r=n(5);e.Type=r.Type;var i=n(40);e.EventEmitter=i.EventEmitter;var o=n(12);e.WrappedException=o.WrappedException;var s=n(14);e.ExceptionHandler=s.ExceptionHandler},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(5),o=n(41);e.PromiseWrapper=o.PromiseWrapper,e.PromiseCompleter=o.PromiseCompleter;var s=n(42),a=n(43),u=n(58),c=n(42);e.Observable=c.Observable;var p=n(42);e.Subject=p.Subject;var l=function(){function t(){}return t.setTimeout=function(t,e){return i.global.setTimeout(t,e)},t.clearTimeout=function(t){i.global.clearTimeout(t)},t.setInterval=function(t,e){return i.global.setInterval(t,e)},t.clearInterval=function(t){i.global.clearInterval(t)},t}();e.TimerWrapper=l;var h=function(){function t(){}return t.subscribe=function(t,e,n,r){return void 0===r&&(r=function(){}),n="function"==typeof n&&n||i.noop,r="function"==typeof r&&r||i.noop,t.subscribe({next:e,error:n,complete:r})},t.isObservable=function(t){return!!t.subscribe},t.hasSubscribers=function(t){return t.observers.length>0},t.dispose=function(t){t.unsubscribe()},t.callNext=function(t,e){t.next(e)},t.callEmit=function(t,e){t.emit(e)},t.callError=function(t,e){t.error(e)},t.callComplete=function(t){t.complete()},t.fromPromise=function(t){return a.PromiseObservable.create(t)},t.toPromise=function(t){return u.toPromise.call(t)},t}();e.ObservableWrapper=h;var f=function(t){function e(e){void 0===e&&(e=!0),t.call(this),this._isAsync=e}return r(e,t),e.prototype.emit=function(e){t.prototype.next.call(this,e)},e.prototype.next=function(e){t.prototype.next.call(this,e)},e.prototype.subscribe=function(e,n,r){var i,o=function(t){return null},s=function(){return null};return e&&"object"==typeof e?(i=this._isAsync?function(t){setTimeout(function(){return e.next(t)})}:function(t){e.next(t)},e.error&&(o=this._isAsync?function(t){setTimeout(function(){return e.error(t)})}:function(t){e.error(t)}),e.complete&&(s=this._isAsync?function(){setTimeout(function(){return e.complete()})}:function(){e.complete()})):(i=this._isAsync?function(t){setTimeout(function(){return e(t)})}:function(t){e(t)},n&&(o=this._isAsync?function(t){setTimeout(function(){return n(t)})}:function(t){n(t)}),r&&(s=this._isAsync?function(){setTimeout(function(){return r()})}:function(){r()})),t.prototype.subscribe.call(this,i,o,s)},e}(s.Subject);e.EventEmitter=f},function(t,e){"use strict";var n=function(){function t(){var t=this;this.promise=new Promise(function(e,n){t.resolve=e,t.reject=n})}return t}();e.PromiseCompleter=n;var r=function(){function t(){}return t.resolve=function(t){return Promise.resolve(t)},t.reject=function(t,e){return Promise.reject(t)},t.catchError=function(t,e){return t["catch"](e)},t.all=function(t){return 0==t.length?Promise.resolve([]):Promise.all(t)},t.then=function(t,e,n){return t.then(e,n)},t.wrap=function(t){return new Promise(function(e,n){try{e(t())}catch(r){n(r)}})},t.scheduleMicrotask=function(e){t.then(t.resolve(null),e,function(t){})},t.isPromise=function(t){return t instanceof Promise},t.completer=function(){return new n},t}();e.PromiseWrapper=r},function(e,n){e.exports=t},function(t,e,n){"use strict";function r(t){var e=t.value,n=t.subscriber;n.isUnsubscribed||(n.next(e),n.complete())}function i(t){var e=t.err,n=t.subscriber;n.isUnsubscribed||n.error(e)}var o=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},s=n(44),a=n(46),u=function(t){function e(e,n){void 0===n&&(n=null),t.call(this),this.promise=e,this.scheduler=n}return o(e,t),e.create=function(t,n){return void 0===n&&(n=null),new e(t,n)},e.prototype._subscribe=function(t){var e=this,n=this.promise,o=this.scheduler;if(null==o)this._isScalar?t.isUnsubscribed||(t.next(this.value),t.complete()):n.then(function(n){e.value=n,e._isScalar=!0,t.isUnsubscribed||(t.next(n),t.complete())},function(e){t.isUnsubscribed||t.error(e)}).then(null,function(t){s.root.setTimeout(function(){throw t})});else if(this._isScalar){if(!t.isUnsubscribed)return o.schedule(r,0,{value:this.value,subscriber:t})}else n.then(function(n){e.value=n,e._isScalar=!0,t.isUnsubscribed||t.add(o.schedule(r,0,{value:n,subscriber:t}))},function(e){t.isUnsubscribed||t.add(o.schedule(i,0,{err:e,subscriber:t}))}).then(null,function(t){s.root.setTimeout(function(){throw t})})},e}(a.Observable);e.PromiseObservable=u},function(t,e,n){(function(t,n){"use strict";var r={"boolean":!1,"function":!0,object:!0,number:!1,string:!1,undefined:!1};e.root=r[typeof self]&&self||r[typeof window]&&window;var i=(r[typeof e]&&e&&!e.nodeType&&e,r[typeof t]&&t&&!t.nodeType&&t,r[typeof n]&&n);!i||i.global!==i&&i.window!==i||(e.root=i)}).call(e,n(45)(t),function(){return this}())},function(t,e){t.exports=function(t){return t.webpackPolyfill||(t.deprecate=function(){},t.paths=[],t.children=[],t.webpackPolyfill=1),t}},function(t,e,n){"use strict";var r=n(44),i=n(47),o=n(48),s=n(54),a=n(55),u=function(){function t(t){this._isScalar=!1,t&&(this._subscribe=t)}return t.prototype.lift=function(e){var n=new t;return n.source=this,n.operator=e,n},t.prototype.subscribe=function(t,e,n){var r=this.operator,i=o.toSubscriber(t,e,n);if(r?i.add(this._subscribe(r.call(i))):i.add(this._subscribe(i)),i.syncErrorThrowable&&(i.syncErrorThrowable=!1,i.syncErrorThrown))throw i.syncErrorValue;return i},t.prototype.forEach=function(t,e,n){if(n||(r.root.Rx&&r.root.Rx.config&&r.root.Rx.config.Promise?n=r.root.Rx.config.Promise:r.root.Promise&&(n=r.root.Promise)),!n)throw new Error("no Promise impl found");var i=this;return new n(function(n,r){i.subscribe(function(n){var i=s.tryCatch(t).call(e,n);i===a.errorObject&&r(a.errorObject.e)},r,n)})},t.prototype._subscribe=function(t){return this.source.subscribe(t)},t.prototype[i.SymbolShim.observable]=function(){return this},t.create=function(e){return new t(e)},t}();e.Observable=u},function(t,e,n){"use strict";function r(t){var e=o(t);return a(e,t),u(e),i(e),e}function i(t){t["for"]||(t["for"]=s)}function o(t){return t.Symbol||(t.Symbol=function(t){return"@@Symbol("+t+"):"+p++}),t.Symbol}function s(t){return"@@"+t}function a(t,e){if(!t.iterator)if("function"==typeof t["for"])t.iterator=t["for"]("iterator");else if(e.Set&&"function"==typeof(new e.Set)["@@iterator"])t.iterator="@@iterator";else if(e.Map)for(var n=Object.getOwnPropertyNames(e.Map.prototype),r=0;ro?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},h=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},f=n(60),d=n(5),v=n(6),y=n(62),m=n(40),g=n(15),_=n(63),b=n(64),P=n(12),E=n(75),w=n(71);e.createNgZone=r;var C,R=!1;e.createPlatform=i,e.assertPlatform=o,e.disposePlatform=s,e.getPlatform=a,e.coreBootstrap=u,e.coreLoadAndBootstrap=c;var S=function(){function t(){}return Object.defineProperty(t.prototype,"injector",{get:function(){throw P.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"disposed",{get:function(){throw P.unimplemented()},enumerable:!0,configurable:!0}),t}();e.PlatformRef=S;var O=function(t){function e(e){if(t.call(this),this._injector=e,this._applications=[],this._disposeListeners=[],this._disposed=!1,!R)throw new P.BaseException("Platforms have to be created via `createPlatform`!");var n=e.get(y.PLATFORM_INITIALIZER,null);d.isPresent(n)&&n.forEach(function(t){return t()})}return p(e,t),e.prototype.registerDisposeListener=function(t){this._disposeListeners.push(t)},Object.defineProperty(e.prototype,"injector",{get:function(){return this._injector},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"disposed",{get:function(){return this._disposed},enumerable:!0,configurable:!0}),e.prototype.addApplication=function(t){this._applications.push(t)},e.prototype.dispose=function(){g.ListWrapper.clone(this._applications).forEach(function(t){return t.dispose()}),this._disposeListeners.forEach(function(t){return t()}),this._disposed=!0},e.prototype._applicationDisposed=function(t){g.ListWrapper.remove(this._applications,t)},e=l([v.Injectable(),h("design:paramtypes",[v.Injector])],e)}(S);e.PlatformRef_=O;var T=function(){function t(){}return Object.defineProperty(t.prototype,"injector",{get:function(){return P.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"zone",{get:function(){return P.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"componentTypes",{get:function(){return P.unimplemented()},enumerable:!0,configurable:!0}),t}();e.ApplicationRef=T;var x=function(t){function e(e,n,r){var i=this;t.call(this),this._platform=e,this._zone=n,this._injector=r,this._bootstrapListeners=[],this._disposeListeners=[],this._rootComponents=[],this._rootComponentTypes=[],this._changeDetectorRefs=[],this._runningTick=!1,this._enforceNoNewChanges=!1;var o=r.get(f.NgZone);this._enforceNoNewChanges=d.assertionsEnabled(),o.run(function(){i._exceptionHandler=r.get(P.ExceptionHandler)}),this._asyncInitDonePromise=this.run(function(){var t,e=r.get(y.APP_INITIALIZER,null),n=[];if(d.isPresent(e))for(var o=0;o0?(t=m.PromiseWrapper.all(n).then(function(t){return i._asyncInitDone=!0}),i._asyncInitDone=!1):(i._asyncInitDone=!0,t=m.PromiseWrapper.resolve(!0)),t}),m.ObservableWrapper.subscribe(o.onError,function(t){i._exceptionHandler.call(t.error,t.stackTrace)}),m.ObservableWrapper.subscribe(this._zone.onMicrotaskEmpty,function(t){i._zone.run(function(){i.tick()})})}return p(e,t),e.prototype.registerBootstrapListener=function(t){this._bootstrapListeners.push(t)},e.prototype.registerDisposeListener=function(t){this._disposeListeners.push(t)},e.prototype.registerChangeDetector=function(t){this._changeDetectorRefs.push(t)},e.prototype.unregisterChangeDetector=function(t){g.ListWrapper.remove(this._changeDetectorRefs,t)},e.prototype.waitForAsyncInitializers=function(){return this._asyncInitDonePromise},e.prototype.run=function(t){var e,n=this,r=this.injector.get(f.NgZone),i=m.PromiseWrapper.completer();return r.run(function(){try{e=t(),d.isPromise(e)&&m.PromiseWrapper.then(e,function(t){i.resolve(t)},function(t,e){i.reject(t,e),n._exceptionHandler.call(t,e)})}catch(r){throw n._exceptionHandler.call(r,r.stack),r}}),d.isPromise(e)?i.promise:e},e.prototype.bootstrap=function(t){var e=this;if(!this._asyncInitDone)throw new P.BaseException("Cannot bootstrap as there are still asynchronous initializers running. Wait for them using waitForAsyncInitializers().");return this.run(function(){e._rootComponentTypes.push(t.componentType);var n=t.create(e._injector,[],t.selector);n.onDestroy(function(){e._unloadComponent(n)});var r=n.injector.get(_.Testability,null);d.isPresent(r)&&n.injector.get(_.TestabilityRegistry).registerApplication(n.location.nativeElement,r),e._loadComponent(n);var i=e._injector.get(E.Console);return d.assertionsEnabled()&&i.log("Angular 2 is running in the development mode. Call enableProdMode() to enable the production mode."),n})},e.prototype._loadComponent=function(t){this._changeDetectorRefs.push(t.changeDetectorRef),this.tick(),this._rootComponents.push(t),this._bootstrapListeners.forEach(function(e){return e(t)})},e.prototype._unloadComponent=function(t){g.ListWrapper.contains(this._rootComponents,t)&&(this.unregisterChangeDetector(t.changeDetectorRef),g.ListWrapper.remove(this._rootComponents,t))},Object.defineProperty(e.prototype,"injector",{get:function(){return this._injector},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"zone",{get:function(){return this._zone},enumerable:!0,configurable:!0}),e.prototype.tick=function(){if(this._runningTick)throw new P.BaseException("ApplicationRef.tick is called recursively");var t=e._tickScope();try{this._runningTick=!0,this._changeDetectorRefs.forEach(function(t){return t.detectChanges()}),this._enforceNoNewChanges&&this._changeDetectorRefs.forEach(function(t){return t.checkNoChanges()})}finally{this._runningTick=!1,w.wtfLeave(t)}},e.prototype.dispose=function(){g.ListWrapper.clone(this._rootComponents).forEach(function(t){return t.destroy()}),this._disposeListeners.forEach(function(t){return t()}),this._platform._applicationDisposed(this)},Object.defineProperty(e.prototype,"componentTypes",{get:function(){return this._rootComponentTypes},enumerable:!0,configurable:!0}),e._tickScope=w.wtfCreateScope("ApplicationRef#tick()"),e=l([v.Injectable(),h("design:paramtypes",[O,f.NgZone,v.Injector])],e)}(T);e.ApplicationRef_=x,e.PLATFORM_CORE_PROVIDERS=d.CONST_EXPR([O,d.CONST_EXPR(new v.Provider(S,{useExisting:O}))]),e.APPLICATION_CORE_PROVIDERS=d.CONST_EXPR([d.CONST_EXPR(new v.Provider(f.NgZone,{useFactory:r,deps:d.CONST_EXPR([])})),x,d.CONST_EXPR(new v.Provider(T,{useExisting:x}))])},function(t,e,n){"use strict";var r=n(40),i=n(61),o=n(12),s=n(61);e.NgZoneError=s.NgZoneError;var a=function(){function t(t){var e=this,n=t.enableLongStackTrace,o=void 0===n?!1:n;this._hasPendingMicrotasks=!1,this._hasPendingMacrotasks=!1,this._isStable=!0,this._nesting=0,this._onUnstable=new r.EventEmitter(!1),this._onMicrotaskEmpty=new r.EventEmitter(!1),this._onStable=new r.EventEmitter(!1),this._onErrorEvents=new r.EventEmitter(!1),this._zoneImpl=new i.NgZoneImpl({trace:o,onEnter:function(){e._nesting++,e._isStable&&(e._isStable=!1,e._onUnstable.emit(null))},onLeave:function(){e._nesting--,e._checkStable()},setMicrotask:function(t){e._hasPendingMicrotasks=t,e._checkStable()},setMacrotask:function(t){e._hasPendingMacrotasks=t},onError:function(t){return e._onErrorEvents.emit(t)}})}return t.isInAngularZone=function(){return i.NgZoneImpl.isInAngularZone()},t.assertInAngularZone=function(){if(!i.NgZoneImpl.isInAngularZone())throw new o.BaseException("Expected to be in Angular Zone, but it is not!")},t.assertNotInAngularZone=function(){if(i.NgZoneImpl.isInAngularZone())throw new o.BaseException("Expected to not be in Angular Zone, but it is!")},t.prototype._checkStable=function(){var t=this;if(0==this._nesting&&!this._hasPendingMicrotasks&&!this._isStable)try{this._nesting++,this._onMicrotaskEmpty.emit(null)}finally{if(this._nesting--,!this._hasPendingMicrotasks)try{this.runOutsideAngular(function(){return t._onStable.emit(null)})}finally{this._isStable=!0}}},Object.defineProperty(t.prototype,"onUnstable",{get:function(){return this._onUnstable},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"onMicrotaskEmpty",{get:function(){return this._onMicrotaskEmpty},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"onStable",{get:function(){return this._onStable},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"onError",{get:function(){return this._onErrorEvents},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"hasPendingMicrotasks",{get:function(){return this._hasPendingMicrotasks},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"hasPendingMacrotasks",{get:function(){return this._hasPendingMacrotasks},enumerable:!0,configurable:!0}),t.prototype.run=function(t){return this._zoneImpl.runInner(t)},t.prototype.runGuarded=function(t){return this._zoneImpl.runInnerGuarded(t)},t.prototype.runOutsideAngular=function(t){return this._zoneImpl.runOuter(t)},t}();e.NgZone=a},function(t,e){"use strict";var n=function(){function t(t,e){this.error=t,this.stackTrace=e}return t}();e.NgZoneError=n;var r=function(){function t(t){var e=this,r=t.trace,i=t.onEnter,o=t.onLeave,s=t.setMicrotask,a=t.setMacrotask,u=t.onError;if(this.onEnter=i,this.onLeave=o,this.setMicrotask=s,this.setMacrotask=a,this.onError=u,!Zone)throw new Error("Angular2 needs to be run with Zone.js polyfill.");this.outer=this.inner=Zone.current,Zone.wtfZoneSpec&&(this.inner=this.inner.fork(Zone.wtfZoneSpec)),r&&Zone.longStackTraceZoneSpec&&(this.inner=this.inner.fork(Zone.longStackTraceZoneSpec)),this.inner=this.inner.fork({name:"angular",properties:{isAngularZone:!0},onInvokeTask:function(t,n,r,i,o,s){try{return e.onEnter(),t.invokeTask(r,i,o,s)}finally{e.onLeave()}},onInvoke:function(t,n,r,i,o,s,a){try{return e.onEnter(),t.invoke(r,i,o,s,a)}finally{e.onLeave()}},onHasTask:function(t,n,r,i){t.hasTask(r,i),n==r&&("microTask"==i.change?e.setMicrotask(i.microTask):"macroTask"==i.change&&e.setMacrotask(i.macroTask))},onHandleError:function(t,r,i,o){return t.handleError(i,o),e.onError(new n(o,o.stack)),!1}})}return t.isInAngularZone=function(){return Zone.current.get("isAngularZone")===!0},t.prototype.runInner=function(t){return this.inner.run(t)},t.prototype.runInnerGuarded=function(t){return this.inner.runGuarded(t)},t.prototype.runOuter=function(t){return this.outer.run(t)},t}();e.NgZoneImpl=r},function(t,e,n){"use strict";function r(){return""+i()+i()+i()}function i(){return s.StringWrapper.fromCharCode(97+s.Math.floor(25*s.Math.random()))}var o=n(6),s=n(5);e.APP_ID=s.CONST_EXPR(new o.OpaqueToken("AppId")),e.APP_ID_RANDOM_PROVIDER=s.CONST_EXPR(new o.Provider(e.APP_ID,{useFactory:r,deps:[]})),e.PLATFORM_INITIALIZER=s.CONST_EXPR(new o.OpaqueToken("Platform Initializer")),e.APP_INITIALIZER=s.CONST_EXPR(new o.OpaqueToken("Application Initializer")),e.PACKAGE_ROOT_URL=s.CONST_EXPR(new o.OpaqueToken("Application Packages Root URL"))},function(t,e,n){"use strict";function r(t){v=t}var i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(6),a=n(15),u=n(5),c=n(12),p=n(60),l=n(40),h=function(){function t(t){this._ngZone=t,this._pendingCount=0,this._isZoneStable=!0,this._didWork=!1,this._callbacks=[],this._watchAngularEvents()}return t.prototype._watchAngularEvents=function(){var t=this;l.ObservableWrapper.subscribe(this._ngZone.onUnstable,function(e){t._didWork=!0,t._isZoneStable=!1}),this._ngZone.runOutsideAngular(function(){l.ObservableWrapper.subscribe(t._ngZone.onStable,function(e){p.NgZone.assertNotInAngularZone(),u.scheduleMicroTask(function(){t._isZoneStable=!0,t._runCallbacksIfReady()})})})},t.prototype.increasePendingRequestCount=function(){return this._pendingCount+=1,this._didWork=!0,this._pendingCount},t.prototype.decreasePendingRequestCount=function(){if(this._pendingCount-=1,this._pendingCount<0)throw new c.BaseException("pending async requests below zero");return this._runCallbacksIfReady(),this._pendingCount},t.prototype.isStable=function(){return this._isZoneStable&&0==this._pendingCount&&!this._ngZone.hasPendingMacrotasks},t.prototype._runCallbacksIfReady=function(){var t=this;this.isStable()?u.scheduleMicroTask(function(){for(;0!==t._callbacks.length;)t._callbacks.pop()(t._didWork);t._didWork=!1}):this._didWork=!0},t.prototype.whenStable=function(t){this._callbacks.push(t),this._runCallbacksIfReady()},t.prototype.getPendingRequestCount=function(){return this._pendingCount},t.prototype.findBindings=function(t,e,n){return[]},t.prototype.findProviders=function(t,e,n){return[]},t=i([s.Injectable(),o("design:paramtypes",[p.NgZone])],t)}();e.Testability=h;var f=function(){function t(){this._applications=new a.Map,v.addToWindow(this)}return t.prototype.registerApplication=function(t,e){this._applications.set(t,e)},t.prototype.getTestability=function(t){return this._applications.get(t)},t.prototype.getAllTestabilities=function(){return a.MapWrapper.values(this._applications)},t.prototype.getAllRootElements=function(){return a.MapWrapper.keys(this._applications)},t.prototype.findTestabilityInTree=function(t,e){return void 0===e&&(e=!0),v.findTestabilityInTree(this,t,e)},t=i([s.Injectable(),o("design:paramtypes",[])],t)}();e.TestabilityRegistry=f;var d=function(){function t(){}return t.prototype.addToWindow=function(t){},t.prototype.findTestabilityInTree=function(t,e,n){return null},t=i([u.CONST(),o("design:paramtypes",[])],t)}();e.setTestabilityGetter=r;var v=u.CONST_EXPR(new d)},function(t,e,n){"use strict";function r(t){return t instanceof h.ComponentFactory}var i=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},o=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},s=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},a=n(6),u=n(5),c=n(12),p=n(40),l=n(18),h=n(65),f=function(){function t(){}return t}();e.ComponentResolver=f;var d=function(t){function e(){t.apply(this,arguments)}return i(e,t),e.prototype.resolveComponent=function(t){var e=l.reflector.annotations(t),n=e.find(r);if(u.isBlank(n))throw new c.BaseException("No precompiled component "+u.stringify(t)+" found");return p.PromiseWrapper.resolve(n)},e.prototype.clearCache=function(){},e=o([a.Injectable(),s("design:paramtypes",[])],e)}(f);e.ReflectorComponentResolver=d},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(5),a=n(12),u=n(66),c=function(){function t(){}return Object.defineProperty(t.prototype,"location",{get:function(){return a.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"injector",{get:function(){return a.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"instance",{get:function(){return a.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"hostView",{get:function(){return a.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"changeDetectorRef",{get:function(){return a.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"componentType",{get:function(){return a.unimplemented()},enumerable:!0,configurable:!0}),t}();e.ComponentRef=c;var p=function(t){function e(e,n){t.call(this),this._hostElement=e,this._componentType=n}return r(e,t),Object.defineProperty(e.prototype,"location",{get:function(){return this._hostElement.elementRef},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"injector",{get:function(){return this._hostElement.injector},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"instance",{get:function(){return this._hostElement.component},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"hostView",{get:function(){return this._hostElement.parentView.ref},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"changeDetectorRef",{get:function(){return this.hostView},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"componentType",{get:function(){return this._componentType},enumerable:!0,configurable:!0}),e.prototype.destroy=function(){this._hostElement.parentView.destroy()},e.prototype.onDestroy=function(t){this.hostView.onDestroy(t)},e}(c);e.ComponentRef_=p;var l=function(){function t(t,e,n){this.selector=t,this._viewFactory=e,this._componentType=n}return Object.defineProperty(t.prototype,"componentType",{get:function(){return this._componentType},enumerable:!0,configurable:!0}),t.prototype.create=function(t,e,n){void 0===e&&(e=null),void 0===n&&(n=null);var r=t.get(u.ViewUtils);s.isBlank(e)&&(e=[]);var i=this._viewFactory(r,t,null),o=i.create(e,n);return new p(o,this._componentType)},t=i([s.CONST(),o("design:paramtypes",[String,Function,s.Type])],t)}();e.ComponentFactory=l},function(t,e,n){"use strict";function r(t){return i(t,[])}function i(t,e){for(var n=0;ni;i++)n[i]=r>i?t[i]:D}else n=t;return n}function s(t,e,n,r,i,o,s,u,c,p,l,h,f,d,v,y,m,g,_,b){switch(t){case 1:return e+a(n)+r;case 2:return e+a(n)+r+a(i)+o;case 3:return e+a(n)+r+a(i)+o+a(s)+u;case 4:return e+a(n)+r+a(i)+o+a(s)+u+a(c)+p;case 5:return e+a(n)+r+a(i)+o+a(s)+u+a(c)+p+a(l)+h;case 6:return e+a(n)+r+a(i)+o+a(s)+u+a(c)+p+a(l)+h+a(f)+d;case 7:return e+a(n)+r+a(i)+o+a(s)+u+a(c)+p+a(l)+h+a(f)+d+a(v)+y;case 8:return e+a(n)+r+a(i)+o+a(s)+u+a(c)+p+a(l)+h+a(f)+d+a(v)+y+a(m)+g;case 9:return e+a(n)+r+a(i)+o+a(s)+u+a(c)+p+a(l)+h+a(f)+d+a(v)+y+a(m)+g+a(_)+b;default:throw new O.BaseException("Does not support more than 9 expressions")}}function a(t){return null!=t?t.toString():""}function u(t,e,n){if(t){if(!A.devModeEqual(e,n))throw new x.ExpressionChangedAfterItHasBeenCheckedException(e,n,null);return!1}return!R.looseIdentical(e,n)}function c(t,e){if(t.length!=e.length)return!1;for(var n=0;no?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},w=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},C=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},R=n(5),S=n(15),O=n(12),T=n(67),x=n(73),A=n(28),I=n(6),M=n(74),k=n(62),N=function(){function t(t,e){this._renderer=t,this._appId=e,this._nextCompTypeId=0}return t.prototype.createRenderComponentType=function(t,e,n,r){return new M.RenderComponentType(this._appId+"-"+this._nextCompTypeId++,t,e,n,r)},t.prototype.renderComponent=function(t){return this._renderer.renderComponent(t)},t=E([I.Injectable(),C(1,I.Inject(k.APP_ID)),w("design:paramtypes",[M.RootRenderer,String])],t)}();e.ViewUtils=N,e.flattenNestedViewRenderNodes=r;var D=R.CONST_EXPR([]);e.ensureSlotCount=o,e.MAX_INTERPOLATION_VALUES=9,e.interpolate=s,e.checkBinding=u,e.arrayLooseIdentical=c,e.mapLooseIdentical=p,e.castByValue=l,e.pureProxy1=h,e.pureProxy2=f,e.pureProxy3=d,e.pureProxy4=v,e.pureProxy5=y,e.pureProxy6=m,e.pureProxy7=g,e.pureProxy8=_,e.pureProxy9=b,e.pureProxy10=P},function(t,e,n){"use strict";var r=n(5),i=n(15),o=n(12),s=n(68),a=n(69),u=n(70),c=function(){function t(t,e,n,r){this.index=t,this.parentIndex=e,this.parentView=n,this.nativeElement=r,this.nestedViews=null,this.componentView=null}return Object.defineProperty(t.prototype,"elementRef",{get:function(){return new a.ElementRef(this.nativeElement)},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"vcRef",{get:function(){return new u.ViewContainerRef_(this)},enumerable:!0,configurable:!0}),t.prototype.initComponent=function(t,e,n){this.component=t,this.componentConstructorViewQueries=e,this.componentView=n},Object.defineProperty(t.prototype,"parentInjector",{get:function(){return this.parentView.injector(this.parentIndex)},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"injector",{get:function(){return this.parentView.injector(this.index)},enumerable:!0,configurable:!0}),t.prototype.mapNestedViews=function(t,e){var n=[];return r.isPresent(this.nestedViews)&&this.nestedViews.forEach(function(r){r.clazz===t&&n.push(e(r))}),n},t.prototype.attachView=function(t,e){if(t.type===s.ViewType.COMPONENT)throw new o.BaseException("Component views can't be moved!");var n=this.nestedViews;null==n&&(n=[],this.nestedViews=n),i.ListWrapper.insert(n,e,t);var a;if(e>0){var u=n[e-1];a=u.lastRootNode}else a=this.nativeElement;r.isPresent(a)&&t.renderer.attachViewAfter(a,t.flatRootNodes),t.addToContentChildren(this)},t.prototype.detachView=function(t){var e=i.ListWrapper.removeAt(this.nestedViews,t);if(e.type===s.ViewType.COMPONENT)throw new o.BaseException("Component views can't be moved!");return e.renderer.detachView(e.flatRootNodes),e.removeFromContentChildren(this),e},t}();e.AppElement=c},function(t,e){"use strict";!function(t){t[t.HOST=0]="HOST",t[t.COMPONENT=1]="COMPONENT",t[t.EMBEDDED=2]="EMBEDDED"}(e.ViewType||(e.ViewType={}));e.ViewType},function(t,e){"use strict";var n=function(){function t(t){this.nativeElement=t}return t}();e.ElementRef=n},function(t,e,n){"use strict";var r=n(15),i=n(12),o=n(5),s=n(71),a=function(){function t(){}return Object.defineProperty(t.prototype,"element",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"injector",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"parentInjector",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"length",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),t}();e.ViewContainerRef=a;var u=function(){function t(t){this._element=t,this._createComponentInContainerScope=s.wtfCreateScope("ViewContainerRef#createComponent()"),this._insertScope=s.wtfCreateScope("ViewContainerRef#insert()"),this._removeScope=s.wtfCreateScope("ViewContainerRef#remove()"),this._detachScope=s.wtfCreateScope("ViewContainerRef#detach()")}return t.prototype.get=function(t){return this._element.nestedViews[t].ref},Object.defineProperty(t.prototype,"length",{get:function(){var t=this._element.nestedViews;return o.isPresent(t)?t.length:0},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"element",{get:function(){return this._element.elementRef},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"injector",{get:function(){return this._element.injector},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"parentInjector",{get:function(){return this._element.parentInjector},enumerable:!0,configurable:!0}),t.prototype.createEmbeddedView=function(t,e){void 0===e&&(e=-1);var n=t.createEmbeddedView();return this.insert(n,e),n},t.prototype.createComponent=function(t,e,n,r){void 0===e&&(e=-1),void 0===n&&(n=null),void 0===r&&(r=null);var i=this._createComponentInContainerScope(),a=o.isPresent(n)?n:this._element.parentInjector,u=t.create(a,r);return this.insert(u.hostView,e),s.wtfLeave(i,u)},t.prototype.insert=function(t,e){void 0===e&&(e=-1);var n=this._insertScope();-1==e&&(e=this.length);var r=t;return this._element.attachView(r.internalView,e),s.wtfLeave(n,r)},t.prototype.indexOf=function(t){return r.ListWrapper.indexOf(this._element.nestedViews,t.internalView)},t.prototype.remove=function(t){void 0===t&&(t=-1);var e=this._removeScope();-1==t&&(t=this.length-1);var n=this._element.detachView(t);n.destroy(),s.wtfLeave(e)},t.prototype.detach=function(t){void 0===t&&(t=-1);var e=this._detachScope();-1==t&&(t=this.length-1);var n=this._element.detachView(t);return s.wtfLeave(e,n.ref)},t.prototype.clear=function(){for(var t=this.length-1;t>=0;t--)this.remove(t)},t}();e.ViewContainerRef_=u},function(t,e,n){"use strict";function r(t,e){return null}var i=n(72);e.wtfEnabled=i.detectWTF(),e.wtfCreateScope=e.wtfEnabled?i.createScope:function(t,e){return r},e.wtfLeave=e.wtfEnabled?i.leave:function(t,e){return e},e.wtfStartTimeRange=e.wtfEnabled?i.startTimeRange:function(t,e){return null},e.wtfEndTimeRange=e.wtfEnabled?i.endTimeRange:function(t){return null}},function(t,e,n){"use strict";function r(){var t=p.global.wtf;return t&&(u=t.trace)?(c=u.events,!0):!1}function i(t,e){return void 0===e&&(e=null),c.createScope(t,e)}function o(t,e){return u.leaveScope(t,e),e}function s(t,e){return u.beginTimeRange(t,e)}function a(t){u.endTimeRange(t)}var u,c,p=n(5);e.detectWTF=r,e.createScope=i,e.leave=o,e.startTimeRange=s,e.endTimeRange=a},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(12),o=function(t){function e(e,n,r){t.call(this,"Expression has changed after it was checked. "+("Previous value: '"+e+"'. Current value: '"+n+"'"))}return r(e,t),e}(i.BaseException);e.ExpressionChangedAfterItHasBeenCheckedException=o;var s=function(t){function e(e,n,r){t.call(this,"Error in "+r.source,e,n,r)}return r(e,t),e}(i.WrappedException);e.ViewWrappedException=s;var a=function(t){function e(e){t.call(this,"Attempt to use a destroyed view: "+e)}return r(e,t),e}(i.BaseException);e.ViewDestroyedException=a},function(t,e,n){"use strict";var r=n(12),i=function(){function t(t,e,n,r,i){this.id=t,this.templateUrl=e,this.slotCount=n,this.encapsulation=r,this.styles=i}return t}();e.RenderComponentType=i;var o=function(){function t(){}return Object.defineProperty(t.prototype,"injector",{get:function(){return r.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"component",{get:function(){ -return r.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"providerTokens",{get:function(){return r.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"locals",{get:function(){return r.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"source",{get:function(){return r.unimplemented()},enumerable:!0,configurable:!0}),t}();e.RenderDebugInfo=o;var s=function(){function t(){}return t}();e.Renderer=s;var a=function(){function t(){}return t}();e.RootRenderer=a},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(6),s=n(5),a=function(){function t(){}return t.prototype.log=function(t){s.print(t)},t=r([o.Injectable(),i("design:paramtypes",[])],t)}();e.Console=a},function(t,e,n){"use strict";var r=n(60);e.NgZone=r.NgZone,e.NgZoneError=r.NgZoneError},function(t,e,n){"use strict";var r=n(74);e.RootRenderer=r.RootRenderer,e.Renderer=r.Renderer,e.RenderComponentType=r.RenderComponentType},function(t,e,n){"use strict";var r=n(64);e.ComponentResolver=r.ComponentResolver;var i=n(79);e.QueryList=i.QueryList;var o=n(80);e.DynamicComponentLoader=o.DynamicComponentLoader;var s=n(69);e.ElementRef=s.ElementRef;var a=n(81);e.TemplateRef=a.TemplateRef;var u=n(82);e.EmbeddedViewRef=u.EmbeddedViewRef,e.ViewRef=u.ViewRef;var c=n(70);e.ViewContainerRef=c.ViewContainerRef;var p=n(65);e.ComponentRef=p.ComponentRef,e.ComponentFactory=p.ComponentFactory;var l=n(73);e.ExpressionChangedAfterItHasBeenCheckedException=l.ExpressionChangedAfterItHasBeenCheckedException},function(t,e,n){"use strict";var r=n(15),i=n(5),o=n(40),s=function(){function t(){this._dirty=!0,this._results=[],this._emitter=new o.EventEmitter}return Object.defineProperty(t.prototype,"changes",{get:function(){return this._emitter},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"length",{get:function(){return this._results.length},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"first",{get:function(){return r.ListWrapper.first(this._results)},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"last",{get:function(){return r.ListWrapper.last(this._results)},enumerable:!0,configurable:!0}),t.prototype.map=function(t){return this._results.map(t)},t.prototype.filter=function(t){return this._results.filter(t)},t.prototype.reduce=function(t,e){return this._results.reduce(t,e)},t.prototype.forEach=function(t){this._results.forEach(t)},t.prototype.toArray=function(){return r.ListWrapper.clone(this._results)},t.prototype[i.getSymbolIterator()]=function(){return this._results[i.getSymbolIterator()]()},t.prototype.toString=function(){return this._results.toString()},t.prototype.reset=function(t){this._results=r.ListWrapper.flatten(t),this._dirty=!1},t.prototype.notifyOnChanges=function(){this._emitter.emit(this)},t.prototype.setDirty=function(){this._dirty=!0},Object.defineProperty(t.prototype,"dirty",{get:function(){return this._dirty},enumerable:!0,configurable:!0}),t}();e.QueryList=s},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(6),a=n(64),u=n(5),c=function(){function t(){}return t}();e.DynamicComponentLoader=c;var p=function(t){function e(e){t.call(this),this._compiler=e}return r(e,t),e.prototype.loadAsRoot=function(t,e,n,r,i){return this._compiler.resolveComponent(t).then(function(t){var o=t.create(n,i,u.isPresent(e)?e:t.selector);return u.isPresent(r)&&o.onDestroy(r),o})},e.prototype.loadNextToLocation=function(t,e,n,r){return void 0===n&&(n=null),void 0===r&&(r=null),this._compiler.resolveComponent(t).then(function(t){var i=e.parentInjector,o=u.isPresent(n)&&n.length>0?s.ReflectiveInjector.fromResolvedProviders(n,i):i;return e.createComponent(t,e.length,o,r)})},e=i([s.Injectable(),o("design:paramtypes",[a.ComponentResolver])],e)}(c);e.DynamicComponentLoader_=p},function(t,e){"use strict";var n=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},r=function(){function t(){}return Object.defineProperty(t.prototype,"elementRef",{get:function(){return null},enumerable:!0,configurable:!0}),t}();e.TemplateRef=r;var i=function(t){function e(e,n){t.call(this),this._appElement=e,this._viewFactory=n}return n(e,t),e.prototype.createEmbeddedView=function(){var t=this._viewFactory(this._appElement.parentView.viewUtils,this._appElement.parentInjector,this._appElement);return t.create(null,null),t.ref},Object.defineProperty(e.prototype,"elementRef",{get:function(){return this._appElement.elementRef},enumerable:!0,configurable:!0}),e}(r);e.TemplateRef_=i},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(12),o=n(34),s=n(33),a=function(t){function e(){t.apply(this,arguments)}return r(e,t),Object.defineProperty(e.prototype,"changeDetectorRef",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"destroyed",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),e}(o.ChangeDetectorRef);e.ViewRef=a;var u=function(t){function e(){t.apply(this,arguments)}return r(e,t),Object.defineProperty(e.prototype,"rootNodes",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),e}(a);e.EmbeddedViewRef=u;var c=function(){function t(t){this._view=t,this._view=t}return Object.defineProperty(t.prototype,"internalView",{get:function(){return this._view},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"changeDetectorRef",{get:function(){return this},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"rootNodes",{get:function(){return this._view.flatRootNodes},enumerable:!0,configurable:!0}),t.prototype.setLocal=function(t,e){this._view.setLocal(t,e)},t.prototype.hasLocal=function(t){return this._view.hasLocal(t)},Object.defineProperty(t.prototype,"destroyed",{get:function(){return this._view.destroyed},enumerable:!0,configurable:!0}),t.prototype.markForCheck=function(){this._view.markPathToRootAsCheckOnce()},t.prototype.detach=function(){this._view.cdMode=s.ChangeDetectionStrategy.Detached},t.prototype.detectChanges=function(){this._view.detectChanges(!1)},t.prototype.checkNoChanges=function(){this._view.detectChanges(!0)},t.prototype.reattach=function(){this._view.cdMode=s.ChangeDetectionStrategy.CheckAlways,this.markForCheck()},t.prototype.onDestroy=function(t){this._view.disposables.push(t)},t.prototype.destroy=function(){this._view.destroy()},t}();e.ViewRef_=c},function(t,e,n){"use strict";function r(t){return t.map(function(t){return t.nativeElement})}function i(t,e,n){t.childNodes.forEach(function(t){t instanceof v&&(e(t)&&n.push(t),i(t,e,n))})}function o(t,e,n){t instanceof v&&t.childNodes.forEach(function(t){e(t)&&n.push(t),t instanceof v&&o(t,e,n)})}function s(t){return y.get(t)}function a(){return h.MapWrapper.values(y)}function u(t){y.set(t.nativeNode,t)}function c(t){y["delete"](t.nativeNode)}var p=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},l=n(5),h=n(15),f=function(){function t(t,e){this.name=t,this.callback=e}return t}();e.EventListener=f;var d=function(){function t(t,e,n){this._debugInfo=n,this.nativeNode=t,l.isPresent(e)&&e instanceof v?e.addChild(this):this.parent=null,this.listeners=[]}return Object.defineProperty(t.prototype,"injector",{get:function(){return l.isPresent(this._debugInfo)?this._debugInfo.injector:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"componentInstance",{get:function(){return l.isPresent(this._debugInfo)?this._debugInfo.component:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"locals",{get:function(){return l.isPresent(this._debugInfo)?this._debugInfo.locals:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"providerTokens",{get:function(){return l.isPresent(this._debugInfo)?this._debugInfo.providerTokens:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"source",{get:function(){return l.isPresent(this._debugInfo)?this._debugInfo.source:null},enumerable:!0,configurable:!0}),t.prototype.inject=function(t){return this.injector.get(t)},t.prototype.getLocal=function(t){return this.locals[t]},t}();e.DebugNode=d;var v=function(t){function e(e,n,r){t.call(this,e,n,r),this.properties={},this.attributes={},this.childNodes=[],this.nativeElement=e}return p(e,t),e.prototype.addChild=function(t){l.isPresent(t)&&(this.childNodes.push(t),t.parent=this)},e.prototype.removeChild=function(t){var e=this.childNodes.indexOf(t);-1!==e&&(t.parent=null,this.childNodes.splice(e,1))},e.prototype.insertChildrenAfter=function(t,e){var n=this.childNodes.indexOf(t);if(-1!==n){var r=this.childNodes.slice(0,n+1),i=this.childNodes.slice(n+1);this.childNodes=h.ListWrapper.concat(h.ListWrapper.concat(r,e),i);for(var o=0;o0?e[0]:null},e.prototype.queryAll=function(t){var e=[];return i(this,t,e),e},e.prototype.queryAllNodes=function(t){var e=[];return o(this,t,e),e},Object.defineProperty(e.prototype,"children",{get:function(){var t=[];return this.childNodes.forEach(function(n){n instanceof e&&t.push(n)}),t},enumerable:!0,configurable:!0}),e.prototype.triggerEventHandler=function(t,e){this.listeners.forEach(function(n){n.name==t&&n.callback(e)})},e}(d);e.DebugElement=v,e.asNativeElements=r;var y=new Map;e.getDebugNode=s,e.getAllDebugNodes=a,e.indexDebugNode=u,e.removeDebugNodeFromIndex=c},function(t,e,n){"use strict";var r=n(6),i=n(5);e.PLATFORM_DIRECTIVES=i.CONST_EXPR(new r.OpaqueToken("Platform Directives")),e.PLATFORM_PIPES=i.CONST_EXPR(new r.OpaqueToken("Platform Pipes"))},function(t,e,n){"use strict";function r(){return a.reflector}var i=n(5),o=n(6),s=n(75),a=n(18),u=n(20),c=n(63),p=n(59);e.PLATFORM_COMMON_PROVIDERS=i.CONST_EXPR([p.PLATFORM_CORE_PROVIDERS,new o.Provider(a.Reflector,{useFactory:r,deps:[]}),new o.Provider(u.ReflectorReader,{useExisting:a.Reflector}),c.TestabilityRegistry,s.Console])},function(t,e,n){"use strict";var r=n(5),i=n(6),o=n(62),s=n(59),a=n(28),u=n(66),c=n(64),p=n(64),l=n(80),h=n(80);e.APPLICATION_COMMON_PROVIDERS=r.CONST_EXPR([s.APPLICATION_CORE_PROVIDERS,new i.Provider(c.ComponentResolver,{useClass:p.ReflectorComponentResolver}),o.APP_ID_RANDOM_PROVIDER,u.ViewUtils,new i.Provider(a.IterableDiffers,{useValue:a.defaultIterableDiffers}),new i.Provider(a.KeyValueDiffers,{useValue:a.defaultKeyValueDiffers}),new i.Provider(l.DynamicComponentLoader,{useClass:h.DynamicComponentLoader_})])},function(t,e,n){"use strict";function r(t){for(var n in t)e.hasOwnProperty(n)||(e[n]=t[n])}r(n(88)),r(n(102)),r(n(112)),r(n(136))},function(t,e,n){"use strict";var r=n(89);e.AsyncPipe=r.AsyncPipe;var i=n(91);e.DatePipe=i.DatePipe;var o=n(93);e.JsonPipe=o.JsonPipe;var s=n(94);e.SlicePipe=s.SlicePipe;var a=n(95);e.LowerCasePipe=a.LowerCasePipe;var u=n(96);e.NumberPipe=u.NumberPipe,e.DecimalPipe=u.DecimalPipe,e.PercentPipe=u.PercentPipe,e.CurrencyPipe=u.CurrencyPipe;var c=n(97);e.UpperCasePipe=c.UpperCasePipe;var p=n(98);e.ReplacePipe=p.ReplacePipe;var l=n(99);e.I18nPluralPipe=l.I18nPluralPipe;var h=n(100);e.I18nSelectPipe=h.I18nSelectPipe;var f=n(101);e.COMMON_PIPES=f.COMMON_PIPES},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(40),a=n(2),u=n(90),c=function(){function t(){}return t.prototype.createSubscription=function(t,e){return s.ObservableWrapper.subscribe(t,e,function(t){throw t})},t.prototype.dispose=function(t){s.ObservableWrapper.dispose(t)},t.prototype.onDestroy=function(t){s.ObservableWrapper.dispose(t)},t}(),p=function(){function t(){}return t.prototype.createSubscription=function(t,e){return t.then(e)},t.prototype.dispose=function(t){},t.prototype.onDestroy=function(t){},t}(),l=new p,h=new c,f=function(){function t(t){this._latestValue=null,this._latestReturnedValue=null,this._subscription=null,this._obj=null,this._strategy=null,this._ref=t}return t.prototype.ngOnDestroy=function(){o.isPresent(this._subscription)&&this._dispose()},t.prototype.transform=function(t){return o.isBlank(this._obj)?(o.isPresent(t)&&this._subscribe(t),this._latestReturnedValue=this._latestValue,this._latestValue):t!==this._obj?(this._dispose(),this.transform(t)):this._latestValue===this._latestReturnedValue?this._latestReturnedValue:(this._latestReturnedValue=this._latestValue,a.WrappedValue.wrap(this._latestValue))},t.prototype._subscribe=function(t){var e=this;this._obj=t,this._strategy=this._selectStrategy(t),this._subscription=this._strategy.createSubscription(t,function(n){return e._updateLatestValue(t,n)})},t.prototype._selectStrategy=function(e){if(o.isPromise(e))return l;if(s.ObservableWrapper.isObservable(e))return h;throw new u.InvalidPipeArgumentException(t,e)},t.prototype._dispose=function(){this._strategy.dispose(this._subscription),this._latestValue=null,this._latestReturnedValue=null,this._subscription=null,this._obj=null},t.prototype._updateLatestValue=function(t,e){t===this._obj&&(this._latestValue=e,this._ref.markForCheck())},t=r([a.Pipe({name:"async",pure:!1}),a.Injectable(),i("design:paramtypes",[a.ChangeDetectorRef])],t)}();e.AsyncPipe=f},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(5),o=n(12),s=function(t){function e(e,n){t.call(this,"Invalid argument '"+n+"' for pipe '"+i.stringify(e)+"'")}return r(e,t),e}(o.BaseException);e.InvalidPipeArgumentException=s},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(92),a=n(2),u=n(15),c=n(90),p="en-US",l=function(){function t(){}return t.prototype.transform=function(e,n){if(void 0===n&&(n="mediumDate"),o.isBlank(e))return null;if(!this.supports(e))throw new c.InvalidPipeArgumentException(t,e);return o.isNumber(e)&&(e=o.DateWrapper.fromMillis(e)),u.StringMapWrapper.contains(t._ALIASES,n)&&(n=u.StringMapWrapper.get(t._ALIASES,n)),s.DateFormatter.format(e,p,n)},t.prototype.supports=function(t){return o.isDate(t)||o.isNumber(t)},t._ALIASES={medium:"yMMMdjms","short":"yMdjm",fullDate:"yMMMMEEEEd",longDate:"yMMMMd",mediumDate:"yMMMd",shortDate:"yMd",mediumTime:"jms",shortTime:"jm"},t=r([o.CONST(),a.Pipe({name:"date",pure:!0}),a.Injectable(),i("design:paramtypes",[])],t)}();e.DatePipe=l},function(t,e){"use strict";function n(t){return 2==t?"2-digit":"numeric"}function r(t){return 4>t?"short":"long"}function i(t){for(var e,i={},o=0;o=3?i.month=r(s):i.month=n(s);break;case"d":i.day=n(s);break;case"E":i.weekday=r(s);break;case"j":i.hour=n(s);break;case"h":i.hour=n(s),i.hour12=!0;break;case"H":i.hour=n(s),i.hour12=!1;break;case"m":i.minute=n(s);break;case"s":i.second=n(s);break;case"z":i.timeZoneName="long";break;case"Z":i.timeZoneName="short"}o=e}return i}!function(t){t[t.Decimal=0]="Decimal",t[t.Percent=1]="Percent",t[t.Currency=2]="Currency"}(e.NumberFormatStyle||(e.NumberFormatStyle={}));var o=e.NumberFormatStyle,s=function(){function t(){}return t.format=function(t,e,n,r){var i=void 0===r?{}:r,s=i.minimumIntegerDigits,a=void 0===s?1:s,u=i.minimumFractionDigits,c=void 0===u?0:u,p=i.maximumFractionDigits,l=void 0===p?3:p,h=i.currency,f=i.currencyAsSymbol,d=void 0===f?!1:f,v={minimumIntegerDigits:a,minimumFractionDigits:c,maximumFractionDigits:l};return v.style=o[n].toLowerCase(),n==o.Currency&&(v.currency=h,v.currencyDisplay=d?"symbol":"code"),new Intl.NumberFormat(e,v).format(t)},t}();e.NumberFormatter=s;var a=new Map,u=function(){function t(){}return t.format=function(t,e,n){var r=e+n;if(a.has(r))return a.get(r).format(t);var o=new Intl.DateTimeFormat(e,i(n));return a.set(r,o),o.format(t)},t}();e.DateFormatter=u},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(2),a=function(){function t(){}return t.prototype.transform=function(t){return o.Json.stringify(t)},t=r([o.CONST(),s.Pipe({name:"json",pure:!1}),s.Injectable(),i("design:paramtypes",[])],t)}();e.JsonPipe=a},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(15),a=n(2),u=n(90),c=function(){function t(){}return t.prototype.transform=function(e,n,r){if(void 0===r&&(r=null),!this.supports(e))throw new u.InvalidPipeArgumentException(t,e);return o.isBlank(e)?e:o.isString(e)?o.StringWrapper.slice(e,n,r):s.ListWrapper.slice(e,n,r)},t.prototype.supports=function(t){return o.isString(t)||o.isArray(t)},t=r([a.Pipe({name:"slice",pure:!1}),a.Injectable(),i("design:paramtypes",[])],t)}();e.SlicePipe=c},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(2),a=n(90),u=function(){function t(){}return t.prototype.transform=function(e){if(o.isBlank(e))return e;if(!o.isString(e))throw new a.InvalidPipeArgumentException(t,e);return e.toLowerCase()},t=r([o.CONST(),s.Pipe({name:"lowercase"}),s.Injectable(),i("design:paramtypes",[])],t)}();e.LowerCasePipe=u},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(5),a=n(12),u=n(92),c=n(2),p=n(90),l="en-US",h=s.RegExpWrapper.create("^(\\d+)?\\.((\\d+)(\\-(\\d+))?)?$"),f=function(){function t(){}return t._format=function(e,n,r,i,o){if(void 0===i&&(i=null),void 0===o&&(o=!1),s.isBlank(e))return null;if(!s.isNumber(e))throw new p.InvalidPipeArgumentException(t,e);var c=1,f=0,d=3;if(s.isPresent(r)){var v=s.RegExpWrapper.firstMatch(h,r);if(s.isBlank(v))throw new a.BaseException(r+" is not a valid digit info for number pipes");s.isPresent(v[1])&&(c=s.NumberWrapper.parseIntAutoRadix(v[1])),s.isPresent(v[3])&&(f=s.NumberWrapper.parseIntAutoRadix(v[3])),s.isPresent(v[5])&&(d=s.NumberWrapper.parseIntAutoRadix(v[5]))}return u.NumberFormatter.format(e,l,n,{minimumIntegerDigits:c,minimumFractionDigits:f,maximumFractionDigits:d,currency:i,currencyAsSymbol:o})},t=i([s.CONST(),c.Injectable(),o("design:paramtypes",[])],t)}();e.NumberPipe=f;var d=function(t){function e(){t.apply(this,arguments)}return r(e,t),e.prototype.transform=function(t,e){return void 0===e&&(e=null),f._format(t,u.NumberFormatStyle.Decimal,e)},e=i([s.CONST(),c.Pipe({name:"number"}),c.Injectable(),o("design:paramtypes",[])],e)}(f);e.DecimalPipe=d;var v=function(t){function e(){t.apply(this,arguments)}return r(e,t),e.prototype.transform=function(t,e){return void 0===e&&(e=null),f._format(t,u.NumberFormatStyle.Percent,e)},e=i([s.CONST(),c.Pipe({name:"percent"}),c.Injectable(),o("design:paramtypes",[])],e)}(f);e.PercentPipe=v;var y=function(t){function e(){t.apply(this,arguments)}return r(e,t),e.prototype.transform=function(t,e,n,r){return void 0===e&&(e="USD"),void 0===n&&(n=!1),void 0===r&&(r=null),f._format(t,u.NumberFormatStyle.Currency,r,e,n)},e=i([s.CONST(),c.Pipe({name:"currency"}),c.Injectable(),o("design:paramtypes",[])],e)}(f);e.CurrencyPipe=y},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(2),a=n(90),u=function(){function t(){}return t.prototype.transform=function(e){if(o.isBlank(e))return e;if(!o.isString(e))throw new a.InvalidPipeArgumentException(t,e);return e.toUpperCase()},t=r([o.CONST(),s.Pipe({name:"uppercase"}),s.Injectable(),i("design:paramtypes",[])],t)}();e.UpperCasePipe=u},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(2),a=n(90),u=function(){function t(){}return t.prototype.transform=function(e,n,r){if(o.isBlank(e))return e;if(!this._supportedInput(e))throw new a.InvalidPipeArgumentException(t,e);var i=e.toString();if(!this._supportedPattern(n))throw new a.InvalidPipeArgumentException(t,n);if(!this._supportedReplacement(r))throw new a.InvalidPipeArgumentException(t,r);if(o.isFunction(r)){var s=o.isString(n)?o.RegExpWrapper.create(n):n;return o.StringWrapper.replaceAllMapped(i,s,r)}return n instanceof RegExp?o.StringWrapper.replaceAll(i,n,r):o.StringWrapper.replace(i,n,r)},t.prototype._supportedInput=function(t){return o.isString(t)||o.isNumber(t)},t.prototype._supportedPattern=function(t){return o.isString(t)||t instanceof RegExp},t.prototype._supportedReplacement=function(t){return o.isString(t)||o.isFunction(t)},t=r([s.Pipe({name:"replace"}),s.Injectable(),i("design:paramtypes",[])],t)}();e.ReplacePipe=u},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(2),a=n(90),u=o.RegExpWrapper.create("#"),c=function(){function t(){}return t.prototype.transform=function(e,n){var r,i;if(!o.isStringMap(n))throw new a.InvalidPipeArgumentException(t,n);return r=0===e||1===e?"="+e:"other",i=o.isPresent(e)?e.toString():"",o.StringWrapper.replaceAll(n[r],u,i)},t=r([o.CONST(),s.Pipe({name:"i18nPlural",pure:!0}),s.Injectable(),i("design:paramtypes",[])],t)}();e.I18nPluralPipe=c},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(15),a=n(2),u=n(90),c=function(){function t(){}return t.prototype.transform=function(e,n){if(!o.isStringMap(n))throw new u.InvalidPipeArgumentException(t,n);return s.StringMapWrapper.contains(n,e)?n[e]:n.other},t=r([o.CONST(),a.Pipe({name:"i18nSelect",pure:!0}),a.Injectable(),i("design:paramtypes",[])],t)}();e.I18nSelectPipe=c},function(t,e,n){"use strict";var r=n(89),i=n(97),o=n(95),s=n(93),a=n(94),u=n(91),c=n(96),p=n(98),l=n(99),h=n(100),f=n(5);e.COMMON_PIPES=f.CONST_EXPR([r.AsyncPipe,i.UpperCasePipe,o.LowerCasePipe,s.JsonPipe,a.SlicePipe,c.DecimalPipe,c.PercentPipe,c.CurrencyPipe,u.DatePipe,p.ReplacePipe,l.I18nPluralPipe,h.I18nSelectPipe])},function(t,e,n){"use strict";function r(t){for(var n in t)e.hasOwnProperty(n)||(e[n]=t[n])}var i=n(103);e.NgClass=i.NgClass;var o=n(104);e.NgFor=o.NgFor;var s=n(105);e.NgIf=s.NgIf;var a=n(106);e.NgTemplateOutlet=a.NgTemplateOutlet;var u=n(107);e.NgStyle=u.NgStyle;var c=n(108);e.NgSwitch=c.NgSwitch,e.NgSwitchWhen=c.NgSwitchWhen,e.NgSwitchDefault=c.NgSwitchDefault;var p=n(109);e.NgPlural=p.NgPlural,e.NgPluralCase=p.NgPluralCase,e.NgLocalization=p.NgLocalization,r(n(110));var l=n(111);e.CORE_DIRECTIVES=l.CORE_DIRECTIVES},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(2),a=n(15),u=function(){function t(t,e,n,r){this._iterableDiffers=t,this._keyValueDiffers=e,this._ngEl=n,this._renderer=r,this._initialClasses=[]}return Object.defineProperty(t.prototype,"initialClasses",{set:function(t){this._applyInitialClasses(!0),this._initialClasses=o.isPresent(t)&&o.isString(t)?t.split(" "):[],this._applyInitialClasses(!1),this._applyClasses(this._rawClass,!1)},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"rawClass",{set:function(t){this._cleanupClasses(this._rawClass),o.isString(t)&&(t=t.split(" ")),this._rawClass=t,this._iterableDiffer=null,this._keyValueDiffer=null,o.isPresent(t)&&(a.isListLikeIterable(t)?this._iterableDiffer=this._iterableDiffers.find(t).create(null):this._keyValueDiffer=this._keyValueDiffers.find(t).create(null))},enumerable:!0,configurable:!0}),t.prototype.ngDoCheck=function(){if(o.isPresent(this._iterableDiffer)){var t=this._iterableDiffer.diff(this._rawClass);o.isPresent(t)&&this._applyIterableChanges(t)}if(o.isPresent(this._keyValueDiffer)){var t=this._keyValueDiffer.diff(this._rawClass);o.isPresent(t)&&this._applyKeyValueChanges(t)}},t.prototype.ngOnDestroy=function(){this._cleanupClasses(this._rawClass)},t.prototype._cleanupClasses=function(t){this._applyClasses(t,!0),this._applyInitialClasses(!1)},t.prototype._applyKeyValueChanges=function(t){var e=this;t.forEachAddedItem(function(t){e._toggleClass(t.key,t.currentValue)}),t.forEachChangedItem(function(t){e._toggleClass(t.key,t.currentValue)}),t.forEachRemovedItem(function(t){t.previousValue&&e._toggleClass(t.key,!1)})},t.prototype._applyIterableChanges=function(t){var e=this;t.forEachAddedItem(function(t){e._toggleClass(t.item,!0)}),t.forEachRemovedItem(function(t){e._toggleClass(t.item,!1)})},t.prototype._applyInitialClasses=function(t){var e=this;this._initialClasses.forEach(function(n){return e._toggleClass(n,!t)})},t.prototype._applyClasses=function(t,e){var n=this;o.isPresent(t)&&(o.isArray(t)?t.forEach(function(t){return n._toggleClass(t,!e)}):t instanceof Set?t.forEach(function(t){return n._toggleClass(t,!e)}):a.StringMapWrapper.forEach(t,function(t,r){o.isPresent(t)&&n._toggleClass(r,!e)}))},t.prototype._toggleClass=function(t,e){if(t=t.trim(),t.length>0)if(t.indexOf(" ")>-1)for(var n=t.split(/\s+/g),r=0,i=n.length;i>r;r++)this._renderer.setElementClass(this._ngEl.nativeElement,n[r],e);else this._renderer.setElementClass(this._ngEl.nativeElement,t,e)},t=r([s.Directive({selector:"[ngClass]",inputs:["rawClass: ngClass","initialClasses: class"]}),i("design:paramtypes",[s.IterableDiffers,s.KeyValueDiffers,s.ElementRef,s.Renderer])],t)}();e.NgClass=u},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(2),s=n(5),a=n(12),u=function(){function t(t,e,n,r){this._viewContainer=t,this._templateRef=e,this._iterableDiffers=n,this._cdr=r}return Object.defineProperty(t.prototype,"ngForOf",{ -set:function(t){if(this._ngForOf=t,s.isBlank(this._differ)&&s.isPresent(t))try{this._differ=this._iterableDiffers.find(t).create(this._cdr,this._ngForTrackBy)}catch(e){throw new a.BaseException("Cannot find a differ supporting object '"+t+"' of type '"+s.getTypeNameForDebugging(t)+"'. NgFor only supports binding to Iterables such as Arrays.")}},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"ngForTemplate",{set:function(t){s.isPresent(t)&&(this._templateRef=t)},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"ngForTrackBy",{set:function(t){this._ngForTrackBy=t},enumerable:!0,configurable:!0}),t.prototype.ngDoCheck=function(){if(s.isPresent(this._differ)){var t=this._differ.diff(this._ngForOf);s.isPresent(t)&&this._applyChanges(t)}},t.prototype._applyChanges=function(t){var e=this,n=[];t.forEachRemovedItem(function(t){return n.push(new c(t,null))}),t.forEachMovedItem(function(t){return n.push(new c(t,null))});var r=this._bulkRemove(n);t.forEachAddedItem(function(t){return r.push(new c(t,null))}),this._bulkInsert(r);for(var i=0;ii;i++){var s=this._viewContainer.get(i);s.setLocal("first",0===i),s.setLocal("last",i===o-1)}t.forEachIdentityChange(function(t){var n=e._viewContainer.get(t.currentIndex);n.setLocal("$implicit",t.item)})},t.prototype._perViewChange=function(t,e){t.setLocal("$implicit",e.item),t.setLocal("index",e.currentIndex),t.setLocal("even",e.currentIndex%2==0),t.setLocal("odd",e.currentIndex%2==1)},t.prototype._bulkRemove=function(t){t.sort(function(t,e){return t.record.previousIndex-e.record.previousIndex});for(var e=[],n=t.length-1;n>=0;n--){var r=t[n];s.isPresent(r.record.currentIndex)?(r.view=this._viewContainer.detach(r.record.previousIndex),e.push(r)):this._viewContainer.remove(r.record.previousIndex)}return e},t.prototype._bulkInsert=function(t){t.sort(function(t,e){return t.record.currentIndex-e.record.currentIndex});for(var e=0;eo?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(2),s=n(5),a=function(){function t(t,e){this._viewContainer=t,this._templateRef=e,this._prevCondition=null}return Object.defineProperty(t.prototype,"ngIf",{set:function(t){!t||!s.isBlank(this._prevCondition)&&this._prevCondition?t||!s.isBlank(this._prevCondition)&&!this._prevCondition||(this._prevCondition=!1,this._viewContainer.clear()):(this._prevCondition=!0,this._viewContainer.createEmbeddedView(this._templateRef))},enumerable:!0,configurable:!0}),t=r([o.Directive({selector:"[ngIf]",inputs:["ngIf"]}),i("design:paramtypes",[o.ViewContainerRef,o.TemplateRef])],t)}();e.NgIf=a},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(2),s=n(5),a=function(){function t(t){this._viewContainerRef=t}return Object.defineProperty(t.prototype,"ngTemplateOutlet",{set:function(t){s.isPresent(this._insertedViewRef)&&this._viewContainerRef.remove(this._viewContainerRef.indexOf(this._insertedViewRef)),s.isPresent(t)&&(this._insertedViewRef=this._viewContainerRef.createEmbeddedView(t))},enumerable:!0,configurable:!0}),r([o.Input(),i("design:type",o.TemplateRef),i("design:paramtypes",[o.TemplateRef])],t.prototype,"ngTemplateOutlet",null),t=r([o.Directive({selector:"[ngTemplateOutlet]"}),i("design:paramtypes",[o.ViewContainerRef])],t)}();e.NgTemplateOutlet=a},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(2),s=n(5),a=function(){function t(t,e,n){this._differs=t,this._ngEl=e,this._renderer=n}return Object.defineProperty(t.prototype,"rawStyle",{set:function(t){this._rawStyle=t,s.isBlank(this._differ)&&s.isPresent(t)&&(this._differ=this._differs.find(this._rawStyle).create(null))},enumerable:!0,configurable:!0}),t.prototype.ngDoCheck=function(){if(s.isPresent(this._differ)){var t=this._differ.diff(this._rawStyle);s.isPresent(t)&&this._applyChanges(t)}},t.prototype._applyChanges=function(t){var e=this;t.forEachAddedItem(function(t){e._setStyle(t.key,t.currentValue)}),t.forEachChangedItem(function(t){e._setStyle(t.key,t.currentValue)}),t.forEachRemovedItem(function(t){e._setStyle(t.key,null)})},t.prototype._setStyle=function(t,e){this._renderer.setElementStyle(this._ngEl.nativeElement,t,e)},t=r([o.Directive({selector:"[ngStyle]",inputs:["rawStyle: ngStyle"]}),i("design:paramtypes",[o.KeyValueDiffers,o.ElementRef,o.Renderer])],t)}();e.NgStyle=a},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},s=n(2),a=n(5),u=n(15),c=a.CONST_EXPR(new Object),p=function(){function t(t,e){this._viewContainerRef=t,this._templateRef=e}return t.prototype.create=function(){this._viewContainerRef.createEmbeddedView(this._templateRef)},t.prototype.destroy=function(){this._viewContainerRef.clear()},t}();e.SwitchView=p;var l=function(){function t(){this._useDefault=!1,this._valueViews=new u.Map,this._activeViews=[]}return Object.defineProperty(t.prototype,"ngSwitch",{set:function(t){this._emptyAllActiveViews(),this._useDefault=!1;var e=this._valueViews.get(t);a.isBlank(e)&&(this._useDefault=!0,e=a.normalizeBlank(this._valueViews.get(c))),this._activateViews(e),this._switchValue=t},enumerable:!0,configurable:!0}),t.prototype._onWhenValueChanged=function(t,e,n){this._deregisterView(t,n),this._registerView(e,n),t===this._switchValue?(n.destroy(),u.ListWrapper.remove(this._activeViews,n)):e===this._switchValue&&(this._useDefault&&(this._useDefault=!1,this._emptyAllActiveViews()),n.create(),this._activeViews.push(n)),0!==this._activeViews.length||this._useDefault||(this._useDefault=!0,this._activateViews(this._valueViews.get(c)))},t.prototype._emptyAllActiveViews=function(){for(var t=this._activeViews,e=0;eo?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},s=n(2),a=n(5),u=n(15),c=n(108),p="other",l=function(){function t(){}return t}();e.NgLocalization=l;var h=function(){function t(t,e,n){this.value=t,this._view=new c.SwitchView(n,e)}return t=r([s.Directive({selector:"[ngPluralCase]"}),o(0,s.Attribute("ngPluralCase")),i("design:paramtypes",[String,s.TemplateRef,s.ViewContainerRef])],t)}();e.NgPluralCase=h;var f=function(){function t(t){this._localization=t,this._caseViews=new u.Map,this.cases=null}return Object.defineProperty(t.prototype,"ngPlural",{set:function(t){this._switchValue=t,this._updateView()},enumerable:!0,configurable:!0}),t.prototype.ngAfterContentInit=function(){var t=this;this.cases.forEach(function(e){t._caseViews.set(t._formatValue(e),e._view)}),this._updateView()},t.prototype._updateView=function(){this._clearViews();var t=this._caseViews.get(this._switchValue);a.isPresent(t)||(t=this._getCategoryView(this._switchValue)),this._activateView(t)},t.prototype._clearViews=function(){a.isPresent(this._activeView)&&this._activeView.destroy()},t.prototype._activateView=function(t){a.isPresent(t)&&(this._activeView=t,this._activeView.create())},t.prototype._getCategoryView=function(t){var e=this._localization.getPluralCategory(t),n=this._caseViews.get(e);return a.isPresent(n)?n:this._caseViews.get(p)},t.prototype._isValueView=function(t){return"="===t.value[0]},t.prototype._formatValue=function(t){return this._isValueView(t)?this._stripValue(t.value):t.value},t.prototype._stripValue=function(t){return a.NumberWrapper.parseInt(t.substring(1),10)},r([s.ContentChildren(h),i("design:type",s.QueryList)],t.prototype,"cases",void 0),r([s.Input(),i("design:type",Number),i("design:paramtypes",[Number])],t.prototype,"ngPlural",null),t=r([s.Directive({selector:"[ngPlural]"}),i("design:paramtypes",[l])],t)}();e.NgPlural=f},function(t,e){"use strict"},function(t,e,n){"use strict";var r=n(5),i=n(103),o=n(104),s=n(105),a=n(106),u=n(107),c=n(108),p=n(109);e.CORE_DIRECTIVES=r.CONST_EXPR([i.NgClass,o.NgFor,s.NgIf,a.NgTemplateOutlet,u.NgStyle,c.NgSwitch,c.NgSwitchWhen,c.NgSwitchDefault,p.NgPlural,p.NgPluralCase])},function(t,e,n){"use strict";var r=n(113);e.AbstractControl=r.AbstractControl,e.Control=r.Control,e.ControlGroup=r.ControlGroup,e.ControlArray=r.ControlArray;var i=n(114);e.AbstractControlDirective=i.AbstractControlDirective;var o=n(115);e.ControlContainer=o.ControlContainer;var s=n(116);e.NgControlName=s.NgControlName;var a=n(127);e.NgFormControl=a.NgFormControl;var u=n(128);e.NgModel=u.NgModel;var c=n(117);e.NgControl=c.NgControl;var p=n(129);e.NgControlGroup=p.NgControlGroup;var l=n(130);e.NgFormModel=l.NgFormModel;var h=n(131);e.NgForm=h.NgForm;var f=n(118);e.NG_VALUE_ACCESSOR=f.NG_VALUE_ACCESSOR;var d=n(121);e.DefaultValueAccessor=d.DefaultValueAccessor;var v=n(132);e.NgControlStatus=v.NgControlStatus;var y=n(123);e.CheckboxControlValueAccessor=y.CheckboxControlValueAccessor;var m=n(124);e.NgSelectOption=m.NgSelectOption,e.SelectControlValueAccessor=m.SelectControlValueAccessor;var g=n(133);e.FORM_DIRECTIVES=g.FORM_DIRECTIVES,e.RadioButtonState=g.RadioButtonState;var _=n(120);e.NG_VALIDATORS=_.NG_VALIDATORS,e.NG_ASYNC_VALIDATORS=_.NG_ASYNC_VALIDATORS,e.Validators=_.Validators;var b=n(134);e.RequiredValidator=b.RequiredValidator,e.MinLengthValidator=b.MinLengthValidator,e.MaxLengthValidator=b.MaxLengthValidator,e.PatternValidator=b.PatternValidator;var P=n(135);e.FormBuilder=P.FormBuilder;var E=n(135),w=n(125),C=n(5);e.FORM_PROVIDERS=C.CONST_EXPR([E.FormBuilder,w.RadioControlRegistry]),e.FORM_BINDINGS=e.FORM_PROVIDERS},function(t,e,n){"use strict";function r(t){return t instanceof l}function i(t,e){return a.isBlank(e)?null:(e instanceof Array||(e=e.split("/")),e instanceof Array&&p.ListWrapper.isEmpty(e)?null:e.reduce(function(t,e){if(t instanceof f)return a.isPresent(t.controls[e])?t.controls[e]:null;if(t instanceof d){var n=e;return a.isPresent(t.at(n))?t.at(n):null}return null},t))}function o(t){return c.PromiseWrapper.isPromise(t)?u.ObservableWrapper.fromPromise(t):t}var s=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},a=n(5),u=n(40),c=n(41),p=n(15);e.VALID="VALID",e.INVALID="INVALID",e.PENDING="PENDING",e.isControl=r;var l=function(){function t(t,e){this.validator=t,this.asyncValidator=e,this._pristine=!0,this._touched=!1}return Object.defineProperty(t.prototype,"value",{get:function(){return this._value},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"status",{get:function(){return this._status},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"valid",{get:function(){return this._status===e.VALID},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"errors",{get:function(){return this._errors},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"pristine",{get:function(){return this._pristine},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"dirty",{get:function(){return!this.pristine},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"touched",{get:function(){return this._touched},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"untouched",{get:function(){return!this._touched},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"valueChanges",{get:function(){return this._valueChanges},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"statusChanges",{get:function(){return this._statusChanges},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"pending",{get:function(){return this._status==e.PENDING},enumerable:!0,configurable:!0}),t.prototype.markAsTouched=function(){this._touched=!0},t.prototype.markAsDirty=function(t){var e=(void 0===t?{}:t).onlySelf;e=a.normalizeBool(e),this._pristine=!1,a.isPresent(this._parent)&&!e&&this._parent.markAsDirty({onlySelf:e})},t.prototype.markAsPending=function(t){var n=(void 0===t?{}:t).onlySelf;n=a.normalizeBool(n),this._status=e.PENDING,a.isPresent(this._parent)&&!n&&this._parent.markAsPending({onlySelf:n})},t.prototype.setParent=function(t){this._parent=t},t.prototype.updateValueAndValidity=function(t){var n=void 0===t?{}:t,r=n.onlySelf,i=n.emitEvent;r=a.normalizeBool(r),i=a.isPresent(i)?i:!0,this._updateValue(),this._errors=this._runValidator(),this._status=this._calculateStatus(),(this._status==e.VALID||this._status==e.PENDING)&&this._runAsyncValidator(i),i&&(u.ObservableWrapper.callEmit(this._valueChanges,this._value),u.ObservableWrapper.callEmit(this._statusChanges,this._status)),a.isPresent(this._parent)&&!r&&this._parent.updateValueAndValidity({onlySelf:r,emitEvent:i})},t.prototype._runValidator=function(){return a.isPresent(this.validator)?this.validator(this):null},t.prototype._runAsyncValidator=function(t){var n=this;if(a.isPresent(this.asyncValidator)){this._status=e.PENDING,this._cancelExistingSubscription();var r=o(this.asyncValidator(this));this._asyncValidationSubscription=u.ObservableWrapper.subscribe(r,function(e){return n.setErrors(e,{emitEvent:t})})}},t.prototype._cancelExistingSubscription=function(){a.isPresent(this._asyncValidationSubscription)&&u.ObservableWrapper.dispose(this._asyncValidationSubscription)},t.prototype.setErrors=function(t,e){var n=(void 0===e?{}:e).emitEvent;n=a.isPresent(n)?n:!0,this._errors=t,this._status=this._calculateStatus(),n&&u.ObservableWrapper.callEmit(this._statusChanges,this._status),a.isPresent(this._parent)&&this._parent._updateControlsErrors()},t.prototype.find=function(t){return i(this,t)},t.prototype.getError=function(t,e){void 0===e&&(e=null);var n=a.isPresent(e)&&!p.ListWrapper.isEmpty(e)?this.find(e):this;return a.isPresent(n)&&a.isPresent(n._errors)?p.StringMapWrapper.get(n._errors,t):null},t.prototype.hasError=function(t,e){return void 0===e&&(e=null),a.isPresent(this.getError(t,e))},Object.defineProperty(t.prototype,"root",{get:function(){for(var t=this;a.isPresent(t._parent);)t=t._parent;return t},enumerable:!0,configurable:!0}),t.prototype._updateControlsErrors=function(){this._status=this._calculateStatus(),a.isPresent(this._parent)&&this._parent._updateControlsErrors()},t.prototype._initObservables=function(){this._valueChanges=new u.EventEmitter,this._statusChanges=new u.EventEmitter},t.prototype._calculateStatus=function(){return a.isPresent(this._errors)?e.INVALID:this._anyControlsHaveStatus(e.PENDING)?e.PENDING:this._anyControlsHaveStatus(e.INVALID)?e.INVALID:e.VALID},t}();e.AbstractControl=l;var h=function(t){function e(e,n,r){void 0===e&&(e=null),void 0===n&&(n=null),void 0===r&&(r=null),t.call(this,n,r),this._value=e,this.updateValueAndValidity({onlySelf:!0,emitEvent:!1}),this._initObservables()}return s(e,t),e.prototype.updateValue=function(t,e){var n=void 0===e?{}:e,r=n.onlySelf,i=n.emitEvent,o=n.emitModelToViewChange;o=a.isPresent(o)?o:!0,this._value=t,a.isPresent(this._onChange)&&o&&this._onChange(this._value),this.updateValueAndValidity({onlySelf:r,emitEvent:i})},e.prototype._updateValue=function(){},e.prototype._anyControlsHaveStatus=function(t){return!1},e.prototype.registerOnChange=function(t){this._onChange=t},e}(l);e.Control=h;var f=function(t){function e(e,n,r,i){void 0===n&&(n=null),void 0===r&&(r=null),void 0===i&&(i=null),t.call(this,r,i),this.controls=e,this._optionals=a.isPresent(n)?n:{},this._initObservables(),this._setParentForControls(),this.updateValueAndValidity({onlySelf:!0,emitEvent:!1})}return s(e,t),e.prototype.addControl=function(t,e){this.controls[t]=e,e.setParent(this)},e.prototype.removeControl=function(t){p.StringMapWrapper["delete"](this.controls,t)},e.prototype.include=function(t){p.StringMapWrapper.set(this._optionals,t,!0),this.updateValueAndValidity()},e.prototype.exclude=function(t){p.StringMapWrapper.set(this._optionals,t,!1),this.updateValueAndValidity()},e.prototype.contains=function(t){var e=p.StringMapWrapper.contains(this.controls,t);return e&&this._included(t)},e.prototype._setParentForControls=function(){var t=this;p.StringMapWrapper.forEach(this.controls,function(e,n){e.setParent(t)})},e.prototype._updateValue=function(){this._value=this._reduceValue()},e.prototype._anyControlsHaveStatus=function(t){var e=this,n=!1;return p.StringMapWrapper.forEach(this.controls,function(r,i){n=n||e.contains(i)&&r.status==t}),n},e.prototype._reduceValue=function(){return this._reduceChildren({},function(t,e,n){return t[n]=e.value,t})},e.prototype._reduceChildren=function(t,e){var n=this,r=t;return p.StringMapWrapper.forEach(this.controls,function(t,i){n._included(i)&&(r=e(r,t,i))}),r},e.prototype._included=function(t){var e=p.StringMapWrapper.contains(this._optionals,t);return!e||p.StringMapWrapper.get(this._optionals,t)},e}(l);e.ControlGroup=f;var d=function(t){function e(e,n,r){void 0===n&&(n=null),void 0===r&&(r=null),t.call(this,n,r),this.controls=e,this._initObservables(),this._setParentForControls(),this.updateValueAndValidity({onlySelf:!0,emitEvent:!1})}return s(e,t),e.prototype.at=function(t){return this.controls[t]},e.prototype.push=function(t){this.controls.push(t),t.setParent(this),this.updateValueAndValidity()},e.prototype.insert=function(t,e){p.ListWrapper.insert(this.controls,t,e),e.setParent(this),this.updateValueAndValidity()},e.prototype.removeAt=function(t){p.ListWrapper.removeAt(this.controls,t),this.updateValueAndValidity()},Object.defineProperty(e.prototype,"length",{get:function(){return this.controls.length},enumerable:!0,configurable:!0}),e.prototype._updateValue=function(){this._value=this.controls.map(function(t){return t.value})},e.prototype._anyControlsHaveStatus=function(t){return this.controls.some(function(e){return e.status==t})},e.prototype._setParentForControls=function(){var t=this;this.controls.forEach(function(e){e.setParent(t)})},e}(l);e.ControlArray=d},function(t,e,n){"use strict";var r=n(5),i=n(12),o=function(){function t(){}return Object.defineProperty(t.prototype,"control",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"value",{get:function(){return r.isPresent(this.control)?this.control.value:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"valid",{get:function(){return r.isPresent(this.control)?this.control.valid:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"errors",{get:function(){return r.isPresent(this.control)?this.control.errors:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"pristine",{get:function(){return r.isPresent(this.control)?this.control.pristine:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"dirty",{get:function(){return r.isPresent(this.control)?this.control.dirty:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"touched",{get:function(){return r.isPresent(this.control)?this.control.touched:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"untouched",{get:function(){return r.isPresent(this.control)?this.control.untouched:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"path",{get:function(){return null},enumerable:!0,configurable:!0}),t}();e.AbstractControlDirective=o},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(114),o=function(t){function e(){t.apply(this,arguments)}return r(e,t),Object.defineProperty(e.prototype,"formDirective",{get:function(){return null},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"path",{get:function(){return null},enumerable:!0,configurable:!0}),e}(i.AbstractControlDirective);e.ControlContainer=o},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},a=n(5),u=n(40),c=n(2),p=n(115),l=n(117),h=n(118),f=n(119),d=n(120),v=a.CONST_EXPR(new c.Provider(l.NgControl,{useExisting:c.forwardRef(function(){return y})})),y=function(t){function e(e,n,r,i){t.call(this),this._parent=e,this._validators=n,this._asyncValidators=r,this.update=new u.EventEmitter,this._added=!1,this.valueAccessor=f.selectValueAccessor(this,i)}return r(e,t),e.prototype.ngOnChanges=function(t){this._added||(this.formDirective.addControl(this),this._added=!0),f.isPropertyUpdated(t,this.viewModel)&&(this.viewModel=this.model,this.formDirective.updateModel(this,this.model))},e.prototype.ngOnDestroy=function(){this.formDirective.removeControl(this)},e.prototype.viewToModelUpdate=function(t){this.viewModel=t,u.ObservableWrapper.callEmit(this.update,t)},Object.defineProperty(e.prototype,"path",{get:function(){return f.controlPath(this.name,this._parent)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"formDirective",{get:function(){return this._parent.formDirective},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"validator",{get:function(){return f.composeValidators(this._validators)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"asyncValidator",{get:function(){return f.composeAsyncValidators(this._asyncValidators)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"control",{get:function(){return this.formDirective.getControl(this)},enumerable:!0,configurable:!0}),e=i([c.Directive({selector:"[ngControl]",bindings:[v],inputs:["name: ngControl","model: ngModel"],outputs:["update: ngModelChange"],exportAs:"ngForm"}),s(0,c.Host()),s(0,c.SkipSelf()),s(1,c.Optional()),s(1,c.Self()),s(1,c.Inject(d.NG_VALIDATORS)),s(2,c.Optional()),s(2,c.Self()),s(2,c.Inject(d.NG_ASYNC_VALIDATORS)),s(3,c.Optional()),s(3,c.Self()),s(3,c.Inject(h.NG_VALUE_ACCESSOR)),o("design:paramtypes",[p.ControlContainer,Array,Array,Array])],e)}(l.NgControl);e.NgControlName=y},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(114),o=n(12),s=function(t){function e(){t.apply(this,arguments),this.name=null,this.valueAccessor=null}return r(e,t),Object.defineProperty(e.prototype,"validator",{get:function(){return o.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"asyncValidator",{get:function(){return o.unimplemented()},enumerable:!0,configurable:!0}),e}(i.AbstractControlDirective);e.NgControl=s},function(t,e,n){"use strict";var r=n(2),i=n(5);e.NG_VALUE_ACCESSOR=i.CONST_EXPR(new r.OpaqueToken("NgValueAccessor"))},function(t,e,n){"use strict";function r(t,e){var n=l.ListWrapper.clone(e.path);return n.push(t),n}function i(t,e){h.isBlank(t)&&s(e,"Cannot find control"),h.isBlank(e.valueAccessor)&&s(e,"No value accessor for"),t.validator=d.Validators.compose([t.validator,e.validator]),t.asyncValidator=d.Validators.composeAsync([t.asyncValidator,e.asyncValidator]),e.valueAccessor.writeValue(t.value),e.valueAccessor.registerOnChange(function(n){e.viewToModelUpdate(n),t.updateValue(n,{emitModelToViewChange:!1}),t.markAsDirty()}),t.registerOnChange(function(t){return e.valueAccessor.writeValue(t)}),e.valueAccessor.registerOnTouched(function(){return t.markAsTouched()})}function o(t,e){h.isBlank(t)&&s(e,"Cannot find control"),t.validator=d.Validators.compose([t.validator,e.validator]),t.asyncValidator=d.Validators.composeAsync([t.asyncValidator,e.asyncValidator])}function s(t,e){var n=t.path.join(" -> ");throw new f.BaseException(e+" '"+n+"'")}function a(t){return h.isPresent(t)?d.Validators.compose(t.map(b.normalizeValidator)):null}function u(t){return h.isPresent(t)?d.Validators.composeAsync(t.map(b.normalizeAsyncValidator)):null}function c(t,e){if(!l.StringMapWrapper.contains(t,"model"))return!1;var n=t.model;return n.isFirstChange()?!0:!h.looseIdentical(e,n.currentValue)}function p(t,e){if(h.isBlank(e))return null;var n,r,i;return e.forEach(function(e){h.hasConstructor(e,v.DefaultValueAccessor)?n=e:h.hasConstructor(e,m.CheckboxControlValueAccessor)||h.hasConstructor(e,y.NumberValueAccessor)||h.hasConstructor(e,g.SelectControlValueAccessor)||h.hasConstructor(e,_.RadioControlValueAccessor)?(h.isPresent(r)&&s(t,"More than one built-in value accessor matches"),r=e):(h.isPresent(i)&&s(t,"More than one custom value accessor matches"),i=e)}),h.isPresent(i)?i:h.isPresent(r)?r:h.isPresent(n)?n:(s(t,"No valid value accessor for"),null)}var l=n(15),h=n(5),f=n(12),d=n(120),v=n(121),y=n(122),m=n(123),g=n(124),_=n(125),b=n(126);e.controlPath=r,e.setUpControl=i,e.setUpControlGroup=o,e.composeValidators=a,e.composeAsyncValidators=u,e.isPropertyUpdated=c,e.selectValueAccessor=p},function(t,e,n){"use strict";function r(t){return u.PromiseWrapper.isPromise(t)?t:c.ObservableWrapper.toPromise(t)}function i(t,e){return e.map(function(e){return e(t)})}function o(t,e){return e.map(function(e){return e(t)})}function s(t){var e=t.reduce(function(t,e){return a.isPresent(e)?p.StringMapWrapper.merge(t,e):t},{});return p.StringMapWrapper.isEmpty(e)?null:e}var a=n(5),u=n(41),c=n(40),p=n(15),l=n(2);e.NG_VALIDATORS=a.CONST_EXPR(new l.OpaqueToken("NgValidators")),e.NG_ASYNC_VALIDATORS=a.CONST_EXPR(new l.OpaqueToken("NgAsyncValidators"));var h=function(){function t(){}return t.required=function(t){return a.isBlank(t.value)||a.isString(t.value)&&""==t.value?{required:!0}:null},t.minLength=function(e){return function(n){if(a.isPresent(t.required(n)))return null;var r=n.value;return r.lengthe?{maxlength:{requiredLength:e,actualLength:r.length}}:null}},t.pattern=function(e){return function(n){if(a.isPresent(t.required(n)))return null;var r=new RegExp("^"+e+"$"),i=n.value;return r.test(i)?null:{pattern:{requiredPattern:"^"+e+"$",actualValue:i}}}},t.nullValidator=function(t){return null},t.compose=function(t){if(a.isBlank(t))return null;var e=t.filter(a.isPresent);return 0==e.length?null:function(t){return s(i(t,e))}},t.composeAsync=function(t){if(a.isBlank(t))return null;var e=t.filter(a.isPresent);return 0==e.length?null:function(t){var n=o(t,e).map(r);return u.PromiseWrapper.all(n).then(s)}},t}();e.Validators=h},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(2),s=n(118),a=n(5),u=a.CONST_EXPR(new o.Provider(s.NG_VALUE_ACCESSOR,{useExisting:o.forwardRef(function(){return c}),multi:!0})),c=function(){function t(t,e){this._renderer=t,this._elementRef=e,this.onChange=function(t){},this.onTouched=function(){}}return t.prototype.writeValue=function(t){var e=a.isBlank(t)?"":t;this._renderer.setElementProperty(this._elementRef.nativeElement,"value",e)},t.prototype.registerOnChange=function(t){this.onChange=t},t.prototype.registerOnTouched=function(t){this.onTouched=t},t=r([o.Directive({selector:"input:not([type=checkbox])[ngControl],textarea[ngControl],input:not([type=checkbox])[ngFormControl],textarea[ngFormControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]", -host:{"(input)":"onChange($event.target.value)","(blur)":"onTouched()"},bindings:[u]}),i("design:paramtypes",[o.Renderer,o.ElementRef])],t)}();e.DefaultValueAccessor=c},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(2),s=n(118),a=n(5),u=a.CONST_EXPR(new o.Provider(s.NG_VALUE_ACCESSOR,{useExisting:o.forwardRef(function(){return c}),multi:!0})),c=function(){function t(t,e){this._renderer=t,this._elementRef=e,this.onChange=function(t){},this.onTouched=function(){}}return t.prototype.writeValue=function(t){this._renderer.setElementProperty(this._elementRef.nativeElement,"value",t)},t.prototype.registerOnChange=function(t){this.onChange=function(e){t(""==e?null:a.NumberWrapper.parseFloat(e))}},t.prototype.registerOnTouched=function(t){this.onTouched=t},t=r([o.Directive({selector:"input[type=number][ngControl],input[type=number][ngFormControl],input[type=number][ngModel]",host:{"(change)":"onChange($event.target.value)","(input)":"onChange($event.target.value)","(blur)":"onTouched()"},bindings:[u]}),i("design:paramtypes",[o.Renderer,o.ElementRef])],t)}();e.NumberValueAccessor=c},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(2),s=n(118),a=n(5),u=a.CONST_EXPR(new o.Provider(s.NG_VALUE_ACCESSOR,{useExisting:o.forwardRef(function(){return c}),multi:!0})),c=function(){function t(t,e){this._renderer=t,this._elementRef=e,this.onChange=function(t){},this.onTouched=function(){}}return t.prototype.writeValue=function(t){this._renderer.setElementProperty(this._elementRef.nativeElement,"checked",t)},t.prototype.registerOnChange=function(t){this.onChange=t},t.prototype.registerOnTouched=function(t){this.onTouched=t},t=r([o.Directive({selector:"input[type=checkbox][ngControl],input[type=checkbox][ngFormControl],input[type=checkbox][ngModel]",host:{"(change)":"onChange($event.target.checked)","(blur)":"onTouched()"},providers:[u]}),i("design:paramtypes",[o.Renderer,o.ElementRef])],t)}();e.CheckboxControlValueAccessor=c},function(t,e,n){"use strict";function r(t,e){return p.isBlank(t)?""+e:(p.isPrimitive(e)||(e="Object"),p.StringWrapper.slice(t+": "+e,0,50))}function i(t){return t.split(":")[0]}var o=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},s=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},a=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},u=n(2),c=n(118),p=n(5),l=n(15),h=p.CONST_EXPR(new u.Provider(c.NG_VALUE_ACCESSOR,{useExisting:u.forwardRef(function(){return f}),multi:!0})),f=function(){function t(t,e){this._renderer=t,this._elementRef=e,this._optionMap=new Map,this._idCounter=0,this.onChange=function(t){},this.onTouched=function(){}}return t.prototype.writeValue=function(t){this.value=t;var e=r(this._getOptionId(t),t);this._renderer.setElementProperty(this._elementRef.nativeElement,"value",e)},t.prototype.registerOnChange=function(t){var e=this;this.onChange=function(n){t(e._getOptionValue(n))}},t.prototype.registerOnTouched=function(t){this.onTouched=t},t.prototype._registerOption=function(){return(this._idCounter++).toString()},t.prototype._getOptionId=function(t){for(var e=0,n=l.MapWrapper.keys(this._optionMap);eo?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(2),s=n(118),a=n(117),u=n(5),c=n(15),p=u.CONST_EXPR(new o.Provider(s.NG_VALUE_ACCESSOR,{useExisting:o.forwardRef(function(){return f}),multi:!0})),l=function(){function t(){this._accessors=[]}return t.prototype.add=function(t,e){this._accessors.push([t,e])},t.prototype.remove=function(t){for(var e=-1,n=0;no?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},a=n(5),u=n(15),c=n(40),p=n(2),l=n(117),h=n(120),f=n(118),d=n(119),v=a.CONST_EXPR(new p.Provider(l.NgControl,{useExisting:p.forwardRef(function(){return y})})),y=function(t){function e(e,n,r){t.call(this),this._validators=e,this._asyncValidators=n,this.update=new c.EventEmitter,this.valueAccessor=d.selectValueAccessor(this,r)}return r(e,t),e.prototype.ngOnChanges=function(t){this._isControlChanged(t)&&(d.setUpControl(this.form,this),this.form.updateValueAndValidity({emitEvent:!1})),d.isPropertyUpdated(t,this.viewModel)&&(this.form.updateValue(this.model),this.viewModel=this.model)},Object.defineProperty(e.prototype,"path",{get:function(){return[]},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"validator",{get:function(){return d.composeValidators(this._validators)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"asyncValidator",{get:function(){return d.composeAsyncValidators(this._asyncValidators)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"control",{get:function(){return this.form},enumerable:!0,configurable:!0}),e.prototype.viewToModelUpdate=function(t){this.viewModel=t,c.ObservableWrapper.callEmit(this.update,t)},e.prototype._isControlChanged=function(t){return u.StringMapWrapper.contains(t,"form")},e=i([p.Directive({selector:"[ngFormControl]",bindings:[v],inputs:["form: ngFormControl","model: ngModel"],outputs:["update: ngModelChange"],exportAs:"ngForm"}),s(0,p.Optional()),s(0,p.Self()),s(0,p.Inject(h.NG_VALIDATORS)),s(1,p.Optional()),s(1,p.Self()),s(1,p.Inject(h.NG_ASYNC_VALIDATORS)),s(2,p.Optional()),s(2,p.Self()),s(2,p.Inject(f.NG_VALUE_ACCESSOR)),o("design:paramtypes",[Array,Array,Array])],e)}(l.NgControl);e.NgFormControl=y},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},a=n(5),u=n(40),c=n(2),p=n(118),l=n(117),h=n(113),f=n(120),d=n(119),v=a.CONST_EXPR(new c.Provider(l.NgControl,{useExisting:c.forwardRef(function(){return y})})),y=function(t){function e(e,n,r){t.call(this),this._validators=e,this._asyncValidators=n,this._control=new h.Control,this._added=!1,this.update=new u.EventEmitter,this.valueAccessor=d.selectValueAccessor(this,r)}return r(e,t),e.prototype.ngOnChanges=function(t){this._added||(d.setUpControl(this._control,this),this._control.updateValueAndValidity({emitEvent:!1}),this._added=!0),d.isPropertyUpdated(t,this.viewModel)&&(this._control.updateValue(this.model),this.viewModel=this.model)},Object.defineProperty(e.prototype,"control",{get:function(){return this._control},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"path",{get:function(){return[]},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"validator",{get:function(){return d.composeValidators(this._validators)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"asyncValidator",{get:function(){return d.composeAsyncValidators(this._asyncValidators)},enumerable:!0,configurable:!0}),e.prototype.viewToModelUpdate=function(t){this.viewModel=t,u.ObservableWrapper.callEmit(this.update,t)},e=i([c.Directive({selector:"[ngModel]:not([ngControl]):not([ngFormControl])",bindings:[v],inputs:["model: ngModel"],outputs:["update: ngModelChange"],exportAs:"ngForm"}),s(0,c.Optional()),s(0,c.Self()),s(0,c.Inject(f.NG_VALIDATORS)),s(1,c.Optional()),s(1,c.Self()),s(1,c.Inject(f.NG_ASYNC_VALIDATORS)),s(2,c.Optional()),s(2,c.Self()),s(2,c.Inject(p.NG_VALUE_ACCESSOR)),o("design:paramtypes",[Array,Array,Array])],e)}(l.NgControl);e.NgModel=y},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},a=n(2),u=n(5),c=n(115),p=n(119),l=n(120),h=u.CONST_EXPR(new a.Provider(c.ControlContainer,{useExisting:a.forwardRef(function(){return f})})),f=function(t){function e(e,n,r){t.call(this),this._validators=n,this._asyncValidators=r,this._parent=e}return r(e,t),e.prototype.ngOnInit=function(){this.formDirective.addControlGroup(this)},e.prototype.ngOnDestroy=function(){this.formDirective.removeControlGroup(this)},Object.defineProperty(e.prototype,"control",{get:function(){return this.formDirective.getControlGroup(this)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"path",{get:function(){return p.controlPath(this.name,this._parent)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"formDirective",{get:function(){return this._parent.formDirective},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"validator",{get:function(){return p.composeValidators(this._validators)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"asyncValidator",{get:function(){return p.composeAsyncValidators(this._asyncValidators)},enumerable:!0,configurable:!0}),e=i([a.Directive({selector:"[ngControlGroup]",providers:[h],inputs:["name: ngControlGroup"],exportAs:"ngForm"}),s(0,a.Host()),s(0,a.SkipSelf()),s(1,a.Optional()),s(1,a.Self()),s(1,a.Inject(l.NG_VALIDATORS)),s(2,a.Optional()),s(2,a.Self()),s(2,a.Inject(l.NG_ASYNC_VALIDATORS)),o("design:paramtypes",[c.ControlContainer,Array,Array])],e)}(c.ControlContainer);e.NgControlGroup=f},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},a=n(5),u=n(15),c=n(12),p=n(40),l=n(2),h=n(115),f=n(119),d=n(120),v=a.CONST_EXPR(new l.Provider(h.ControlContainer,{useExisting:l.forwardRef(function(){return y})})),y=function(t){function e(e,n){t.call(this),this._validators=e,this._asyncValidators=n,this.form=null,this.directives=[],this.ngSubmit=new p.EventEmitter}return r(e,t),e.prototype.ngOnChanges=function(t){if(this._checkFormPresent(),u.StringMapWrapper.contains(t,"form")){var e=f.composeValidators(this._validators);this.form.validator=d.Validators.compose([this.form.validator,e]);var n=f.composeAsyncValidators(this._asyncValidators);this.form.asyncValidator=d.Validators.composeAsync([this.form.asyncValidator,n]),this.form.updateValueAndValidity({onlySelf:!0,emitEvent:!1})}this._updateDomValue()},Object.defineProperty(e.prototype,"formDirective",{get:function(){return this},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"control",{get:function(){return this.form},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"path",{get:function(){return[]},enumerable:!0,configurable:!0}),e.prototype.addControl=function(t){var e=this.form.find(t.path);f.setUpControl(e,t),e.updateValueAndValidity({emitEvent:!1}),this.directives.push(t)},e.prototype.getControl=function(t){return this.form.find(t.path)},e.prototype.removeControl=function(t){u.ListWrapper.remove(this.directives,t)},e.prototype.addControlGroup=function(t){var e=this.form.find(t.path);f.setUpControlGroup(e,t),e.updateValueAndValidity({emitEvent:!1})},e.prototype.removeControlGroup=function(t){},e.prototype.getControlGroup=function(t){return this.form.find(t.path)},e.prototype.updateModel=function(t,e){var n=this.form.find(t.path);n.updateValue(e)},e.prototype.onSubmit=function(){return p.ObservableWrapper.callEmit(this.ngSubmit,null),!1},e.prototype._updateDomValue=function(){var t=this;this.directives.forEach(function(e){var n=t.form.find(e.path);e.valueAccessor.writeValue(n.value)})},e.prototype._checkFormPresent=function(){if(a.isBlank(this.form))throw new c.BaseException('ngFormModel expects a form. Please pass one in. Example:
')},e=i([l.Directive({selector:"[ngFormModel]",bindings:[v],inputs:["form: ngFormModel"],host:{"(submit)":"onSubmit()"},outputs:["ngSubmit"],exportAs:"ngForm"}),s(0,l.Optional()),s(0,l.Self()),s(0,l.Inject(d.NG_VALIDATORS)),s(1,l.Optional()),s(1,l.Self()),s(1,l.Inject(d.NG_ASYNC_VALIDATORS)),o("design:paramtypes",[Array,Array])],e)}(h.ControlContainer);e.NgFormModel=y},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},a=n(40),u=n(15),c=n(5),p=n(2),l=n(115),h=n(113),f=n(119),d=n(120),v=c.CONST_EXPR(new p.Provider(l.ControlContainer,{useExisting:p.forwardRef(function(){return y})})),y=function(t){function e(e,n){t.call(this),this.ngSubmit=new a.EventEmitter,this.form=new h.ControlGroup({},null,f.composeValidators(e),f.composeAsyncValidators(n))}return r(e,t),Object.defineProperty(e.prototype,"formDirective",{get:function(){return this},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"control",{get:function(){return this.form},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"path",{get:function(){return[]},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"controls",{get:function(){return this.form.controls},enumerable:!0,configurable:!0}),e.prototype.addControl=function(t){var e=this;a.PromiseWrapper.scheduleMicrotask(function(){var n=e._findContainer(t.path),r=new h.Control;f.setUpControl(r,t),n.addControl(t.name,r),r.updateValueAndValidity({emitEvent:!1})})},e.prototype.getControl=function(t){return this.form.find(t.path)},e.prototype.removeControl=function(t){var e=this;a.PromiseWrapper.scheduleMicrotask(function(){var n=e._findContainer(t.path);c.isPresent(n)&&(n.removeControl(t.name),n.updateValueAndValidity({emitEvent:!1}))})},e.prototype.addControlGroup=function(t){var e=this;a.PromiseWrapper.scheduleMicrotask(function(){var n=e._findContainer(t.path),r=new h.ControlGroup({});f.setUpControlGroup(r,t),n.addControl(t.name,r),r.updateValueAndValidity({emitEvent:!1})})},e.prototype.removeControlGroup=function(t){var e=this;a.PromiseWrapper.scheduleMicrotask(function(){var n=e._findContainer(t.path);c.isPresent(n)&&(n.removeControl(t.name),n.updateValueAndValidity({emitEvent:!1}))})},e.prototype.getControlGroup=function(t){return this.form.find(t.path)},e.prototype.updateModel=function(t,e){var n=this;a.PromiseWrapper.scheduleMicrotask(function(){var r=n.form.find(t.path);r.updateValue(e)})},e.prototype.onSubmit=function(){return a.ObservableWrapper.callEmit(this.ngSubmit,null),!1},e.prototype._findContainer=function(t){return t.pop(),u.ListWrapper.isEmpty(t)?this.form:this.form.find(t)},e=i([p.Directive({selector:"form:not([ngNoForm]):not([ngFormModel]),ngForm,[ngForm]",bindings:[v],host:{"(submit)":"onSubmit()"},outputs:["ngSubmit"],exportAs:"ngForm"}),s(0,p.Optional()),s(0,p.Self()),s(0,p.Inject(d.NG_VALIDATORS)),s(1,p.Optional()),s(1,p.Self()),s(1,p.Inject(d.NG_ASYNC_VALIDATORS)),o("design:paramtypes",[Array,Array])],e)}(l.ControlContainer);e.NgForm=y},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},s=n(2),a=n(117),u=n(5),c=function(){function t(t){this._cd=t}return Object.defineProperty(t.prototype,"ngClassUntouched",{get:function(){return u.isPresent(this._cd.control)?this._cd.control.untouched:!1},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"ngClassTouched",{get:function(){return u.isPresent(this._cd.control)?this._cd.control.touched:!1},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"ngClassPristine",{get:function(){return u.isPresent(this._cd.control)?this._cd.control.pristine:!1},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"ngClassDirty",{get:function(){return u.isPresent(this._cd.control)?this._cd.control.dirty:!1},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"ngClassValid",{get:function(){return u.isPresent(this._cd.control)?this._cd.control.valid:!1},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"ngClassInvalid",{get:function(){return u.isPresent(this._cd.control)?!this._cd.control.valid:!1},enumerable:!0,configurable:!0}),t=r([s.Directive({selector:"[ngControl],[ngModel],[ngFormControl]",host:{"[class.ng-untouched]":"ngClassUntouched","[class.ng-touched]":"ngClassTouched","[class.ng-pristine]":"ngClassPristine","[class.ng-dirty]":"ngClassDirty","[class.ng-valid]":"ngClassValid","[class.ng-invalid]":"ngClassInvalid"}}),o(0,s.Self()),i("design:paramtypes",[a.NgControl])],t)}();e.NgControlStatus=c},function(t,e,n){"use strict";var r=n(5),i=n(116),o=n(127),s=n(128),a=n(129),u=n(130),c=n(131),p=n(121),l=n(123),h=n(122),f=n(125),d=n(132),v=n(124),y=n(134),m=n(116);e.NgControlName=m.NgControlName;var g=n(127);e.NgFormControl=g.NgFormControl;var _=n(128);e.NgModel=_.NgModel;var b=n(129);e.NgControlGroup=b.NgControlGroup;var P=n(130);e.NgFormModel=P.NgFormModel;var E=n(131);e.NgForm=E.NgForm;var w=n(121);e.DefaultValueAccessor=w.DefaultValueAccessor;var C=n(123);e.CheckboxControlValueAccessor=C.CheckboxControlValueAccessor;var R=n(125);e.RadioControlValueAccessor=R.RadioControlValueAccessor,e.RadioButtonState=R.RadioButtonState;var S=n(122);e.NumberValueAccessor=S.NumberValueAccessor;var O=n(132);e.NgControlStatus=O.NgControlStatus;var T=n(124);e.SelectControlValueAccessor=T.SelectControlValueAccessor,e.NgSelectOption=T.NgSelectOption;var x=n(134);e.RequiredValidator=x.RequiredValidator,e.MinLengthValidator=x.MinLengthValidator,e.MaxLengthValidator=x.MaxLengthValidator,e.PatternValidator=x.PatternValidator;var A=n(117);e.NgControl=A.NgControl,e.FORM_DIRECTIVES=r.CONST_EXPR([i.NgControlName,a.NgControlGroup,o.NgFormControl,s.NgModel,u.NgFormModel,c.NgForm,v.NgSelectOption,p.DefaultValueAccessor,h.NumberValueAccessor,l.CheckboxControlValueAccessor,v.SelectControlValueAccessor,f.RadioControlValueAccessor,d.NgControlStatus,y.RequiredValidator,y.MinLengthValidator,y.MaxLengthValidator,y.PatternValidator])},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},s=n(2),a=n(5),u=n(120),c=n(5),p=u.Validators.required,l=a.CONST_EXPR(new s.Provider(u.NG_VALIDATORS,{useValue:p,multi:!0})),h=function(){function t(){}return t=r([s.Directive({selector:"[required][ngControl],[required][ngFormControl],[required][ngModel]",providers:[l]}),i("design:paramtypes",[])],t)}();e.RequiredValidator=h;var f=a.CONST_EXPR(new s.Provider(u.NG_VALIDATORS,{useExisting:s.forwardRef(function(){return d}),multi:!0})),d=function(){function t(t){this._validator=u.Validators.minLength(c.NumberWrapper.parseInt(t,10))}return t.prototype.validate=function(t){return this._validator(t)},t=r([s.Directive({selector:"[minlength][ngControl],[minlength][ngFormControl],[minlength][ngModel]",providers:[f]}),o(0,s.Attribute("minlength")),i("design:paramtypes",[String])],t)}();e.MinLengthValidator=d;var v=a.CONST_EXPR(new s.Provider(u.NG_VALIDATORS,{useExisting:s.forwardRef(function(){return y}),multi:!0})),y=function(){function t(t){this._validator=u.Validators.maxLength(c.NumberWrapper.parseInt(t,10))}return t.prototype.validate=function(t){return this._validator(t)},t=r([s.Directive({selector:"[maxlength][ngControl],[maxlength][ngFormControl],[maxlength][ngModel]",providers:[v]}),o(0,s.Attribute("maxlength")),i("design:paramtypes",[String])],t)}();e.MaxLengthValidator=y;var m=a.CONST_EXPR(new s.Provider(u.NG_VALIDATORS,{useExisting:s.forwardRef(function(){return g}),multi:!0})),g=function(){function t(t){this._validator=u.Validators.pattern(t)}return t.prototype.validate=function(t){return this._validator(t)},t=r([s.Directive({selector:"[pattern][ngControl],[pattern][ngFormControl],[pattern][ngModel]",providers:[m]}),o(0,s.Attribute("pattern")),i("design:paramtypes",[String])],t)}();e.PatternValidator=g},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(2),s=n(15),a=n(5),u=n(113),c=function(){function t(){}return t.prototype.group=function(t,e){void 0===e&&(e=null);var n=this._reduceControls(t),r=a.isPresent(e)?s.StringMapWrapper.get(e,"optionals"):null,i=a.isPresent(e)?s.StringMapWrapper.get(e,"validator"):null,o=a.isPresent(e)?s.StringMapWrapper.get(e,"asyncValidator"):null;return new u.ControlGroup(n,r,i,o)},t.prototype.control=function(t,e,n){return void 0===e&&(e=null),void 0===n&&(n=null),new u.Control(t,e,n)},t.prototype.array=function(t,e,n){var r=this;void 0===e&&(e=null),void 0===n&&(n=null);var i=t.map(function(t){return r._createControl(t)});return new u.ControlArray(i,e,n)},t.prototype._reduceControls=function(t){var e=this,n={};return s.StringMapWrapper.forEach(t,function(t,r){n[r]=e._createControl(t)}),n},t.prototype._createControl=function(t){if(t instanceof u.Control||t instanceof u.ControlGroup||t instanceof u.ControlArray)return t;if(a.isArray(t)){var e=t[0],n=t.length>1?t[1]:null,r=t.length>2?t[2]:null;return this.control(e,n,r)}return this.control(t)},t=r([o.Injectable(),i("design:paramtypes",[])],t)}();e.FormBuilder=c},function(t,e,n){"use strict";var r=n(5),i=n(112),o=n(102);e.COMMON_DIRECTIVES=r.CONST_EXPR([o.CORE_DIRECTIVES,i.FORM_DIRECTIVES])},function(t,e,n){"use strict";function r(t){for(var n in t)e.hasOwnProperty(n)||(e[n]=t[n])}var i=n(138);e.PLATFORM_DIRECTIVES=i.PLATFORM_DIRECTIVES,e.PLATFORM_PIPES=i.PLATFORM_PIPES,e.COMPILER_PROVIDERS=i.COMPILER_PROVIDERS,e.TEMPLATE_TRANSFORMS=i.TEMPLATE_TRANSFORMS,e.CompilerConfig=i.CompilerConfig,e.RenderTypes=i.RenderTypes,e.UrlResolver=i.UrlResolver,e.DEFAULT_PACKAGE_URL_PROVIDER=i.DEFAULT_PACKAGE_URL_PROVIDER,e.createOfflineCompileUrlResolver=i.createOfflineCompileUrlResolver,e.XHR=i.XHR,e.ViewResolver=i.ViewResolver,e.DirectiveResolver=i.DirectiveResolver,e.PipeResolver=i.PipeResolver,e.SourceModule=i.SourceModule,e.NormalizedComponentWithViewDirectives=i.NormalizedComponentWithViewDirectives,e.OfflineCompiler=i.OfflineCompiler,e.CompileMetadataWithIdentifier=i.CompileMetadataWithIdentifier,e.CompileMetadataWithType=i.CompileMetadataWithType,e.CompileIdentifierMetadata=i.CompileIdentifierMetadata,e.CompileDiDependencyMetadata=i.CompileDiDependencyMetadata,e.CompileProviderMetadata=i.CompileProviderMetadata,e.CompileFactoryMetadata=i.CompileFactoryMetadata,e.CompileTokenMetadata=i.CompileTokenMetadata,e.CompileTypeMetadata=i.CompileTypeMetadata,e.CompileQueryMetadata=i.CompileQueryMetadata,e.CompileTemplateMetadata=i.CompileTemplateMetadata,e.CompileDirectiveMetadata=i.CompileDirectiveMetadata,e.CompilePipeMetadata=i.CompilePipeMetadata,r(n(139))},function(t,e,n){"use strict";function r(t){for(var n in t)e.hasOwnProperty(n)||(e[n]=t[n])}function i(){return new b.CompilerConfig(h.assertionsEnabled(),!1,!0)}var o=n(84);e.PLATFORM_DIRECTIVES=o.PLATFORM_DIRECTIVES,e.PLATFORM_PIPES=o.PLATFORM_PIPES,r(n(139));var s=n(140);e.TEMPLATE_TRANSFORMS=s.TEMPLATE_TRANSFORMS;var a=n(162);e.CompilerConfig=a.CompilerConfig,e.RenderTypes=a.RenderTypes,r(n(155)),r(n(163));var u=n(165);e.RuntimeCompiler=u.RuntimeCompiler,r(n(157)),r(n(184));var c=n(188);e.ViewResolver=c.ViewResolver;var p=n(186); -e.DirectiveResolver=p.DirectiveResolver;var l=n(187);e.PipeResolver=l.PipeResolver;var h=n(5),f=n(6),d=n(140),v=n(144),y=n(183),m=n(185),g=n(166),_=n(168),b=n(162),P=n(64),E=n(165),w=n(150),C=n(199),R=n(157),S=n(142),O=n(143),T=n(188),x=n(186),A=n(187);e.COMPILER_PROVIDERS=h.CONST_EXPR([O.Lexer,S.Parser,v.HtmlParser,d.TemplateParser,y.DirectiveNormalizer,m.RuntimeMetadataResolver,R.DEFAULT_PACKAGE_URL_PROVIDER,g.StyleCompiler,_.ViewCompiler,new f.Provider(b.CompilerConfig,{useFactory:i,deps:[]}),E.RuntimeCompiler,new f.Provider(P.ComponentResolver,{useExisting:E.RuntimeCompiler}),C.DomElementSchemaRegistry,new f.Provider(w.ElementSchemaRegistry,{useExisting:C.DomElementSchemaRegistry}),R.UrlResolver,T.ViewResolver,x.DirectiveResolver,A.PipeResolver])},function(t,e,n){"use strict";function r(t,e,n){void 0===n&&(n=null);var r=[];return e.forEach(function(e){var o=e.visit(t,n);i.isPresent(o)&&r.push(o)}),r}var i=n(5),o=function(){function t(t,e,n){this.value=t,this.ngContentIndex=e,this.sourceSpan=n}return t.prototype.visit=function(t,e){return t.visitText(this,e)},t}();e.TextAst=o;var s=function(){function t(t,e,n){this.value=t,this.ngContentIndex=e,this.sourceSpan=n}return t.prototype.visit=function(t,e){return t.visitBoundText(this,e)},t}();e.BoundTextAst=s;var a=function(){function t(t,e,n){this.name=t,this.value=e,this.sourceSpan=n}return t.prototype.visit=function(t,e){return t.visitAttr(this,e)},t}();e.AttrAst=a;var u=function(){function t(t,e,n,r,i){this.name=t,this.type=e,this.value=n,this.unit=r,this.sourceSpan=i}return t.prototype.visit=function(t,e){return t.visitElementProperty(this,e)},t}();e.BoundElementPropertyAst=u;var c=function(){function t(t,e,n,r){this.name=t,this.target=e,this.handler=n,this.sourceSpan=r}return t.prototype.visit=function(t,e){return t.visitEvent(this,e)},Object.defineProperty(t.prototype,"fullName",{get:function(){return i.isPresent(this.target)?this.target+":"+this.name:this.name},enumerable:!0,configurable:!0}),t}();e.BoundEventAst=c;var p=function(){function t(t,e,n){this.name=t,this.value=e,this.sourceSpan=n}return t.prototype.visit=function(t,e){return t.visitVariable(this,e)},t}();e.VariableAst=p;var l=function(){function t(t,e,n,r,i,o,s,a,u,c,p){this.name=t,this.attrs=e,this.inputs=n,this.outputs=r,this.exportAsVars=i,this.directives=o,this.providers=s,this.hasViewContainer=a,this.children=u,this.ngContentIndex=c,this.sourceSpan=p}return t.prototype.visit=function(t,e){return t.visitElement(this,e)},t.prototype.isBound=function(){return this.inputs.length>0||this.outputs.length>0||this.exportAsVars.length>0||this.directives.length>0},t.prototype.getComponent=function(){for(var t=0;t0;n||e.push(t)}),e}var s=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},a=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},u=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},c=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},p=n(15),l=n(5),h=n(2),f=n(5),d=n(12),v=n(141),y=n(142),m=n(144),g=n(148),_=n(147),b=n(66),P=n(139),E=n(149),w=n(150),C=n(151),R=n(152),S=n(145),O=n(153),T=n(154),x=/^(?:(?:(?:(bind-)|(var-|#)|(on-)|(bindon-))(.+))|\[\(([^\)]+)\)\]|\[([^\]]+)\]|\(([^\)]+)\))$/g,A="template",I="template",M="*",k="class",N=".",D="attr",V="class",j="style",L=E.CssSelector.parse("*")[0];e.TEMPLATE_TRANSFORMS=f.CONST_EXPR(new h.OpaqueToken("TemplateTransforms"));var B=function(t){function e(e,n){t.call(this,n,e)}return s(e,t),e}(_.ParseError);e.TemplateParseError=B;var F=function(){function t(t,e){this.templateAst=t,this.errors=e}return t}();e.TemplateParseResult=F;var U=function(){function t(t,e,n,r){this._exprParser=t,this._schemaRegistry=e,this._htmlParser=n,this.transforms=r}return t.prototype.parse=function(t,e,n,r,i){var o=this.tryParse(t,e,n,r,i);if(l.isPresent(o.errors)){var s=o.errors.join("\n");throw new d.BaseException("Template parse errors:\n"+s)}return o.templateAst},t.prototype.tryParse=function(t,e,n,r,i){var s,a=this._htmlParser.parse(e,i),u=a.errors;if(a.rootNodes.length>0){var c=o(n),p=o(r),h=new T.ProviderViewContext(t,a.rootNodes[0].sourceSpan),f=new W(h,c,p,this._exprParser,this._schemaRegistry);s=S.htmlVisitAll(f,a.rootNodes,G),u=u.concat(f.errors).concat(h.errors)}else s=[];return u.length>0?new F(s,u):(l.isPresent(this.transforms)&&this.transforms.forEach(function(t){s=P.templateVisitAll(t,s)}),new F(s))},t=a([h.Injectable(),c(3,h.Optional()),c(3,h.Inject(e.TEMPLATE_TRANSFORMS)),u("design:paramtypes",[y.Parser,w.ElementSchemaRegistry,m.HtmlParser,Array])],t)}();e.TemplateParser=U;var W=function(){function t(t,e,n,r,i){var o=this;this.providerViewContext=t,this._exprParser=r,this._schemaRegistry=i,this.errors=[],this.directivesIndex=new Map,this.ngContentCount=0,this.selectorMatcher=new E.SelectorMatcher,p.ListWrapper.forEachWithIndex(e,function(t,e){var n=E.CssSelector.parse(t.selector);o.selectorMatcher.addSelectables(n,t),o.directivesIndex.set(t,e)}),this.pipesByName=new Map,n.forEach(function(t){return o.pipesByName.set(t.name,t)})}return t.prototype._reportError=function(t,e){this.errors.push(new B(t,e))},t.prototype._parseInterpolation=function(t,e){var n=e.start.toString();try{var r=this._exprParser.parseInterpolation(t,n);if(this._checkPipes(r,e),l.isPresent(r)&&r.ast.expressions.length>b.MAX_INTERPOLATION_VALUES)throw new d.BaseException("Only support at most "+b.MAX_INTERPOLATION_VALUES+" interpolation values!");return r}catch(i){return this._reportError(""+i,e),this._exprParser.wrapLiteralPrimitive("ERROR",n)}},t.prototype._parseAction=function(t,e){var n=e.start.toString();try{var r=this._exprParser.parseAction(t,n);return this._checkPipes(r,e),r}catch(i){return this._reportError(""+i,e),this._exprParser.wrapLiteralPrimitive("ERROR",n)}},t.prototype._parseBinding=function(t,e){var n=e.start.toString();try{var r=this._exprParser.parseBinding(t,n);return this._checkPipes(r,e),r}catch(i){return this._reportError(""+i,e),this._exprParser.wrapLiteralPrimitive("ERROR",n)}},t.prototype._parseTemplateBindings=function(t,e){var n=this,r=e.start.toString();try{var i=this._exprParser.parseTemplateBindings(t,r);return i.forEach(function(t){l.isPresent(t.expression)&&n._checkPipes(t.expression,e)}),i}catch(o){return this._reportError(""+o,e),[]}},t.prototype._checkPipes=function(t,e){var n=this;if(l.isPresent(t)){var r=new K;t.visit(r),r.pipes.forEach(function(t){n.pipesByName.has(t)||n._reportError("The pipe '"+t+"' could not be found",e)})}},t.prototype.visitExpansion=function(t,e){return null},t.prototype.visitExpansionCase=function(t,e){return null},t.prototype.visitText=function(t,e){var n=e.findNgContentIndex(L),r=this._parseInterpolation(t.value,t.sourceSpan);return l.isPresent(r)?new P.BoundTextAst(r,n,t.sourceSpan):new P.TextAst(t.value,n,t.sourceSpan)},t.prototype.visitAttr=function(t,e){return new P.AttrAst(t.name,t.value,t.sourceSpan)},t.prototype.visitComment=function(t,e){return null},t.prototype.visitElement=function(t,e){var n=this,r=t.name,o=C.preparseElement(t);if(o.type===C.PreparsedElementType.SCRIPT||o.type===C.PreparsedElementType.STYLE)return null;if(o.type===C.PreparsedElementType.STYLESHEET&&R.isStyleUrlResolvable(o.hrefAttr))return null;var s=[],a=[],u=[],c=[],p=[],h=[],f=[],d=!1,v=[];t.attrs.forEach(function(t){var e=n._parseAttr(t,s,a,c,u),r=n._parseInlineTemplateBinding(t,f,p,h);e||r||(v.push(n.visitAttr(t,null)),s.push([t.name,t.value])),r&&(d=!0)});var y=g.splitNsName(r.toLowerCase())[1],m=y==A,_=i(r,s),b=this._parseDirectives(this.selectorMatcher,_),w=this._createDirectiveAsts(t.name,b,a,m?[]:u,t.sourceSpan),O=this._createElementPropertyAsts(t.name,a,w),x=e.isTemplateElement||d,I=new T.ProviderElementContext(this.providerViewContext,e.providerContext,x,w,v,u,t.sourceSpan),M=S.htmlVisitAll(o.nonBindable?z:this,t.children,q.create(m,w,m?e.providerContext:I));I.afterElement();var k,N=l.isPresent(o.projectAs)?E.CssSelector.parse(o.projectAs)[0]:_,D=e.findNgContentIndex(N);if(o.type===C.PreparsedElementType.NG_CONTENT)l.isPresent(t.children)&&t.children.length>0&&this._reportError(" element cannot have content. must be immediately followed by ",t.sourceSpan),k=new P.NgContentAst(this.ngContentCount++,d?null:D,t.sourceSpan);else if(m)this._assertAllEventsPublishedByDirectives(w,c),this._assertNoComponentsNorElementBindingsOnTemplate(w,O,t.sourceSpan),k=new P.EmbeddedTemplateAst(v,c,u,I.transformedDirectiveAsts,I.transformProviders,I.transformedHasViewContainer,M,d?null:D,t.sourceSpan);else{this._assertOnlyOneComponent(w,t.sourceSpan);var V=u.filter(function(t){return 0===t.value.length}),j=d?null:e.findNgContentIndex(N);k=new P.ElementAst(r,v,O,c,V,I.transformedDirectiveAsts,I.transformProviders,I.transformedHasViewContainer,M,d?null:j,t.sourceSpan)}if(d){var L=i(A,f),B=this._parseDirectives(this.selectorMatcher,L),F=this._createDirectiveAsts(t.name,B,p,[],t.sourceSpan),U=this._createElementPropertyAsts(t.name,p,F);this._assertNoComponentsNorElementBindingsOnTemplate(F,U,t.sourceSpan);var W=new T.ProviderElementContext(this.providerViewContext,e.providerContext,e.isTemplateElement,F,[],h,t.sourceSpan);W.afterElement(),k=new P.EmbeddedTemplateAst([],[],h,W.transformedDirectiveAsts,W.transformProviders,W.transformedHasViewContainer,[k],D,t.sourceSpan)}return k},t.prototype._parseInlineTemplateBinding=function(t,e,n,r){var i=null;if(t.name==I)i=t.value;else if(t.name.startsWith(M)){var o=t.name.substring(M.length);i=0==t.value.length?o:o+" "+t.value}if(l.isPresent(i)){for(var s=this._parseTemplateBindings(i,t.sourceSpan),a=0;a-1&&this._reportError('"-" is not allowed in variable names',n),r.push(new P.VariableAst(t,e,n))},t.prototype._parseProperty=function(t,e,n,r,i){this._parsePropertyAst(t,this._parseBinding(e,n),n,r,i)},t.prototype._parsePropertyInterpolation=function(t,e,n,r,i){var o=this._parseInterpolation(e,n);return l.isPresent(o)?(this._parsePropertyAst(t,o,n,r,i),!0):!1},t.prototype._parsePropertyAst=function(t,e,n,r,i){r.push([t,e.source]),i.push(new X(t,e,!1,n))},t.prototype._parseAssignmentEvent=function(t,e,n,r,i){this._parseEvent(t+"Change",e+"=$event",n,r,i)},t.prototype._parseEvent=function(t,e,n,r,i){var o=O.splitAtColon(t,[null,t]),s=o[0],a=o[1],u=this._parseAction(e,n);r.push([t,u.source]),i.push(new P.BoundEventAst(a,s,u,n))},t.prototype._parseLiteralAttr=function(t,e,n,r){r.push(new X(t,this._exprParser.wrapLiteralPrimitive(e,""),!0,n))},t.prototype._parseDirectives=function(t,e){var n=this,r=[];return t.match(e,function(t,e){r.push(e)}),p.ListWrapper.sort(r,function(t,e){var r=t.isComponent,i=e.isComponent;return r&&!i?-1:!r&&i?1:n.directivesIndex.get(t)-n.directivesIndex.get(e)}),r},t.prototype._createDirectiveAsts=function(t,e,n,r,i){var o=this,s=new Set,a=e.map(function(e){var a=[],u=[],c=[];o._createDirectiveHostPropertyAsts(t,e.hostProperties,i,a),o._createDirectiveHostEventAsts(e.hostListeners,i,u),o._createDirectivePropertyAsts(e.inputs,n,c);var p=[];return r.forEach(function(t){(0===t.value.length&&e.isComponent||e.exportAs==t.value)&&(p.push(t),s.add(t.name))}),new P.DirectiveAst(e,c,a,u,p,i)});return r.forEach(function(t){t.value.length>0&&!p.SetWrapper.has(s,t.name)&&o._reportError('There is no directive with "exportAs" set to "'+t.value+'"',t.sourceSpan)}),a},t.prototype._createDirectiveHostPropertyAsts=function(t,e,n,r){var i=this;l.isPresent(e)&&p.StringMapWrapper.forEach(e,function(e,o){var s=i._parseBinding(e,n);r.push(i._createElementPropertyAst(t,o,s,n))})},t.prototype._createDirectiveHostEventAsts=function(t,e,n){var r=this;l.isPresent(t)&&p.StringMapWrapper.forEach(t,function(t,i){r._parseEvent(i,t,e,[],n)})},t.prototype._createDirectivePropertyAsts=function(t,e,n){if(l.isPresent(t)){var r=new Map;e.forEach(function(t){var e=r.get(t.name);(l.isBlank(e)||e.isLiteral)&&r.set(t.name,t)}),p.StringMapWrapper.forEach(t,function(t,e){var i=r.get(t);l.isPresent(i)&&n.push(new P.BoundDirectivePropertyAst(e,i.name,i.expression,i.sourceSpan))})}},t.prototype._createElementPropertyAsts=function(t,e,n){var r=this,i=[],o=new Map;return n.forEach(function(t){t.inputs.forEach(function(t){o.set(t.templateName,t)})}),e.forEach(function(e){!e.isLiteral&&l.isBlank(o.get(e.name))&&i.push(r._createElementPropertyAst(t,e.name,e.expression,e.sourceSpan))}),i},t.prototype._createElementPropertyAst=function(t,e,n,r){var i,o,s=null,a=e.split(N);if(1===a.length)o=this._schemaRegistry.getMappedPropName(a[0]),i=P.PropertyBindingType.Property,this._schemaRegistry.hasProperty(t,o)||this._reportError("Can't bind to '"+o+"' since it isn't a known native property",r);else if(a[0]==D){o=a[1];var u=o.indexOf(":");if(u>-1){var c=o.substring(0,u),p=o.substring(u+1);o=g.mergeNsAndName(c,p)}i=P.PropertyBindingType.Attribute}else a[0]==V?(o=a[1],i=P.PropertyBindingType.Class):a[0]==j?(s=a.length>2?a[2]:null,o=a[1],i=P.PropertyBindingType.Style):(this._reportError("Invalid property name '"+e+"'",r),i=null);return new P.BoundElementPropertyAst(o,i,n,s,r)},t.prototype._findComponentDirectiveNames=function(t){var e=[];return t.forEach(function(t){var n=t.directive.type.name;t.directive.isComponent&&e.push(n)}),e},t.prototype._assertOnlyOneComponent=function(t,e){var n=this._findComponentDirectiveNames(t);n.length>1&&this._reportError("More than one component: "+n.join(","),e)},t.prototype._assertNoComponentsNorElementBindingsOnTemplate=function(t,e,n){var r=this,i=this._findComponentDirectiveNames(t);i.length>0&&this._reportError("Components on an embedded template: "+i.join(","),n),e.forEach(function(t){r._reportError("Property binding "+t.name+" not used by any directive on an embedded template",n)})},t.prototype._assertAllEventsPublishedByDirectives=function(t,e){var n=this,r=new Set;t.forEach(function(t){p.StringMapWrapper.forEach(t.directive.outputs,function(t,e){r.add(t)})}),e.forEach(function(t){(l.isPresent(t.target)||!p.SetWrapper.has(r,t.name))&&n._reportError("Event binding "+t.fullName+" not emitted by any directive on an embedded template",t.sourceSpan)})},t}(),H=function(){function t(){}return t.prototype.visitElement=function(t,e){var n=C.preparseElement(t);if(n.type===C.PreparsedElementType.SCRIPT||n.type===C.PreparsedElementType.STYLE||n.type===C.PreparsedElementType.STYLESHEET)return null;var r=t.attrs.map(function(t){return[t.name,t.value]}),o=i(t.name,r),s=e.findNgContentIndex(o),a=S.htmlVisitAll(this,t.children,G);return new P.ElementAst(t.name,S.htmlVisitAll(this,t.attrs),[],[],[],[],[],!1,a,s,t.sourceSpan)},t.prototype.visitComment=function(t,e){return null},t.prototype.visitAttr=function(t,e){return new P.AttrAst(t.name,t.value,t.sourceSpan)},t.prototype.visitText=function(t,e){var n=e.findNgContentIndex(L);return new P.TextAst(t.value,n,t.sourceSpan)},t.prototype.visitExpansion=function(t,e){return t},t.prototype.visitExpansionCase=function(t,e){return t},t}(),X=function(){function t(t,e,n,r){this.name=t,this.expression=e,this.isLiteral=n,this.sourceSpan=r}return t}();e.splitClasses=r;var q=function(){function t(t,e,n,r){this.isTemplateElement=t,this._ngContentIndexMatcher=e,this._wildcardNgContentIndex=n,this.providerContext=r}return t.create=function(e,n,r){var i=new E.SelectorMatcher,o=null;if(n.length>0&&n[0].directive.isComponent)for(var s=n[0].directive.template.ngContentSelectors,a=0;a0?e[0]:null},t}(),G=new q(!0,new E.SelectorMatcher,null,null),z=new H,K=function(t){function e(){t.apply(this,arguments),this.pipes=new Set}return s(e,t),e.prototype.visitPipe=function(t,e){return this.pipes.add(t.name),t.exp.visit(this),this.visitAll(t.args,e),null},e}(v.RecursiveAstVisitor);e.PipeCollector=K},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(15),o=function(){function t(){}return t.prototype.visit=function(t,e){return void 0===e&&(e=null),null},t.prototype.toString=function(){return"AST"},t}();e.AST=o;var s=function(t){function e(e,n,r){t.call(this),this.prefix=e,this.uninterpretedExpression=n,this.location=r}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitQuote(this,e)},e.prototype.toString=function(){return"Quote"},e}(o);e.Quote=s;var a=function(t){function e(){t.apply(this,arguments)}return r(e,t),e.prototype.visit=function(t,e){void 0===e&&(e=null)},e}(o);e.EmptyExpr=a;var u=function(t){function e(){t.apply(this,arguments)}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitImplicitReceiver(this,e)},e}(o);e.ImplicitReceiver=u;var c=function(t){function e(e){t.call(this),this.expressions=e}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitChain(this,e)},e}(o);e.Chain=c;var p=function(t){function e(e,n,r){t.call(this),this.condition=e,this.trueExp=n,this.falseExp=r}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitConditional(this,e)},e}(o);e.Conditional=p;var l=function(t){function e(e,n){t.call(this),this.receiver=e,this.name=n}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitPropertyRead(this,e)},e}(o);e.PropertyRead=l;var h=function(t){function e(e,n,r){t.call(this),this.receiver=e,this.name=n,this.value=r}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitPropertyWrite(this,e)},e}(o);e.PropertyWrite=h;var f=function(t){function e(e,n){t.call(this),this.receiver=e,this.name=n}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitSafePropertyRead(this,e)},e}(o);e.SafePropertyRead=f;var d=function(t){function e(e,n){t.call(this),this.obj=e,this.key=n}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitKeyedRead(this,e)},e}(o);e.KeyedRead=d;var v=function(t){function e(e,n,r){t.call(this),this.obj=e,this.key=n,this.value=r}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitKeyedWrite(this,e)},e}(o);e.KeyedWrite=v;var y=function(t){function e(e,n,r){t.call(this),this.exp=e,this.name=n,this.args=r}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitPipe(this,e)},e}(o);e.BindingPipe=y;var m=function(t){function e(e){t.call(this),this.value=e}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitLiteralPrimitive(this,e)},e}(o);e.LiteralPrimitive=m;var g=function(t){function e(e){t.call(this),this.expressions=e}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitLiteralArray(this,e)},e}(o);e.LiteralArray=g;var _=function(t){function e(e,n){t.call(this),this.keys=e,this.values=n}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitLiteralMap(this,e)},e}(o);e.LiteralMap=_;var b=function(t){function e(e,n){t.call(this),this.strings=e,this.expressions=n}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitInterpolation(this,e)},e}(o);e.Interpolation=b;var P=function(t){function e(e,n,r){t.call(this),this.operation=e,this.left=n,this.right=r}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitBinary(this,e)},e}(o);e.Binary=P;var E=function(t){function e(e){t.call(this),this.expression=e}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitPrefixNot(this,e)},e}(o);e.PrefixNot=E;var w=function(t){function e(e,n,r){t.call(this),this.receiver=e,this.name=n,this.args=r}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitMethodCall(this,e)},e}(o);e.MethodCall=w;var C=function(t){function e(e,n,r){t.call(this),this.receiver=e,this.name=n,this.args=r}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitSafeMethodCall(this,e)},e}(o);e.SafeMethodCall=C;var R=function(t){function e(e,n){t.call(this),this.target=e,this.args=n}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),t.visitFunctionCall(this,e)},e}(o);e.FunctionCall=R;var S=function(t){function e(e,n,r){t.call(this),this.ast=e,this.source=n,this.location=r}return r(e,t),e.prototype.visit=function(t,e){return void 0===e&&(e=null),this.ast.visit(t,e)},e.prototype.toString=function(){return this.source+" in "+this.location},e}(o);e.ASTWithSource=S;var O=function(){function t(t,e,n,r){this.key=t,this.keyIsVar=e,this.name=n,this.expression=r}return t}();e.TemplateBinding=O;var T=function(){function t(){}return t.prototype.visitBinary=function(t,e){return t.left.visit(this),t.right.visit(this),null},t.prototype.visitChain=function(t,e){return this.visitAll(t.expressions,e)},t.prototype.visitConditional=function(t,e){return t.condition.visit(this),t.trueExp.visit(this),t.falseExp.visit(this),null},t.prototype.visitPipe=function(t,e){return t.exp.visit(this),this.visitAll(t.args,e),null},t.prototype.visitFunctionCall=function(t,e){return t.target.visit(this),this.visitAll(t.args,e),null},t.prototype.visitImplicitReceiver=function(t,e){return null},t.prototype.visitInterpolation=function(t,e){return this.visitAll(t.expressions,e)},t.prototype.visitKeyedRead=function(t,e){return t.obj.visit(this),t.key.visit(this),null},t.prototype.visitKeyedWrite=function(t,e){return t.obj.visit(this),t.key.visit(this),t.value.visit(this),null},t.prototype.visitLiteralArray=function(t,e){return this.visitAll(t.expressions,e)},t.prototype.visitLiteralMap=function(t,e){return this.visitAll(t.values,e)},t.prototype.visitLiteralPrimitive=function(t,e){return null},t.prototype.visitMethodCall=function(t,e){return t.receiver.visit(this),this.visitAll(t.args,e)},t.prototype.visitPrefixNot=function(t,e){return t.expression.visit(this),null},t.prototype.visitPropertyRead=function(t,e){return t.receiver.visit(this),null},t.prototype.visitPropertyWrite=function(t,e){return t.receiver.visit(this),t.value.visit(this),null},t.prototype.visitSafePropertyRead=function(t,e){return t.receiver.visit(this),null},t.prototype.visitSafeMethodCall=function(t,e){return t.receiver.visit(this),this.visitAll(t.args,e)},t.prototype.visitAll=function(t,e){var n=this;return t.forEach(function(t){return t.visit(n,e)}),null},t.prototype.visitQuote=function(t,e){return null},t}();e.RecursiveAstVisitor=T;var x=function(){function t(){}return t.prototype.visitImplicitReceiver=function(t,e){return t},t.prototype.visitInterpolation=function(t,e){return new b(t.strings,this.visitAll(t.expressions))},t.prototype.visitLiteralPrimitive=function(t,e){return new m(t.value)},t.prototype.visitPropertyRead=function(t,e){return new l(t.receiver.visit(this),t.name)},t.prototype.visitPropertyWrite=function(t,e){return new h(t.receiver.visit(this),t.name,t.value)},t.prototype.visitSafePropertyRead=function(t,e){return new f(t.receiver.visit(this),t.name)},t.prototype.visitMethodCall=function(t,e){return new w(t.receiver.visit(this),t.name,this.visitAll(t.args))},t.prototype.visitSafeMethodCall=function(t,e){return new C(t.receiver.visit(this),t.name,this.visitAll(t.args))},t.prototype.visitFunctionCall=function(t,e){return new R(t.target.visit(this),this.visitAll(t.args))},t.prototype.visitLiteralArray=function(t,e){return new g(this.visitAll(t.expressions))},t.prototype.visitLiteralMap=function(t,e){return new _(t.keys,this.visitAll(t.values))},t.prototype.visitBinary=function(t,e){return new P(t.operation,t.left.visit(this),t.right.visit(this))},t.prototype.visitPrefixNot=function(t,e){return new E(t.expression.visit(this))},t.prototype.visitConditional=function(t,e){return new p(t.condition.visit(this),t.trueExp.visit(this),t.falseExp.visit(this))},t.prototype.visitPipe=function(t,e){return new y(t.exp.visit(this),t.name,this.visitAll(t.args))},t.prototype.visitKeyedRead=function(t,e){return new d(t.obj.visit(this),t.key.visit(this))},t.prototype.visitKeyedWrite=function(t,e){return new v(t.obj.visit(this),t.key.visit(this),t.value.visit(this))},t.prototype.visitAll=function(t){for(var e=i.ListWrapper.createFixedSize(t.length),n=0;no?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(8),a=n(5),u=n(12),c=n(15),p=n(143),l=n(141),h=new l.ImplicitReceiver,f=/\{\{([\s\S]*?)\}\}/g,d=function(t){function e(e,n,r,i){t.call(this,"Parser Error: "+e+" "+r+" ["+n+"] in "+i)}return r(e,t),e}(u.BaseException),v=function(){function t(t,e){this.strings=t,this.expressions=e}return t}();e.SplitInterpolation=v;var y=function(){function t(t){this._lexer=t}return t.prototype.parseAction=function(t,e){this._checkNoInterpolation(t,e);var n=this._lexer.tokenize(this._stripComments(t)),r=new m(t,e,n,!0).parseChain();return new l.ASTWithSource(r,t,e)},t.prototype.parseBinding=function(t,e){var n=this._parseBindingAst(t,e);return new l.ASTWithSource(n,t,e)},t.prototype.parseSimpleBinding=function(t,e){var n=this._parseBindingAst(t,e);if(!g.check(n))throw new d("Host binding expression can only contain field access and constants",t,e);return new l.ASTWithSource(n,t,e)},t.prototype._parseBindingAst=function(t,e){var n=this._parseQuote(t,e);if(a.isPresent(n))return n;this._checkNoInterpolation(t,e);var r=this._lexer.tokenize(this._stripComments(t));return new m(t,e,r,!1).parseChain()},t.prototype._parseQuote=function(t,e){if(a.isBlank(t))return null;var n=t.indexOf(":");if(-1==n)return null;var r=t.substring(0,n).trim();if(!p.isIdentifier(r))return null;var i=t.substring(n+1);return new l.Quote(r,i,e)},t.prototype.parseTemplateBindings=function(t,e){var n=this._lexer.tokenize(t);return new m(t,e,n,!1).parseTemplateBindings()},t.prototype.parseInterpolation=function(t,e){var n=this.splitInterpolation(t,e);if(null==n)return null;for(var r=[],i=0;i0))throw new d("Blank expressions are not allowed in interpolated strings",t,"at column "+this._findInterpolationErrorColumn(n,o)+" in",e);i.push(s)}}return new v(r,i)},t.prototype.wrapLiteralPrimitive=function(t,e){return new l.ASTWithSource(new l.LiteralPrimitive(t),t,e)},t.prototype._stripComments=function(t){var e=this._commentStart(t);return a.isPresent(e)?t.substring(0,e).trim():t},t.prototype._commentStart=function(t){for(var e=null,n=0;n1)throw new d("Got interpolation ({{}}) where expression was expected",t,"at column "+this._findInterpolationErrorColumn(n,1)+" in",e)},t.prototype._findInterpolationErrorColumn=function(t,e){for(var n="",r=0;e>r;r++)n+=r%2===0?t[r]:"{{"+t[r]+"}}";return n.length},t=i([s.Injectable(),o("design:paramtypes",[p.Lexer])],t)}();e.Parser=y;var m=function(){function t(t,e,n,r){this.input=t,this.location=e,this.tokens=n,this.parseAction=r,this.index=0}return t.prototype.peek=function(t){var e=this.index+t;return e"))t=new l.Binary(">",t,this.parseAdditive());else if(this.optionalOperator("<="))t=new l.Binary("<=",t,this.parseAdditive());else{if(!this.optionalOperator(">="))return t;t=new l.Binary(">=",t,this.parseAdditive())}},t.prototype.parseAdditive=function(){for(var t=this.parseMultiplicative();;)if(this.optionalOperator("+"))t=new l.Binary("+",t,this.parseMultiplicative());else{if(!this.optionalOperator("-"))return t;t=new l.Binary("-",t,this.parseMultiplicative())}},t.prototype.parseMultiplicative=function(){for(var t=this.parsePrefix();;)if(this.optionalOperator("*"))t=new l.Binary("*",t,this.parsePrefix());else if(this.optionalOperator("%"))t=new l.Binary("%",t,this.parsePrefix());else{if(!this.optionalOperator("/"))return t;t=new l.Binary("/",t,this.parsePrefix())}},t.prototype.parsePrefix=function(){return this.optionalOperator("+")?this.parsePrefix():this.optionalOperator("-")?new l.Binary("-",new l.LiteralPrimitive(0),this.parsePrefix()):this.optionalOperator("!")?new l.PrefixNot(this.parsePrefix()):this.parseCallChain()},t.prototype.parseCallChain=function(){for(var t=this.parsePrimary();;)if(this.optionalCharacter(p.$PERIOD))t=this.parseAccessMemberOrMethodCall(t,!1);else if(this.optionalOperator("?."))t=this.parseAccessMemberOrMethodCall(t,!0);else if(this.optionalCharacter(p.$LBRACKET)){var e=this.parsePipe();if(this.expectCharacter(p.$RBRACKET),this.optionalOperator("=")){var n=this.parseConditional();t=new l.KeyedWrite(t,e,n)}else t=new l.KeyedRead(t,e)}else{if(!this.optionalCharacter(p.$LPAREN))return t;var r=this.parseCallArguments();this.expectCharacter(p.$RPAREN),t=new l.FunctionCall(t,r)}},t.prototype.parsePrimary=function(){if(this.optionalCharacter(p.$LPAREN)){var t=this.parsePipe();return this.expectCharacter(p.$RPAREN),t}if(this.next.isKeywordNull()||this.next.isKeywordUndefined())return this.advance(),new l.LiteralPrimitive(null);if(this.next.isKeywordTrue())return this.advance(),new l.LiteralPrimitive(!0);if(this.next.isKeywordFalse())return this.advance(),new l.LiteralPrimitive(!1);if(this.optionalCharacter(p.$LBRACKET)){var e=this.parseExpressionList(p.$RBRACKET);return this.expectCharacter(p.$RBRACKET),new l.LiteralArray(e)}if(this.next.isCharacter(p.$LBRACE))return this.parseLiteralMap();if(this.next.isIdentifier())return this.parseAccessMemberOrMethodCall(h,!1);if(this.next.isNumber()){var n=this.next.toNumber();return this.advance(),new l.LiteralPrimitive(n)}if(this.next.isString()){var r=this.next.toString();return this.advance(),new l.LiteralPrimitive(r)}throw this.index>=this.tokens.length?this.error("Unexpected end of expression: "+this.input):this.error("Unexpected token "+this.next),new u.BaseException("Fell through all cases in parsePrimary")},t.prototype.parseExpressionList=function(t){var e=[];if(!this.next.isCharacter(t))do e.push(this.parsePipe());while(this.optionalCharacter(p.$COMMA));return e},t.prototype.parseLiteralMap=function(){var t=[],e=[];if(this.expectCharacter(p.$LBRACE),!this.optionalCharacter(p.$RBRACE)){do{var n=this.expectIdentifierOrKeywordOrString();t.push(n),this.expectCharacter(p.$COLON),e.push(this.parsePipe())}while(this.optionalCharacter(p.$COMMA));this.expectCharacter(p.$RBRACE)}return new l.LiteralMap(t,e)},t.prototype.parseAccessMemberOrMethodCall=function(t,e){void 0===e&&(e=!1);var n=this.expectIdentifierOrKeyword();if(this.optionalCharacter(p.$LPAREN)){var r=this.parseCallArguments();return this.expectCharacter(p.$RPAREN),e?new l.SafeMethodCall(t,n,r):new l.MethodCall(t,n,r)}if(!e){if(this.optionalOperator("=")){this.parseAction||this.error("Bindings cannot contain assignments");var i=this.parseConditional();return new l.PropertyWrite(t,n,i)}return new l.PropertyRead(t,n)}return this.optionalOperator("=")?(this.error("The '?.' operator cannot be used in the assignment"),null):new l.SafePropertyRead(t,n)},t.prototype.parseCallArguments=function(){if(this.next.isCharacter(p.$RPAREN))return[];var t=[];do t.push(this.parsePipe());while(this.optionalCharacter(p.$COMMA));return t},t.prototype.parseBlockContent=function(){this.parseAction||this.error("Binding expression cannot contain chained expression");for(var t=[];this.index=e.$TAB&&t<=e.$SPACE||t==X}function p(t){return t>=D&&H>=t||t>=A&&M>=t||t==N||t==e.$$}function l(t){if(0==t.length)return!1;var n=new G(t);if(!p(n.peek))return!1;for(n.advance();n.peek!==e.$EOF;){if(!h(n.peek))return!1;n.advance()}return!0}function h(t){return t>=D&&H>=t||t>=A&&M>=t||t>=T&&x>=t||t==N||t==e.$$}function f(t){return t>=T&&x>=t}function d(t){return t==V||t==I}function v(t){return t==e.$MINUS||t==e.$PLUS}function y(t){return t===e.$SQ||t===e.$DQ||t===e.$BT}function m(t){switch(t){case L:return e.$LF;case j:return e.$FF;case B:return e.$CR;case F:return e.$TAB;case W:return e.$VTAB;default:return t}}var g=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},_=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},b=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},P=n(8),E=n(15),w=n(5),C=n(12);!function(t){t[t.Character=0]="Character",t[t.Identifier=1]="Identifier",t[t.Keyword=2]="Keyword",t[t.String=3]="String",t[t.Operator=4]="Operator",t[t.Number=5]="Number"}(e.TokenType||(e.TokenType={}));var R=e.TokenType,S=function(){function t(){}return t.prototype.tokenize=function(t){for(var e=new G(t),n=[],r=e.scanToken();null!=r;)n.push(r),r=e.scanToken();return n},t=_([P.Injectable(),b("design:paramtypes",[])],t)}();e.Lexer=S;var O=function(){function t(t,e,n,r){this.index=t,this.type=e,this.numValue=n,this.strValue=r}return t.prototype.isCharacter=function(t){return this.type==R.Character&&this.numValue==t},t.prototype.isNumber=function(){return this.type==R.Number},t.prototype.isString=function(){return this.type==R.String},t.prototype.isOperator=function(t){return this.type==R.Operator&&this.strValue==t},t.prototype.isIdentifier=function(){return this.type==R.Identifier},t.prototype.isKeyword=function(){return this.type==R.Keyword},t.prototype.isKeywordVar=function(){return this.type==R.Keyword&&"var"==this.strValue},t.prototype.isKeywordNull=function(){return this.type==R.Keyword&&"null"==this.strValue},t.prototype.isKeywordUndefined=function(){return this.type==R.Keyword&&"undefined"==this.strValue},t.prototype.isKeywordTrue=function(){return this.type==R.Keyword&&"true"==this.strValue},t.prototype.isKeywordFalse=function(){return this.type==R.Keyword&&"false"==this.strValue},t.prototype.toNumber=function(){return this.type==R.Number?this.numValue:-1},t.prototype.toString=function(){switch(this.type){case R.Character:case R.Identifier:case R.Keyword:case R.Operator:case R.String:return this.strValue;case R.Number:return this.numValue.toString();default:return null}},t}();e.Token=O,e.EOF=new O(-1,R.Character,0,""),e.$EOF=0,e.$TAB=9,e.$LF=10,e.$VTAB=11,e.$FF=12,e.$CR=13,e.$SPACE=32,e.$BANG=33,e.$DQ=34,e.$HASH=35,e.$$=36,e.$PERCENT=37,e.$AMPERSAND=38,e.$SQ=39,e.$LPAREN=40,e.$RPAREN=41,e.$STAR=42,e.$PLUS=43,e.$COMMA=44,e.$MINUS=45,e.$PERIOD=46,e.$SLASH=47,e.$COLON=58,e.$SEMICOLON=59,e.$LT=60,e.$EQ=61,e.$GT=62,e.$QUESTION=63;var T=48,x=57,A=65,I=69,M=90;e.$LBRACKET=91,e.$BACKSLASH=92,e.$RBRACKET=93;var k=94,N=95;e.$BT=96;var D=97,V=101,j=102,L=110,B=114,F=116,U=117,W=118,H=122;e.$LBRACE=123,e.$BAR=124,e.$RBRACE=125;var X=160,q=function(t){function e(e){t.call(this),this.message=e}return g(e,t),e.prototype.toString=function(){return this.message},e}(C.BaseException);e.ScannerError=q;var G=function(){function t(t){this.input=t,this.peek=0,this.index=-1,this.length=t.length,this.advance()}return t.prototype.advance=function(){this.peek=++this.index>=this.length?e.$EOF:w.StringWrapper.charCodeAt(this.input,this.index)},t.prototype.scanToken=function(){for(var t=this.input,n=this.length,i=this.peek,o=this.index;i<=e.$SPACE;){if(++o>=n){i=e.$EOF;break}i=w.StringWrapper.charCodeAt(t,o)}if(this.peek=i,this.index=o,o>=n)return null;if(p(i))return this.scanIdentifier();if(f(i))return this.scanNumber(o);var s=o;switch(i){case e.$PERIOD:return this.advance(),f(this.peek)?this.scanNumber(s):r(s,e.$PERIOD);case e.$LPAREN:case e.$RPAREN:case e.$LBRACE:case e.$RBRACE:case e.$LBRACKET:case e.$RBRACKET:case e.$COMMA:case e.$COLON:case e.$SEMICOLON:return this.scanCharacter(s,i);case e.$SQ:case e.$DQ:return this.scanString();case e.$HASH:case e.$PLUS:case e.$MINUS:case e.$STAR:case e.$SLASH:case e.$PERCENT:case k:return this.scanOperator(s,w.StringWrapper.fromCharCode(i));case e.$QUESTION:return this.scanComplexOperator(s,"?",e.$PERIOD,".");case e.$LT:case e.$GT:return this.scanComplexOperator(s,w.StringWrapper.fromCharCode(i),e.$EQ,"=");case e.$BANG:case e.$EQ:return this.scanComplexOperator(s,w.StringWrapper.fromCharCode(i),e.$EQ,"=",e.$EQ,"=");case e.$AMPERSAND:return this.scanComplexOperator(s,"&",e.$AMPERSAND,"&");case e.$BAR:return this.scanComplexOperator(s,"|",e.$BAR,"|");case X:for(;c(this.peek);)this.advance();return this.scanToken()}return this.error("Unexpected character ["+w.StringWrapper.fromCharCode(i)+"]",0),null},t.prototype.scanCharacter=function(t,e){return this.advance(),r(t,e)},t.prototype.scanOperator=function(t,e){return this.advance(),s(t,e)},t.prototype.scanComplexOperator=function(t,e,n,r,i,o){this.advance();var a=e;return this.peek==n&&(this.advance(),a+=r),w.isPresent(i)&&this.peek==i&&(this.advance(),a+=o),s(t,a)},t.prototype.scanIdentifier=function(){var t=this.index;for(this.advance();h(this.peek);)this.advance();var e=this.input.substring(t,this.index);return E.SetWrapper.has(z,e)?o(t,e):i(t,e)},t.prototype.scanNumber=function(t){var n=this.index===t;for(this.advance();;){if(f(this.peek));else if(this.peek==e.$PERIOD)n=!1;else{if(!d(this.peek))break;this.advance(),v(this.peek)&&this.advance(),f(this.peek)||this.error("Invalid exponent",-1),n=!1}this.advance()}var r=this.input.substring(t,this.index),i=n?w.NumberWrapper.parseIntAutoRadix(r):w.NumberWrapper.parseFloat(r);return u(t,i)},t.prototype.scanString=function(){var t=this.index,n=this.peek;this.advance();for(var r,i=this.index,o=this.input;this.peek!=n;)if(this.peek==e.$BACKSLASH){null==r&&(r=new w.StringJoiner),r.add(o.substring(i,this.index)),this.advance();var s;if(this.peek==U){var u=o.substring(this.index+1,this.index+5);try{s=w.NumberWrapper.parseInt(u,16)}catch(c){this.error("Invalid unicode escape [\\u"+u+"]",0)}for(var p=0;5>p;p++)this.advance()}else s=m(this.peek),this.advance();r.add(w.StringWrapper.fromCharCode(s)),i=this.index}else this.peek==e.$EOF?this.error("Unterminated quote",0):this.advance();var l=o.substring(i,this.index);this.advance();var h=l;return null!=r&&(r.add(l),h=r.toString()),a(t,h)},t.prototype.error=function(t,e){var n=this.index+e;throw new q("Lexer Error: "+t+" at column "+n+" in expression ["+this.input+"]")},t}();e.isIdentifier=l,e.isQuote=y;var z=(E.SetWrapper.createFromList(["+","-","*","/","%","^","=","==","!=","===","!==","<",">","<=",">=","&&","||","&","|","!","?","#","?."]),E.SetWrapper.createFromList(["var","null","undefined","true","false","if","else"]))},function(t,e,n){"use strict";function r(t,e,n){return u.isBlank(t)&&(t=d.getHtmlTagDefinition(e).implicitNamespacePrefix,u.isBlank(t)&&u.isPresent(n)&&(t=d.getNsPrefix(n.name))),d.mergeNsAndName(t,e)}function i(t,e){return t.length>0&&t[t.length-1]===e}var o=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},s=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},a=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},u=n(5),c=n(15),p=n(145),l=n(6),h=n(146),f=n(147),d=n(148),v=function(t){function e(e,n,r){t.call(this,n,r),this.elementName=e}return o(e,t),e.create=function(t,n,r){return new e(t,n,r)},e}(f.ParseError);e.HtmlTreeError=v;var y=function(){function t(t,e){this.rootNodes=t,this.errors=e}return t}();e.HtmlParseTreeResult=y;var m=function(){function t(){}return t.prototype.parse=function(t,e,n){void 0===n&&(n=!1);var r=h.tokenizeHtml(t,e,n),i=new g(r.tokens).build();return new y(i.rootNodes,r.errors.concat(i.errors))},t=s([l.Injectable(),a("design:paramtypes",[])],t)}();e.HtmlParser=m;var g=function(){function t(t){this.tokens=t,this.index=-1,this.rootNodes=[],this.errors=[],this.elementStack=[],this._advance()}return t.prototype.build=function(){for(;this.peek.type!==h.HtmlTokenType.EOF;)this.peek.type===h.HtmlTokenType.TAG_OPEN_START?this._consumeStartTag(this._advance()):this.peek.type===h.HtmlTokenType.TAG_CLOSE?this._consumeEndTag(this._advance()):this.peek.type===h.HtmlTokenType.CDATA_START?(this._closeVoidElement(),this._consumeCdata(this._advance())):this.peek.type===h.HtmlTokenType.COMMENT_START?(this._closeVoidElement(),this._consumeComment(this._advance())):this.peek.type===h.HtmlTokenType.TEXT||this.peek.type===h.HtmlTokenType.RAW_TEXT||this.peek.type===h.HtmlTokenType.ESCAPABLE_RAW_TEXT?(this._closeVoidElement(),this._consumeText(this._advance())):this.peek.type===h.HtmlTokenType.EXPANSION_FORM_START?this._consumeExpansion(this._advance()):this._advance();return new y(this.rootNodes,this.errors)},t.prototype._advance=function(){var t=this.peek;return this.index0)return this.errors=this.errors.concat(o.errors),null;var s=new f.ParseSourceSpan(e.sourceSpan.start,i.sourceSpan.end),a=new f.ParseSourceSpan(n.sourceSpan.start,i.sourceSpan.end);return new p.HtmlExpansionCaseAst(e.parts[0],o.rootNodes,s,e.sourceSpan,a)},t.prototype._collectExpansionExpTokens=function(t){for(var e=[],n=[h.HtmlTokenType.EXPANSION_CASE_EXP_START];;){if((this.peek.type===h.HtmlTokenType.EXPANSION_FORM_START||this.peek.type===h.HtmlTokenType.EXPANSION_CASE_EXP_START)&&n.push(this.peek.type),this.peek.type===h.HtmlTokenType.EXPANSION_CASE_EXP_END){if(!i(n,h.HtmlTokenType.EXPANSION_CASE_EXP_START))return this.errors.push(v.create(null,t.sourceSpan,"Invalid expansion form. Missing '}'.")),null;if(n.pop(),0==n.length)return e}if(this.peek.type===h.HtmlTokenType.EXPANSION_FORM_END){if(!i(n,h.HtmlTokenType.EXPANSION_FORM_START))return this.errors.push(v.create(null,t.sourceSpan,"Invalid expansion form. Missing '}'.")),null;n.pop()}if(this.peek.type===h.HtmlTokenType.EOF)return this.errors.push(v.create(null,t.sourceSpan,"Invalid expansion form. Missing '}'.")),null;e.push(this._advance())}},t.prototype._consumeText=function(t){var e=t.parts[0];if(e.length>0&&"\n"==e[0]){var n=this._getParentElement();u.isPresent(n)&&0==n.children.length&&d.getHtmlTagDefinition(n.name).ignoreFirstLf&&(e=e.substring(1))}e.length>0&&this._addToParent(new p.HtmlTextAst(e,t.sourceSpan))},t.prototype._closeVoidElement=function(){if(this.elementStack.length>0){var t=c.ListWrapper.last(this.elementStack);d.getHtmlTagDefinition(t.name).isVoid&&this.elementStack.pop()}},t.prototype._consumeStartTag=function(t){for(var e=t.parts[0],n=t.parts[1],i=[];this.peek.type===h.HtmlTokenType.ATTR_NAME;)i.push(this._consumeAttr(this._advance()));var o=r(e,n,this._getParentElement()),s=!1;this.peek.type===h.HtmlTokenType.TAG_OPEN_END_VOID?(this._advance(),s=!0,null!=d.getNsPrefix(o)||d.getHtmlTagDefinition(o).isVoid||this.errors.push(v.create(o,t.sourceSpan,'Only void and foreign elements can be self closed "'+t.parts[1]+'"'))):this.peek.type===h.HtmlTokenType.TAG_OPEN_END&&(this._advance(),s=!1);var a=this.peek.sourceSpan.start,u=new f.ParseSourceSpan(t.sourceSpan.start,a),c=new p.HtmlElementAst(o,i,[],u,u,null);this._pushElement(c),s&&(this._popElement(o),c.endSourceSpan=u)},t.prototype._pushElement=function(t){if(this.elementStack.length>0){var e=c.ListWrapper.last(this.elementStack);d.getHtmlTagDefinition(e.name).isClosedByChild(t.name)&&this.elementStack.pop()}var n=d.getHtmlTagDefinition(t.name),e=this._getParentElement();if(n.requireExtraParent(u.isPresent(e)?e.name:null)){var r=new p.HtmlElementAst(n.parentToAdd,[],[t],t.sourceSpan,t.startSourceSpan,t.endSourceSpan);this._addToParent(r),this.elementStack.push(r),this.elementStack.push(t)}else this._addToParent(t),this.elementStack.push(t)},t.prototype._consumeEndTag=function(t){var e=r(t.parts[0],t.parts[1],this._getParentElement());this._getParentElement().endSourceSpan=t.sourceSpan,d.getHtmlTagDefinition(e).isVoid?this.errors.push(v.create(e,t.sourceSpan,'Void elements do not have end tags "'+t.parts[1]+'"')):this._popElement(e)||this.errors.push(v.create(e,t.sourceSpan,'Unexpected closing tag "'+t.parts[1]+'"'))},t.prototype._popElement=function(t){for(var e=this.elementStack.length-1;e>=0;e--){var n=this.elementStack[e];if(n.name==t)return c.ListWrapper.splice(this.elementStack,e,this.elementStack.length-e),!0;if(!d.getHtmlTagDefinition(n.name).closedByParent)return!1}return!1},t.prototype._consumeAttr=function(t){var e=d.mergeNsAndName(t.parts[0],t.parts[1]),n=t.sourceSpan.end,r="";if(this.peek.type===h.HtmlTokenType.ATTR_VALUE){var i=this._advance();r=i.parts[0],n=i.sourceSpan.end}return new p.HtmlAttrAst(e,r,new f.ParseSourceSpan(t.sourceSpan.start,n))},t.prototype._getParentElement=function(){return this.elementStack.length>0?c.ListWrapper.last(this.elementStack):null},t.prototype._addToParent=function(t){var e=this._getParentElement();u.isPresent(e)?e.children.push(t):this.rootNodes.push(t)},t}()},function(t,e,n){"use strict";function r(t,e,n){void 0===n&&(n=null);var r=[];return e.forEach(function(e){var o=e.visit(t,n);i.isPresent(o)&&r.push(o)}),r}var i=n(5),o=function(){function t(t,e){this.value=t,this.sourceSpan=e}return t.prototype.visit=function(t,e){return t.visitText(this,e)},t}();e.HtmlTextAst=o;var s=function(){function t(t,e,n,r,i){this.switchValue=t,this.type=e,this.cases=n,this.sourceSpan=r,this.switchValueSourceSpan=i}return t.prototype.visit=function(t,e){return t.visitExpansion(this,e)},t}();e.HtmlExpansionAst=s;var a=function(){function t(t,e,n,r,i){this.value=t,this.expression=e,this.sourceSpan=n,this.valueSourceSpan=r,this.expSourceSpan=i}return t.prototype.visit=function(t,e){return t.visitExpansionCase(this,e)},t}();e.HtmlExpansionCaseAst=a;var u=function(){function t(t,e,n){this.name=t,this.value=e,this.sourceSpan=n}return t.prototype.visit=function(t,e){return t.visitAttr(this,e)},t}();e.HtmlAttrAst=u;var c=function(){function t(t,e,n,r,i,o){this.name=t,this.attrs=e,this.children=n,this.sourceSpan=r,this.startSourceSpan=i,this.endSourceSpan=o}return t.prototype.visit=function(t,e){return t.visitElement(this,e)},t}();e.HtmlElementAst=c;var p=function(){function t(t,e){this.value=t,this.sourceSpan=e}return t.prototype.visit=function(t,e){return t.visitComment(this,e)},t}();e.HtmlCommentAst=p,e.htmlVisitAll=r},function(t,e,n){"use strict";function r(t,e,n){return void 0===n&&(n=!1),new ut(new P.ParseSourceFile(t,e),n).tokenize()}function i(t){var e=t===O?"EOF":_.StringWrapper.fromCharCode(t);return'Unexpected character "'+e+'"'}function o(t){return'Unknown entity "'+t+'" - use the "&#;" or "&#x;" syntax'}function s(t){return!a(t)||t===O}function a(t){return t>=T&&I>=t||t===ot}function u(t){return a(t)||t===q||t===L||t===V||t===k||t===X}function c(t){return(et>t||t>rt)&&(J>t||t>tt)&&(B>t||t>U)}function p(t){return t==F||t==O||!d(t)}function l(t){return t==F||t==O||!f(t)}function h(t,e){return t===K&&e!=K}function f(t){return t>=et&&rt>=t||t>=J&&tt>=t}function d(t){return t>=et&&nt>=t||t>=J&&Z>=t||t>=B&&U>=t}function v(t,e){return y(t)==y(e)}function y(t){return t>=et&&rt>=t?t-et+J:t}function m(t){for(var e,n=[],r=0;r=this.length)throw this._createError(i(O),this._getSpan());this.peek===x?(this.line++,this.column=0):this.peek!==x&&this.peek!==A&&this.column++,this.index++,this.peek=this.index>=this.length?O:_.StringWrapper.charCodeAt(this.input,this.index),this.nextPeek=this.index+1>=this.length?O:_.StringWrapper.charCodeAt(this.input,this.index+1)},t.prototype._attemptCharCode=function(t){return this.peek===t?(this._advance(),!0):!1},t.prototype._attemptCharCodeCaseInsensitive=function(t){return v(this.peek,t)?(this._advance(),!0):!1},t.prototype._requireCharCode=function(t){var e=this._getLocation();if(!this._attemptCharCode(t))throw this._createError(i(this.peek),this._getSpan(e,e))},t.prototype._attemptStr=function(t){for(var e=0;er.offset&&o.push(this.input.substring(r.offset,this.index));this.peek!==e;)o.push(this._readChar(t))}return this._endToken([this._processCarriageReturns(o.join(""))],r)},t.prototype._consumeComment=function(t){var e=this;this._beginToken(w.COMMENT_START,t),this._requireCharCode(j),this._endToken([]);var n=this._consumeRawText(!1,j,function(){return e._attemptStr("->")});this._beginToken(w.COMMENT_END,n.sourceSpan.end),this._endToken([])},t.prototype._consumeCdata=function(t){var e=this;this._beginToken(w.CDATA_START,t),this._requireStr("CDATA["),this._endToken([]);var n=this._consumeRawText(!1,z,function(){return e._attemptStr("]>")});this._beginToken(w.CDATA_END,n.sourceSpan.end),this._endToken([])},t.prototype._consumeDocType=function(t){this._beginToken(w.DOC_TYPE,t),this._attemptUntilChar(q),this._advance(),this._endToken([this.input.substring(t.offset+2,this.index-1)])},t.prototype._consumePrefixAndName=function(){for(var t=this.index,e=null;this.peek!==W&&!c(this.peek);)this._advance();var n;this.peek===W?(this._advance(),e=this.input.substring(t,this.index-1),n=this.index):n=t,this._requireCharCodeUntilFn(u,this.index===n?1:0);var r=this.input.substring(n,this.index);return[e,r]},t.prototype._consumeTagOpen=function(t){var e,n=this._savePosition();try{if(!f(this.peek))throw this._createError(i(this.peek),this._getSpan());var r=this.index;for(this._consumeTagOpenStart(t),e=this.input.substring(r,this.index).toLowerCase(),this._attemptCharCodeUntilFn(s);this.peek!==L&&this.peek!==q;)this._consumeAttributeName(),this._attemptCharCodeUntilFn(s),this._attemptCharCode(X)&&(this._attemptCharCodeUntilFn(s),this._consumeAttributeValue()),this._attemptCharCodeUntilFn(s);this._consumeTagOpenEnd()}catch(o){if(o instanceof at)return this._restorePosition(n),this._beginToken(w.TEXT,t),void this._endToken(["<"]);throw o}var a=E.getHtmlTagDefinition(e).contentType;a===E.HtmlTagContentType.RAW_TEXT?this._consumeRawTextWithTagClose(e,!1):a===E.HtmlTagContentType.ESCAPABLE_RAW_TEXT&&this._consumeRawTextWithTagClose(e,!0)},t.prototype._consumeRawTextWithTagClose=function(t,e){var n=this,r=this._consumeRawText(e,H,function(){return n._attemptCharCode(L)?(n._attemptCharCodeUntilFn(s),n._attemptStrCaseInsensitive(t)?(n._attemptCharCodeUntilFn(s),n._attemptCharCode(q)?!0:!1):!1):!1});this._beginToken(w.TAG_CLOSE,r.sourceSpan.end),this._endToken([null,t])},t.prototype._consumeTagOpenStart=function(t){this._beginToken(w.TAG_OPEN_START,t);var e=this._consumePrefixAndName();this._endToken(e)},t.prototype._consumeAttributeName=function(){this._beginToken(w.ATTR_NAME);var t=this._consumePrefixAndName();this._endToken(t)},t.prototype._consumeAttributeValue=function(){this._beginToken(w.ATTR_VALUE);var t;if(this.peek===V||this.peek===k){var e=this.peek;this._advance();for(var n=[];this.peek!==e;)n.push(this._readChar(!0));t=n.join(""),this._advance()}else{var r=this.index;this._requireCharCodeUntilFn(u,1),t=this.input.substring(r,this.index)}this._endToken([this._processCarriageReturns(t)])},t.prototype._consumeTagOpenEnd=function(){var t=this._attemptCharCode(L)?w.TAG_OPEN_END_VOID:w.TAG_OPEN_END;this._beginToken(t),this._requireCharCode(q),this._endToken([])},t.prototype._consumeTagClose=function(t){this._beginToken(w.TAG_CLOSE,t),this._attemptCharCodeUntilFn(s);var e;e=this._consumePrefixAndName(),this._attemptCharCodeUntilFn(s),this._requireCharCode(q),this._endToken(e)},t.prototype._consumeExpansionFormStart=function(){this._beginToken(w.EXPANSION_FORM_START,this._getLocation()),this._requireCharCode(K),this._endToken([]),this._beginToken(w.RAW_TEXT,this._getLocation());var t=this._readUntil(Q);this._endToken([t],this._getLocation()),this._requireCharCode(Q),this._attemptCharCodeUntilFn(s),this._beginToken(w.RAW_TEXT,this._getLocation());var e=this._readUntil(Q);this._endToken([e],this._getLocation()),this._requireCharCode(Q),this._attemptCharCodeUntilFn(s),this.expansionCaseStack.push(w.EXPANSION_FORM_START)},t.prototype._consumeExpansionCaseStart=function(){this._requireCharCode(X),this._beginToken(w.EXPANSION_CASE_VALUE,this._getLocation());var t=this._readUntil(K).trim();this._endToken([t],this._getLocation()),this._attemptCharCodeUntilFn(s),this._beginToken(w.EXPANSION_CASE_EXP_START,this._getLocation()),this._requireCharCode(K),this._endToken([],this._getLocation()),this._attemptCharCodeUntilFn(s),this.expansionCaseStack.push(w.EXPANSION_CASE_EXP_START)},t.prototype._consumeExpansionCaseEnd=function(){this._beginToken(w.EXPANSION_CASE_EXP_END,this._getLocation()),this._requireCharCode($),this._endToken([],this._getLocation()),this._attemptCharCodeUntilFn(s),this.expansionCaseStack.pop()},t.prototype._consumeExpansionFormEnd=function(){this._beginToken(w.EXPANSION_FORM_END,this._getLocation()),this._requireCharCode($),this._endToken([]),this.expansionCaseStack.pop()},t.prototype._consumeText=function(){var t=this._getLocation();this._beginToken(w.TEXT,t);var e=[],n=!1;for(this.peek===K&&this.nextPeek===K?(e.push(this._readChar(!0)),e.push(this._readChar(!0)),n=!0):e.push(this._readChar(!0));!this.isTextEnd(n);)this.peek===K&&this.nextPeek===K?(e.push(this._readChar(!0)),e.push(this._readChar(!0)),n=!0):this.peek===$&&this.nextPeek===$&&n?(e.push(this._readChar(!0)),e.push(this._readChar(!0)),n=!1):e.push(this._readChar(!0));this._endToken([this._processCarriageReturns(e.join(""))])},t.prototype.isTextEnd=function(t){if(this.peek===H||this.peek===O)return!0;if(this.tokenizeExpansionForms){if(h(this.peek,this.nextPeek))return!0;if(this.peek===$&&!t&&(this.isInExpansionCase()||this.isInExpansionForm()))return!0}return!1},t.prototype._savePosition=function(){return[this.peek,this.index,this.column,this.line,this.tokens.length]},t.prototype._readUntil=function(t){var e=this.index;return this._attemptUntilChar(t),this.input.substring(e,this.index)},t.prototype._restorePosition=function(t){this.peek=t[0],this.index=t[1],this.column=t[2],this.line=t[3];var e=t[4];e0&&this.expansionCaseStack[this.expansionCaseStack.length-1]===w.EXPANSION_CASE_EXP_START},t.prototype.isInExpansionForm=function(){return this.expansionCaseStack.length>0&&this.expansionCaseStack[this.expansionCaseStack.length-1]===w.EXPANSION_FORM_START},t}()},function(t,e){"use strict";var n=function(){function t(t,e,n,r){this.file=t,this.offset=e,this.line=n,this.col=r}return t.prototype.toString=function(){return this.file.url+"@"+this.line+":"+this.col},t}();e.ParseLocation=n;var r=function(){function t(t,e){this.content=t,this.url=e}return t}();e.ParseSourceFile=r;var i=function(){function t(t,e){this.start=t,this.end=e}return t.prototype.toString=function(){return this.start.file.content.substring(this.start.offset,this.end.offset)},t}();e.ParseSourceSpan=i;var o=function(){function t(t,e){this.span=t,this.msg=e}return t.prototype.toString=function(){var t=this.span.start.file.content,e=this.span.start.offset;e>t.length-1&&(e=t.length-1);for(var n=e,r=0,i=0;100>r&&e>0&&(e--,r++,"\n"!=t[e]||3!=++i););for(r=0,i=0;100>r&&n]"+t.substring(this.span.start.offset,n+1);return this.msg+' ("'+o+'"): '+this.span.start},t}();e.ParseError=o},function(t,e,n){"use strict";function r(t){var e=p[t.toLowerCase()];return a.isPresent(e)?e:l}function i(t){if("@"!=t[0])return[null,t];var e=a.RegExpWrapper.firstMatch(h,t);return[e[1],e[2]]}function o(t){return i(t)[0]}function s(t,e){return a.isPresent(t)?"@"+t+":"+e:e}var a=n(5);e.NAMED_ENTITIES=a.CONST_EXPR({Aacute:"Á",aacute:"á",Acirc:"Â",acirc:"â",acute:"´",AElig:"Æ",aelig:"æ",Agrave:"À",agrave:"à",alefsym:"ℵ",Alpha:"Α",alpha:"α",amp:"&",and:"∧",ang:"∠",apos:"'",Aring:"Å",aring:"å",asymp:"≈",Atilde:"Ã",atilde:"ã",Auml:"Ä",auml:"ä",bdquo:"„",Beta:"Β",beta:"β",brvbar:"¦",bull:"•",cap:"∩",Ccedil:"Ç",ccedil:"ç",cedil:"¸",cent:"¢",Chi:"Χ",chi:"χ",circ:"ˆ",clubs:"♣",cong:"≅",copy:"©",crarr:"↵",cup:"∪",curren:"¤",dagger:"†",Dagger:"‡",darr:"↓",dArr:"⇓",deg:"°",Delta:"Δ",delta:"δ",diams:"♦",divide:"÷",Eacute:"É",eacute:"é",Ecirc:"Ê",ecirc:"ê",Egrave:"È",egrave:"è",empty:"∅",emsp:" ",ensp:" ",Epsilon:"Ε",epsilon:"ε",equiv:"≡",Eta:"Η",eta:"η",ETH:"Ð",eth:"ð",Euml:"Ë",euml:"ë",euro:"€",exist:"∃",fnof:"ƒ",forall:"∀",frac12:"½",frac14:"¼",frac34:"¾",frasl:"⁄",Gamma:"Γ",gamma:"γ",ge:"≥",gt:">",harr:"↔",hArr:"⇔",hearts:"♥",hellip:"…",Iacute:"Í",iacute:"í",Icirc:"Î",icirc:"î",iexcl:"¡",Igrave:"Ì",igrave:"ì",image:"ℑ",infin:"∞","int":"∫",Iota:"Ι",iota:"ι",iquest:"¿",isin:"∈",Iuml:"Ï",iuml:"ï",Kappa:"Κ",kappa:"κ",Lambda:"Λ",lambda:"λ",lang:"⟨",laquo:"«",larr:"←",lArr:"⇐",lceil:"⌈",ldquo:"“",le:"≤",lfloor:"⌊",lowast:"∗",loz:"◊",lrm:"‎",lsaquo:"‹",lsquo:"‘",lt:"<",macr:"¯",mdash:"—",micro:"µ",middot:"·",minus:"−",Mu:"Μ",mu:"μ",nabla:"∇",nbsp:" ",ndash:"–",ne:"≠",ni:"∋",not:"¬",notin:"∉",nsub:"⊄",Ntilde:"Ñ",ntilde:"ñ",Nu:"Ν",nu:"ν",Oacute:"Ó",oacute:"ó",Ocirc:"Ô",ocirc:"ô",OElig:"Œ",oelig:"œ",Ograve:"Ò",ograve:"ò",oline:"‾",Omega:"Ω",omega:"ω",Omicron:"Ο",omicron:"ο",oplus:"⊕",or:"∨",ordf:"ª",ordm:"º",Oslash:"Ø",oslash:"ø",Otilde:"Õ",otilde:"õ",otimes:"⊗",Ouml:"Ö",ouml:"ö",para:"¶",permil:"‰",perp:"⊥",Phi:"Φ",phi:"φ",Pi:"Π",pi:"π",piv:"ϖ",plusmn:"±",pound:"£",prime:"′",Prime:"″",prod:"∏",prop:"∝",Psi:"Ψ",psi:"ψ",quot:'"',radic:"√",rang:"⟩",raquo:"»",rarr:"→",rArr:"⇒",rceil:"⌉",rdquo:"”",real:"ℜ",reg:"®",rfloor:"⌋",Rho:"Ρ",rho:"ρ",rlm:"‏",rsaquo:"›",rsquo:"’",sbquo:"‚",Scaron:"Š",scaron:"š",sdot:"⋅",sect:"§",shy:"­",Sigma:"Σ",sigma:"σ",sigmaf:"ς",sim:"∼",spades:"♠",sub:"⊂",sube:"⊆",sum:"∑",sup:"⊃",sup1:"¹",sup2:"²",sup3:"³",supe:"⊇",szlig:"ß",Tau:"Τ",tau:"τ",there4:"∴",Theta:"Θ",theta:"θ",thetasym:"ϑ",thinsp:" ",THORN:"Þ",thorn:"þ",tilde:"˜",times:"×",trade:"™",Uacute:"Ú",uacute:"ú",uarr:"↑",uArr:"⇑",Ucirc:"Û",ucirc:"û",Ugrave:"Ù",ugrave:"ù",uml:"¨",upsih:"ϒ",Upsilon:"Υ",upsilon:"υ",Uuml:"Ü",uuml:"ü",weierp:"℘",Xi:"Ξ",xi:"ξ",Yacute:"Ý",yacute:"ý",yen:"¥",yuml:"ÿ",Yuml:"Ÿ",Zeta:"Ζ",zeta:"ζ",zwj:"‍",zwnj:"‌"}),function(t){t[t.RAW_TEXT=0]="RAW_TEXT",t[t.ESCAPABLE_RAW_TEXT=1]="ESCAPABLE_RAW_TEXT",t[t.PARSABLE_DATA=2]="PARSABLE_DATA"}(e.HtmlTagContentType||(e.HtmlTagContentType={}));var u=e.HtmlTagContentType,c=function(){function t(t){var e=this,n=void 0===t?{}:t,r=n.closedByChildren,i=n.requiredParents,o=n.implicitNamespacePrefix,s=n.contentType,c=n.closedByParent,p=n.isVoid,l=n.ignoreFirstLf;this.closedByChildren={},this.closedByParent=!1,a.isPresent(r)&&r.length>0&&r.forEach(function(t){return e.closedByChildren[t]=!0}),this.isVoid=a.normalizeBool(p),this.closedByParent=a.normalizeBool(c)||this.isVoid,a.isPresent(i)&&i.length>0&&(this.requiredParents={},this.parentToAdd=i[0],i.forEach(function(t){return e.requiredParents[t]=!0})),this.implicitNamespacePrefix=o,this.contentType=a.isPresent(s)?s:u.PARSABLE_DATA,this.ignoreFirstLf=a.normalizeBool(l)}return t.prototype.requireExtraParent=function(t){if(a.isBlank(this.requiredParents))return!1;if(a.isBlank(t))return!0;var e=t.toLowerCase();return 1!=this.requiredParents[e]&&"template"!=e},t.prototype.isClosedByChild=function(t){return this.isVoid||a.normalizeBool(this.closedByChildren[t.toLowerCase()])},t}();e.HtmlTagDefinition=c;var p={base:new c({isVoid:!0}),meta:new c({isVoid:!0}),area:new c({isVoid:!0}),embed:new c({isVoid:!0}),link:new c({isVoid:!0}),img:new c({isVoid:!0}),input:new c({isVoid:!0}),param:new c({isVoid:!0}),hr:new c({isVoid:!0}),br:new c({isVoid:!0}),source:new c({isVoid:!0}),track:new c({isVoid:!0}),wbr:new c({isVoid:!0}),p:new c({closedByChildren:["address","article","aside","blockquote","div","dl","fieldset","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","hr","main","nav","ol","p","pre","section","table","ul"],closedByParent:!0}),thead:new c({closedByChildren:["tbody","tfoot"]}),tbody:new c({closedByChildren:["tbody","tfoot"],closedByParent:!0}),tfoot:new c({closedByChildren:["tbody"],closedByParent:!0}),tr:new c({closedByChildren:["tr"],requiredParents:["tbody","tfoot","thead"],closedByParent:!0}),td:new c({closedByChildren:["td","th"],closedByParent:!0}),th:new c({closedByChildren:["td","th"],closedByParent:!0}),col:new c({requiredParents:["colgroup"],isVoid:!0}),svg:new c({implicitNamespacePrefix:"svg"}),math:new c({implicitNamespacePrefix:"math"}),li:new c({closedByChildren:["li"],closedByParent:!0}),dt:new c({closedByChildren:["dt","dd"]}),dd:new c({closedByChildren:["dt","dd"],closedByParent:!0}),rb:new c({closedByChildren:["rb","rt","rtc","rp"],closedByParent:!0}),rt:new c({closedByChildren:["rb","rt","rtc","rp"],closedByParent:!0}),rtc:new c({closedByChildren:["rb","rtc","rp"],closedByParent:!0}),rp:new c({closedByChildren:["rb","rt","rtc","rp"],closedByParent:!0}),optgroup:new c({closedByChildren:["optgroup"],closedByParent:!0}),option:new c({closedByChildren:["option","optgroup"],closedByParent:!0}),pre:new c({ignoreFirstLf:!0}),listing:new c({ignoreFirstLf:!0}),style:new c({contentType:u.RAW_TEXT}),script:new c({contentType:u.RAW_TEXT}),title:new c({contentType:u.ESCAPABLE_RAW_TEXT}),textarea:new c({contentType:u.ESCAPABLE_RAW_TEXT,ignoreFirstLf:!0})},l=new c;e.getHtmlTagDefinition=r;var h=/^@([^:]+):(.+)/g;e.splitNsName=i,e.getNsPrefix=o,e.mergeNsAndName=s},function(t,e,n){"use strict";var r=n(15),i=n(5),o=n(12),s="",a=i.RegExpWrapper.create("(\\:not\\()|([-\\w]+)|(?:\\.([-\\w]+))|(?:\\[([-\\w*]+)(?:=([^\\]]*))?\\])|(\\))|(\\s*,\\s*)"),u=function(){function t(){this.element=null,this.classNames=[],this.attrs=[],this.notSelectors=[]}return t.parse=function(e){for(var n,s=[],u=function(t,e){e.notSelectors.length>0&&i.isBlank(e.element)&&r.ListWrapper.isEmpty(e.classNames)&&r.ListWrapper.isEmpty(e.attrs)&&(e.element="*"),t.push(e)},c=new t,p=i.RegExpWrapper.matcher(a,e),l=c,h=!1;i.isPresent(n=i.RegExpMatcherWrapper.next(p));){if(i.isPresent(n[1])){if(h)throw new o.BaseException("Nesting :not is not allowed in a selector");h=!0,l=new t,c.notSelectors.push(l)}if(i.isPresent(n[2])&&l.setElement(n[2]),i.isPresent(n[3])&&l.addClassName(n[3]),i.isPresent(n[4])&&l.addAttribute(n[4],n[5]),i.isPresent(n[6])&&(h=!1,l=c),i.isPresent(n[7])){if(h)throw new o.BaseException("Multiple selectors in :not are not supported");u(s,c),c=l=new t}}return u(s,c),s},t.prototype.isElementSelector=function(){return i.isPresent(this.element)&&r.ListWrapper.isEmpty(this.classNames)&&r.ListWrapper.isEmpty(this.attrs)&&0===this.notSelectors.length},t.prototype.setElement=function(t){void 0===t&&(t=null),this.element=t},t.prototype.getMatchingElementTemplate=function(){for(var t=i.isPresent(this.element)?this.element:"div",e=this.classNames.length>0?' class="'+this.classNames.join(" ")+'"':"",n="",r=0;r"},t.prototype.addAttribute=function(t,e){void 0===e&&(e=s),this.attrs.push(t),e=i.isPresent(e)?e.toLowerCase():s,this.attrs.push(e)},t.prototype.addClassName=function(t){this.classNames.push(t.toLowerCase())},t.prototype.toString=function(){var t="";if(i.isPresent(this.element)&&(t+=this.element),i.isPresent(this.classNames))for(var e=0;e0&&(t+="="+r),t+="]"}return this.notSelectors.forEach(function(e){return t+=":not("+e+")"}),t},t}();e.CssSelector=u;var c=function(){function t(){this._elementMap=new r.Map,this._elementPartialMap=new r.Map,this._classMap=new r.Map,this._classPartialMap=new r.Map,this._attrValueMap=new r.Map,this._attrValuePartialMap=new r.Map,this._listContexts=[]}return t.createNotMatcher=function(e){var n=new t;return n.addSelectables(e,null),n},t.prototype.addSelectables=function(t,e){var n=null;t.length>1&&(n=new p(t),this._listContexts.push(n));for(var r=0;r0&&(i.isBlank(this.listContext)||!this.listContext.alreadyMatched)){var r=c.createNotMatcher(this.notSelectors);n=!r.match(t,null)}return n&&i.isPresent(e)&&(i.isBlank(this.listContext)||!this.listContext.alreadyMatched)&&(i.isPresent(this.listContext)&&(this.listContext.alreadyMatched=!0),e(this.selector,this.cbContext)),n},t}();e.SelectorContext=l},function(t,e){"use strict";var n=function(){function t(){}return t.prototype.hasProperty=function(t,e){return!0},t.prototype.getMappedPropName=function(t){return t},t}();e.ElementSchemaRegistry=n},function(t,e,n){"use strict";function r(t){var e=null,n=null,r=null,o=!1,_=null;t.attrs.forEach(function(t){var i=t.name.toLowerCase();i==a?e=t.value:i==l?n=t.value:i==p?r=t.value:t.name==v?o=!0:t.name==y&&t.value.length>0&&(_=t.value)}),e=i(e);var b=t.name.toLowerCase(),P=m.OTHER;return s.splitNsName(b)[1]==u?P=m.NG_CONTENT:b==f?P=m.STYLE:b==d?P=m.SCRIPT:b==c&&r==h&&(P=m.STYLESHEET),new g(P,e,n,o,_)}function i(t){return o.isBlank(t)||0===t.length?"*":t}var o=n(5),s=n(148),a="select",u="ng-content",c="link",p="rel",l="href",h="stylesheet",f="style",d="script",v="ngNonBindable",y="ngProjectAs";e.preparseElement=r,function(t){t[t.NG_CONTENT=0]="NG_CONTENT",t[t.STYLE=1]="STYLE",t[t.STYLESHEET=2]="STYLESHEET",t[t.SCRIPT=3]="SCRIPT",t[t.OTHER=4]="OTHER"}(e.PreparsedElementType||(e.PreparsedElementType={}));var m=e.PreparsedElementType,g=function(){function t(t,e,n,r,i){this.type=t,this.selectAttr=e,this.hrefAttr=n,this.nonBindable=r,this.projectAs=i}return t}();e.PreparsedElement=g},function(t,e,n){"use strict";function r(t){if(o.isBlank(t)||0===t.length||"/"==t[0])return!1;var e=o.RegExpWrapper.firstMatch(u,t);return o.isBlank(e)||"package"==e[1]||"asset"==e[1]}function i(t,e,n){var i=[],u=o.StringWrapper.replaceAllMapped(n,a,function(n){var s=o.isPresent(n[1])?n[1]:n[2];return r(s)?(i.push(t.resolve(e,s)),""):n[0]});return new s(u,i)}var o=n(5),s=function(){function t(t,e){this.style=t,this.styleUrls=e}return t}();e.StyleWithImports=s,e.isStyleUrlResolvable=r,e.extractStyleUrls=i;var a=/@import\s+(?:url\()?\s*(?:(?:['"]([^'"]*))|([^;\)\s]*))[^;]*;?/g,u=/^([a-zA-Z\-\+\.]+):/g},function(t,e,n){"use strict";function r(t){return a.StringWrapper.replaceAllMapped(t,u,function(t){return"-"+t[1].toLowerCase()})}function i(t){return a.StringWrapper.replaceAllMapped(t,c,function(t){return t[1].toUpperCase()})}function o(t,e){var n=a.StringWrapper.split(t.trim(),/\s*:\s*/g);return n.length>1?n:e}function s(t){return a.StringWrapper.replaceAll(t,/\W/g,"_")}var a=n(5);e.MODULE_SUFFIX=a.IS_DART?".dart":"";var u=/([A-Z])/g,c=/-([a-z])/g;e.camelCaseToDashCase=r,e.dashCaseToCamelCase=i,e.splitAtColon=o,e.sanitizeIdentifier=s},function(t,e,n){"use strict";function r(t,e){var n=e.useExisting,r=e.useValue,i=e.deps;return new v.CompileProviderMetadata({token:t.token,useClass:t.useClass,useExisting:n,useFactory:t.useFactory,useValue:r,deps:i,multi:t.multi})}function i(t,e){var n=e.eager,r=e.providers;return new d.ProviderAst(t.token,t.multiProvider,t.eager||n,r,t.providerType,t.sourceSpan)}function o(t,e,n,r){return void 0===r&&(r=null),h.isBlank(r)&&(r=[]),h.isPresent(t)&&t.forEach(function(t){if(h.isArray(t))o(t,e,n,r);else{var i;t instanceof v.CompileProviderMetadata?i=t:t instanceof v.CompileTypeMetadata?i=new v.CompileProviderMetadata({token:new v.CompileTokenMetadata({identifier:t}),useClass:t}):n.push(new g("Unknown provider type "+t,e)),h.isPresent(i)&&r.push(i)}}),r}function s(t,e,n){var r=new v.CompileTokenMap;t.forEach(function(t){var i=new v.CompileProviderMetadata({token:new v.CompileTokenMetadata({identifier:t.type}),useClass:t.type});a([i],t.isComponent?d.ProviderAstType.Component:d.ProviderAstType.Directive,!0,e,n,r)});var i=t.filter(function(t){return t.isComponent}).concat(t.filter(function(t){return!t.isComponent}));return i.forEach(function(t){a(o(t.providers,e,n),d.ProviderAstType.PublicService,!1,e,n,r),a(o(t.viewProviders,e,n),d.ProviderAstType.PrivateService,!1,e,n,r)}),r}function a(t,e,n,r,i,o){t.forEach(function(t){var s=o.get(t.token);h.isPresent(s)&&s.multiProvider!==t.multi&&i.push(new g("Mixing multi and non multi provider is not possible for token "+s.token.name,r)),h.isBlank(s)?(s=new d.ProviderAst(t.token,t.multi,n,[t],e,r),o.add(t.token,s)):(t.multi||f.ListWrapper.clear(s.providers),s.providers.push(t))})}function u(t){var e=new v.CompileTokenMap;return h.isPresent(t.viewQueries)&&t.viewQueries.forEach(function(t){return p(e,t)}),t.type.diDeps.forEach(function(t){h.isPresent(t.viewQuery)&&p(e,t.viewQuery)}),e}function c(t){var e=new v.CompileTokenMap;return t.forEach(function(t){h.isPresent(t.queries)&&t.queries.forEach(function(t){return p(e,t)}),t.type.diDeps.forEach(function(t){h.isPresent(t.query)&&p(e,t.query)})}),e}function p(t,e){e.selectors.forEach(function(n){var r=t.get(n);h.isBlank(r)&&(r=[],t.add(n,r)),r.push(e)})}var l=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},h=n(5),f=n(15),d=n(139),v=n(155),y=n(158),m=n(147),g=function(t){function e(e,n){t.call(this,n,e)}return l(e,t),e}(m.ParseError);e.ProviderError=g;var _=function(){function t(t,e){var n=this;this.component=t,this.sourceSpan=e,this.errors=[],this.viewQueries=u(t),this.viewProviders=new v.CompileTokenMap,o(t.viewProviders,e,this.errors).forEach(function(t){h.isBlank(n.viewProviders.get(t.token))&&n.viewProviders.add(t.token,!0)})}return t}();e.ProviderViewContext=_;var b=function(){function t(t,e,n,r,i,o,a){var u=this;this._viewContext=t,this._parent=e,this._isViewRoot=n,this._directiveAsts=r,this._sourceSpan=a,this._transformedProviders=new v.CompileTokenMap,this._seenProviders=new v.CompileTokenMap,this._hasViewContainer=!1,this._attrs={},i.forEach(function(t){return u._attrs[t.name]=t.value});var p=r.map(function(t){return t.directive});this._allProviders=s(p,a,t.errors),this._contentQueries=c(p);var l=new v.CompileTokenMap;this._allProviders.values().forEach(function(t){u._addQueryReadsTo(t.token,l)}),o.forEach(function(t){var e=new v.CompileTokenMetadata({value:t.name});u._addQueryReadsTo(e,l)}),h.isPresent(l.get(y.identifierToken(y.Identifiers.ViewContainerRef)))&&(this._hasViewContainer=!0),this._allProviders.values().forEach(function(t){var e=t.eager||h.isPresent(l.get(t.token));e&&u._getOrCreateLocalProvider(t.providerType,t.token,!0)})}return t.prototype.afterElement=function(){var t=this;this._allProviders.values().forEach(function(e){t._getOrCreateLocalProvider(e.providerType,e.token,!1)})},Object.defineProperty(t.prototype,"transformProviders",{get:function(){return this._transformedProviders.values()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"transformedDirectiveAsts",{get:function(){var t=this._transformedProviders.values().map(function(t){return t.token.identifier}),e=f.ListWrapper.clone(this._directiveAsts);return f.ListWrapper.sort(e,function(e,n){return t.indexOf(e.directive.type)-t.indexOf(n.directive.type)}),e},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"transformedHasViewContainer",{get:function(){return this._hasViewContainer},enumerable:!0,configurable:!0}),t.prototype._addQueryReadsTo=function(t,e){this._getQueriesFor(t).forEach(function(n){var r=h.isPresent(n.read)?n.read:t;h.isBlank(e.get(r))&&e.add(r,!0)})},t.prototype._getQueriesFor=function(t){for(var e,n=[],r=this,i=0;null!==r;)e=r._contentQueries.get(t),h.isPresent(e)&&f.ListWrapper.addAll(n,e.filter(function(t){return t.descendants||1>=i})),r._directiveAsts.length>0&&i++,r=r._parent;return e=this._viewContext.viewQueries.get(t),h.isPresent(e)&&f.ListWrapper.addAll(n,e),n},t.prototype._getOrCreateLocalProvider=function(t,e,n){var o=this,s=this._allProviders.get(e);if(h.isBlank(s)||(t===d.ProviderAstType.Directive||t===d.ProviderAstType.PublicService)&&s.providerType===d.ProviderAstType.PrivateService||(t===d.ProviderAstType.PrivateService||t===d.ProviderAstType.PublicService)&&s.providerType===d.ProviderAstType.Builtin)return null;var a=this._transformedProviders.get(e);if(h.isPresent(a))return a;if(h.isPresent(this._seenProviders.get(e)))return this._viewContext.errors.push(new g("Cannot instantiate cyclic dependency! "+e.name,this._sourceSpan)),null;this._seenProviders.add(e,!0);var u=s.providers.map(function(t){var e,i=t.useValue,a=t.useExisting;if(h.isPresent(t.useExisting)){var u=o._getDependency(s.providerType,new v.CompileDiDependencyMetadata({token:t.useExisting}),n);h.isPresent(u.token)?a=u.token:(a=null,i=u.value)}else if(h.isPresent(t.useFactory)){var c=h.isPresent(t.deps)?t.deps:t.useFactory.diDeps;e=c.map(function(t){return o._getDependency(s.providerType,t,n)})}else if(h.isPresent(t.useClass)){var c=h.isPresent(t.deps)?t.deps:t.useClass.diDeps;e=c.map(function(t){return o._getDependency(s.providerType,t,n)})}return r(t,{useExisting:a,useValue:i,deps:e})});return a=i(s,{eager:n,providers:u}),this._transformedProviders.add(e,a),a},t.prototype._getLocalDependency=function(t,e,n){if(void 0===n&&(n=null),e.isAttribute){var r=this._attrs[e.token.value];return new v.CompileDiDependencyMetadata({isValue:!0,value:h.normalizeBlank(r)})}if(h.isPresent(e.query)||h.isPresent(e.viewQuery))return e;if(h.isPresent(e.token)){if(t===d.ProviderAstType.Directive||t===d.ProviderAstType.Component){if(e.token.equalsTo(y.identifierToken(y.Identifiers.Renderer))||e.token.equalsTo(y.identifierToken(y.Identifiers.ElementRef))||e.token.equalsTo(y.identifierToken(y.Identifiers.ChangeDetectorRef))||e.token.equalsTo(y.identifierToken(y.Identifiers.TemplateRef)))return e;e.token.equalsTo(y.identifierToken(y.Identifiers.ViewContainerRef))&&(this._hasViewContainer=!0)}if(e.token.equalsTo(y.identifierToken(y.Identifiers.Injector)))return e;if(h.isPresent(this._getOrCreateLocalProvider(t,e.token,n)))return e}return null},t.prototype._getDependency=function(t,e,n){void 0===n&&(n=null);var r=this,i=n,o=null;if(e.isSkipSelf||(o=this._getLocalDependency(t,e,n)),e.isSelf)h.isBlank(o)&&e.isOptional&&(o=new v.CompileDiDependencyMetadata({isValue:!0,value:null}));else{for(;h.isBlank(o)&&h.isPresent(r._parent);){var s=r;r=r._parent,s._isViewRoot&&(i=!1),o=r._getLocalDependency(d.ProviderAstType.PublicService,e,i)}h.isBlank(o)&&(o=!e.isHost||this._viewContext.component.type.isHost||y.identifierToken(this._viewContext.component.type).equalsTo(e.token)||h.isPresent(this._viewContext.viewProviders.get(e.token))?e:e.isOptional?o=new v.CompileDiDependencyMetadata({ -isValue:!0,value:null}):null)}return h.isBlank(o)&&this._viewContext.errors.push(new g("No provider for "+e.token.name,this._sourceSpan)),o},t}();e.ProviderElementContext=b},function(t,e,n){"use strict";function r(t){return N[t["class"]](t)}function i(t,e){var n=y.CssSelector.parse(e)[0].getMatchingElementTemplate();return M.create({type:new x({runtime:Object,name:t.name+"_Host",moduleUrl:t.moduleUrl,isHost:!0}),template:new I({template:n,templateUrl:"",styles:[],styleUrls:[],ngContentSelectors:[]}),changeDetection:d.ChangeDetectionStrategy.Default,inputs:[],outputs:[],host:{},lifecycleHooks:[],isComponent:!0,selector:"*",providers:[],viewProviders:[],queries:[],viewQueries:[]})}function o(t,e){return l.isBlank(t)?null:t.map(function(t){return a(t,e)})}function s(t){return l.isBlank(t)?null:t.map(u)}function a(t,e){return l.isArray(t)?o(t,e):l.isString(t)||l.isBlank(t)||l.isBoolean(t)||l.isNumber(t)?t:e(t)}function u(t){return l.isArray(t)?s(t):l.isString(t)||l.isBlank(t)||l.isBoolean(t)||l.isNumber(t)?t:t.toJson()}function c(t){return l.isPresent(t)?t:[]}var p=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},l=n(5),h=n(12),f=n(15),d=n(28),v=n(36),y=n(149),m=n(153),g=n(156),_=n(157),b=/^(?:(?:\[([^\]]+)\])|(?:\(([^\)]+)\)))$/g,P=function(){function t(){}return Object.defineProperty(t.prototype,"identifier",{get:function(){return h.unimplemented()},enumerable:!0,configurable:!0}),t}();e.CompileMetadataWithIdentifier=P;var E=function(t){function e(){t.apply(this,arguments)}return p(e,t),Object.defineProperty(e.prototype,"type",{get:function(){return h.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"identifier",{get:function(){return h.unimplemented()},enumerable:!0,configurable:!0}),e}(P);e.CompileMetadataWithType=E,e.metadataFromJson=r;var w=function(){function t(t){var e=void 0===t?{}:t,n=e.runtime,r=e.name,i=e.moduleUrl,o=e.prefix,s=e.value;this.runtime=n,this.name=r,this.prefix=o,this.moduleUrl=i,this.value=s}return t.fromJson=function(e){var n=l.isArray(e.value)?o(e.value,r):a(e.value,r);return new t({name:e.name,prefix:e.prefix,moduleUrl:e.moduleUrl,value:n})},t.prototype.toJson=function(){var t=l.isArray(this.value)?s(this.value):u(this.value);return{"class":"Identifier",name:this.name,moduleUrl:this.moduleUrl,prefix:this.prefix,value:t}},Object.defineProperty(t.prototype,"identifier",{get:function(){return this},enumerable:!0,configurable:!0}),t}();e.CompileIdentifierMetadata=w;var C=function(){function t(t){var e=void 0===t?{}:t,n=e.isAttribute,r=e.isSelf,i=e.isHost,o=e.isSkipSelf,s=e.isOptional,a=e.isValue,u=e.query,c=e.viewQuery,p=e.token,h=e.value;this.isAttribute=l.normalizeBool(n),this.isSelf=l.normalizeBool(r),this.isHost=l.normalizeBool(i),this.isSkipSelf=l.normalizeBool(o),this.isOptional=l.normalizeBool(s),this.isValue=l.normalizeBool(a),this.query=u,this.viewQuery=c,this.token=p,this.value=h}return t.fromJson=function(e){return new t({token:a(e.token,O.fromJson),query:a(e.query,A.fromJson),viewQuery:a(e.viewQuery,A.fromJson),value:e.value,isAttribute:e.isAttribute,isSelf:e.isSelf,isHost:e.isHost,isSkipSelf:e.isSkipSelf,isOptional:e.isOptional,isValue:e.isValue})},t.prototype.toJson=function(){return{token:u(this.token),query:u(this.query),viewQuery:u(this.viewQuery),value:this.value,isAttribute:this.isAttribute,isSelf:this.isSelf,isHost:this.isHost,isSkipSelf:this.isSkipSelf,isOptional:this.isOptional,isValue:this.isValue}},t}();e.CompileDiDependencyMetadata=C;var R=function(){function t(t){var e=t.token,n=t.useClass,r=t.useValue,i=t.useExisting,o=t.useFactory,s=t.deps,a=t.multi;this.token=e,this.useClass=n,this.useValue=r,this.useExisting=i,this.useFactory=o,this.deps=l.normalizeBlank(s),this.multi=l.normalizeBool(a)}return t.fromJson=function(e){return new t({token:a(e.token,O.fromJson),useClass:a(e.useClass,x.fromJson),useExisting:a(e.useExisting,O.fromJson),useValue:a(e.useValue,w.fromJson),useFactory:a(e.useFactory,S.fromJson),multi:e.multi,deps:o(e.deps,C.fromJson)})},t.prototype.toJson=function(){return{"class":"Provider",token:u(this.token),useClass:u(this.useClass),useExisting:u(this.useExisting),useValue:u(this.useValue),useFactory:u(this.useFactory),multi:this.multi,deps:s(this.deps)}},t}();e.CompileProviderMetadata=R;var S=function(){function t(t){var e=t.runtime,n=t.name,r=t.moduleUrl,i=t.prefix,o=t.diDeps,s=t.value;this.runtime=e,this.name=n,this.prefix=i,this.moduleUrl=r,this.diDeps=c(o),this.value=s}return Object.defineProperty(t.prototype,"identifier",{get:function(){return this},enumerable:!0,configurable:!0}),t.fromJson=function(e){return new t({name:e.name,prefix:e.prefix,moduleUrl:e.moduleUrl,value:e.value,diDeps:o(e.diDeps,C.fromJson)})},t.prototype.toJson=function(){return{"class":"Factory",name:this.name,prefix:this.prefix,moduleUrl:this.moduleUrl,value:this.value,diDeps:s(this.diDeps)}},t}();e.CompileFactoryMetadata=S;var O=function(){function t(t){var e=t.value,n=t.identifier,r=t.identifierIsInstance;this.value=e,this.identifier=n,this.identifierIsInstance=l.normalizeBool(r)}return t.fromJson=function(e){return new t({value:e.value,identifier:a(e.identifier,w.fromJson),identifierIsInstance:e.identifierIsInstance})},t.prototype.toJson=function(){return{value:this.value,identifier:u(this.identifier),identifierIsInstance:this.identifierIsInstance}},Object.defineProperty(t.prototype,"runtimeCacheKey",{get:function(){return l.isPresent(this.identifier)?this.identifier.runtime:this.value},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"assetCacheKey",{get:function(){return l.isPresent(this.identifier)?l.isPresent(this.identifier.moduleUrl)&&l.isPresent(_.getUrlScheme(this.identifier.moduleUrl))?this.identifier.name+"|"+this.identifier.moduleUrl+"|"+this.identifierIsInstance:null:this.value},enumerable:!0,configurable:!0}),t.prototype.equalsTo=function(t){var e=this.runtimeCacheKey,n=this.assetCacheKey;return l.isPresent(e)&&e==t.runtimeCacheKey||l.isPresent(n)&&n==t.assetCacheKey},Object.defineProperty(t.prototype,"name",{get:function(){return l.isPresent(this.value)?m.sanitizeIdentifier(this.value):this.identifier.name},enumerable:!0,configurable:!0}),t}();e.CompileTokenMetadata=O;var T=function(){function t(){this._valueMap=new Map,this._values=[]}return t.prototype.add=function(t,e){var n=this.get(t);if(l.isPresent(n))throw new h.BaseException("Can only add to a TokenMap! Token: "+t.name);this._values.push(e);var r=t.runtimeCacheKey;l.isPresent(r)&&this._valueMap.set(r,e);var i=t.assetCacheKey;l.isPresent(i)&&this._valueMap.set(i,e)},t.prototype.get=function(t){var e,n=t.runtimeCacheKey,r=t.assetCacheKey;return l.isPresent(n)&&(e=this._valueMap.get(n)),l.isBlank(e)&&l.isPresent(r)&&(e=this._valueMap.get(r)),e},t.prototype.values=function(){return this._values},Object.defineProperty(t.prototype,"size",{get:function(){return this._values.length},enumerable:!0,configurable:!0}),t}();e.CompileTokenMap=T;var x=function(){function t(t){var e=void 0===t?{}:t,n=e.runtime,r=e.name,i=e.moduleUrl,o=e.prefix,s=e.isHost,a=e.value,u=e.diDeps;this.runtime=n,this.name=r,this.moduleUrl=i,this.prefix=o,this.isHost=l.normalizeBool(s),this.value=a,this.diDeps=c(u)}return t.fromJson=function(e){return new t({name:e.name,moduleUrl:e.moduleUrl,prefix:e.prefix,isHost:e.isHost,value:e.value,diDeps:o(e.diDeps,C.fromJson)})},Object.defineProperty(t.prototype,"identifier",{get:function(){return this},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"type",{get:function(){return this},enumerable:!0,configurable:!0}),t.prototype.toJson=function(){return{"class":"Type",name:this.name,moduleUrl:this.moduleUrl,prefix:this.prefix,isHost:this.isHost,value:this.value,diDeps:s(this.diDeps)}},t}();e.CompileTypeMetadata=x;var A=function(){function t(t){var e=void 0===t?{}:t,n=e.selectors,r=e.descendants,i=e.first,o=e.propertyName,s=e.read;this.selectors=n,this.descendants=l.normalizeBool(r),this.first=l.normalizeBool(i),this.propertyName=o,this.read=s}return t.fromJson=function(e){return new t({selectors:o(e.selectors,O.fromJson),descendants:e.descendants,first:e.first,propertyName:e.propertyName,read:a(e.read,O.fromJson)})},t.prototype.toJson=function(){return{selectors:s(this.selectors),descendants:this.descendants,first:this.first,propertyName:this.propertyName,read:u(this.read)}},t}();e.CompileQueryMetadata=A;var I=function(){function t(t){var e=void 0===t?{}:t,n=e.encapsulation,r=e.template,i=e.templateUrl,o=e.styles,s=e.styleUrls,a=e.ngContentSelectors;this.encapsulation=l.isPresent(n)?n:v.ViewEncapsulation.Emulated,this.template=r,this.templateUrl=i,this.styles=l.isPresent(o)?o:[],this.styleUrls=l.isPresent(s)?s:[],this.ngContentSelectors=l.isPresent(a)?a:[]}return t.fromJson=function(e){return new t({encapsulation:l.isPresent(e.encapsulation)?v.VIEW_ENCAPSULATION_VALUES[e.encapsulation]:e.encapsulation,template:e.template,templateUrl:e.templateUrl,styles:e.styles,styleUrls:e.styleUrls,ngContentSelectors:e.ngContentSelectors})},t.prototype.toJson=function(){return{encapsulation:l.isPresent(this.encapsulation)?l.serializeEnum(this.encapsulation):this.encapsulation,template:this.template,templateUrl:this.templateUrl,styles:this.styles,styleUrls:this.styleUrls,ngContentSelectors:this.ngContentSelectors}},t}();e.CompileTemplateMetadata=I;var M=function(){function t(t){var e=void 0===t?{}:t,n=e.type,r=e.isComponent,i=e.selector,o=e.exportAs,s=e.changeDetection,a=e.inputs,u=e.outputs,p=e.hostListeners,l=e.hostProperties,h=e.hostAttributes,f=e.lifecycleHooks,d=e.providers,v=e.viewProviders,y=e.queries,m=e.viewQueries,g=e.template;this.type=n,this.isComponent=r,this.selector=i,this.exportAs=o,this.changeDetection=s,this.inputs=a,this.outputs=u,this.hostListeners=p,this.hostProperties=l,this.hostAttributes=h,this.lifecycleHooks=c(f),this.providers=c(d),this.viewProviders=c(v),this.queries=c(y),this.viewQueries=c(m),this.template=g}return t.create=function(e){var n=void 0===e?{}:e,r=n.type,i=n.isComponent,o=n.selector,s=n.exportAs,a=n.changeDetection,u=n.inputs,c=n.outputs,p=n.host,h=n.lifecycleHooks,d=n.providers,v=n.viewProviders,y=n.queries,g=n.viewQueries,_=n.template,P={},E={},w={};l.isPresent(p)&&f.StringMapWrapper.forEach(p,function(t,e){var n=l.RegExpWrapper.firstMatch(b,e);l.isBlank(n)?w[e]=t:l.isPresent(n[1])?E[n[1]]=t:l.isPresent(n[2])&&(P[n[2]]=t)});var C={};l.isPresent(u)&&u.forEach(function(t){var e=m.splitAtColon(t,[t,t]);C[e[0]]=e[1]});var R={};return l.isPresent(c)&&c.forEach(function(t){var e=m.splitAtColon(t,[t,t]);R[e[0]]=e[1]}),new t({type:r,isComponent:l.normalizeBool(i),selector:o,exportAs:s,changeDetection:a,inputs:C,outputs:R,hostListeners:P,hostProperties:E,hostAttributes:w,lifecycleHooks:l.isPresent(h)?h:[],providers:d,viewProviders:v,queries:y,viewQueries:g,template:_})},Object.defineProperty(t.prototype,"identifier",{get:function(){return this.type},enumerable:!0,configurable:!0}),t.fromJson=function(e){return new t({isComponent:e.isComponent,selector:e.selector,exportAs:e.exportAs,type:l.isPresent(e.type)?x.fromJson(e.type):e.type,changeDetection:l.isPresent(e.changeDetection)?d.CHANGE_DETECTION_STRATEGY_VALUES[e.changeDetection]:e.changeDetection,inputs:e.inputs,outputs:e.outputs,hostListeners:e.hostListeners,hostProperties:e.hostProperties,hostAttributes:e.hostAttributes,lifecycleHooks:e.lifecycleHooks.map(function(t){return g.LIFECYCLE_HOOKS_VALUES[t]}),template:l.isPresent(e.template)?I.fromJson(e.template):e.template,providers:o(e.providers,r),viewProviders:o(e.viewProviders,r),queries:o(e.queries,A.fromJson),viewQueries:o(e.viewQueries,A.fromJson)})},t.prototype.toJson=function(){return{"class":"Directive",isComponent:this.isComponent,selector:this.selector,exportAs:this.exportAs,type:l.isPresent(this.type)?this.type.toJson():this.type,changeDetection:l.isPresent(this.changeDetection)?l.serializeEnum(this.changeDetection):this.changeDetection,inputs:this.inputs,outputs:this.outputs,hostListeners:this.hostListeners,hostProperties:this.hostProperties,hostAttributes:this.hostAttributes,lifecycleHooks:this.lifecycleHooks.map(function(t){return l.serializeEnum(t)}),template:l.isPresent(this.template)?this.template.toJson():this.template,providers:s(this.providers),viewProviders:s(this.viewProviders),queries:s(this.queries),viewQueries:s(this.viewQueries)}},t}();e.CompileDirectiveMetadata=M,e.createHostComponentMeta=i;var k=function(){function t(t){var e=void 0===t?{}:t,n=e.type,r=e.name,i=e.pure,o=e.lifecycleHooks;this.type=n,this.name=r,this.pure=l.normalizeBool(i),this.lifecycleHooks=c(o)}return Object.defineProperty(t.prototype,"identifier",{get:function(){return this.type},enumerable:!0,configurable:!0}),t.fromJson=function(e){return new t({type:l.isPresent(e.type)?x.fromJson(e.type):e.type,name:e.name,pure:e.pure})},t.prototype.toJson=function(){return{"class":"Pipe",type:l.isPresent(this.type)?this.type.toJson():null,name:this.name,pure:this.pure}},t}();e.CompilePipeMetadata=k;var N={Directive:M.fromJson,Pipe:k.fromJson,Type:x.fromJson,Provider:R.fromJson,Identifier:w.fromJson,Factory:S.fromJson}},function(t,e){"use strict";!function(t){t[t.OnInit=0]="OnInit",t[t.OnDestroy=1]="OnDestroy",t[t.DoCheck=2]="DoCheck",t[t.OnChanges=3]="OnChanges",t[t.AfterContentInit=4]="AfterContentInit",t[t.AfterContentChecked=5]="AfterContentChecked",t[t.AfterViewInit=6]="AfterViewInit",t[t.AfterViewChecked=7]="AfterViewChecked"}(e.LifecycleHooks||(e.LifecycleHooks={}));var n=e.LifecycleHooks;e.LIFECYCLE_HOOKS_VALUES=[n.OnInit,n.OnDestroy,n.DoCheck,n.OnChanges,n.AfterContentInit,n.AfterContentChecked,n.AfterViewInit,n.AfterViewChecked]},function(t,e,n){"use strict";function r(){return new _}function i(){return new _(g)}function o(t){var e=a(t);return e&&e[b.Scheme]||""}function s(t,e,n,r,i,o,s){var a=[];return v.isPresent(t)&&a.push(t+":"),v.isPresent(n)&&(a.push("//"),v.isPresent(e)&&a.push(e+"@"),a.push(n),v.isPresent(r)&&a.push(":"+r)),v.isPresent(i)&&a.push(i),v.isPresent(o)&&a.push("?"+o),v.isPresent(s)&&a.push("#"+s),a.join("")}function a(t){return v.RegExpWrapper.firstMatch(P,t)}function u(t){if("/"==t)return"/";for(var e="/"==t[0]?"/":"",n="/"===t[t.length-1]?"/":"",r=t.split("/"),i=[],o=0,s=0;s0?i.pop():o++;break;default:i.push(a)}}if(""==e){for(;o-- >0;)i.unshift("..");0===i.length&&i.push(".")}return e+i.join("/")+n}function c(t){var e=t[b.Path];return e=v.isBlank(e)?"":u(e),t[b.Path]=e,s(t[b.Scheme],t[b.UserInfo],t[b.Domain],t[b.Port],e,t[b.QueryData],t[b.Fragment])}function p(t,e){var n=a(encodeURI(e)),r=a(t);if(v.isPresent(n[b.Scheme]))return c(n);n[b.Scheme]=r[b.Scheme];for(var i=b.Scheme;i<=b.Port;i++)v.isBlank(n[i])&&(n[i]=r[i]);if("/"==n[b.Path][0])return c(n);var o=r[b.Path];v.isBlank(o)&&(o="/");var s=o.lastIndexOf("/");return o=o.substring(0,s+1)+n[b.Path],n[b.Path]=o,c(n)}var l=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},h=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},f=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},d=n(6),v=n(5),y=n(62),m=n(6),g="asset:";e.createUrlResolverWithoutPackagePrefix=r,e.createOfflineCompileUrlResolver=i,e.DEFAULT_PACKAGE_URL_PROVIDER=new m.Provider(y.PACKAGE_ROOT_URL,{useValue:"/"});var _=function(){function t(t){void 0===t&&(t=null),this._packagePrefix=t}return t.prototype.resolve=function(t,e){var n=e;v.isPresent(t)&&t.length>0&&(n=p(t,n));var r=a(n),i=this._packagePrefix;if(v.isPresent(i)&&v.isPresent(r)&&"package"==r[b.Scheme]){var o=r[b.Path];if(this._packagePrefix!==g)return i=v.StringWrapper.stripRight(i,"/"),o=v.StringWrapper.stripLeft(o,"/"),i+"/"+o;var s=o.split(/\//);n="asset:"+s[0]+"/lib/"+s.slice(1).join("/")}return n},t=l([d.Injectable(),f(0,d.Inject(y.PACKAGE_ROOT_URL)),h("design:paramtypes",[String])],t)}();e.UrlResolver=_,e.getUrlScheme=o;var b,P=v.RegExpWrapper.create("^(?:([^:/?#.]+):)?(?://(?:([^/?#]*)@)?([\\w\\d\\-\\u0100-\\uffff.%]*)(?::([0-9]+))?)?([^?#]+)?(?:\\?([^#]*))?(?:#(.*))?$");!function(t){t[t.Scheme=1]="Scheme",t[t.UserInfo=2]="UserInfo",t[t.Domain=3]="Domain",t[t.Port=4]="Port",t[t.Path=5]="Path",t[t.QueryData=6]="QueryData",t[t.Fragment=7]="Fragment"}(b||(b={}))},function(t,e,n){"use strict";function r(t){return new i.CompileTokenMetadata({identifier:t})}var i=n(155),o=n(159),s=n(160),a=n(66),u=n(28),c=n(67),p=n(69),l=n(70),h=n(74),f=n(36),d=n(68),v=n(78),y=n(11),m=n(81),g=n(153),_="asset:angular2/lib/src/core/linker/view"+g.MODULE_SUFFIX,b="asset:angular2/lib/src/core/linker/view_utils"+g.MODULE_SUFFIX,P="asset:angular2/lib/src/core/change_detection/change_detection"+g.MODULE_SUFFIX,E=a.ViewUtils,w=o.AppView,C=s.DebugContext,R=c.AppElement,S=p.ElementRef,O=l.ViewContainerRef,T=u.ChangeDetectorRef,x=h.RenderComponentType,A=v.QueryList,I=m.TemplateRef,M=m.TemplateRef_,k=u.ValueUnwrapper,N=y.Injector,D=f.ViewEncapsulation,V=d.ViewType,j=u.ChangeDetectionStrategy,L=s.StaticNodeDebugInfo,B=h.Renderer,F=u.SimpleChange,U=u.uninitialized,W=u.ChangeDetectorState,H=a.flattenNestedViewRenderNodes,X=u.devModeEqual,q=a.interpolate,G=a.checkBinding,z=a.castByValue,K=function(){function t(){}return t.ViewUtils=new i.CompileIdentifierMetadata({name:"ViewUtils",moduleUrl:"asset:angular2/lib/src/core/linker/view_utils"+g.MODULE_SUFFIX,runtime:E}),t.AppView=new i.CompileIdentifierMetadata({name:"AppView",moduleUrl:_,runtime:w}),t.AppElement=new i.CompileIdentifierMetadata({name:"AppElement",moduleUrl:"asset:angular2/lib/src/core/linker/element"+g.MODULE_SUFFIX,runtime:R}),t.ElementRef=new i.CompileIdentifierMetadata({name:"ElementRef",moduleUrl:"asset:angular2/lib/src/core/linker/element_ref"+g.MODULE_SUFFIX,runtime:S}),t.ViewContainerRef=new i.CompileIdentifierMetadata({name:"ViewContainerRef",moduleUrl:"asset:angular2/lib/src/core/linker/view_container_ref"+g.MODULE_SUFFIX,runtime:O}),t.ChangeDetectorRef=new i.CompileIdentifierMetadata({name:"ChangeDetectorRef",moduleUrl:"asset:angular2/lib/src/core/change_detection/change_detector_ref"+g.MODULE_SUFFIX,runtime:T}),t.RenderComponentType=new i.CompileIdentifierMetadata({name:"RenderComponentType",moduleUrl:"asset:angular2/lib/src/core/render/api"+g.MODULE_SUFFIX,runtime:x}),t.QueryList=new i.CompileIdentifierMetadata({name:"QueryList",moduleUrl:"asset:angular2/lib/src/core/linker/query_list"+g.MODULE_SUFFIX,runtime:A}),t.TemplateRef=new i.CompileIdentifierMetadata({name:"TemplateRef",moduleUrl:"asset:angular2/lib/src/core/linker/template_ref"+g.MODULE_SUFFIX,runtime:I}),t.TemplateRef_=new i.CompileIdentifierMetadata({name:"TemplateRef_",moduleUrl:"asset:angular2/lib/src/core/linker/template_ref"+g.MODULE_SUFFIX,runtime:M}),t.ValueUnwrapper=new i.CompileIdentifierMetadata({name:"ValueUnwrapper",moduleUrl:P,runtime:k}),t.Injector=new i.CompileIdentifierMetadata({name:"Injector",moduleUrl:"asset:angular2/lib/src/core/di/injector"+g.MODULE_SUFFIX,runtime:N}),t.ViewEncapsulation=new i.CompileIdentifierMetadata({name:"ViewEncapsulation",moduleUrl:"asset:angular2/lib/src/core/metadata/view"+g.MODULE_SUFFIX,runtime:D}),t.ViewType=new i.CompileIdentifierMetadata({name:"ViewType",moduleUrl:"asset:angular2/lib/src/core/linker/view_type"+g.MODULE_SUFFIX,runtime:V}),t.ChangeDetectionStrategy=new i.CompileIdentifierMetadata({name:"ChangeDetectionStrategy",moduleUrl:P,runtime:j}),t.StaticNodeDebugInfo=new i.CompileIdentifierMetadata({name:"StaticNodeDebugInfo",moduleUrl:"asset:angular2/lib/src/core/linker/debug_context"+g.MODULE_SUFFIX,runtime:L}),t.DebugContext=new i.CompileIdentifierMetadata({name:"DebugContext",moduleUrl:"asset:angular2/lib/src/core/linker/debug_context"+g.MODULE_SUFFIX,runtime:C}),t.Renderer=new i.CompileIdentifierMetadata({name:"Renderer",moduleUrl:"asset:angular2/lib/src/core/render/api"+g.MODULE_SUFFIX,runtime:B}),t.SimpleChange=new i.CompileIdentifierMetadata({name:"SimpleChange",moduleUrl:P,runtime:F}),t.uninitialized=new i.CompileIdentifierMetadata({name:"uninitialized",moduleUrl:P,runtime:U}),t.ChangeDetectorState=new i.CompileIdentifierMetadata({name:"ChangeDetectorState",moduleUrl:P,runtime:W}),t.checkBinding=new i.CompileIdentifierMetadata({name:"checkBinding",moduleUrl:b,runtime:G}),t.flattenNestedViewRenderNodes=new i.CompileIdentifierMetadata({name:"flattenNestedViewRenderNodes",moduleUrl:b,runtime:H}),t.devModeEqual=new i.CompileIdentifierMetadata({name:"devModeEqual",moduleUrl:P,runtime:X}),t.interpolate=new i.CompileIdentifierMetadata({name:"interpolate",moduleUrl:b,runtime:q}),t.castByValue=new i.CompileIdentifierMetadata({name:"castByValue",moduleUrl:b,runtime:z}),t.pureProxies=[null,new i.CompileIdentifierMetadata({name:"pureProxy1",moduleUrl:b,runtime:a.pureProxy1}),new i.CompileIdentifierMetadata({name:"pureProxy2",moduleUrl:b,runtime:a.pureProxy2}),new i.CompileIdentifierMetadata({name:"pureProxy3",moduleUrl:b,runtime:a.pureProxy3}),new i.CompileIdentifierMetadata({name:"pureProxy4",moduleUrl:b,runtime:a.pureProxy4}),new i.CompileIdentifierMetadata({name:"pureProxy5",moduleUrl:b,runtime:a.pureProxy5}),new i.CompileIdentifierMetadata({name:"pureProxy6",moduleUrl:b,runtime:a.pureProxy6}),new i.CompileIdentifierMetadata({name:"pureProxy7",moduleUrl:b,runtime:a.pureProxy7}),new i.CompileIdentifierMetadata({name:"pureProxy8",moduleUrl:b,runtime:a.pureProxy8}),new i.CompileIdentifierMetadata({name:"pureProxy9",moduleUrl:b,runtime:a.pureProxy9}),new i.CompileIdentifierMetadata({name:"pureProxy10",moduleUrl:b,runtime:a.pureProxy10})],t}();e.Identifiers=K,e.identifierToken=r},function(t,e,n){"use strict";function r(t){var e;if(t instanceof o.AppElement){var n=t;if(e=n.nativeElement,s.isPresent(n.nestedViews))for(var i=n.nestedViews.length-1;i>=0;i--){var a=n.nestedViews[i];a.rootNodesOrAppElements.length>0&&(e=r(a.rootNodesOrAppElements[a.rootNodesOrAppElements.length-1]))}}else e=t;return e}var i=n(15),o=n(67),s=n(5),a=n(40),u=n(82),c=n(68),p=n(66),l=n(28),h=n(71),f=n(73),d=n(160),v=n(161),y=s.CONST_EXPR(new Object),m=h.wtfCreateScope("AppView#check(ascii id)"),g=function(){function t(t,e,n,r,i,o,s,a,p){this.clazz=t,this.componentType=e,this.type=n,this.locals=r,this.viewUtils=i,this.parentInjector=o,this.declarationAppElement=s,this.cdMode=a,this.staticNodeDebugInfos=p,this.contentChildren=[],this.viewChildren=[],this.viewContainerElement=null,this.cdState=l.ChangeDetectorState.NeverChecked,this.context=null,this.destroyed=!1,this._currentDebugContext=null,this.ref=new u.ViewRef_(this),n===c.ViewType.COMPONENT||n===c.ViewType.HOST?this.renderer=i.renderComponent(e):this.renderer=s.parentView.renderer}return t.prototype.create=function(t,e){var n,r;switch(this.type){case c.ViewType.COMPONENT:n=this.declarationAppElement.component,r=p.ensureSlotCount(t,this.componentType.slotCount);break;case c.ViewType.EMBEDDED:n=this.declarationAppElement.parentView.context,r=this.declarationAppElement.parentView.projectableNodes;break;case c.ViewType.HOST:n=y,r=t}if(this._hasExternalHostElement=s.isPresent(e),this.context=n,this.projectableNodes=r,!this.debugMode)return this.createInternal(e);this._resetDebug();try{return this.createInternal(e)}catch(i){throw this._rethrowWithContext(i,i.stack),i}},t.prototype.createInternal=function(t){return null},t.prototype.init=function(t,e,n,r){this.rootNodesOrAppElements=t,this.allNodes=e,this.disposables=n,this.subscriptions=r,this.type===c.ViewType.COMPONENT&&(this.declarationAppElement.parentView.viewChildren.push(this),this.renderParent=this.declarationAppElement.parentView,this.dirtyParentQueriesInternal())},t.prototype.selectOrCreateHostElement=function(t,e,n){var r;return r=s.isPresent(e)?this.renderer.selectRootElement(e,n):this.renderer.createElement(null,t,n)},t.prototype.injectorGet=function(t,e,n){if(!this.debugMode)return this.injectorGetInternal(t,e,n);this._resetDebug();try{return this.injectorGetInternal(t,e,n)}catch(r){throw this._rethrowWithContext(r,r.stack),r}},t.prototype.injectorGetInternal=function(t,e,n){return n},t.prototype.injector=function(t){return s.isPresent(t)?new v.ElementInjector(this,t):this.parentInjector},t.prototype.destroy=function(){this._hasExternalHostElement?this.renderer.detachView(this.flatRootNodes):s.isPresent(this.viewContainerElement)&&this.viewContainerElement.detachView(this.viewContainerElement.nestedViews.indexOf(this)),this._destroyRecurse()},t.prototype._destroyRecurse=function(){if(!this.destroyed){for(var t=this.contentChildren,e=0;e0?this.rootNodesOrAppElements[this.rootNodesOrAppElements.length-1]:null;return r(t)},enumerable:!0,configurable:!0}),t.prototype.hasLocal=function(t){return i.StringMapWrapper.contains(this.locals,t)},t.prototype.setLocal=function(t,e){this.locals[t]=e},t.prototype.dirtyParentQueriesInternal=function(){},t.prototype.addRenderContentChild=function(t){this.contentChildren.push(t),t.renderParent=this,t.dirtyParentQueriesInternal()},t.prototype.removeContentChild=function(t){i.ListWrapper.remove(this.contentChildren,t),t.dirtyParentQueriesInternal(),t.renderParent=null},t.prototype.detectChanges=function(t){var e=m(this.clazz);if(this.cdMode!==l.ChangeDetectionStrategy.Detached&&this.cdMode!==l.ChangeDetectionStrategy.Checked&&this.cdState!==l.ChangeDetectorState.Errored){if(this.destroyed&&this.throwDestroyedError("detectChanges"),this.debugMode){this._resetDebug();try{this.detectChangesInternal(t)}catch(n){throw this._rethrowWithContext(n,n.stack),n}}else this.detectChangesInternal(t);this.cdMode===l.ChangeDetectionStrategy.CheckOnce&&(this.cdMode=l.ChangeDetectionStrategy.Checked),this.cdState=l.ChangeDetectorState.CheckedBefore,h.wtfLeave(e)}},t.prototype.detectChangesInternal=function(t){this.detectContentChildrenChanges(t),this.detectViewChildrenChanges(t)},t.prototype.detectContentChildrenChanges=function(t){for(var e=0;eo?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=n(15),a=n(68),u=function(){function t(t,e,n){this.providerTokens=t,this.componentToken=e,this.varTokens=n}return t=r([o.CONST(),i("design:paramtypes",[Array,Object,Object])],t)}();e.StaticNodeDebugInfo=u;var c=function(){function t(t,e,n,r){this._view=t,this._nodeIndex=e,this._tplRow=n,this._tplCol=r}return Object.defineProperty(t.prototype,"_staticNodeInfo",{get:function(){return o.isPresent(this._nodeIndex)?this._view.staticNodeDebugInfos[this._nodeIndex]:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"context",{get:function(){return this._view.context},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"component",{get:function(){var t=this._staticNodeInfo;return o.isPresent(t)&&o.isPresent(t.componentToken)?this.injector.get(t.componentToken):null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"componentRenderElement",{get:function(){for(var t=this._view;o.isPresent(t.declarationAppElement)&&t.type!==a.ViewType.COMPONENT;)t=t.declarationAppElement.parentView;return o.isPresent(t.declarationAppElement)?t.declarationAppElement.nativeElement:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"injector",{get:function(){return this._view.injector(this._nodeIndex)},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"renderNode",{get:function(){return o.isPresent(this._nodeIndex)&&o.isPresent(this._view.allNodes)?this._view.allNodes[this._nodeIndex]:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"providerTokens",{get:function(){var t=this._staticNodeInfo;return o.isPresent(t)?t.providerTokens:null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"source",{get:function(){return this._view.componentType.templateUrl+":"+this._tplRow+":"+this._tplCol},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"locals",{get:function(){var t=this,e={};return s.ListWrapper.forEachWithIndex(this._view.staticNodeDebugInfos,function(n,r){var i=n.varTokens;s.StringMapWrapper.forEach(i,function(n,i){var s;s=o.isBlank(n)?o.isPresent(t._view.allNodes)?t._view.allNodes[r]:null:t._view.injectorGet(n,r,null), -e[i]=s})}),s.StringMapWrapper.forEach(this._view.locals,function(t,n){e[n]=t}),e},enumerable:!0,configurable:!0}),t}();e.DebugContext=c},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(5),o=n(11),s=i.CONST_EXPR(new Object),a=function(t){function e(e,n){t.call(this),this._view=e,this._nodeIndex=n}return r(e,t),e.prototype.get=function(t,e){void 0===e&&(e=o.THROW_IF_NOT_FOUND);var n=s;return n===s&&(n=this._view.injectorGet(t,this._nodeIndex,s)),n===s&&(n=this._view.parentInjector.get(t,e)),n},e}(o.Injector);e.ElementInjector=a},function(t,e,n){"use strict";var r=n(5),i=n(12),o=n(158),s=function(){function t(t,e,n,i){void 0===i&&(i=null),this.genDebugInfo=t,this.logBindingUpdate=e,this.useJit=n,r.isBlank(i)&&(i=new u),this.renderTypes=i}return t}();e.CompilerConfig=s;var a=function(){function t(){}return Object.defineProperty(t.prototype,"renderer",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"renderText",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"renderElement",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"renderComment",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"renderNode",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"renderEvent",{get:function(){return i.unimplemented()},enumerable:!0,configurable:!0}),t}();e.RenderTypes=a;var u=function(){function t(){this.renderer=o.Identifiers.Renderer,this.renderText=null,this.renderElement=null,this.renderComment=null,this.renderNode=null,this.renderEvent=null}return t}();e.DefaultRenderTypes=u},function(t,e,n){"use strict";function r(t){return t.dependencies.forEach(function(t){t.factoryPlaceholder.moduleUrl=o(t.comp)}),t.statements}function i(t){return t.dependencies.forEach(function(t){t.valuePlaceholder.moduleUrl=s(t.sourceUrl,t.isShimmed)}),t.statements}function o(t){var e=t.type.moduleUrl,n=e.substring(0,e.length-f.MODULE_SUFFIX.length);return n+".ngfactory"+f.MODULE_SUFFIX}function s(t,e){return e?t+".shim"+f.MODULE_SUFFIX:""+t+f.MODULE_SUFFIX}function a(t){if(!t.isComponent)throw new c.BaseException("Could not compile '"+t.type.name+"' because it is not a component.")}var u=n(155),c=n(12),p=n(15),l=n(164),h=n(65),f=n(153),d=new u.CompileIdentifierMetadata({name:"ComponentFactory",runtime:h.ComponentFactory,moduleUrl:"asset:angular2/lib/src/core/linker/component_factory"+f.MODULE_SUFFIX}),v=function(){function t(t,e){this.moduleUrl=t,this.source=e}return t}();e.SourceModule=v;var y=function(){function t(t,e,n){this.component=t,this.directives=e,this.pipes=n}return t}();e.NormalizedComponentWithViewDirectives=y;var m=function(){function t(t,e,n,r,i){this._directiveNormalizer=t,this._templateParser=e,this._styleCompiler=n,this._viewCompiler=r,this._outputEmitter=i}return t.prototype.normalizeDirectiveMetadata=function(t){return this._directiveNormalizer.normalizeDirective(t)},t.prototype.compileTemplates=function(t){var e=this;if(0===t.length)throw new c.BaseException("No components given");var n=[],r=[],i=o(t[0].component);return t.forEach(function(t){var i=t.component;a(i);var o=e._compileComponent(i,t.directives,t.pipes,n);r.push(o);var s=u.createHostComponentMeta(i.type,i.selector),c=e._compileComponent(s,[i],[],n),p=i.type.name+"NgFactory";n.push(l.variable(p).set(l.importExpr(d).instantiate([l.literal(i.selector),l.variable(c),l.importExpr(i.type)],l.importType(d,null,[l.TypeModifier.Const]))).toDeclStmt(null,[l.StmtModifier.Final])),r.push(p)}),this._codegenSourceModule(i,n,r)},t.prototype.compileStylesheet=function(t,e){var n=this._styleCompiler.compileStylesheet(t,e,!1),r=this._styleCompiler.compileStylesheet(t,e,!0);return[this._codegenSourceModule(s(t,!1),i(n),[n.stylesVar]),this._codegenSourceModule(s(t,!0),i(r),[r.stylesVar])]},t.prototype._compileComponent=function(t,e,n,o){var s=this._styleCompiler.compileComponent(t),a=this._templateParser.parse(t,t.template.template,e,n,t.type.name),u=this._viewCompiler.compileComponent(t,a,l.variable(s.stylesVar),n);return p.ListWrapper.addAll(o,i(s)),p.ListWrapper.addAll(o,r(u)),u.viewFactoryVar},t.prototype._codegenSourceModule=function(t,e,n){return new v(t,this._outputEmitter.emitStatements(t,e,n))},t}();e.OfflineCompiler=m},function(t,e,n){"use strict";function r(t,e,n){var r=new ot(t,e);return n.visitExpression(r,null)}function i(t){var e=new st;return e.visitAllStatements(t,null),e.varNames}function o(t,e){return void 0===e&&(e=null),new C(t,e)}function s(t,e){return void 0===e&&(e=null),new M(t,null,e)}function a(t,e,n){return void 0===e&&(e=null),void 0===n&&(n=null),d.isPresent(t)?new g(t,e,n):null}function u(t,e){return void 0===e&&(e=null),new I(t,e)}function c(t,e){return void 0===e&&(e=null),new U(t,e)}function p(t,e){return void 0===e&&(e=null),new W(t,e)}function l(t){return new N(t)}function h(t,e,n){return void 0===n&&(n=null),new j(t,e,n)}var f=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},d=n(5);!function(t){t[t.Const=0]="Const"}(e.TypeModifier||(e.TypeModifier={}));var v=(e.TypeModifier,function(){function t(t){void 0===t&&(t=null),this.modifiers=t,d.isBlank(t)&&(this.modifiers=[])}return t.prototype.hasModifier=function(t){return-1!==this.modifiers.indexOf(t)},t}());e.Type=v,function(t){t[t.Dynamic=0]="Dynamic",t[t.Bool=1]="Bool",t[t.String=2]="String",t[t.Int=3]="Int",t[t.Number=4]="Number",t[t.Function=5]="Function"}(e.BuiltinTypeName||(e.BuiltinTypeName={}));var y=e.BuiltinTypeName,m=function(t){function e(e,n){void 0===n&&(n=null),t.call(this,n),this.name=e}return f(e,t),e.prototype.visitType=function(t,e){return t.visitBuiltintType(this,e)},e}(v);e.BuiltinType=m;var g=function(t){function e(e,n,r){void 0===n&&(n=null),void 0===r&&(r=null),t.call(this,r),this.value=e,this.typeParams=n}return f(e,t),e.prototype.visitType=function(t,e){return t.visitExternalType(this,e)},e}(v);e.ExternalType=g;var _=function(t){function e(e,n){void 0===n&&(n=null),t.call(this,n),this.of=e}return f(e,t),e.prototype.visitType=function(t,e){return t.visitArrayType(this,e)},e}(v);e.ArrayType=_;var b=function(t){function e(e,n){void 0===n&&(n=null),t.call(this,n),this.valueType=e}return f(e,t),e.prototype.visitType=function(t,e){return t.visitMapType(this,e)},e}(v);e.MapType=b,e.DYNAMIC_TYPE=new m(y.Dynamic),e.BOOL_TYPE=new m(y.Bool),e.INT_TYPE=new m(y.Int),e.NUMBER_TYPE=new m(y.Number),e.STRING_TYPE=new m(y.String),e.FUNCTION_TYPE=new m(y.Function),function(t){t[t.Equals=0]="Equals",t[t.NotEquals=1]="NotEquals",t[t.Identical=2]="Identical",t[t.NotIdentical=3]="NotIdentical",t[t.Minus=4]="Minus",t[t.Plus=5]="Plus",t[t.Divide=6]="Divide",t[t.Multiply=7]="Multiply",t[t.Modulo=8]="Modulo",t[t.And=9]="And",t[t.Or=10]="Or",t[t.Lower=11]="Lower",t[t.LowerEquals=12]="LowerEquals",t[t.Bigger=13]="Bigger",t[t.BiggerEquals=14]="BiggerEquals"}(e.BinaryOperator||(e.BinaryOperator={}));var P=e.BinaryOperator,E=function(){function t(t){this.type=t}return t.prototype.prop=function(t){return new B(this,t)},t.prototype.key=function(t,e){return void 0===e&&(e=null),new F(this,t,e)},t.prototype.callMethod=function(t,e){return new T(this,t,e)},t.prototype.callFn=function(t){return new x(this,t)},t.prototype.instantiate=function(t,e){return void 0===e&&(e=null),new A(this,t,e)},t.prototype.conditional=function(t,e){return void 0===e&&(e=null),new k(this,t,e)},t.prototype.equals=function(t){return new L(P.Equals,this,t)},t.prototype.notEquals=function(t){return new L(P.NotEquals,this,t)},t.prototype.identical=function(t){return new L(P.Identical,this,t)},t.prototype.notIdentical=function(t){return new L(P.NotIdentical,this,t)},t.prototype.minus=function(t){return new L(P.Minus,this,t)},t.prototype.plus=function(t){return new L(P.Plus,this,t)},t.prototype.divide=function(t){return new L(P.Divide,this,t)},t.prototype.multiply=function(t){return new L(P.Multiply,this,t)},t.prototype.modulo=function(t){return new L(P.Modulo,this,t)},t.prototype.and=function(t){return new L(P.And,this,t)},t.prototype.or=function(t){return new L(P.Or,this,t)},t.prototype.lower=function(t){return new L(P.Lower,this,t)},t.prototype.lowerEquals=function(t){return new L(P.LowerEquals,this,t)},t.prototype.bigger=function(t){return new L(P.Bigger,this,t)},t.prototype.biggerEquals=function(t){return new L(P.BiggerEquals,this,t)},t.prototype.isBlank=function(){return this.equals(e.NULL_EXPR)},t.prototype.cast=function(t){return new D(this,t)},t.prototype.toStmt=function(){return new G(this)},t}();e.Expression=E,function(t){t[t.This=0]="This",t[t.Super=1]="Super",t[t.CatchError=2]="CatchError",t[t.CatchStack=3]="CatchStack"}(e.BuiltinVar||(e.BuiltinVar={}));var w=e.BuiltinVar,C=function(t){function e(e,n){void 0===n&&(n=null),t.call(this,n),d.isString(e)?(this.name=e,this.builtin=null):(this.name=null,this.builtin=e)}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitReadVarExpr(this,e)},e.prototype.set=function(t){return new R(this.name,t)},e}(E);e.ReadVarExpr=C;var R=function(t){function e(e,n,r){void 0===r&&(r=null),t.call(this,d.isPresent(r)?r:n.type),this.name=e,this.value=n}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitWriteVarExpr(this,e)},e.prototype.toDeclStmt=function(t,e){return void 0===t&&(t=null),void 0===e&&(e=null),new X(this.name,this.value,t,e)},e}(E);e.WriteVarExpr=R;var S=function(t){function e(e,n,r,i){void 0===i&&(i=null),t.call(this,d.isPresent(i)?i:r.type),this.receiver=e,this.index=n,this.value=r}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitWriteKeyExpr(this,e)},e}(E);e.WriteKeyExpr=S;var O=function(t){function e(e,n,r,i){void 0===i&&(i=null),t.call(this,d.isPresent(i)?i:r.type),this.receiver=e,this.name=n,this.value=r}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitWritePropExpr(this,e)},e}(E);e.WritePropExpr=O,function(t){t[t.ConcatArray=0]="ConcatArray",t[t.SubscribeObservable=1]="SubscribeObservable",t[t.bind=2]="bind"}(e.BuiltinMethod||(e.BuiltinMethod={}));var T=(e.BuiltinMethod,function(t){function e(e,n,r,i){void 0===i&&(i=null),t.call(this,i),this.receiver=e,this.args=r,d.isString(n)?(this.name=n,this.builtin=null):(this.name=null,this.builtin=n)}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitInvokeMethodExpr(this,e)},e}(E));e.InvokeMethodExpr=T;var x=function(t){function e(e,n,r){void 0===r&&(r=null),t.call(this,r),this.fn=e,this.args=n}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitInvokeFunctionExpr(this,e)},e}(E);e.InvokeFunctionExpr=x;var A=function(t){function e(e,n,r){t.call(this,r),this.classExpr=e,this.args=n}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitInstantiateExpr(this,e)},e}(E);e.InstantiateExpr=A;var I=function(t){function e(e,n){void 0===n&&(n=null),t.call(this,n),this.value=e}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitLiteralExpr(this,e)},e}(E);e.LiteralExpr=I;var M=function(t){function e(e,n,r){void 0===n&&(n=null),void 0===r&&(r=null),t.call(this,n),this.value=e,this.typeParams=r}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitExternalExpr(this,e)},e}(E);e.ExternalExpr=M;var k=function(t){function e(e,n,r,i){void 0===r&&(r=null),void 0===i&&(i=null),t.call(this,d.isPresent(i)?i:n.type),this.condition=e,this.falseCase=r,this.trueCase=n}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitConditionalExpr(this,e)},e}(E);e.ConditionalExpr=k;var N=function(t){function n(n){t.call(this,e.BOOL_TYPE),this.condition=n}return f(n,t),n.prototype.visitExpression=function(t,e){return t.visitNotExpr(this,e)},n}(E);e.NotExpr=N;var D=function(t){function e(e,n){t.call(this,n),this.value=e}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitCastExpr(this,e)},e}(E);e.CastExpr=D;var V=function(){function t(t,e){void 0===e&&(e=null),this.name=t,this.type=e}return t}();e.FnParam=V;var j=function(t){function e(e,n,r){void 0===r&&(r=null),t.call(this,r),this.params=e,this.statements=n}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitFunctionExpr(this,e)},e.prototype.toDeclStmt=function(t,e){return void 0===e&&(e=null),new q(t,this.params,this.statements,this.type,e)},e}(E);e.FunctionExpr=j;var L=function(t){function e(e,n,r,i){void 0===i&&(i=null),t.call(this,d.isPresent(i)?i:n.type),this.operator=e,this.rhs=r,this.lhs=n}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitBinaryOperatorExpr(this,e)},e}(E);e.BinaryOperatorExpr=L;var B=function(t){function e(e,n,r){void 0===r&&(r=null),t.call(this,r),this.receiver=e,this.name=n}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitReadPropExpr(this,e)},e.prototype.set=function(t){return new O(this.receiver,this.name,t)},e}(E);e.ReadPropExpr=B;var F=function(t){function e(e,n,r){void 0===r&&(r=null),t.call(this,r),this.receiver=e,this.index=n}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitReadKeyExpr(this,e)},e.prototype.set=function(t){return new S(this.receiver,this.index,t)},e}(E);e.ReadKeyExpr=F;var U=function(t){function e(e,n){void 0===n&&(n=null),t.call(this,n),this.entries=e}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitLiteralArrayExpr(this,e)},e}(E);e.LiteralArrayExpr=U;var W=function(t){function e(e,n){void 0===n&&(n=null),t.call(this,n),this.entries=e,this.valueType=null,d.isPresent(n)&&(this.valueType=n.valueType)}return f(e,t),e.prototype.visitExpression=function(t,e){return t.visitLiteralMapExpr(this,e)},e}(E);e.LiteralMapExpr=W,e.THIS_EXPR=new C(w.This),e.SUPER_EXPR=new C(w.Super),e.CATCH_ERROR_VAR=new C(w.CatchError),e.CATCH_STACK_VAR=new C(w.CatchStack),e.NULL_EXPR=new I(null,null),function(t){t[t.Final=0]="Final",t[t.Private=1]="Private"}(e.StmtModifier||(e.StmtModifier={}));var H=(e.StmtModifier,function(){function t(t){void 0===t&&(t=null),this.modifiers=t,d.isBlank(t)&&(this.modifiers=[])}return t.prototype.hasModifier=function(t){return-1!==this.modifiers.indexOf(t)},t}());e.Statement=H;var X=function(t){function e(e,n,r,i){void 0===r&&(r=null),void 0===i&&(i=null),t.call(this,i),this.name=e,this.value=n,this.type=d.isPresent(r)?r:n.type}return f(e,t),e.prototype.visitStatement=function(t,e){return t.visitDeclareVarStmt(this,e)},e}(H);e.DeclareVarStmt=X;var q=function(t){function e(e,n,r,i,o){void 0===i&&(i=null),void 0===o&&(o=null),t.call(this,o),this.name=e,this.params=n,this.statements=r,this.type=i}return f(e,t),e.prototype.visitStatement=function(t,e){return t.visitDeclareFunctionStmt(this,e)},e}(H);e.DeclareFunctionStmt=q;var G=function(t){function e(e){t.call(this),this.expr=e}return f(e,t),e.prototype.visitStatement=function(t,e){return t.visitExpressionStmt(this,e)},e}(H);e.ExpressionStatement=G;var z=function(t){function e(e){t.call(this),this.value=e}return f(e,t),e.prototype.visitStatement=function(t,e){return t.visitReturnStmt(this,e)},e}(H);e.ReturnStatement=z;var K=function(){function t(t,e){void 0===t&&(t=null),this.type=t,this.modifiers=e,d.isBlank(e)&&(this.modifiers=[])}return t.prototype.hasModifier=function(t){return-1!==this.modifiers.indexOf(t)},t}();e.AbstractClassPart=K;var $=function(t){function e(e,n,r){void 0===n&&(n=null),void 0===r&&(r=null),t.call(this,n,r),this.name=e}return f(e,t),e}(K);e.ClassField=$;var Q=function(t){function e(e,n,r,i,o){void 0===i&&(i=null),void 0===o&&(o=null),t.call(this,i,o),this.name=e,this.params=n,this.body=r}return f(e,t),e}(K);e.ClassMethod=Q;var J=function(t){function e(e,n,r,i){void 0===r&&(r=null),void 0===i&&(i=null),t.call(this,r,i),this.name=e,this.body=n}return f(e,t),e}(K);e.ClassGetter=J;var Z=function(t){function e(e,n,r,i,o,s,a){void 0===a&&(a=null),t.call(this,a),this.name=e,this.parent=n,this.fields=r,this.getters=i,this.constructorMethod=o,this.methods=s}return f(e,t),e.prototype.visitStatement=function(t,e){return t.visitDeclareClassStmt(this,e)},e}(H);e.ClassStmt=Z;var Y=function(t){function e(e,n,r){void 0===r&&(r=d.CONST_EXPR([])),t.call(this),this.condition=e,this.trueCase=n,this.falseCase=r}return f(e,t),e.prototype.visitStatement=function(t,e){return t.visitIfStmt(this,e)},e}(H);e.IfStmt=Y;var tt=function(t){function e(e){t.call(this),this.comment=e}return f(e,t),e.prototype.visitStatement=function(t,e){return t.visitCommentStmt(this,e)},e}(H);e.CommentStmt=tt;var et=function(t){function e(e,n){t.call(this),this.bodyStmts=e,this.catchStmts=n}return f(e,t),e.prototype.visitStatement=function(t,e){return t.visitTryCatchStmt(this,e)},e}(H);e.TryCatchStmt=et;var nt=function(t){function e(e){t.call(this),this.error=e}return f(e,t),e.prototype.visitStatement=function(t,e){return t.visitThrowStmt(this,e)},e}(H);e.ThrowStmt=nt;var rt=function(){function t(){}return t.prototype.visitReadVarExpr=function(t,e){return t},t.prototype.visitWriteVarExpr=function(t,e){return new R(t.name,t.value.visitExpression(this,e))},t.prototype.visitWriteKeyExpr=function(t,e){return new S(t.receiver.visitExpression(this,e),t.index.visitExpression(this,e),t.value.visitExpression(this,e))},t.prototype.visitWritePropExpr=function(t,e){return new O(t.receiver.visitExpression(this,e),t.name,t.value.visitExpression(this,e))},t.prototype.visitInvokeMethodExpr=function(t,e){var n=d.isPresent(t.builtin)?t.builtin:t.name;return new T(t.receiver.visitExpression(this,e),n,this.visitAllExpressions(t.args,e),t.type)},t.prototype.visitInvokeFunctionExpr=function(t,e){return new x(t.fn.visitExpression(this,e),this.visitAllExpressions(t.args,e),t.type)},t.prototype.visitInstantiateExpr=function(t,e){return new A(t.classExpr.visitExpression(this,e),this.visitAllExpressions(t.args,e),t.type)},t.prototype.visitLiteralExpr=function(t,e){return t},t.prototype.visitExternalExpr=function(t,e){return t},t.prototype.visitConditionalExpr=function(t,e){return new k(t.condition.visitExpression(this,e),t.trueCase.visitExpression(this,e),t.falseCase.visitExpression(this,e))},t.prototype.visitNotExpr=function(t,e){return new N(t.condition.visitExpression(this,e))},t.prototype.visitCastExpr=function(t,e){return new D(t.value.visitExpression(this,e),e)},t.prototype.visitFunctionExpr=function(t,e){return t},t.prototype.visitBinaryOperatorExpr=function(t,e){return new L(t.operator,t.lhs.visitExpression(this,e),t.rhs.visitExpression(this,e),t.type)},t.prototype.visitReadPropExpr=function(t,e){return new B(t.receiver.visitExpression(this,e),t.name,t.type)},t.prototype.visitReadKeyExpr=function(t,e){return new F(t.receiver.visitExpression(this,e),t.index.visitExpression(this,e),t.type)},t.prototype.visitLiteralArrayExpr=function(t,e){return new U(this.visitAllExpressions(t.entries,e))},t.prototype.visitLiteralMapExpr=function(t,e){var n=this;return new W(t.entries.map(function(t){return[t[0],t[1].visitExpression(n,e)]}))},t.prototype.visitAllExpressions=function(t,e){var n=this;return t.map(function(t){return t.visitExpression(n,e)})},t.prototype.visitDeclareVarStmt=function(t,e){return new X(t.name,t.value.visitExpression(this,e),t.type,t.modifiers)},t.prototype.visitDeclareFunctionStmt=function(t,e){return t},t.prototype.visitExpressionStmt=function(t,e){return new G(t.expr.visitExpression(this,e))},t.prototype.visitReturnStmt=function(t,e){return new z(t.value.visitExpression(this,e))},t.prototype.visitDeclareClassStmt=function(t,e){return t},t.prototype.visitIfStmt=function(t,e){return new Y(t.condition.visitExpression(this,e),this.visitAllStatements(t.trueCase,e),this.visitAllStatements(t.falseCase,e))},t.prototype.visitTryCatchStmt=function(t,e){return new et(this.visitAllStatements(t.bodyStmts,e),this.visitAllStatements(t.catchStmts,e))},t.prototype.visitThrowStmt=function(t,e){return new nt(t.error.visitExpression(this,e))},t.prototype.visitCommentStmt=function(t,e){return t},t.prototype.visitAllStatements=function(t,e){var n=this;return t.map(function(t){return t.visitStatement(n,e)})},t}();e.ExpressionTransformer=rt;var it=function(){function t(){}return t.prototype.visitReadVarExpr=function(t,e){return t},t.prototype.visitWriteVarExpr=function(t,e){return t.value.visitExpression(this,e),t},t.prototype.visitWriteKeyExpr=function(t,e){return t.receiver.visitExpression(this,e),t.index.visitExpression(this,e),t.value.visitExpression(this,e),t},t.prototype.visitWritePropExpr=function(t,e){return t.receiver.visitExpression(this,e),t.value.visitExpression(this,e),t},t.prototype.visitInvokeMethodExpr=function(t,e){return t.receiver.visitExpression(this,e),this.visitAllExpressions(t.args,e),t},t.prototype.visitInvokeFunctionExpr=function(t,e){return t.fn.visitExpression(this,e),this.visitAllExpressions(t.args,e),t},t.prototype.visitInstantiateExpr=function(t,e){return t.classExpr.visitExpression(this,e),this.visitAllExpressions(t.args,e),t},t.prototype.visitLiteralExpr=function(t,e){return t},t.prototype.visitExternalExpr=function(t,e){return t},t.prototype.visitConditionalExpr=function(t,e){return t.condition.visitExpression(this,e),t.trueCase.visitExpression(this,e),t.falseCase.visitExpression(this,e),t},t.prototype.visitNotExpr=function(t,e){return t.condition.visitExpression(this,e),t},t.prototype.visitCastExpr=function(t,e){return t.value.visitExpression(this,e),t},t.prototype.visitFunctionExpr=function(t,e){return t},t.prototype.visitBinaryOperatorExpr=function(t,e){return t.lhs.visitExpression(this,e),t.rhs.visitExpression(this,e),t},t.prototype.visitReadPropExpr=function(t,e){return t.receiver.visitExpression(this,e),t},t.prototype.visitReadKeyExpr=function(t,e){return t.receiver.visitExpression(this,e),t.index.visitExpression(this,e),t},t.prototype.visitLiteralArrayExpr=function(t,e){return this.visitAllExpressions(t.entries,e),t},t.prototype.visitLiteralMapExpr=function(t,e){var n=this;return t.entries.forEach(function(t){return t[1].visitExpression(n,e)}),t},t.prototype.visitAllExpressions=function(t,e){var n=this;t.forEach(function(t){return t.visitExpression(n,e)})},t.prototype.visitDeclareVarStmt=function(t,e){return t.value.visitExpression(this,e),t},t.prototype.visitDeclareFunctionStmt=function(t,e){return t},t.prototype.visitExpressionStmt=function(t,e){return t.expr.visitExpression(this,e),t},t.prototype.visitReturnStmt=function(t,e){return t.value.visitExpression(this,e),t},t.prototype.visitDeclareClassStmt=function(t,e){return t},t.prototype.visitIfStmt=function(t,e){return t.condition.visitExpression(this,e),this.visitAllStatements(t.trueCase,e),this.visitAllStatements(t.falseCase,e),t},t.prototype.visitTryCatchStmt=function(t,e){return this.visitAllStatements(t.bodyStmts,e),this.visitAllStatements(t.catchStmts,e),t},t.prototype.visitThrowStmt=function(t,e){return t.error.visitExpression(this,e),t},t.prototype.visitCommentStmt=function(t,e){return t},t.prototype.visitAllStatements=function(t,e){var n=this;t.forEach(function(t){return t.visitStatement(n,e)})},t}();e.RecursiveExpressionVisitor=it,e.replaceVarInExpression=r;var ot=function(t){function e(e,n){t.call(this),this._varName=e,this._newValue=n}return f(e,t),e.prototype.visitReadVarExpr=function(t,e){return t.name==this._varName?this._newValue:t},e}(rt);e.findReadVarNames=i;var st=function(t){function e(){t.apply(this,arguments),this.varNames=new Set}return f(e,t),e.prototype.visitReadVarExpr=function(t,e){return this.varNames.add(t.name),null},e}(it);e.variable=o,e.importExpr=s,e.importType=a,e.literal=u,e.literalArr=c,e.literalMap=p,e.not=l,e.fn=h},function(t,e,n){"use strict";function r(t){if(!t.isComponent)throw new a.BaseException("Could not compile '"+t.type.name+"' because it is not a component.")}var i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(5),a=n(12),u=n(15),c=n(40),p=n(155),l=n(6),h=n(166),f=n(168),d=n(140),v=n(183),y=n(185),m=n(65),g=n(162),_=n(164),b=n(191),P=n(194),E=n(198),w=n(184),C=function(){function t(t,e,n,r,i,o,s){this._runtimeMetadataResolver=t,this._templateNormalizer=e,this._templateParser=n,this._styleCompiler=r,this._viewCompiler=i,this._xhr=o,this._genConfig=s,this._styleCache=new Map,this._hostCacheKeys=new Map,this._compiledTemplateCache=new Map,this._compiledTemplateDone=new Map}return t.prototype.resolveComponent=function(t){var e=this._runtimeMetadataResolver.getDirectiveMetadata(t),n=this._hostCacheKeys.get(t);if(s.isBlank(n)){n=new Object,this._hostCacheKeys.set(t,n),r(e);var i=p.createHostComponentMeta(e.type,e.selector);this._loadAndCompileComponent(n,i,[e],[],[])}return this._compiledTemplateDone.get(n).then(function(n){return new m.ComponentFactory(e.selector,n.viewFactory,t)})},t.prototype.clearCache=function(){this._styleCache.clear(),this._compiledTemplateCache.clear(),this._compiledTemplateDone.clear(),this._hostCacheKeys.clear()},t.prototype._loadAndCompileComponent=function(t,e,n,r,i){var o=this,a=this._compiledTemplateCache.get(t),u=this._compiledTemplateDone.get(t);return s.isBlank(a)&&(a=new R,this._compiledTemplateCache.set(t,a),u=c.PromiseWrapper.all([this._compileComponentStyles(e)].concat(n.map(function(t){return o._templateNormalizer.normalizeDirective(t)}))).then(function(t){var n=t.slice(1),s=t[0],u=o._templateParser.parse(e,e.template.template,n,r,e.type.name),p=[];return a.init(o._compileComponent(e,u,s,r,i,p)),c.PromiseWrapper.all(p).then(function(t){return a})}),this._compiledTemplateDone.set(t,u)),a},t.prototype._compileComponent=function(t,e,n,r,i,o){var a=this,c=this._viewCompiler.compileComponent(t,e,new _.ExternalExpr(new p.CompileIdentifierMetadata({runtime:n})),r);c.dependencies.forEach(function(t){var e=u.ListWrapper.clone(i),n=t.comp.type.runtime,r=a._runtimeMetadataResolver.getViewDirectivesMetadata(t.comp.type.runtime),s=a._runtimeMetadataResolver.getViewPipesMetadata(t.comp.type.runtime),c=u.ListWrapper.contains(e,n);e.push(n);var p=a._loadAndCompileComponent(t.comp.type.runtime,t.comp,r,s,e);t.factoryPlaceholder.runtime=p.proxyViewFactory,t.factoryPlaceholder.name="viewFactory_"+t.comp.type.name,c||o.push(a._compiledTemplateDone.get(n))});var l;return l=s.IS_DART||!this._genConfig.useJit?P.interpretStatements(c.statements,c.viewFactoryVar,new E.InterpretiveAppViewInstanceFactory):b.jitStatements(t.type.name+".template.js",c.statements,c.viewFactoryVar)},t.prototype._compileComponentStyles=function(t){var e=this._styleCompiler.compileComponent(t);return this._resolveStylesCompileResult(t.type.name,e)},t.prototype._resolveStylesCompileResult=function(t,e){var n=this,r=e.dependencies.map(function(t){return n._loadStylesheetDep(t)});return c.PromiseWrapper.all(r).then(function(t){for(var r=[],i=0;io?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(155),a=n(164),u=n(36),c=n(167),p=n(157),l=n(152),h=n(6),f=n(5),d="%COMP%",v="_nghost-"+d,y="_ngcontent-"+d,m=function(){function t(t,e,n){this.sourceUrl=t,this.isShimmed=e,this.valuePlaceholder=n}return t}();e.StylesCompileDependency=m;var g=function(){function t(t,e,n){this.statements=t,this.stylesVar=e,this.dependencies=n}return t}();e.StylesCompileResult=g;var _=function(){function t(t){this._urlResolver=t,this._shadowCss=new c.ShadowCss}return t.prototype.compileComponent=function(t){var e=t.template.encapsulation===u.ViewEncapsulation.Emulated;return this._compileStyles(r(t),t.template.styles,t.template.styleUrls,e)},t.prototype.compileStylesheet=function(t,e,n){var i=l.extractStyleUrls(this._urlResolver,t,e);return this._compileStyles(r(null),[i.style],i.styleUrls,n)},t.prototype._compileStyles=function(t,e,n,i){for(var o=this,u=e.map(function(t){return a.literal(o._shimIfNeeded(t,i))}),c=[],p=0;p0?o.push(u):(o.length>0&&(r.push(o.join("")),n.push(x),o=[]),n.push(u)),u==O&&i++}return o.length>0&&(r.push(o.join("")),n.push(x)),new I(n.join(""),r)}var s=n(15),a=n(5),u=function(){function t(){this.strictStyling=!0}return t.prototype.shimCssText=function(t,e,n){return void 0===n&&(n=""),t=r(t),t=this._insertDirectives(t),this._scopeCssText(t,e,n)},t.prototype._insertDirectives=function(t){return t=this._insertPolyfillDirectivesInCssText(t),this._insertPolyfillRulesInCssText(t)},t.prototype._insertPolyfillDirectivesInCssText=function(t){return a.StringWrapper.replaceAllMapped(t,c,function(t){return t[1]+"{"})},t.prototype._insertPolyfillRulesInCssText=function(t){return a.StringWrapper.replaceAllMapped(t,p,function(t){var e=t[0];return e=a.StringWrapper.replace(e,t[1],""),e=a.StringWrapper.replace(e,t[2],""),t[3]+e})},t.prototype._scopeCssText=function(t,e,n){var r=this._extractUnscopedRulesFromCssText(t);return t=this._insertPolyfillHostInCssText(t),t=this._convertColonHost(t),t=this._convertColonHostContext(t),t=this._convertShadowDOMSelectors(t), -a.isPresent(e)&&(t=this._scopeSelectors(t,e,n)),t=t+"\n"+r,t.trim()},t.prototype._extractUnscopedRulesFromCssText=function(t){for(var e,n="",r=a.RegExpWrapper.matcher(l,t);a.isPresent(e=a.RegExpMatcherWrapper.next(r));){var i=e[0];i=a.StringWrapper.replace(i,e[2],""),i=a.StringWrapper.replace(i,e[1],e[3]),n+=i+"\n\n"}return n},t.prototype._convertColonHost=function(t){return this._convertColonRule(t,v,this._colonHostPartReplacer)},t.prototype._convertColonHostContext=function(t){return this._convertColonRule(t,y,this._colonHostContextPartReplacer)},t.prototype._convertColonRule=function(t,e,n){return a.StringWrapper.replaceAllMapped(t,e,function(t){if(a.isPresent(t[2])){for(var e=t[2].split(","),r=[],i=0;i","+","~"],i=t,o="["+e+"]",u=0;u0&&!s.ListWrapper.contains(r,e)&&!a.StringWrapper.contains(e,o)){var n=/([^:]*)(:*)(.*)/g,i=a.RegExpWrapper.firstMatch(n,e);a.isPresent(i)&&(t=i[1]+o+i[2]+i[3])}return t}).join(c)}return i},t.prototype._insertPolyfillHostInCssText=function(t){return t=a.StringWrapper.replaceAll(t,w,f),t=a.StringWrapper.replaceAll(t,E,h)},t}();e.ShadowCss=u;var c=/polyfill-next-selector[^}]*content:[\s]*?['"](.*?)['"][;\s]*}([^{]*?){/gim,p=/(polyfill-rule)[^}]*(content:[\s]*['"](.*?)['"])[;\s]*[^}]*}/gim,l=/(polyfill-unscoped-rule)[^}]*(content:[\s]*['"](.*?)['"])[;\s]*[^}]*}/gim,h="-shadowcsshost",f="-shadowcsscontext",d=")(?:\\(((?:\\([^)(]*\\)|[^)(]*)+?)\\))?([^,{]*)",v=a.RegExpWrapper.create("("+h+d,"im"),y=a.RegExpWrapper.create("("+f+d,"im"),m=h+"-no-combinator",g=[/::shadow/g,/::content/g,/\/shadow-deep\//g,/\/shadow\//g],_=/(?:>>>)|(?:\/deep\/)/g,b="([>\\s~+[.,{:][\\s\\S]*)?$",P=a.RegExpWrapper.create(h,"im"),E=/:host/gim,w=/:host-context/gim,C=/\/\*[\s\S]*?\*\//g,R=/(\s*)([^;\{\}]+?)(\s*)((?:{%BLOCK%}?\s*;?)|(?:\s*;))/g,S=/([{}])/g,O="{",T="}",x="%BLOCK%",A=function(){function t(t,e){this.selector=t,this.content=e}return t}();e.CssRule=A,e.processRules=i;var I=function(){function t(t,e){this.escapedString=t,this.blocks=e}return t}()},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(6),s=n(169),a=n(174),u=n(176),c=n(162),p=function(){function t(t,e,n){this.statements=t,this.viewFactoryVar=e,this.dependencies=n}return t}();e.ViewCompileResult=p;var l=function(){function t(t){this._genConfig=t}return t.prototype.compileComponent=function(t,e,n,r){var i=[],o=[],c=new a.CompileView(t,this._genConfig,r,n,0,s.CompileElement.createNull(),[]);return u.buildView(c,e,o,i),new p(i,c.viewFactory.name,o)},t=r([o.Injectable(),i("design:paramtypes",[c.CompilerConfig])],t)}();e.ViewCompiler=l},function(t,e,n){"use strict";function r(t,e,n,r){var i;return i=e>0?s.literal(t).lowerEquals(u.InjectMethodVars.requestNodeIndex).and(u.InjectMethodVars.requestNodeIndex.lowerEquals(s.literal(t+e))):s.literal(t).identical(u.InjectMethodVars.requestNodeIndex),new s.IfStmt(u.InjectMethodVars.token.identical(f.createDiTokenExpression(n.token)).and(i),[new s.ReturnStatement(r)])}function i(t,e,n,r,i,o){var a,u,p=o.view;if(r?(a=s.literalArr(n),u=new s.ArrayType(s.DYNAMIC_TYPE)):(a=n[0],u=n[0].type),c.isBlank(u)&&(u=s.DYNAMIC_TYPE),i)p.fields.push(new s.ClassField(t,u,[s.StmtModifier.Private])),p.createMethod.addStmt(s.THIS_EXPR.prop(t).set(a).toStmt());else{var l="_"+t;p.fields.push(new s.ClassField(l,u,[s.StmtModifier.Private]));var h=new v.CompileMethod(p);h.resetDebugInfo(o.nodeIndex,o.sourceAst),h.addStmt(new s.IfStmt(s.THIS_EXPR.prop(l).isBlank(),[s.THIS_EXPR.prop(l).set(a).toStmt()])),h.addStmt(new s.ReturnStatement(s.THIS_EXPR.prop(l))),p.getters.push(new s.ClassGetter(t,h.finish(),u))}return s.THIS_EXPR.prop(t)}var o=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},s=n(164),a=n(158),u=n(170),c=n(5),p=n(15),l=n(139),h=n(155),f=n(171),d=n(172),v=n(173),y=function(){function t(t,e,n,r,i){this.parent=t,this.view=e,this.nodeIndex=n,this.renderNode=r,this.sourceAst=i}return t.prototype.isNull=function(){return c.isBlank(this.renderNode)},t.prototype.isRootElement=function(){return this.view!=this.parent.view},t}();e.CompileNode=y;var m=function(t){function e(e,n,r,i,o,u,p,l,f,d,v){t.call(this,e,n,r,i,o),this.component=u,this._directives=p,this._resolvedProvidersArray=l,this.hasViewContainer=f,this.hasEmbeddedView=d,this.variableTokens=v,this._compViewExpr=null,this._instances=new h.CompileTokenMap,this._queryCount=0,this._queries=new h.CompileTokenMap,this._componentConstructorViewQueryLists=[],this.contentNodesByNgContentIndex=null,this.elementRef=s.importExpr(a.Identifiers.ElementRef).instantiate([this.renderNode]),this._instances.add(a.identifierToken(a.Identifiers.ElementRef),this.elementRef),this.injector=s.THIS_EXPR.callMethod("injector",[s.literal(this.nodeIndex)]),this._instances.add(a.identifierToken(a.Identifiers.Injector),this.injector),this._instances.add(a.identifierToken(a.Identifiers.Renderer),s.THIS_EXPR.prop("renderer")),(this.hasViewContainer||this.hasEmbeddedView||c.isPresent(this.component))&&this._createAppElement()}return o(e,t),e.createNull=function(){return new e(null,null,null,null,null,null,[],[],!1,!1,{})},e.prototype._createAppElement=function(){var t="_appEl_"+this.nodeIndex,e=this.isRootElement()?null:this.parent.nodeIndex;this.view.fields.push(new s.ClassField(t,s.importType(a.Identifiers.AppElement),[s.StmtModifier.Private]));var n=s.THIS_EXPR.prop(t).set(s.importExpr(a.Identifiers.AppElement).instantiate([s.literal(this.nodeIndex),s.literal(e),s.THIS_EXPR,this.renderNode])).toStmt();this.view.createMethod.addStmt(n),this.appElement=s.THIS_EXPR.prop(t),this._instances.add(a.identifierToken(a.Identifiers.AppElement),this.appElement)},e.prototype.setComponentView=function(t){this._compViewExpr=t,this.contentNodesByNgContentIndex=p.ListWrapper.createFixedSize(this.component.template.ngContentSelectors.length);for(var e=0;e=i})),r._directives.length>0&&i++,r=r.parent;return e=this.view.componentView.viewQueries.get(t),c.isPresent(e)&&p.ListWrapper.addAll(n,e),n},e.prototype._addQuery=function(t,e){var n="_query_"+t.selectors[0].name+"_"+this.nodeIndex+"_"+this._queryCount++,r=d.createQueryList(t,e,n,this.view),i=new d.CompileQuery(t,r,e,this.view);return d.addQueryToTokenMap(this._queries,i),i},e.prototype._getLocalDependency=function(t,e){var n=null;if(c.isBlank(n)&&c.isPresent(e.query)&&(n=this._addQuery(e.query,null).queryList),c.isBlank(n)&&c.isPresent(e.viewQuery)&&(n=d.createQueryList(e.viewQuery,null,"_viewQuery_"+e.viewQuery.selectors[0].name+"_"+this.nodeIndex+"_"+this._componentConstructorViewQueryLists.length,this.view),this._componentConstructorViewQueryLists.push(n)),c.isPresent(e.token)){if(c.isBlank(n)&&e.token.equalsTo(a.identifierToken(a.Identifiers.ChangeDetectorRef)))return t===l.ProviderAstType.Component?this._compViewExpr.prop("ref"):s.THIS_EXPR.prop("ref");c.isBlank(n)&&(n=this._instances.get(e.token))}return n},e.prototype._getDependency=function(t,e){var n=this,r=null;for(e.isValue&&(r=s.literal(e.value)),c.isBlank(r)&&!e.isSkipSelf&&(r=this._getLocalDependency(t,e));c.isBlank(r)&&!n.parent.isNull();)n=n.parent,r=n._getLocalDependency(l.ProviderAstType.PublicService,new h.CompileDiDependencyMetadata({token:e.token}));return c.isBlank(r)&&(r=f.injectFromViewParentInjector(e.token,e.isOptional)),c.isBlank(r)&&(r=s.NULL_EXPR),f.getPropertyInView(r,this.view,n.view)},e}(y);e.CompileElement=m;var g=function(){function t(t,e){this.query=t,this.read=c.isPresent(t.meta.read)?t.meta.read:e}return t}()},function(t,e,n){"use strict";function r(t,e){if(i.isBlank(e))return c.NULL_EXPR;var n=i.resolveEnumToken(t.runtime,e);return c.importExpr(new o.CompileIdentifierMetadata({name:t.name+"."+n,moduleUrl:t.moduleUrl,runtime:e}))}var i=n(5),o=n(155),s=n(28),a=n(36),u=n(68),c=n(164),p=n(158),l=function(){function t(){}return t.fromValue=function(t){return r(p.Identifiers.ViewType,t)},t.HOST=t.fromValue(u.ViewType.HOST),t.COMPONENT=t.fromValue(u.ViewType.COMPONENT),t.EMBEDDED=t.fromValue(u.ViewType.EMBEDDED),t}();e.ViewTypeEnum=l;var h=function(){function t(){}return t.fromValue=function(t){return r(p.Identifiers.ViewEncapsulation,t)},t.Emulated=t.fromValue(a.ViewEncapsulation.Emulated),t.Native=t.fromValue(a.ViewEncapsulation.Native),t.None=t.fromValue(a.ViewEncapsulation.None),t}();e.ViewEncapsulationEnum=h;var f=function(){function t(){}return t.fromValue=function(t){return r(p.Identifiers.ChangeDetectorState,t)},t.NeverChecked=t.fromValue(s.ChangeDetectorState.NeverChecked),t.CheckedBefore=t.fromValue(s.ChangeDetectorState.CheckedBefore),t.Errored=t.fromValue(s.ChangeDetectorState.Errored),t}();e.ChangeDetectorStateEnum=f;var d=function(){function t(){}return t.fromValue=function(t){return r(p.Identifiers.ChangeDetectionStrategy,t)},t.CheckOnce=t.fromValue(s.ChangeDetectionStrategy.CheckOnce),t.Checked=t.fromValue(s.ChangeDetectionStrategy.Checked),t.CheckAlways=t.fromValue(s.ChangeDetectionStrategy.CheckAlways),t.Detached=t.fromValue(s.ChangeDetectionStrategy.Detached),t.OnPush=t.fromValue(s.ChangeDetectionStrategy.OnPush),t.Default=t.fromValue(s.ChangeDetectionStrategy.Default),t}();e.ChangeDetectionStrategyEnum=d;var v=function(){function t(){}return t.viewUtils=c.variable("viewUtils"),t.parentInjector=c.variable("parentInjector"),t.declarationEl=c.variable("declarationEl"),t}();e.ViewConstructorVars=v;var y=function(){function t(){}return t.renderer=c.THIS_EXPR.prop("renderer"),t.projectableNodes=c.THIS_EXPR.prop("projectableNodes"),t.viewUtils=c.THIS_EXPR.prop("viewUtils"),t}();e.ViewProperties=y;var m=function(){function t(){}return t.event=c.variable("$event"),t}();e.EventHandlerVars=m;var g=function(){function t(){}return t.token=c.variable("token"),t.requestNodeIndex=c.variable("requestNodeIndex"),t.notFoundResult=c.variable("notFoundResult"),t}();e.InjectMethodVars=g;var _=function(){function t(){}return t.throwOnChange=c.variable("throwOnChange"),t.changes=c.variable("changes"),t.changed=c.variable("changed"),t.valUnwrapper=c.variable("valUnwrapper"),t}();e.DetectChangesVars=_},function(t,e,n){"use strict";function r(t,e,n){if(e===n)return t;for(var r=l.THIS_EXPR,i=e;i!==n&&c.isPresent(i.declarationElement.view);)i=i.declarationElement.view,r=r.prop("parent");if(i!==n)throw new p.BaseException("Internal error: Could not calculate a property in a parent view: "+t);if(t instanceof l.ReadPropExpr){var o=t;(n.fields.some(function(t){return t.name==o.name})||n.getters.some(function(t){return t.name==o.name}))&&(r=r.cast(n.classType))}return l.replaceVarInExpression(l.THIS_EXPR.name,r,t)}function i(t,e){var n=[s(t)];return e&&n.push(l.NULL_EXPR),l.THIS_EXPR.prop("parentInjector").callMethod("get",n)}function o(t,e){return"viewFactory_"+t.type.name+e}function s(t){return c.isPresent(t.value)?l.literal(t.value):t.identifierIsInstance?l.importExpr(t.identifier).instantiate([],l.importType(t.identifier,[],[l.TypeModifier.Const])):l.importExpr(t.identifier)}function a(t){for(var e=[],n=l.literalArr([]),r=0;r0&&(n=n.callMethod(l.BuiltinMethod.ConcatArray,[l.literalArr(e)]),e=[]),n=n.callMethod(l.BuiltinMethod.ConcatArray,[i])):e.push(i)}return e.length>0&&(n=n.callMethod(l.BuiltinMethod.ConcatArray,[l.literalArr(e)])),n}function u(t,e,n,r){r.fields.push(new l.ClassField(n.name,null,[l.StmtModifier.Private]));var i=e0?s.values[s.values.length-1]:null;if(e instanceof h&&e.view===t.embeddedView)s=e;else{var n=new h(t.embeddedView,[]);s.values.push(n),s=n}}),s.values.push(t),r.length>0&&e.dirtyParentQueriesMethod.addStmt(o.callMethod("setDirty",[]).toStmt())},t.prototype.afterChildren=function(t){var e=r(this._values),n=[this.queryList.callMethod("reset",[c.literalArr(e)]).toStmt()];if(a.isPresent(this.ownerDirectiveExpression)){var i=this.meta.first?this.queryList.prop("first"):this.queryList;n.push(this.ownerDirectiveExpression.prop(this.meta.propertyName).set(i).toStmt())}this.meta.first||n.push(this.queryList.callMethod("notifyOnChanges",[]).toStmt()),t.addStmt(new c.IfStmt(this.queryList.prop("dirty"),n))},t}();e.CompileQuery=f,e.createQueryList=o,e.addQueryToTokenMap=s},function(t,e,n){"use strict";var r=n(5),i=n(15),o=n(164),s=function(){function t(t,e){this.nodeIndex=t,this.sourceAst=e}return t}(),a=new s(null,null),u=function(){function t(t){this._view=t,this._newState=a,this._currState=a,this._bodyStatements=[],this._debugEnabled=this._view.genConfig.genDebugInfo}return t.prototype._updateDebugContextIfNeeded=function(){if(this._newState.nodeIndex!==this._currState.nodeIndex||this._newState.sourceAst!==this._currState.sourceAst){var t=this._updateDebugContext(this._newState);r.isPresent(t)&&this._bodyStatements.push(t.toStmt())}},t.prototype._updateDebugContext=function(t){if(this._currState=this._newState=t,this._debugEnabled){var e=r.isPresent(t.sourceAst)?t.sourceAst.sourceSpan.start:null;return o.THIS_EXPR.callMethod("debug",[o.literal(t.nodeIndex),r.isPresent(e)?o.literal(e.line):o.NULL_EXPR,r.isPresent(e)?o.literal(e.col):o.NULL_EXPR])}return null},t.prototype.resetDebugInfoExpr=function(t,e){var n=this._updateDebugContext(new s(t,e));return r.isPresent(n)?n:o.NULL_EXPR},t.prototype.resetDebugInfo=function(t,e){this._newState=new s(t,e)},t.prototype.addStmt=function(t){this._updateDebugContextIfNeeded(),this._bodyStatements.push(t)},t.prototype.addStmts=function(t){this._updateDebugContextIfNeeded(),i.ListWrapper.addAll(this._bodyStatements,t)},t.prototype.finish=function(){return this._bodyStatements},t.prototype.isEmpty=function(){return 0===this._bodyStatements.length},t}();e.CompileMethod=u},function(t,e,n){"use strict";function r(t,e){return e>0?l.ViewType.EMBEDDED:t.type.isHost?l.ViewType.HOST:l.ViewType.COMPONENT}var i=n(5),o=n(15),s=n(164),a=n(170),u=n(172),c=n(173),p=n(175),l=n(68),h=n(155),f=n(171),d=function(){function t(t,e,n,a,p,d,v){var y=this;this.component=t,this.genConfig=e,this.pipeMetas=n,this.styles=a,this.viewIndex=p,this.declarationElement=d,this.templateVariableBindings=v,this.nodes=[],this.rootNodesOrAppElements=[],this.bindings=[],this.classStatements=[],this.eventHandlerMethods=[],this.fields=[],this.getters=[],this.disposables=[],this.subscriptions=[],this.purePipes=new Map,this.pipes=[],this.variables=new Map,this.literalArrayCount=0,this.literalMapCount=0,this.pipeCount=0,this.createMethod=new c.CompileMethod(this),this.injectorGetMethod=new c.CompileMethod(this),this.updateContentQueriesMethod=new c.CompileMethod(this),this.dirtyParentQueriesMethod=new c.CompileMethod(this),this.updateViewQueriesMethod=new c.CompileMethod(this),this.detectChangesInInputsMethod=new c.CompileMethod(this),this.detectChangesRenderPropertiesMethod=new c.CompileMethod(this),this.afterContentLifecycleCallbacksMethod=new c.CompileMethod(this),this.afterViewLifecycleCallbacksMethod=new c.CompileMethod(this),this.destroyMethod=new c.CompileMethod(this),this.viewType=r(t,p),this.className="_View_"+t.type.name+p,this.classType=s.importType(new h.CompileIdentifierMetadata({name:this.className})),this.viewFactory=s.variable(f.getViewFactoryName(t,p)),this.viewType===l.ViewType.COMPONENT||this.viewType===l.ViewType.HOST?this.componentView=this:this.componentView=this.declarationElement.view.componentView;var m=new h.CompileTokenMap;if(this.viewType===l.ViewType.COMPONENT){var g=s.THIS_EXPR.prop("context");o.ListWrapper.forEachWithIndex(this.component.viewQueries,function(t,e){var n="_viewQuery_"+t.selectors[0].name+"_"+e,r=u.createQueryList(t,g,n,y),i=new u.CompileQuery(t,r,g,y);u.addQueryToTokenMap(m,i)});var _=0;this.component.type.diDeps.forEach(function(t){if(i.isPresent(t.viewQuery)){var e=s.THIS_EXPR.prop("declarationAppElement").prop("componentConstructorViewQueries").key(s.literal(_++)),n=new u.CompileQuery(t.viewQuery,e,null,y);u.addQueryToTokenMap(m,n)}})}this.viewQueries=m,v.forEach(function(t){y.variables.set(t[1],s.THIS_EXPR.prop("locals").key(s.literal(t[0])))}),this.declarationElement.isNull()||this.declarationElement.setEmbeddedView(this)}return t.prototype.callPipe=function(t,e,n){var r=this.componentView,o=r.purePipes.get(t);return i.isBlank(o)&&(o=new p.CompilePipe(r,t),o.pure&&r.purePipes.set(t,o),r.pipes.push(o)),o.call(this,[e].concat(n))},t.prototype.getVariable=function(t){if(t==a.EventHandlerVars.event.name)return a.EventHandlerVars.event;for(var e=this,n=e.variables.get(t);i.isBlank(n)&&i.isPresent(e.declarationElement.view);)e=e.declarationElement.view,n=e.variables.get(t);return i.isPresent(n)?f.getPropertyInView(n,this,e):null},t.prototype.createLiteralArray=function(t){for(var e=s.THIS_EXPR.prop("_arr_"+this.literalArrayCount++),n=[],r=[],i=0;i=0;r--){var s=t.pipeMetas[r];if(s.name==e){n=s;break}}if(i.isBlank(n))throw new o.BaseException("Illegal state: Could not find pipe "+e+" although the parser should have detected this error!");return n}var i=n(5),o=n(12),s=n(164),a=n(158),u=n(171),c=function(){function t(t,e){this.instance=t,this.argCount=e}return t}(),p=function(){function t(t,e){this.view=t,this._purePipeProxies=[],this.meta=r(t,e),this.instance=s.THIS_EXPR.prop("_pipe_"+e+"_"+t.pipeCount++)}return Object.defineProperty(t.prototype,"pure",{get:function(){return this.meta.pure},enumerable:!0,configurable:!0}),t.prototype.create=function(){var t=this,e=this.meta.type.diDeps.map(function(t){return t.token.equalsTo(a.identifierToken(a.Identifiers.ChangeDetectorRef))?s.THIS_EXPR.prop("ref"):u.injectFromViewParentInjector(t.token,!1)});this.view.fields.push(new s.ClassField(this.instance.name,s.importType(this.meta.type),[s.StmtModifier.Private])),this.view.createMethod.resetDebugInfo(null,null),this.view.createMethod.addStmt(s.THIS_EXPR.prop(this.instance.name).set(s.importExpr(this.meta.type).instantiate(e)).toStmt()),this._purePipeProxies.forEach(function(e){u.createPureProxy(t.instance.prop("transform").callMethod(s.BuiltinMethod.bind,[t.instance]),e.argCount,e.instance,t.view)})},t.prototype.call=function(t,e){if(this.meta.pure){var n=new c(s.THIS_EXPR.prop(this.instance.name+"_"+this._purePipeProxies.length),e.length);return this._purePipeProxies.push(n),u.getPropertyInView(s.importExpr(a.Identifiers.castByValue).callFn([n.instance,this.instance.prop("transform")]),t,this.view).callFn(e)}return u.getPropertyInView(this.instance,t,this.view).callMethod("transform",e)},t}();e.CompilePipe=p},function(t,e,n){"use strict";function r(t,e,n,r){var i=new L(t,n,r);return S.templateVisitAll(i,e,t.declarationElement.isNull()?t.declarationElement:t.declarationElement.parent),I.bindView(t,e),t.afterNodes(),c(t,r),i.nestedViewCount}function i(t,e){var n={};return _.StringMapWrapper.forEach(t,function(t,e){n[e]=t}),e.forEach(function(t){_.StringMapWrapper.forEach(t.hostAttributes,function(t,e){var r=n[e];n[e]=g.isPresent(r)?a(e,r,t):t})}),u(n)}function o(t){var e={};return t.forEach(function(t){e[t.name]=t.value}),e}function s(t,e,n){var r={},i=null;return e.forEach(function(t){t.directive.isComponent&&(i=t.directive),t.exportAsVars.forEach(function(e){r[e.name]=P.identifierToken(t.directive.type)})}),t.forEach(function(t){r[t.name]=g.isPresent(i)?P.identifierToken(i.type):null}),r}function a(t,e,n){return t==k||t==N?e+" "+n:n}function u(t){var e=[];_.StringMapWrapper.forEach(t,function(t,n){e.push([n,t])}),_.ListWrapper.sort(e,function(t,e){return g.StringWrapper.compare(t[0],e[0])});var n=[];return e.forEach(function(t){n.push([t[0],t[1]])}),n}function c(t,e){var n=b.NULL_EXPR;t.genConfig.genDebugInfo&&(n=b.variable("nodeDebugInfos_"+t.component.type.name+t.viewIndex),e.push(n.set(b.literalArr(t.nodes.map(p),new b.ArrayType(new b.ExternalType(P.Identifiers.StaticNodeDebugInfo),[b.TypeModifier.Const]))).toDeclStmt(null,[b.StmtModifier.Final])));var r=b.variable("renderType_"+t.component.type.name);0===t.viewIndex&&e.push(r.set(b.NULL_EXPR).toDeclStmt(b.importType(P.Identifiers.RenderComponentType)));var i=l(t,r,n);e.push(i),e.push(h(t,i,r))}function p(t){var e=t instanceof R.CompileElement?t:null,n=[],r=b.NULL_EXPR,i=[];return g.isPresent(e)&&(n=e.getProviderTokens(),g.isPresent(e.component)&&(r=O.createDiTokenExpression(P.identifierToken(e.component.type))),_.StringMapWrapper.forEach(e.variableTokens,function(t,e){i.push([e,g.isPresent(t)?O.createDiTokenExpression(t):b.NULL_EXPR])})),b.importExpr(P.Identifiers.StaticNodeDebugInfo).instantiate([b.literalArr(n,new b.ArrayType(b.DYNAMIC_TYPE,[b.TypeModifier.Const])),r,b.literalMap(i,new b.MapType(b.DYNAMIC_TYPE,[b.TypeModifier.Const]))],b.importType(P.Identifiers.StaticNodeDebugInfo,null,[b.TypeModifier.Const]))}function l(t,e,n){var r=t.templateVariableBindings.map(function(t){return[t[0],b.NULL_EXPR]}),i=[new b.FnParam(E.ViewConstructorVars.viewUtils.name,b.importType(P.Identifiers.ViewUtils)),new b.FnParam(E.ViewConstructorVars.parentInjector.name,b.importType(P.Identifiers.Injector)),new b.FnParam(E.ViewConstructorVars.declarationEl.name,b.importType(P.Identifiers.AppElement))],o=new b.ClassMethod(null,i,[b.SUPER_EXPR.callFn([b.variable(t.className),e,E.ViewTypeEnum.fromValue(t.viewType),b.literalMap(r),E.ViewConstructorVars.viewUtils,E.ViewConstructorVars.parentInjector,E.ViewConstructorVars.declarationEl,E.ChangeDetectionStrategyEnum.fromValue(m(t)),n]).toStmt()]),s=[new b.ClassMethod("createInternal",[new b.FnParam(V.name,b.STRING_TYPE)],f(t),b.importType(P.Identifiers.AppElement)),new b.ClassMethod("injectorGetInternal",[new b.FnParam(E.InjectMethodVars.token.name,b.DYNAMIC_TYPE),new b.FnParam(E.InjectMethodVars.requestNodeIndex.name,b.NUMBER_TYPE),new b.FnParam(E.InjectMethodVars.notFoundResult.name,b.DYNAMIC_TYPE)],v(t.injectorGetMethod.finish(),E.InjectMethodVars.notFoundResult),b.DYNAMIC_TYPE),new b.ClassMethod("detectChangesInternal",[new b.FnParam(E.DetectChangesVars.throwOnChange.name,b.BOOL_TYPE)],d(t)),new b.ClassMethod("dirtyParentQueriesInternal",[],t.dirtyParentQueriesMethod.finish()),new b.ClassMethod("destroyInternal",[],t.destroyMethod.finish())].concat(t.eventHandlerMethods),a=new b.ClassStmt(t.className,b.importExpr(P.Identifiers.AppView,[y(t)]),t.fields,t.getters,o,s.filter(function(t){return t.body.length>0}));return a}function h(t,e,n){var r,i=[new b.FnParam(E.ViewConstructorVars.viewUtils.name,b.importType(P.Identifiers.ViewUtils)),new b.FnParam(E.ViewConstructorVars.parentInjector.name,b.importType(P.Identifiers.Injector)),new b.FnParam(E.ViewConstructorVars.declarationEl.name,b.importType(P.Identifiers.AppElement))],o=[];return r=t.component.template.templateUrl==t.component.type.moduleUrl?t.component.type.moduleUrl+" class "+t.component.type.name+" - inline template":t.component.template.templateUrl,0===t.viewIndex&&(o=[new b.IfStmt(n.identical(b.NULL_EXPR),[n.set(E.ViewConstructorVars.viewUtils.callMethod("createRenderComponentType",[b.literal(r),b.literal(t.component.template.ngContentSelectors.length),E.ViewEncapsulationEnum.fromValue(t.component.template.encapsulation),t.styles])).toStmt()])]), -b.fn(i,o.concat([new b.ReturnStatement(b.variable(e.name).instantiate(e.constructorMethod.params.map(function(t){return b.variable(t.name)})))]),b.importType(P.Identifiers.AppView,[y(t)])).toDeclStmt(t.viewFactory.name,[b.StmtModifier.Final])}function f(t){var e=b.NULL_EXPR,n=[];t.viewType===T.ViewType.COMPONENT&&(e=E.ViewProperties.renderer.callMethod("createViewRoot",[b.THIS_EXPR.prop("declarationAppElement").prop("nativeElement")]),n=[D.set(e).toDeclStmt(b.importType(t.genConfig.renderTypes.renderNode),[b.StmtModifier.Final])]);var r;return r=t.viewType===T.ViewType.HOST?t.nodes[0].appElement:b.NULL_EXPR,n.concat(t.createMethod.finish()).concat([b.THIS_EXPR.callMethod("init",[O.createFlatArray(t.rootNodesOrAppElements),b.literalArr(t.nodes.map(function(t){return t.renderNode})),b.literalArr(t.disposables),b.literalArr(t.subscriptions)]).toStmt(),new b.ReturnStatement(r)])}function d(t){var e=[];if(t.detectChangesInInputsMethod.isEmpty()&&t.updateContentQueriesMethod.isEmpty()&&t.afterContentLifecycleCallbacksMethod.isEmpty()&&t.detectChangesRenderPropertiesMethod.isEmpty()&&t.updateViewQueriesMethod.isEmpty()&&t.afterViewLifecycleCallbacksMethod.isEmpty())return e;_.ListWrapper.addAll(e,t.detectChangesInInputsMethod.finish()),e.push(b.THIS_EXPR.callMethod("detectContentChildrenChanges",[E.DetectChangesVars.throwOnChange]).toStmt());var n=t.updateContentQueriesMethod.finish().concat(t.afterContentLifecycleCallbacksMethod.finish());n.length>0&&e.push(new b.IfStmt(b.not(E.DetectChangesVars.throwOnChange),n)),_.ListWrapper.addAll(e,t.detectChangesRenderPropertiesMethod.finish()),e.push(b.THIS_EXPR.callMethod("detectViewChildrenChanges",[E.DetectChangesVars.throwOnChange]).toStmt());var r=t.updateViewQueriesMethod.finish().concat(t.afterViewLifecycleCallbacksMethod.finish());r.length>0&&e.push(new b.IfStmt(b.not(E.DetectChangesVars.throwOnChange),r));var i=[],o=b.findReadVarNames(e);return _.SetWrapper.has(o,E.DetectChangesVars.changed.name)&&i.push(E.DetectChangesVars.changed.set(b.literal(!0)).toDeclStmt(b.BOOL_TYPE)),_.SetWrapper.has(o,E.DetectChangesVars.changes.name)&&i.push(E.DetectChangesVars.changes.set(b.NULL_EXPR).toDeclStmt(new b.MapType(b.importType(P.Identifiers.SimpleChange)))),_.SetWrapper.has(o,E.DetectChangesVars.valUnwrapper.name)&&i.push(E.DetectChangesVars.valUnwrapper.set(b.importExpr(P.Identifiers.ValueUnwrapper).instantiate([])).toDeclStmt(null,[b.StmtModifier.Final])),i.concat(e)}function v(t,e){return t.length>0?t.concat([new b.ReturnStatement(e)]):t}function y(t){var e=t.component.type;return e.isHost?b.DYNAMIC_TYPE:b.importType(e)}function m(t){var e;return e=t.viewType===T.ViewType.COMPONENT?w.isDefaultChangeDetectionStrategy(t.component.changeDetection)?w.ChangeDetectionStrategy.CheckAlways:w.ChangeDetectionStrategy.CheckOnce:w.ChangeDetectionStrategy.CheckAlways}var g=n(5),_=n(15),b=n(164),P=n(158),E=n(170),w=n(28),C=n(174),R=n(169),S=n(139),O=n(171),T=n(68),x=n(36),A=n(155),I=n(177),M="$implicit",k="class",N="style",D=b.variable("parentRenderNode"),V=b.variable("rootSelector"),j=function(){function t(t,e){this.comp=t,this.factoryPlaceholder=e}return t}();e.ViewCompileDependency=j,e.buildView=r;var L=function(){function t(t,e,n){this.view=t,this.targetDependencies=e,this.targetStatements=n,this.nestedViewCount=0}return t.prototype._isRootNode=function(t){return t.view!==this.view},t.prototype._addRootNodeAndProject=function(t,e,n){var r=t instanceof R.CompileElement&&t.hasViewContainer?t.appElement:null;this._isRootNode(n)?this.view.viewType!==T.ViewType.COMPONENT&&this.view.rootNodesOrAppElements.push(g.isPresent(r)?r:t.renderNode):g.isPresent(n.component)&&g.isPresent(e)&&n.addContentNode(e,g.isPresent(r)?r:t.renderNode)},t.prototype._getParentRenderNode=function(t){return this._isRootNode(t)?this.view.viewType===T.ViewType.COMPONENT?D:b.NULL_EXPR:g.isPresent(t.component)&&t.component.template.encapsulation!==x.ViewEncapsulation.Native?b.NULL_EXPR:t.renderNode},t.prototype.visitBoundText=function(t,e){return this._visitText(t,"",t.ngContentIndex,e)},t.prototype.visitText=function(t,e){return this._visitText(t,t.value,t.ngContentIndex,e)},t.prototype._visitText=function(t,e,n,r){var i="_text_"+this.view.nodes.length;this.view.fields.push(new b.ClassField(i,b.importType(this.view.genConfig.renderTypes.renderText),[b.StmtModifier.Private]));var o=b.THIS_EXPR.prop(i),s=new R.CompileNode(r,this.view,this.view.nodes.length,o,t),a=b.THIS_EXPR.prop(i).set(E.ViewProperties.renderer.callMethod("createText",[this._getParentRenderNode(r),b.literal(e),this.view.createMethod.resetDebugInfoExpr(this.view.nodes.length,t)])).toStmt();return this.view.nodes.push(s),this.view.createMethod.addStmt(a),this._addRootNodeAndProject(s,n,r),o},t.prototype.visitNgContent=function(t,e){this.view.createMethod.resetDebugInfo(null,t);var n=this._getParentRenderNode(e),r=E.ViewProperties.projectableNodes.key(b.literal(t.index),new b.ArrayType(b.importType(this.view.genConfig.renderTypes.renderNode)));return n!==b.NULL_EXPR?this.view.createMethod.addStmt(E.ViewProperties.renderer.callMethod("projectNodes",[n,b.importExpr(P.Identifiers.flattenNestedViewRenderNodes).callFn([r])]).toStmt()):this._isRootNode(e)?this.view.viewType!==T.ViewType.COMPONENT&&this.view.rootNodesOrAppElements.push(r):g.isPresent(e.component)&&g.isPresent(t.ngContentIndex)&&e.addContentNode(t.ngContentIndex,r),null},t.prototype.visitElement=function(t,e){var n,r=this.view.nodes.length,a=this.view.createMethod.resetDebugInfoExpr(r,t);n=0===r&&this.view.viewType===T.ViewType.HOST?b.THIS_EXPR.callMethod("selectOrCreateHostElement",[b.literal(t.name),V,a]):E.ViewProperties.renderer.callMethod("createElement",[this._getParentRenderNode(e),b.literal(t.name),a]);var u="_el_"+r;this.view.fields.push(new b.ClassField(u,b.importType(this.view.genConfig.renderTypes.renderElement),[b.StmtModifier.Private])),this.view.createMethod.addStmt(b.THIS_EXPR.prop(u).set(n).toStmt());for(var c=b.THIS_EXPR.prop(u),p=t.getComponent(),l=t.directives.map(function(t){return t.directive}),h=s(t.exportAsVars,t.directives,this.view.viewType),f=o(t.attrs),d=i(f,l),v=0;v0?t.value:M,t.name]}),a=t.directives.map(function(t){return t.directive}),u=new R.CompileElement(e,this.view,n,o,t,null,a,t.providers,t.hasViewContainer,!0,{});this.view.nodes.push(u),this.nestedViewCount++;var c=new C.CompileView(this.view.component,this.view.genConfig,this.view.pipeMetas,b.NULL_EXPR,this.view.viewIndex+this.nestedViewCount,u,s);return this.nestedViewCount+=r(c,t.children,this.targetDependencies,this.targetStatements),u.beforeChildren(),this._addRootNodeAndProject(u,t.ngContentIndex,e),u.afterChildren(0),null},t.prototype.visitAttr=function(t,e){return null},t.prototype.visitDirective=function(t,e){return null},t.prototype.visitEvent=function(t,e){return null},t.prototype.visitVariable=function(t,e){return null},t.prototype.visitDirectiveProperty=function(t,e){return null},t.prototype.visitElementProperty=function(t,e){return null},t}()},function(t,e,n){"use strict";function r(t,e){var n=new c(t);o.templateVisitAll(n,e),t.pipes.forEach(function(t){u.bindPipeDestroyLifecycleCallbacks(t.meta,t.instance,t.view)})}var i=n(15),o=n(139),s=n(178),a=n(181),u=n(182);e.bindView=r;var c=function(){function t(t){this.view=t,this._nodeIndex=0}return t.prototype.visitBoundText=function(t,e){var n=this.view.nodes[this._nodeIndex++];return s.bindRenderText(t,n,this.view),null},t.prototype.visitText=function(t,e){return this._nodeIndex++,null},t.prototype.visitNgContent=function(t,e){return null},t.prototype.visitElement=function(t,e){var n=this.view.nodes[this._nodeIndex++],r=a.collectEventListeners(t.outputs,t.directives,n);return s.bindRenderInputs(t.inputs,n),a.bindRenderOutputs(r),i.ListWrapper.forEachWithIndex(t.directives,function(t,e){var i=n.directiveInstances[e];s.bindDirectiveInputs(t,i,n),u.bindDirectiveDetectChangesLifecycleCallbacks(t,i,n),s.bindDirectiveHostProps(t,i,n),a.bindDirectiveOutputs(t,i,r)}),o.templateVisitAll(this,t.children,n),i.ListWrapper.forEachWithIndex(t.directives,function(t,e){var r=n.directiveInstances[e];u.bindDirectiveAfterContentLifecycleCallbacks(t.directive,r,n),u.bindDirectiveAfterViewLifecycleCallbacks(t.directive,r,n),u.bindDirectiveDestroyLifecycleCallbacks(t.directive,r,n)}),null},t.prototype.visitEmbeddedTemplate=function(t,e){var n=this.view.nodes[this._nodeIndex++],r=a.collectEventListeners(t.outputs,t.directives,n);return i.ListWrapper.forEachWithIndex(t.directives,function(t,e){var i=n.directiveInstances[e];s.bindDirectiveInputs(t,i,n),u.bindDirectiveDetectChangesLifecycleCallbacks(t,i,n),a.bindDirectiveOutputs(t,i,r),u.bindDirectiveAfterContentLifecycleCallbacks(t.directive,i,n),u.bindDirectiveAfterViewLifecycleCallbacks(t.directive,i,n),u.bindDirectiveDestroyLifecycleCallbacks(t.directive,i,n)}),null},t.prototype.visitAttr=function(t,e){return null},t.prototype.visitDirective=function(t,e){return null},t.prototype.visitEvent=function(t,e){return null},t.prototype.visitVariable=function(t,e){return null},t.prototype.visitDirectiveProperty=function(t,e){return null},t.prototype.visitElementProperty=function(t,e){return null},t}()},function(t,e,n){"use strict";function r(t){return h.THIS_EXPR.prop("_expr_"+t)}function i(t){return h.variable("currVal_"+t)}function o(t,e,n,r,i,o,s){var a=b.convertCdExpressionToIr(t,i,r,d.DetectChangesVars.valUnwrapper);if(!y.isBlank(a.expression)){if(t.fields.push(new h.ClassField(n.name,null,[h.StmtModifier.Private])),t.createMethod.addStmt(h.THIS_EXPR.prop(n.name).set(h.importExpr(f.Identifiers.uninitialized)).toStmt()),a.needsValueUnwrapper){var u=d.DetectChangesVars.valUnwrapper.callMethod("reset",[]).toStmt();s.addStmt(u)}s.addStmt(e.set(a.expression).toDeclStmt(null,[h.StmtModifier.Final]));var c=h.importExpr(f.Identifiers.checkBinding).callFn([d.DetectChangesVars.throwOnChange,n,e]);a.needsValueUnwrapper&&(c=d.DetectChangesVars.valUnwrapper.prop("hasWrappedValue").or(c)),s.addStmt(new h.IfStmt(c,o.concat([h.THIS_EXPR.prop(n.name).set(e).toStmt()])))}}function s(t,e,n){var s=n.bindings.length;n.bindings.push(new P.CompileBinding(e,t));var a=i(s),u=r(s);n.detectChangesRenderPropertiesMethod.resetDebugInfo(e.nodeIndex,t),o(n,a,u,t.value,h.THIS_EXPR.prop("context"),[h.THIS_EXPR.prop("renderer").callMethod("setText",[e.renderNode,a]).toStmt()],n.detectChangesRenderPropertiesMethod)}function a(t,e,n){var s=n.view,a=n.renderNode;t.forEach(function(t){var u=s.bindings.length;s.bindings.push(new P.CompileBinding(n,t)),s.detectChangesRenderPropertiesMethod.resetDebugInfo(n.nodeIndex,t);var c,p=r(u),f=i(u),d=f,m=[];switch(t.type){case v.PropertyBindingType.Property:c="setElementProperty",s.genConfig.logBindingUpdate&&m.push(l(a,t.name,f));break;case v.PropertyBindingType.Attribute:c="setElementAttribute",d=d.isBlank().conditional(h.NULL_EXPR,d.callMethod("toString",[]));break;case v.PropertyBindingType.Class:c="setElementClass";break;case v.PropertyBindingType.Style:c="setElementStyle";var g=d.callMethod("toString",[]);y.isPresent(t.unit)&&(g=g.plus(h.literal(t.unit))),d=d.isBlank().conditional(h.NULL_EXPR,g)}m.push(h.THIS_EXPR.prop("renderer").callMethod(c,[a,h.literal(t.name),d]).toStmt()),o(s,f,p,t.value,e,m,s.detectChangesRenderPropertiesMethod)})}function u(t,e){a(t,h.THIS_EXPR.prop("context"),e)}function c(t,e,n){a(t.hostProperties,e,n)}function p(t,e,n){if(0!==t.inputs.length){var s=n.view,a=s.detectChangesInInputsMethod;a.resetDebugInfo(n.nodeIndex,n.sourceAst);var u=t.directive.lifecycleHooks,c=-1!==u.indexOf(m.LifecycleHooks.OnChanges),p=t.directive.isComponent&&!g.isDefaultChangeDetectionStrategy(t.directive.changeDetection);c&&a.addStmt(d.DetectChangesVars.changes.set(h.NULL_EXPR).toStmt()),p&&a.addStmt(d.DetectChangesVars.changed.set(h.literal(!1)).toStmt()),t.inputs.forEach(function(t){var u=s.bindings.length;s.bindings.push(new P.CompileBinding(n,t)),a.resetDebugInfo(n.nodeIndex,t);var v=r(u),y=i(u),m=[e.prop(t.directiveName).set(y).toStmt()];c&&(m.push(new h.IfStmt(d.DetectChangesVars.changes.identical(h.NULL_EXPR),[d.DetectChangesVars.changes.set(h.literalMap([],new h.MapType(h.importType(f.Identifiers.SimpleChange)))).toStmt()])),m.push(d.DetectChangesVars.changes.key(h.literal(t.directiveName)).set(h.importExpr(f.Identifiers.SimpleChange).instantiate([v,y])).toStmt())),p&&m.push(d.DetectChangesVars.changed.set(h.literal(!0)).toStmt()),s.genConfig.logBindingUpdate&&m.push(l(n.renderNode,t.directiveName,y)),o(s,y,v,t.value,h.THIS_EXPR.prop("context"),m,a)}),p&&a.addStmt(new h.IfStmt(d.DetectChangesVars.changed,[n.appElement.prop("componentView").callMethod("markAsCheckOnce",[]).toStmt()]))}}function l(t,e,n){return h.THIS_EXPR.prop("renderer").callMethod("setBindingDebugInfo",[t,h.literal("ng-reflect-"+_.camelCaseToDashCase(e)),n.isBlank().conditional(h.NULL_EXPR,n.callMethod("toString",[]))]).toStmt()}var h=n(164),f=n(158),d=n(170),v=n(139),y=n(5),m=n(156),g=n(33),_=n(153),b=n(179),P=n(180);e.bindRenderText=s,e.bindRenderInputs=u,e.bindDirectiveHostProps=c,e.bindDirectiveInputs=p},function(t,e,n){"use strict";function r(t,e,n,r){var i=new y(t,e,r),o=n.visit(i,v.Expression);return new d(o,i.needsValueUnwrapper)}function i(t,e,n){var r=new y(t,e,null),i=[];return u(n.visit(r,v.Statement),i),i}function o(t,e){if(t!==v.Statement)throw new l.BaseException("Expected a statement, but saw "+e)}function s(t,e){if(t!==v.Expression)throw new l.BaseException("Expected an expression, but saw "+e)}function a(t,e){return t===v.Statement?e.toStmt():e}function u(t,e){h.isArray(t)?t.forEach(function(t){return u(t,e)}):e.push(t)}var c=n(164),p=n(158),l=n(12),h=n(5),f=c.variable("#implicit"),d=function(){function t(t,e){this.expression=t,this.needsValueUnwrapper=e}return t}();e.ExpressionWithWrappedValueInfo=d,e.convertCdExpressionToIr=r,e.convertCdStatementToIr=i;var v;!function(t){t[t.Statement=0]="Statement",t[t.Expression=1]="Expression"}(v||(v={}));var y=function(){function t(t,e,n){this._nameResolver=t,this._implicitReceiver=e,this._valueUnwrapper=n,this.needsValueUnwrapper=!1}return t.prototype.visitBinary=function(t,e){var n;switch(t.operation){case"+":n=c.BinaryOperator.Plus;break;case"-":n=c.BinaryOperator.Minus;break;case"*":n=c.BinaryOperator.Multiply;break;case"/":n=c.BinaryOperator.Divide;break;case"%":n=c.BinaryOperator.Modulo;break;case"&&":n=c.BinaryOperator.And;break;case"||":n=c.BinaryOperator.Or;break;case"==":n=c.BinaryOperator.Equals;break;case"!=":n=c.BinaryOperator.NotEquals;break;case"===":n=c.BinaryOperator.Identical;break;case"!==":n=c.BinaryOperator.NotIdentical;break;case"<":n=c.BinaryOperator.Lower;break;case">":n=c.BinaryOperator.Bigger;break;case"<=":n=c.BinaryOperator.LowerEquals;break;case">=":n=c.BinaryOperator.BiggerEquals;break;default:throw new l.BaseException("Unsupported operation "+t.operation)}return a(e,new c.BinaryOperatorExpr(n,t.left.visit(this,v.Expression),t.right.visit(this,v.Expression)))},t.prototype.visitChain=function(t,e){return o(e,t),this.visitAll(t.expressions,e)},t.prototype.visitConditional=function(t,e){var n=t.condition.visit(this,v.Expression);return a(e,n.conditional(t.trueExp.visit(this,v.Expression),t.falseExp.visit(this,v.Expression)))},t.prototype.visitPipe=function(t,e){var n=t.exp.visit(this,v.Expression),r=this.visitAll(t.args,v.Expression),i=this._nameResolver.callPipe(t.name,n,r);return this.needsValueUnwrapper=!0,a(e,this._valueUnwrapper.callMethod("unwrap",[i]))},t.prototype.visitFunctionCall=function(t,e){return a(e,t.target.visit(this,v.Expression).callFn(this.visitAll(t.args,v.Expression)))},t.prototype.visitImplicitReceiver=function(t,e){return s(e,t),f},t.prototype.visitInterpolation=function(t,e){s(e,t);for(var n=[c.literal(t.expressions.length)],r=0;r=0){var a=i[o],c=s(a),p=l.variable("pd_"+this._actionResultExprs.length);this._actionResultExprs.push(p),u.isPresent(c)&&(i[o]=p.set(c.cast(l.DYNAMIC_TYPE).notIdentical(l.literal(!1))).toDeclStmt(null,[l.StmtModifier.Final]))}this._method.addStmts(i)},t.prototype.finishMethod=function(){var t=this._hasComponentHostListener?this.compileElement.appElement.prop("componentView"):l.THIS_EXPR,e=l.literal(!0);this._actionResultExprs.forEach(function(t){e=e.and(t)});var n=[t.callMethod("markPathToRootAsCheckOnce",[]).toStmt()].concat(this._method.finish()).concat([new l.ReturnStatement(e)]);this.compileElement.view.eventHandlerMethods.push(new l.ClassMethod(this._methodName,[this._eventParam],n,l.BOOL_TYPE,[l.StmtModifier.Private]))},t.prototype.listenToRenderer=function(){var t,e=l.THIS_EXPR.callMethod("eventHandler",[l.fn([this._eventParam],[new l.ReturnStatement(l.THIS_EXPR.callMethod(this._methodName,[p.EventHandlerVars.event]))])]);t=u.isPresent(this.eventTarget)?p.ViewProperties.renderer.callMethod("listenGlobal",[l.literal(this.eventTarget),l.literal(this.eventName),e]):p.ViewProperties.renderer.callMethod("listen",[this.compileElement.renderNode,l.literal(this.eventName),e]);var n=l.variable("disposable_"+this.compileElement.view.disposables.length);this.compileElement.view.disposables.push(n),this.compileElement.view.createMethod.addStmt(n.set(t).toDeclStmt(l.FUNCTION_TYPE,[l.StmtModifier.Private]))},t.prototype.listenToDirective=function(t,e){var n=l.variable("subscription_"+this.compileElement.view.subscriptions.length);this.compileElement.view.subscriptions.push(n);var r=l.THIS_EXPR.callMethod("eventHandler",[l.fn([this._eventParam],[l.THIS_EXPR.callMethod(this._methodName,[p.EventHandlerVars.event]).toStmt()])]);this.compileElement.view.createMethod.addStmt(n.set(t.prop(e).callMethod(l.BuiltinMethod.SubscribeObservable,[r])).toDeclStmt(null,[l.StmtModifier.Final]))},t}();e.CompileEventListener=v,e.collectEventListeners=r,e.bindDirectiveOutputs=i,e.bindRenderOutputs=o},function(t,e,n){"use strict";function r(t,e,n){var r=n.view,i=r.detectChangesInInputsMethod,o=t.directive.lifecycleHooks;-1!==o.indexOf(p.LifecycleHooks.OnChanges)&&t.inputs.length>0&&i.addStmt(new u.IfStmt(c.DetectChangesVars.changes.notIdentical(u.NULL_EXPR),[e.callMethod("ngOnChanges",[c.DetectChangesVars.changes]).toStmt()])),-1!==o.indexOf(p.LifecycleHooks.OnInit)&&i.addStmt(new u.IfStmt(l.and(h),[e.callMethod("ngOnInit",[]).toStmt()])),-1!==o.indexOf(p.LifecycleHooks.DoCheck)&&i.addStmt(new u.IfStmt(h,[e.callMethod("ngDoCheck",[]).toStmt()]))}function i(t,e,n){var r=n.view,i=t.lifecycleHooks,o=r.afterContentLifecycleCallbacksMethod;o.resetDebugInfo(n.nodeIndex,n.sourceAst),-1!==i.indexOf(p.LifecycleHooks.AfterContentInit)&&o.addStmt(new u.IfStmt(l,[e.callMethod("ngAfterContentInit",[]).toStmt()])),-1!==i.indexOf(p.LifecycleHooks.AfterContentChecked)&&o.addStmt(e.callMethod("ngAfterContentChecked",[]).toStmt())}function o(t,e,n){var r=n.view,i=t.lifecycleHooks,o=r.afterViewLifecycleCallbacksMethod;o.resetDebugInfo(n.nodeIndex,n.sourceAst),-1!==i.indexOf(p.LifecycleHooks.AfterViewInit)&&o.addStmt(new u.IfStmt(l,[e.callMethod("ngAfterViewInit",[]).toStmt()])),-1!==i.indexOf(p.LifecycleHooks.AfterViewChecked)&&o.addStmt(e.callMethod("ngAfterViewChecked",[]).toStmt())}function s(t,e,n){var r=n.view.destroyMethod;r.resetDebugInfo(n.nodeIndex,n.sourceAst),-1!==t.lifecycleHooks.indexOf(p.LifecycleHooks.OnDestroy)&&r.addStmt(e.callMethod("ngOnDestroy",[]).toStmt())}function a(t,e,n){var r=n.destroyMethod;-1!==t.lifecycleHooks.indexOf(p.LifecycleHooks.OnDestroy)&&r.addStmt(e.callMethod("ngOnDestroy",[]).toStmt())}var u=n(164),c=n(170),p=n(156),l=u.THIS_EXPR.prop("cdState").identical(c.ChangeDetectorStateEnum.NeverChecked),h=u.not(c.DetectChangesVars.throwOnChange);e.bindDirectiveDetectChangesLifecycleCallbacks=r,e.bindDirectiveAfterContentLifecycleCallbacks=i,e.bindDirectiveAfterViewLifecycleCallbacks=o,e.bindDirectiveDestroyLifecycleCallbacks=s,e.bindPipeDestroyLifecycleCallbacks=a},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(155),s=n(5),a=n(12),u=n(40),c=n(184),p=n(157),l=n(152),h=n(6),f=n(36),d=n(145),v=n(144),y=n(151),m=function(){function t(t,e,n){this._xhr=t,this._urlResolver=e,this._htmlParser=n}return t.prototype.normalizeDirective=function(t){return t.isComponent?this.normalizeTemplate(t.type,t.template).then(function(e){return new o.CompileDirectiveMetadata({type:t.type,isComponent:t.isComponent,selector:t.selector,exportAs:t.exportAs,changeDetection:t.changeDetection,inputs:t.inputs,outputs:t.outputs,hostListeners:t.hostListeners,hostProperties:t.hostProperties,hostAttributes:t.hostAttributes,lifecycleHooks:t.lifecycleHooks,providers:t.providers,viewProviders:t.viewProviders,queries:t.queries,viewQueries:t.viewQueries,template:e})}):u.PromiseWrapper.resolve(t)},t.prototype.normalizeTemplate=function(t,e){var n=this;if(s.isPresent(e.template))return u.PromiseWrapper.resolve(this.normalizeLoadedTemplate(t,e,e.template,t.moduleUrl));if(s.isPresent(e.templateUrl)){var r=this._urlResolver.resolve(t.moduleUrl,e.templateUrl);return this._xhr.get(r).then(function(i){return n.normalizeLoadedTemplate(t,e,i,r)})}throw new a.BaseException("No template specified for component "+t.name)},t.prototype.normalizeLoadedTemplate=function(t,e,n,r){var i=this,s=this._htmlParser.parse(n,t.name);if(s.errors.length>0){var u=s.errors.join("\n");throw new a.BaseException("Template parse errors:\n"+u)}var c=new g;d.htmlVisitAll(c,s.rootNodes);var p=e.styles.concat(c.styles),h=c.styleUrls.filter(l.isStyleUrlResolvable).map(function(t){return i._urlResolver.resolve(r,t)}).concat(e.styleUrls.filter(l.isStyleUrlResolvable).map(function(e){return i._urlResolver.resolve(t.moduleUrl,e)})),v=p.map(function(t){var e=l.extractStyleUrls(i._urlResolver,r,t);return e.styleUrls.forEach(function(t){return h.push(t)}),e.style}),y=e.encapsulation;return y===f.ViewEncapsulation.Emulated&&0===v.length&&0===h.length&&(y=f.ViewEncapsulation.None),new o.CompileTemplateMetadata({encapsulation:y,template:n,templateUrl:r,styles:v,styleUrls:h,ngContentSelectors:c.ngContentSelectors})},t=r([h.Injectable(),i("design:paramtypes",[c.XHR,p.UrlResolver,v.HtmlParser])],t)}();e.DirectiveNormalizer=m;var g=function(){function t(){this.ngContentSelectors=[],this.styles=[],this.styleUrls=[],this.ngNonBindableStackCount=0}return t.prototype.visitElement=function(t,e){var n=y.preparseElement(t);switch(n.type){case y.PreparsedElementType.NG_CONTENT:0===this.ngNonBindableStackCount&&this.ngContentSelectors.push(n.selectAttr);break;case y.PreparsedElementType.STYLE:var r="";t.children.forEach(function(t){t instanceof d.HtmlTextAst&&(r+=t.value)}),this.styles.push(r);break;case y.PreparsedElementType.STYLESHEET:this.styleUrls.push(n.hrefAttr)}return n.nonBindable&&this.ngNonBindableStackCount++,d.htmlVisitAll(this,t.children),n.nonBindable&&this.ngNonBindableStackCount--,null},t.prototype.visitComment=function(t,e){return null},t.prototype.visitAttr=function(t,e){return null},t.prototype.visitText=function(t,e){return null},t.prototype.visitExpansion=function(t,e){return null},t.prototype.visitExpansionCase=function(t,e){return null},t}()},function(t,e){"use strict";var n=function(){function t(){}return t.prototype.get=function(t){return null},t}();e.XHR=n},function(t,e,n){"use strict";function r(t,e){var n=[];return h.isPresent(e)&&o(e,n),h.isPresent(t.directives)&&o(t.directives,n),n}function i(t,e){var n=[];return h.isPresent(e)&&o(e,n),h.isPresent(t.pipes)&&o(t.pipes,n),n}function o(t,e){for(var n=0;n0?r:"package:"+r+O.MODULE_SUFFIX}return t.importUri(e)}var u=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},c=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},p=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},l=n(6),h=n(5),f=n(15),d=n(12),v=n(23),y=n(155),m=n(26),g=n(4),_=n(186),b=n(187),P=n(188),E=n(189),w=n(156),C=n(18),R=n(6),S=n(84),O=n(153),T=n(190),x=n(157),A=n(24),I=n(17),M=n(7),k=n(4),N=n(20),D=function(){function t(t,e,n,r,i,o){this._directiveResolver=t,this._pipeResolver=e,this._viewResolver=n,this._platformDirectives=r,this._platformPipes=i,this._directiveCache=new Map,this._pipeCache=new Map,this._anonymousTypes=new Map,this._anonymousTypeIndex=0,h.isPresent(o)?this._reflector=o:this._reflector=C.reflector}return t.prototype.sanitizeTokenName=function(t){var e=h.stringify(t);if(e.indexOf("(")>=0){var n=this._anonymousTypes.get(t);h.isBlank(n)&&(this._anonymousTypes.set(t,this._anonymousTypeIndex++),n=this._anonymousTypes.get(t)),e="anonymous_token_"+n+"_"}return O.sanitizeIdentifier(e)},t.prototype.getDirectiveMetadata=function(t){var e=this._directiveCache.get(t);if(h.isBlank(e)){var n=this._directiveResolver.resolve(t),r=null,i=null,o=null,s=[];if(n instanceof m.ComponentMetadata){T.assertArrayOfStrings("styles",n.styles);var u=n;r=a(this._reflector,t,u);var c=this._viewResolver.resolve(t);T.assertArrayOfStrings("styles",c.styles),i=new y.CompileTemplateMetadata({encapsulation:c.encapsulation,template:c.template,templateUrl:c.templateUrl,styles:c.styles,styleUrls:c.styleUrls}),o=u.changeDetection,h.isPresent(n.viewProviders)&&(s=this.getProvidersMetadata(n.viewProviders))}var p=[];h.isPresent(n.providers)&&(p=this.getProvidersMetadata(n.providers));var l=[],f=[];h.isPresent(n.queries)&&(l=this.getQueriesMetadata(n.queries,!1), -f=this.getQueriesMetadata(n.queries,!0)),e=y.CompileDirectiveMetadata.create({selector:n.selector,exportAs:n.exportAs,isComponent:h.isPresent(i),type:this.getTypeMetadata(t,r),template:i,changeDetection:o,inputs:n.inputs,outputs:n.outputs,host:n.host,lifecycleHooks:w.LIFECYCLE_HOOKS_VALUES.filter(function(e){return E.hasLifecycleHook(e,t)}),providers:p,viewProviders:s,queries:l,viewQueries:f}),this._directiveCache.set(t,e)}return e},t.prototype.getTypeMetadata=function(t,e){return new y.CompileTypeMetadata({name:this.sanitizeTokenName(t),moduleUrl:e,runtime:t,diDeps:this.getDependenciesMetadata(t,null)})},t.prototype.getFactoryMetadata=function(t,e){return new y.CompileFactoryMetadata({name:this.sanitizeTokenName(t),moduleUrl:e,runtime:t,diDeps:this.getDependenciesMetadata(t,null)})},t.prototype.getPipeMetadata=function(t){var e=this._pipeCache.get(t);if(h.isBlank(e)){var n=this._pipeResolver.resolve(t),r=this._reflector.importUri(t);e=new y.CompilePipeMetadata({type:this.getTypeMetadata(t,r),name:n.name,pure:n.pure,lifecycleHooks:w.LIFECYCLE_HOOKS_VALUES.filter(function(e){return E.hasLifecycleHook(e,t)})}),this._pipeCache.set(t,e)}return e},t.prototype.getViewDirectivesMetadata=function(t){for(var e=this,n=this._viewResolver.resolve(t),i=r(n,this._platformDirectives),o=0;oo?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(6),a=n(5),u=n(12),c=n(15),p=n(3),l=n(18),h=n(20),f=function(){function t(t){a.isPresent(t)?this._reflector=t:this._reflector=l.reflector}return t.prototype.resolve=function(t){var e=this._reflector.annotations(s.resolveForwardRef(t));if(a.isPresent(e)){var n=e.find(r);if(a.isPresent(n)){var i=this._reflector.propMetadata(t);return this._mergeWithPropertyMetadata(n,i,t)}}throw new u.BaseException("No Directive annotation found on "+a.stringify(t))},t.prototype._mergeWithPropertyMetadata=function(t,e,n){var r=[],i=[],o={},s={};return c.StringMapWrapper.forEach(e,function(t,e){t.forEach(function(t){if(t instanceof p.InputMetadata&&(a.isPresent(t.bindingPropertyName)?r.push(e+": "+t.bindingPropertyName):r.push(e)),t instanceof p.OutputMetadata&&(a.isPresent(t.bindingPropertyName)?i.push(e+": "+t.bindingPropertyName):i.push(e)),t instanceof p.HostBindingMetadata&&(a.isPresent(t.hostPropertyName)?o["["+t.hostPropertyName+"]"]=e:o["["+e+"]"]=e),t instanceof p.HostListenerMetadata){var n=a.isPresent(t.args)?t.args.join(", "):"";o["("+t.eventName+")"]=e+"("+n+")"}t instanceof p.ContentChildrenMetadata&&(s[e]=t),t instanceof p.ViewChildrenMetadata&&(s[e]=t),t instanceof p.ContentChildMetadata&&(s[e]=t),t instanceof p.ViewChildMetadata&&(s[e]=t)})}),this._merge(t,r,i,o,s,n)},t.prototype._merge=function(t,e,n,r,i,o){var s,l=a.isPresent(t.inputs)?c.ListWrapper.concat(t.inputs,e):e;a.isPresent(t.outputs)?(t.outputs.forEach(function(t){if(c.ListWrapper.contains(n,t))throw new u.BaseException("Output event '"+t+"' defined multiple times in '"+a.stringify(o)+"'")}),s=c.ListWrapper.concat(t.outputs,n)):s=n;var h=a.isPresent(t.host)?c.StringMapWrapper.merge(t.host,r):r,f=a.isPresent(t.queries)?c.StringMapWrapper.merge(t.queries,i):i;return t instanceof p.ComponentMetadata?new p.ComponentMetadata({selector:t.selector,inputs:l,outputs:s,host:h,exportAs:t.exportAs,moduleId:t.moduleId,queries:f,changeDetection:t.changeDetection,providers:t.providers,viewProviders:t.viewProviders}):new p.DirectiveMetadata({selector:t.selector,inputs:l,outputs:s,host:h,exportAs:t.exportAs,queries:f,providers:t.providers})},t=i([s.Injectable(),o("design:paramtypes",[h.ReflectorReader])],t)}();e.DirectiveResolver=f,e.CODEGEN_DIRECTIVE_RESOLVER=new f(l.reflector)},function(t,e,n){"use strict";function r(t){return t instanceof c.PipeMetadata}var i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(6),a=n(5),u=n(12),c=n(3),p=n(20),l=n(18),h=function(){function t(t){a.isPresent(t)?this._reflector=t:this._reflector=l.reflector}return t.prototype.resolve=function(t){var e=this._reflector.annotations(s.resolveForwardRef(t));if(a.isPresent(e)){var n=e.find(r);if(a.isPresent(n))return n}throw new u.BaseException("No Pipe decorator found on "+a.stringify(t))},t=i([s.Injectable(),o("design:paramtypes",[p.ReflectorReader])],t)}();e.PipeResolver=h,e.CODEGEN_PIPE_RESOLVER=new h(l.reflector)},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(6),s=n(36),a=n(26),u=n(5),c=n(12),p=n(15),l=n(20),h=n(18),f=function(){function t(t){this._cache=new p.Map,u.isPresent(t)?this._reflector=t:this._reflector=h.reflector}return t.prototype.resolve=function(t){var e=this._cache.get(t);return u.isBlank(e)&&(e=this._resolve(t),this._cache.set(t,e)),e},t.prototype._resolve=function(t){var e,n;if(this._reflector.annotations(t).forEach(function(t){t instanceof s.ViewMetadata&&(n=t),t instanceof a.ComponentMetadata&&(e=t)}),!u.isPresent(e)){if(u.isBlank(n))throw new c.BaseException("Could not compile '"+u.stringify(t)+"' because it is not a component.");return n}if(u.isBlank(e.template)&&u.isBlank(e.templateUrl)&&u.isBlank(n))throw new c.BaseException("Component '"+u.stringify(t)+"' must have either 'template' or 'templateUrl' set.");if(u.isPresent(e.template)&&u.isPresent(n))this._throwMixingViewAndComponent("template",t);else if(u.isPresent(e.templateUrl)&&u.isPresent(n))this._throwMixingViewAndComponent("templateUrl",t);else if(u.isPresent(e.directives)&&u.isPresent(n))this._throwMixingViewAndComponent("directives",t);else if(u.isPresent(e.pipes)&&u.isPresent(n))this._throwMixingViewAndComponent("pipes",t);else if(u.isPresent(e.encapsulation)&&u.isPresent(n))this._throwMixingViewAndComponent("encapsulation",t);else if(u.isPresent(e.styles)&&u.isPresent(n))this._throwMixingViewAndComponent("styles",t);else{if(!u.isPresent(e.styleUrls)||!u.isPresent(n))return u.isPresent(n)?n:new s.ViewMetadata({templateUrl:e.templateUrl,template:e.template,directives:e.directives,pipes:e.pipes,encapsulation:e.encapsulation,styles:e.styles,styleUrls:e.styleUrls});this._throwMixingViewAndComponent("styleUrls",t)}return null},t.prototype._throwMixingViewAndComponent=function(t,e){throw new c.BaseException("Component '"+u.stringify(e)+"' cannot have both '"+t+"' and '@View' set at the same time\"")},t=r([o.Injectable(),i("design:paramtypes",[l.ReflectorReader])],t)}();e.ViewResolver=f},function(t,e,n){"use strict";function r(t,e){if(!(e instanceof i.Type))return!1;var n=e.prototype;switch(t){case o.LifecycleHooks.AfterContentInit:return!!n.ngAfterContentInit;case o.LifecycleHooks.AfterContentChecked:return!!n.ngAfterContentChecked;case o.LifecycleHooks.AfterViewInit:return!!n.ngAfterViewInit;case o.LifecycleHooks.AfterViewChecked:return!!n.ngAfterViewChecked;case o.LifecycleHooks.OnChanges:return!!n.ngOnChanges;case o.LifecycleHooks.DoCheck:return!!n.ngDoCheck;case o.LifecycleHooks.OnDestroy:return!!n.ngOnDestroy;case o.LifecycleHooks.OnInit:return!!n.ngOnInit;default:return!1}}var i=n(5),o=n(156);e.hasLifecycleHook=r},function(t,e,n){"use strict";function r(t,e){if(i.assertionsEnabled()&&!i.isBlank(e)){if(!i.isArray(e))throw new o.BaseException("Expected '"+t+"' to be an array of strings.");for(var n=0;nn;n++)e+=" ";return e}var o=n(5),s=n(12),a=n(164),u=/'|\\|\n|\r|\$/g;e.CATCH_ERROR_VAR=a.variable("error"),e.CATCH_STACK_VAR=a.variable("stack");var c=function(){function t(){}return t}();e.OutputEmitter=c;var p=function(){function t(t){this.indent=t,this.parts=[]}return t}(),l=function(){function t(t,e){this._exportedVars=t,this._indent=e,this._classes=[],this._lines=[new p(e)]}return t.createRoot=function(e){return new t(e,0)},Object.defineProperty(t.prototype,"_currentLine",{get:function(){return this._lines[this._lines.length-1]},enumerable:!0,configurable:!0}),t.prototype.isExportedVar=function(t){return-1!==this._exportedVars.indexOf(t)},t.prototype.println=function(t){void 0===t&&(t=""),this.print(t,!0)},t.prototype.lineIsEmpty=function(){return 0===this._currentLine.parts.length},t.prototype.print=function(t,e){void 0===e&&(e=!1),t.length>0&&this._currentLine.parts.push(t),e&&this._lines.push(new p(this._indent))},t.prototype.removeEmptyLastLine=function(){this.lineIsEmpty()&&this._lines.pop()},t.prototype.incIndent=function(){this._indent++,this._currentLine.indent=this._indent},t.prototype.decIndent=function(){this._indent--,this._currentLine.indent=this._indent},t.prototype.pushClass=function(t){this._classes.push(t)},t.prototype.popClass=function(){return this._classes.pop()},Object.defineProperty(t.prototype,"currentClass",{get:function(){return this._classes.length>0?this._classes[this._classes.length-1]:null},enumerable:!0,configurable:!0}),t.prototype.toSource=function(){var t=this._lines;return 0===t[t.length-1].parts.length&&(t=t.slice(0,t.length-1)),t.map(function(t){return t.parts.length>0?i(t.indent)+t.parts.join(""):""}).join("\n")},t}();e.EmitterVisitorContext=l;var h=function(){function t(t){this._escapeDollarInStrings=t}return t.prototype.visitExpressionStmt=function(t,e){return t.expr.visitExpression(this,e),e.println(";"),null},t.prototype.visitReturnStmt=function(t,e){return e.print("return "),t.value.visitExpression(this,e),e.println(";"),null},t.prototype.visitIfStmt=function(t,e){e.print("if ("),t.condition.visitExpression(this,e),e.print(") {");var n=o.isPresent(t.falseCase)&&t.falseCase.length>0;return t.trueCase.length<=1&&!n?(e.print(" "),this.visitAllStatements(t.trueCase,e),e.removeEmptyLastLine(),e.print(" ")):(e.println(),e.incIndent(),this.visitAllStatements(t.trueCase,e),e.decIndent(),n&&(e.println("} else {"),e.incIndent(),this.visitAllStatements(t.falseCase,e),e.decIndent())),e.println("}"),null},t.prototype.visitThrowStmt=function(t,e){return e.print("throw "),t.error.visitExpression(this,e),e.println(";"),null},t.prototype.visitCommentStmt=function(t,e){var n=t.comment.split("\n");return n.forEach(function(t){e.println("// "+t)}),null},t.prototype.visitWriteVarExpr=function(t,e){var n=e.lineIsEmpty();return n||e.print("("),e.print(t.name+" = "),t.value.visitExpression(this,e),n||e.print(")"),null},t.prototype.visitWriteKeyExpr=function(t,e){var n=e.lineIsEmpty();return n||e.print("("),t.receiver.visitExpression(this,e),e.print("["),t.index.visitExpression(this,e),e.print("] = "),t.value.visitExpression(this,e),n||e.print(")"),null},t.prototype.visitWritePropExpr=function(t,e){var n=e.lineIsEmpty();return n||e.print("("),t.receiver.visitExpression(this,e),e.print("."+t.name+" = "),t.value.visitExpression(this,e),n||e.print(")"),null},t.prototype.visitInvokeMethodExpr=function(t,e){t.receiver.visitExpression(this,e);var n=t.name;return o.isPresent(t.builtin)&&(n=this.getBuiltinMethodName(t.builtin),o.isBlank(n))?null:(e.print("."+n+"("),this.visitAllExpressions(t.args,e,","),e.print(")"),null)},t.prototype.visitInvokeFunctionExpr=function(t,e){return t.fn.visitExpression(this,e),e.print("("),this.visitAllExpressions(t.args,e,","),e.print(")"),null},t.prototype.visitReadVarExpr=function(t,n){var r=t.name;if(o.isPresent(t.builtin))switch(t.builtin){case a.BuiltinVar.Super:r="super";break;case a.BuiltinVar.This:r="this";break;case a.BuiltinVar.CatchError:r=e.CATCH_ERROR_VAR.name;break;case a.BuiltinVar.CatchStack:r=e.CATCH_STACK_VAR.name;break;default:throw new s.BaseException("Unknown builtin variable "+t.builtin)}return n.print(r),null},t.prototype.visitInstantiateExpr=function(t,e){return e.print("new "),t.classExpr.visitExpression(this,e),e.print("("),this.visitAllExpressions(t.args,e,","),e.print(")"),null},t.prototype.visitLiteralExpr=function(t,e){var n=t.value;return o.isString(n)?e.print(r(n,this._escapeDollarInStrings)):o.isBlank(n)?e.print("null"):e.print(""+n),null},t.prototype.visitConditionalExpr=function(t,e){return t.condition.visitExpression(this,e),e.print("? "),t.trueCase.visitExpression(this,e),e.print(": "),t.falseCase.visitExpression(this,e),null},t.prototype.visitNotExpr=function(t,e){return e.print("!"),t.condition.visitExpression(this,e),null},t.prototype.visitBinaryOperatorExpr=function(t,e){var n;switch(t.operator){case a.BinaryOperator.Equals:n="==";break;case a.BinaryOperator.Identical:n="===";break;case a.BinaryOperator.NotEquals:n="!=";break;case a.BinaryOperator.NotIdentical:n="!==";break;case a.BinaryOperator.And:n="&&";break;case a.BinaryOperator.Or:n="||";break;case a.BinaryOperator.Plus:n="+";break;case a.BinaryOperator.Minus:n="-";break;case a.BinaryOperator.Divide:n="/";break;case a.BinaryOperator.Multiply:n="*";break;case a.BinaryOperator.Modulo:n="%";break;case a.BinaryOperator.Lower:n="<";break;case a.BinaryOperator.LowerEquals:n="<=";break;case a.BinaryOperator.Bigger:n=">";break;case a.BinaryOperator.BiggerEquals:n=">=";break;default:throw new s.BaseException("Unknown operator "+t.operator)}return e.print("("),t.lhs.visitExpression(this,e),e.print(" "+n+" "),t.rhs.visitExpression(this,e),e.print(")"),null},t.prototype.visitReadPropExpr=function(t,e){return t.receiver.visitExpression(this,e),e.print("."),e.print(t.name),null},t.prototype.visitReadKeyExpr=function(t,e){return t.receiver.visitExpression(this,e),e.print("["),t.index.visitExpression(this,e),e.print("]"),null},t.prototype.visitLiteralArrayExpr=function(t,e){var n=t.entries.length>1;return e.print("[",n),e.incIndent(),this.visitAllExpressions(t.entries,e,",",n),e.decIndent(),e.print("]",n),null},t.prototype.visitLiteralMapExpr=function(t,e){var n=this,i=t.entries.length>1;return e.print("{",i),e.incIndent(),this.visitAllObjects(function(t){e.print(r(t[0],n._escapeDollarInStrings)+": "),t[1].visitExpression(n,e)},t.entries,e,",",i),e.decIndent(),e.print("}",i),null},t.prototype.visitAllExpressions=function(t,e,n,r){var i=this;void 0===r&&(r=!1),this.visitAllObjects(function(t){return t.visitExpression(i,e)},t,e,n,r)},t.prototype.visitAllObjects=function(t,e,n,r,i){void 0===i&&(i=!1);for(var o=0;o0&&n.print(r,i),t(e[o]);i&&n.println()},t.prototype.visitAllStatements=function(t,e){var n=this;t.forEach(function(t){return t.visitStatement(n,e)})},t}();e.AbstractEmitterVisitor=h,e.escapeSingleQuoteString=r},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(5),o=n(12),s=n(164),a=n(192),u=function(t){function e(){t.call(this,!1)}return r(e,t),e.prototype.visitDeclareClassStmt=function(t,e){var n=this;return e.pushClass(t),this._visitClassConstructor(t,e),i.isPresent(t.parent)&&(e.print(t.name+".prototype = Object.create("),t.parent.visitExpression(this,e),e.println(".prototype);")),t.getters.forEach(function(r){return n._visitClassGetter(t,r,e)}),t.methods.forEach(function(r){return n._visitClassMethod(t,r,e)}),e.popClass(),null},e.prototype._visitClassConstructor=function(t,e){e.print("function "+t.name+"("),i.isPresent(t.constructorMethod)&&this._visitParams(t.constructorMethod.params,e),e.println(") {"),e.incIndent(),i.isPresent(t.constructorMethod)&&t.constructorMethod.body.length>0&&(e.println("var self = this;"),this.visitAllStatements(t.constructorMethod.body,e)),e.decIndent(),e.println("}")},e.prototype._visitClassGetter=function(t,e,n){n.println("Object.defineProperty("+t.name+".prototype, '"+e.name+"', { get: function() {"),n.incIndent(),e.body.length>0&&(n.println("var self = this;"),this.visitAllStatements(e.body,n)),n.decIndent(),n.println("}});")},e.prototype._visitClassMethod=function(t,e,n){n.print(t.name+".prototype."+e.name+" = function("),this._visitParams(e.params,n),n.println(") {"),n.incIndent(),e.body.length>0&&(n.println("var self = this;"),this.visitAllStatements(e.body,n)),n.decIndent(),n.println("};")},e.prototype.visitReadVarExpr=function(e,n){if(e.builtin===s.BuiltinVar.This)n.print("self");else{if(e.builtin===s.BuiltinVar.Super)throw new o.BaseException("'super' needs to be handled at a parent ast node, not at the variable level!");t.prototype.visitReadVarExpr.call(this,e,n)}return null},e.prototype.visitDeclareVarStmt=function(t,e){return e.print("var "+t.name+" = "),t.value.visitExpression(this,e),e.println(";"),null},e.prototype.visitCastExpr=function(t,e){return t.value.visitExpression(this,e),null},e.prototype.visitInvokeFunctionExpr=function(e,n){var r=e.fn;return r instanceof s.ReadVarExpr&&r.builtin===s.BuiltinVar.Super?(n.currentClass.parent.visitExpression(this,n),n.print(".call(this"),e.args.length>0&&(n.print(", "),this.visitAllExpressions(e.args,n,",")),n.print(")")):t.prototype.visitInvokeFunctionExpr.call(this,e,n),null},e.prototype.visitFunctionExpr=function(t,e){return e.print("function("),this._visitParams(t.params,e),e.println(") {"),e.incIndent(),this.visitAllStatements(t.statements,e),e.decIndent(),e.print("}"),null},e.prototype.visitDeclareFunctionStmt=function(t,e){return e.print("function "+t.name+"("),this._visitParams(t.params,e),e.println(") {"),e.incIndent(),this.visitAllStatements(t.statements,e),e.decIndent(),e.println("}"),null},e.prototype.visitTryCatchStmt=function(t,e){e.println("try {"),e.incIndent(),this.visitAllStatements(t.bodyStmts,e),e.decIndent(),e.println("} catch ("+a.CATCH_ERROR_VAR.name+") {"),e.incIndent();var n=[a.CATCH_STACK_VAR.set(a.CATCH_ERROR_VAR.prop("stack")).toDeclStmt(null,[s.StmtModifier.Final])].concat(t.catchStmts);return this.visitAllStatements(n,e),e.decIndent(),e.println("}"),null},e.prototype._visitParams=function(t,e){this.visitAllObjects(function(t){return e.print(t.name)},t,e,",")},e.prototype.getBuiltinMethodName=function(t){var e;switch(t){case s.BuiltinMethod.ConcatArray:e="concat";break;case s.BuiltinMethod.SubscribeObservable:e="subscribe";break;case s.BuiltinMethod.bind:e="bind";break;default:throw new o.BaseException("Unknown builtin method: "+t)}return e},e}(a.AbstractEmitterVisitor);e.AbstractJsEmitterVisitor=u},function(t,e,n){"use strict";function r(t,e,n){var r=t.concat([new c.ReturnStatement(c.variable(e))]),i=new y(null,null,null,null,new Map,new Map,new Map,new Map,n),o=new _,s=o.visitAllStatements(r,i);return a.isPresent(s)?s.value:null}function i(t){return a.IS_DART?t instanceof v:a.isPresent(t)&&a.isPresent(t.props)&&a.isPresent(t.getters)&&a.isPresent(t.methods)}function o(t,e,n,r,i){for(var o=r.createChildWihtLocalVars(),s=0;si();case c.BinaryOperator.BiggerEquals:return r()>=i();default:throw new l.BaseException("Unknown operator "+t.operator)}},t.prototype.visitReadPropExpr=function(t,e){var n,r=t.receiver.visitExpression(this,e);if(i(r)){var o=r;n=o.props.has(t.name)?o.props.get(t.name):o.getters.has(t.name)?o.getters.get(t.name)():o.methods.has(t.name)?o.methods.get(t.name):p.reflector.getter(t.name)(r)}else n=p.reflector.getter(t.name)(r);return n},t.prototype.visitReadKeyExpr=function(t,e){var n=t.receiver.visitExpression(this,e),r=t.index.visitExpression(this,e);return n[r]},t.prototype.visitLiteralArrayExpr=function(t,e){return this.visitAllExpressions(t.entries,e)},t.prototype.visitLiteralMapExpr=function(t,e){var n=this,r={};return t.entries.forEach(function(t){return r[t[0]]=t[1].visitExpression(n,e)}),r},t.prototype.visitAllExpressions=function(t,e){var n=this;return t.map(function(t){return t.visitExpression(n,e)})},t.prototype.visitAllStatements=function(t,e){for(var n=0;n0?i(n[0]):null;a.isPresent(r)&&(e.print(": "),r.visitExpression(this,e),n=n.slice(1)),e.println(" {"),e.incIndent(),this.visitAllStatements(n,e),e.decIndent(),e.println("}")},e.prototype._visitClassMethod=function(t,e){a.isPresent(t.type)?t.type.visitType(this,e):e.print("void"),e.print(" "+t.name+"("),this._visitParams(t.params,e),e.println(") {"),e.incIndent(),this.visitAllStatements(t.body,e),e.decIndent(),e.println("}")},e.prototype.visitFunctionExpr=function(t,e){return e.print("("),this._visitParams(t.params,e),e.println(") {"),e.incIndent(),this.visitAllStatements(t.statements,e),e.decIndent(),e.print("}"),null},e.prototype.visitDeclareFunctionStmt=function(t,e){return a.isPresent(t.type)?t.type.visitType(this,e):e.print("void"),e.print(" "+t.name+"("),this._visitParams(t.params,e),e.println(") {"),e.incIndent(),this.visitAllStatements(t.statements,e),e.decIndent(),e.println("}"),null},e.prototype.getBuiltinMethodName=function(t){var e;switch(t){case c.BuiltinMethod.ConcatArray:e=".addAll";break;case c.BuiltinMethod.SubscribeObservable:e="listen";break;case c.BuiltinMethod.bind:e=null;break;default:throw new u.BaseException("Unknown builtin method: "+t)}return e},e.prototype.visitTryCatchStmt=function(t,e){return e.println("try {"),e.incIndent(),this.visitAllStatements(t.bodyStmts,e),e.decIndent(),e.println("} catch ("+p.CATCH_ERROR_VAR.name+", "+p.CATCH_STACK_VAR.name+") {"),e.incIndent(),this.visitAllStatements(t.catchStmts,e),e.decIndent(),e.println("}"),null},e.prototype.visitBinaryOperatorExpr=function(e,n){switch(e.operator){case c.BinaryOperator.Identical:n.print("identical("),e.lhs.visitExpression(this,n),n.print(", "),e.rhs.visitExpression(this,n),n.print(")");break;case c.BinaryOperator.NotIdentical:n.print("!identical("),e.lhs.visitExpression(this,n),n.print(", "),e.rhs.visitExpression(this,n),n.print(")");break;default:t.prototype.visitBinaryOperatorExpr.call(this,e,n)}return null},e.prototype.visitLiteralArrayExpr=function(e,n){return o(e.type)&&n.print("const "),t.prototype.visitLiteralArrayExpr.call(this,e,n)},e.prototype.visitLiteralMapExpr=function(e,n){return o(e.type)&&n.print("const "),a.isPresent(e.valueType)&&(n.print("")),t.prototype.visitLiteralMapExpr.call(this,e,n)},e.prototype.visitInstantiateExpr=function(t,e){return e.print(o(t.type)?"const":"new"),e.print(" "),t.classExpr.visitExpression(this,e),e.print("("),this.visitAllExpressions(t.args,e,","),e.print(")"),null},e.prototype.visitBuiltintType=function(t,e){var n;switch(t.name){case c.BuiltinTypeName.Bool:n="bool";break;case c.BuiltinTypeName.Dynamic:n="dynamic";break;case c.BuiltinTypeName.Function:n="Function";break;case c.BuiltinTypeName.Number:n="num";break;case c.BuiltinTypeName.Int:n="int";break;case c.BuiltinTypeName.String:n="String";break;default:throw new u.BaseException("Unsupported builtin type "+t.name)}return e.print(n),null},e.prototype.visitExternalType=function(t,e){return this._visitIdentifier(t.value,t.typeParams,e),null},e.prototype.visitArrayType=function(t,e){return e.print("List<"),a.isPresent(t.of)?t.of.visitType(this,e):e.print("dynamic"),e.print(">"),null},e.prototype.visitMapType=function(t,e){return e.print("Map"),null},e.prototype._visitParams=function(t,e){var n=this;this.visitAllObjects(function(t){a.isPresent(t.type)&&(t.type.visitType(n,e),e.print(" ")),e.print(t.name)},t,e,",")},e.prototype._visitIdentifier=function(t,e,n){var r=this;if(a.isPresent(t.moduleUrl)&&t.moduleUrl!=this._moduleUrl){var i=this.importsWithPrefixes.get(t.moduleUrl);a.isBlank(i)&&(i="import"+this.importsWithPrefixes.size,this.importsWithPrefixes.set(t.moduleUrl,i)),n.print(i+".")}n.print(t.name),a.isPresent(e)&&e.length>0&&(n.print("<"),this.visitAllObjects(function(t){return t.visitType(r,n)},e,n,","),n.print(">"))},e}(p.AbstractEmitterVisitor)},function(t,e,n){"use strict";function r(t,e,n){var r=n===l.Dart?"package:":"",o=h.parse(t,!1),u=h.parse(e,!0);if(a.isBlank(u))return e;if(o.firstLevelDir==u.firstLevelDir&&o.packageName==u.packageName)return i(o.modulePath,u.modulePath,n);if("lib"==u.firstLevelDir)return""+r+u.packageName+"/"+u.modulePath;throw new s.BaseException("Can't import url "+e+" from "+t)}function i(t,e,n){for(var r=t.split(p),i=e.split(p),s=o(r,i),a=[],u=r.length-1-s,h=0;u>h;h++)a.push("..");0>=u&&n===l.JS&&a.push(".");for(var h=s;hn&&t[n]==e[n];)n++;return n}var s=n(12),a=n(5),u=/asset:([^\/]+)\/([^\/]+)\/(.+)/g,c="/",p=/\//g;!function(t){t[t.Dart=0]="Dart",t[t.JS=1]="JS"}(e.ImportEnv||(e.ImportEnv={}));var l=e.ImportEnv;e.getImportModulePath=r;var h=function(){function t(t,e,n){this.packageName=t,this.firstLevelDir=e,this.modulePath=n}return t.parse=function(e,n){var r=a.RegExpWrapper.firstMatch(u,e);if(a.isPresent(r))return new t(r[1],r[2],r[3]);if(n)return null;throw new s.BaseException("Url "+e+" is not a valid asset: url")},t}();e.getRelativePath=i,e.getLongestPathSegmentPrefix=o},function(t,e,n){"use strict";function r(t){var e,n=new h(p),r=u.EmitterVisitorContext.createRoot([]);return e=s.isArray(t)?t:[t],e.forEach(function(t){if(t instanceof o.Statement)t.visitStatement(n,r);else if(t instanceof o.Expression)t.visitExpression(n,r);else{if(!(t instanceof o.Type))throw new a.BaseException("Don't know how to print debug info for "+t);t.visitType(n,r)}}),r.toSource()}var i=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},o=n(164),s=n(5),a=n(12),u=n(192),c=n(196),p="asset://debug/lib";e.debugOutputAstAsTypeScript=r;var l=function(){function t(){}return t.prototype.emitStatements=function(t,e,n){var r=new h(t),i=u.EmitterVisitorContext.createRoot(n);r.visitAllStatements(e,i);var o=[];return r.importsWithPrefixes.forEach(function(e,n){o.push("imp"+("ort * as "+e+" from '"+c.getImportModulePath(t,n,c.ImportEnv.JS)+"';"))}),o.push(i.toSource()),o.join("\n")},t}();e.TypeScriptEmitter=l;var h=function(t){function e(e){t.call(this,!1),this._moduleUrl=e,this.importsWithPrefixes=new Map}return i(e,t),e.prototype.visitExternalExpr=function(t,e){return this._visitIdentifier(t.value,t.typeParams,e),null},e.prototype.visitDeclareVarStmt=function(t,e){return e.isExportedVar(t.name)&&e.print("export "),t.hasModifier(o.StmtModifier.Final)?e.print("const"):e.print("var"),e.print(" "+t.name),s.isPresent(t.type)&&(e.print(":"),t.type.visitType(this,e)),e.print(" = "),t.value.visitExpression(this,e),e.println(";"),null},e.prototype.visitCastExpr=function(t,e){return e.print("(<"),t.type.visitType(this,e),e.print(">"),t.value.visitExpression(this,e),e.print(")"),null},e.prototype.visitDeclareClassStmt=function(t,e){var n=this;return e.pushClass(t),e.isExportedVar(t.name)&&e.print("export "),e.print("class "+t.name),s.isPresent(t.parent)&&(e.print(" extends "),t.parent.visitExpression(this,e)),e.println(" {"),e.incIndent(),t.fields.forEach(function(t){return n._visitClassField(t,e)}),s.isPresent(t.constructorMethod)&&this._visitClassConstructor(t,e),t.getters.forEach(function(t){return n._visitClassGetter(t,e)}),t.methods.forEach(function(t){return n._visitClassMethod(t,e)}),e.decIndent(),e.println("}"),e.popClass(),null},e.prototype._visitClassField=function(t,e){t.hasModifier(o.StmtModifier.Private)&&e.print("private "),e.print(t.name),s.isPresent(t.type)?(e.print(":"),t.type.visitType(this,e)):e.print(": any"),e.println(";")},e.prototype._visitClassGetter=function(t,e){t.hasModifier(o.StmtModifier.Private)&&e.print("private "),e.print("get "+t.name+"()"),s.isPresent(t.type)&&(e.print(":"),t.type.visitType(this,e)),e.println(" {"),e.incIndent(),this.visitAllStatements(t.body,e),e.decIndent(),e.println("}")},e.prototype._visitClassConstructor=function(t,e){e.print("constructor("),this._visitParams(t.constructorMethod.params,e),e.println(") {"),e.incIndent(),this.visitAllStatements(t.constructorMethod.body,e),e.decIndent(),e.println("}")},e.prototype._visitClassMethod=function(t,e){t.hasModifier(o.StmtModifier.Private)&&e.print("private "),e.print(t.name+"("),this._visitParams(t.params,e),e.print("):"),s.isPresent(t.type)?t.type.visitType(this,e):e.print("void"),e.println(" {"),e.incIndent(),this.visitAllStatements(t.body,e),e.decIndent(),e.println("}")},e.prototype.visitFunctionExpr=function(t,e){return e.print("("),this._visitParams(t.params,e),e.print("):"),s.isPresent(t.type)?t.type.visitType(this,e):e.print("void"),e.println(" => {"),e.incIndent(),this.visitAllStatements(t.statements,e),e.decIndent(),e.print("}"),null},e.prototype.visitDeclareFunctionStmt=function(t,e){return e.isExportedVar(t.name)&&e.print("export "),e.print("function "+t.name+"("),this._visitParams(t.params,e),e.print("):"),s.isPresent(t.type)?t.type.visitType(this,e):e.print("void"),e.println(" {"),e.incIndent(),this.visitAllStatements(t.statements,e),e.decIndent(),e.println("}"),null},e.prototype.visitTryCatchStmt=function(t,e){e.println("try {"),e.incIndent(),this.visitAllStatements(t.bodyStmts,e),e.decIndent(),e.println("} catch ("+u.CATCH_ERROR_VAR.name+") {"),e.incIndent();var n=[u.CATCH_STACK_VAR.set(u.CATCH_ERROR_VAR.prop("stack")).toDeclStmt(null,[o.StmtModifier.Final])].concat(t.catchStmts);return this.visitAllStatements(n,e),e.decIndent(),e.println("}"),null},e.prototype.visitBuiltintType=function(t,e){var n;switch(t.name){case o.BuiltinTypeName.Bool:n="boolean";break;case o.BuiltinTypeName.Dynamic:n="any";break;case o.BuiltinTypeName.Function:n="Function";break;case o.BuiltinTypeName.Number:n="number";break;case o.BuiltinTypeName.Int:n="number";break;case o.BuiltinTypeName.String:n="string";break;default:throw new a.BaseException("Unsupported builtin type "+t.name)}return e.print(n),null},e.prototype.visitExternalType=function(t,e){return this._visitIdentifier(t.value,t.typeParams,e),null},e.prototype.visitArrayType=function(t,e){return s.isPresent(t.of)?t.of.visitType(this,e):e.print("any"),e.print("[]"),null},e.prototype.visitMapType=function(t,e){return e.print("{[key: string]:"),s.isPresent(t.valueType)?t.valueType.visitType(this,e):e.print("any"),e.print("}"),null},e.prototype.getBuiltinMethodName=function(t){var e;switch(t){case o.BuiltinMethod.ConcatArray:e="concat";break;case o.BuiltinMethod.SubscribeObservable:e="subscribe";break;case o.BuiltinMethod.bind:e="bind";break;default:throw new a.BaseException("Unknown builtin method: "+t)}return e},e.prototype._visitParams=function(t,e){var n=this;this.visitAllObjects(function(t){e.print(t.name),s.isPresent(t.type)&&(e.print(":"),t.type.visitType(n,e))},t,e,",")},e.prototype._visitIdentifier=function(t,e,n){var r=this;if(s.isPresent(t.moduleUrl)&&t.moduleUrl!=this._moduleUrl){var i=this.importsWithPrefixes.get(t.moduleUrl);s.isBlank(i)&&(i="import"+this.importsWithPrefixes.size,this.importsWithPrefixes.set(t.moduleUrl,i)),n.print(i+".")}n.print(t.name),s.isPresent(e)&&e.length>0&&(n.print("<"),this.visitAllObjects(function(t){return t.visitType(r,n)},e,n,","),n.print(">"))},e}(u.AbstractEmitterVisitor)},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(5),o=n(159),s=n(12),a=function(){function t(){}return t.prototype.createInstance=function(t,e,n,r,i,a){if(t===o.AppView)return new u(n,r,i,a);throw new s.BaseException("Can't instantiate class "+t+" in interpretative mode")},t}();e.InterpretiveAppViewInstanceFactory=a;var u=function(t){function e(e,n,r,i){t.call(this,e[0],e[1],e[2],e[3],e[4],e[5],e[6],e[7],e[8]),this.props=n,this.getters=r,this.methods=i}return r(e,t),e.prototype.createInternal=function(e){var n=this.methods.get("createInternal");return i.isPresent(n)?n(e):t.prototype.createInternal.call(this,e)},e.prototype.injectorGetInternal=function(e,n,r){var o=this.methods.get("injectorGetInternal");return i.isPresent(o)?o(e,n,r):t.prototype.injectorGet.call(this,e,n,r)},e.prototype.destroyInternal=function(){var e=this.methods.get("destroyInternal");return i.isPresent(e)?e():t.prototype.destroyInternal.call(this)},e.prototype.dirtyParentQueriesInternal=function(){var e=this.methods.get("dirtyParentQueriesInternal");return i.isPresent(e)?e():t.prototype.dirtyParentQueriesInternal.call(this)},e.prototype.detectChangesInternal=function(e){var n=this.methods.get("detectChangesInternal");return i.isPresent(n)?n(e):t.prototype.detectChangesInternal.call(this,e)},e}(o.AppView)},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(6),a=n(5),u=n(15),c=n(200),p=n(148),l=n(150),h=a.CONST_EXPR({xlink:"http://www.w3.org/1999/xlink",svg:"http://www.w3.org/2000/svg"}),f=function(t){function e(){t.apply(this,arguments),this._protoElements=new Map}return r(e,t),e.prototype._getProtoElement=function(t){var e=this._protoElements.get(t);if(a.isBlank(e)){var n=p.splitNsName(t);e=a.isPresent(n[0])?c.DOM.createElementNS(h[n[0]],n[1]):c.DOM.createElement(n[1]),this._protoElements.set(t,e)}return e},e.prototype.hasProperty=function(t,e){if(-1!==t.indexOf("-"))return!0;var n=this._getProtoElement(t);return c.DOM.hasProperty(n,e)},e.prototype.getMappedPropName=function(t){var e=u.StringMapWrapper.get(c.DOM.attrToPropMap,t);return a.isPresent(e)?e:t},e=i([s.Injectable(),o("design:paramtypes",[])],e)}(l.ElementSchemaRegistry);e.DomElementSchemaRegistry=f},function(t,e,n){"use strict";function r(t){i.isBlank(e.DOM)&&(e.DOM=t)}var i=n(5);e.DOM=null,e.setRootDomAdapter=r;var o=function(){function t(){}return Object.defineProperty(t.prototype,"attrToPropMap",{get:function(){return this._attrToPropMap},set:function(t){this._attrToPropMap=t},enumerable:!0,configurable:!0}),t}();e.DomAdapter=o},function(t,e,n){"use strict";function r(){return s.isBlank(c.getPlatform())&&c.createPlatform(c.ReflectiveInjector.resolveAndCreate(a.BROWSER_PROVIDERS)),c.assertPlatform(a.BROWSER_PLATFORM_MARKER)}function i(t,n){c.reflector.reflectionCapabilities=new p.ReflectionCapabilities;var i=c.ReflectiveInjector.resolveAndCreate([e.BROWSER_APP_PROVIDERS,s.isPresent(n)?n:[]],r().injector);return c.coreLoadAndBootstrap(i,t)}var o=n(202);e.BROWSER_PROVIDERS=o.BROWSER_PROVIDERS,e.CACHED_TEMPLATE_PROVIDER=o.CACHED_TEMPLATE_PROVIDER,e.ELEMENT_PROBE_PROVIDERS=o.ELEMENT_PROBE_PROVIDERS,e.ELEMENT_PROBE_PROVIDERS_PROD_MODE=o.ELEMENT_PROBE_PROVIDERS_PROD_MODE,e.inspectNativeElement=o.inspectNativeElement,e.BrowserDomAdapter=o.BrowserDomAdapter,e.By=o.By,e.Title=o.Title,e.DOCUMENT=o.DOCUMENT,e.enableDebugTools=o.enableDebugTools,e.disableDebugTools=o.disableDebugTools;var s=n(5),a=n(202),u=n(137),c=n(2),p=n(21),l=n(220),h=n(137),f=n(6);e.BROWSER_APP_PROVIDERS=s.CONST_EXPR([a.BROWSER_APP_COMMON_PROVIDERS,u.COMPILER_PROVIDERS,new f.Provider(h.XHR,{useClass:l.XHRImpl})]),e.browserPlatform=r,e.bootstrap=i},function(t,e,n){"use strict";function r(){return new c.ExceptionHandler(h.DOM,!s.IS_DART)}function i(){return h.DOM.defaultDoc()}function o(){E.BrowserDomAdapter.makeCurrent(),R.wtfInit(),w.BrowserGetTestability.init()}var s=n(5),a=n(6),u=n(184),c=n(2),p=n(87),l=n(63),h=n(200),f=n(203),d=n(205),v=n(206),y=n(208),m=n(209),g=n(217),_=n(217),b=n(216),P=n(210),E=n(218),w=n(221),C=n(222),R=n(223),S=n(204),O=n(206),T=n(224),x=n(208);e.DOCUMENT=x.DOCUMENT;var A=n(228);e.Title=A.Title;var I=n(224);e.ELEMENT_PROBE_PROVIDERS=I.ELEMENT_PROBE_PROVIDERS,e.ELEMENT_PROBE_PROVIDERS_PROD_MODE=I.ELEMENT_PROBE_PROVIDERS_PROD_MODE,e.inspectNativeElement=I.inspectNativeElement,e.By=I.By;var M=n(218);e.BrowserDomAdapter=M.BrowserDomAdapter;var k=n(229);e.enableDebugTools=k.enableDebugTools,e.disableDebugTools=k.disableDebugTools;var N=n(206);e.HAMMER_GESTURE_CONFIG=N.HAMMER_GESTURE_CONFIG,e.HammerGestureConfig=N.HammerGestureConfig,e.BROWSER_PLATFORM_MARKER=s.CONST_EXPR(new a.OpaqueToken("BrowserPlatformMarker")),e.BROWSER_PROVIDERS=s.CONST_EXPR([new a.Provider(e.BROWSER_PLATFORM_MARKER,{useValue:!0}),c.PLATFORM_COMMON_PROVIDERS,new a.Provider(c.PLATFORM_INITIALIZER,{useValue:o,multi:!0})]),e.BROWSER_APP_COMMON_PROVIDERS=s.CONST_EXPR([c.APPLICATION_COMMON_PROVIDERS,p.FORM_PROVIDERS,new a.Provider(c.PLATFORM_PIPES,{useValue:p.COMMON_PIPES,multi:!0}),new a.Provider(c.PLATFORM_DIRECTIVES,{useValue:p.COMMON_DIRECTIVES,multi:!0}),new a.Provider(c.ExceptionHandler,{useFactory:r,deps:[]}),new a.Provider(y.DOCUMENT,{useFactory:i,deps:[]}),new a.Provider(S.EVENT_MANAGER_PLUGINS,{useClass:f.DomEventsPlugin,multi:!0}),new a.Provider(S.EVENT_MANAGER_PLUGINS,{useClass:d.KeyEventsPlugin,multi:!0}),new a.Provider(S.EVENT_MANAGER_PLUGINS,{useClass:v.HammerGesturesPlugin,multi:!0}),new a.Provider(O.HAMMER_GESTURE_CONFIG,{useClass:O.HammerGestureConfig}),new a.Provider(m.DomRootRenderer,{useClass:m.DomRootRenderer_}),new a.Provider(c.RootRenderer,{useExisting:m.DomRootRenderer}),new a.Provider(_.SharedStylesHost,{useExisting:g.DomSharedStylesHost}),g.DomSharedStylesHost,l.Testability,b.BrowserDetails,P.AnimationBuilder,S.EventManager,T.ELEMENT_PROBE_PROVIDERS]),e.CACHED_TEMPLATE_PROVIDER=s.CONST_EXPR([new a.Provider(u.XHR,{useClass:C.CachedXHR})]),e.initDomAdapter=o},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(200),a=n(2),u=n(204),c=function(t){function e(){t.apply(this,arguments)}return r(e,t),e.prototype.supports=function(t){return!0},e.prototype.addEventListener=function(t,e,n){var r=this.manager.getZone(),i=function(t){return r.runGuarded(function(){return n(t)})};return this.manager.getZone().runOutsideAngular(function(){return s.DOM.onAndCancel(t,e,i)})},e.prototype.addGlobalEventListener=function(t,e,n){var r=s.DOM.getGlobalEventTarget(t),i=this.manager.getZone(),o=function(t){return i.runGuarded(function(){return n(t)})};return this.manager.getZone().runOutsideAngular(function(){return s.DOM.onAndCancel(r,e,o)})},e=i([a.Injectable(),o("design:paramtypes",[])],e)}(u.EventManagerPlugin);e.DomEventsPlugin=c},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},s=n(5),a=n(12),u=n(6),c=n(60),p=n(15);e.EVENT_MANAGER_PLUGINS=s.CONST_EXPR(new u.OpaqueToken("EventManagerPlugins"));var l=function(){function t(t,e){var n=this;this._zone=e,t.forEach(function(t){return t.manager=n}),this._plugins=p.ListWrapper.reversed(t)}return t.prototype.addEventListener=function(t,e,n){var r=this._findPluginFor(e);return r.addEventListener(t,e,n)},t.prototype.addGlobalEventListener=function(t,e,n){var r=this._findPluginFor(e);return r.addGlobalEventListener(t,e,n)},t.prototype.getZone=function(){return this._zone},t.prototype._findPluginFor=function(t){for(var e=this._plugins,n=0;no?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(200),a=n(5),u=n(15),c=n(204),p=n(6),l=["alt","control","meta","shift"],h={alt:function(t){return t.altKey},control:function(t){return t.ctrlKey},meta:function(t){return t.metaKey},shift:function(t){return t.shiftKey}},f=function(t){function e(){t.call(this)}return r(e,t),e.prototype.supports=function(t){return a.isPresent(e.parseEventName(t))},e.prototype.addEventListener=function(t,n,r){var i=e.parseEventName(n),o=e.eventCallback(t,u.StringMapWrapper.get(i,"fullKey"),r,this.manager.getZone());return this.manager.getZone().runOutsideAngular(function(){return s.DOM.onAndCancel(t,u.StringMapWrapper.get(i,"domEventName"),o)})},e.parseEventName=function(t){var n=t.toLowerCase().split("."),r=n.shift();if(0===n.length||!a.StringWrapper.equals(r,"keydown")&&!a.StringWrapper.equals(r,"keyup"))return null;var i=e._normalizeKey(n.pop()),o="";if(l.forEach(function(t){u.ListWrapper.contains(n,t)&&(u.ListWrapper.remove(n,t),o+=t+".")}),o+=i,0!=n.length||0===i.length)return null;var s=u.StringMapWrapper.create();return u.StringMapWrapper.set(s,"domEventName",r),u.StringMapWrapper.set(s,"fullKey",o),s},e.getEventFullKey=function(t){var e="",n=s.DOM.getEventKey(t);return n=n.toLowerCase(),a.StringWrapper.equals(n," ")?n="space":a.StringWrapper.equals(n,".")&&(n="dot"),l.forEach(function(r){if(r!=n){var i=u.StringMapWrapper.get(h,r);i(t)&&(e+=r+".")}}),e+=n},e.eventCallback=function(t,n,r,i){return function(t){a.StringWrapper.equals(e.getEventFullKey(t),n)&&i.runGuarded(function(){return r(t)})}},e._normalizeKey=function(t){switch(t){case"esc":return"escape";default:return t}},e=i([p.Injectable(),o("design:paramtypes",[])],e)}(c.EventManagerPlugin);e.KeyEventsPlugin=f},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},a=n(207),u=n(5),c=n(12),p=n(2);e.HAMMER_GESTURE_CONFIG=u.CONST_EXPR(new p.OpaqueToken("HammerGestureConfig"));var l=function(){function t(){this.events=[],this.overrides={}}return t.prototype.buildHammer=function(t){var e=new Hammer(t);e.get("pinch").set({enable:!0}),e.get("rotate").set({enable:!0});for(var n in this.overrides)e.get(n).set(this.overrides[n]);return e},t=i([p.Injectable(),o("design:paramtypes",[])],t)}();e.HammerGestureConfig=l;var h=function(t){function n(e){t.call(this),this._config=e}return r(n,t),n.prototype.supports=function(e){if(!t.prototype.supports.call(this,e)&&!this.isCustomEvent(e))return!1;if(!u.isPresent(window.Hammer))throw new c.BaseException("Hammer.js is not loaded, can not bind "+e+" event");return!0},n.prototype.addEventListener=function(t,e,n){var r=this,i=this.manager.getZone();return e=e.toLowerCase(),i.runOutsideAngular(function(){var o=r._config.buildHammer(t),s=function(t){i.runGuarded(function(){n(t)})};return o.on(e,s),function(){o.off(e,s)}})},n.prototype.isCustomEvent=function(t){return this._config.events.indexOf(t)>-1},n=i([p.Injectable(),s(0,p.Inject(e.HAMMER_GESTURE_CONFIG)),o("design:paramtypes",[l])],n)}(a.HammerGesturesPluginCommon);e.HammerGesturesPlugin=h},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(204),o=n(15),s={pan:!0,panstart:!0,panmove:!0,panend:!0,pancancel:!0,panleft:!0,panright:!0,panup:!0,pandown:!0,pinch:!0,pinchstart:!0,pinchmove:!0,pinchend:!0,pinchcancel:!0,pinchin:!0,pinchout:!0,press:!0,pressup:!0,rotate:!0,rotatestart:!0,rotatemove:!0,rotateend:!0,rotatecancel:!0,swipe:!0,swipeleft:!0,swiperight:!0,swipeup:!0,swipedown:!0,tap:!0},a=function(t){function e(){t.call(this)}return r(e,t),e.prototype.supports=function(t){return t=t.toLowerCase(),o.StringMapWrapper.contains(s,t)},e}(i.EventManagerPlugin);e.HammerGesturesPluginCommon=a},function(t,e,n){"use strict";var r=n(6),i=n(5);e.DOCUMENT=i.CONST_EXPR(new r.OpaqueToken("DocumentToken"))},function(t,e,n){"use strict";function r(t,e){var n=E.DOM.parentElement(t);if(e.length>0&&y.isPresent(n)){var r=E.DOM.nextSibling(t);if(y.isPresent(r))for(var i=0;io?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s); -return o>3&&s&&Object.defineProperty(e,n,s),s},h=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},f=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},d=n(6),v=n(210),y=n(5),m=n(12),g=n(217),_=n(204),b=n(208),P=n(3),E=n(200),w=n(215),C=y.CONST_EXPR({xlink:"http://www.w3.org/1999/xlink",svg:"http://www.w3.org/2000/svg"}),R="template bindings={}",S=/^template bindings=(.*)$/g,O=function(){function t(t,e,n,r){this.document=t,this.eventManager=e,this.sharedStylesHost=n,this.animate=r,this._registeredComponents=new Map}return t.prototype.renderComponent=function(t){var e=this._registeredComponents.get(t.id);return y.isBlank(e)&&(e=new x(this,t),this._registeredComponents.set(t.id,e)),e},t}();e.DomRootRenderer=O;var T=function(t){function e(e,n,r,i){t.call(this,e,n,r,i)}return p(e,t),e=l([d.Injectable(),f(0,d.Inject(b.DOCUMENT)),h("design:paramtypes",[Object,_.EventManager,g.DomSharedStylesHost,v.AnimationBuilder])],e)}(O);e.DomRootRenderer_=T;var x=function(){function t(t,e){this._rootRenderer=t,this.componentProto=e,this._styles=u(e.id,e.styles,[]),e.encapsulation!==P.ViewEncapsulation.Native&&this._rootRenderer.sharedStylesHost.addStyles(this._styles),this.componentProto.encapsulation===P.ViewEncapsulation.Emulated?(this._contentAttr=s(e.id),this._hostAttr=a(e.id)):(this._contentAttr=null,this._hostAttr=null)}return t.prototype.selectRootElement=function(t,e){var n;if(y.isString(t)){if(n=E.DOM.querySelector(this._rootRenderer.document,t),y.isBlank(n))throw new m.BaseException('The selector "'+t+'" did not match any elements')}else n=t;return E.DOM.clearNodes(n),n},t.prototype.createElement=function(t,e,n){var r=c(e),i=y.isPresent(r[0])?E.DOM.createElementNS(C[r[0]],r[1]):E.DOM.createElement(r[1]);return y.isPresent(this._contentAttr)&&E.DOM.setAttribute(i,this._contentAttr,""),y.isPresent(t)&&E.DOM.appendChild(t,i),i},t.prototype.createViewRoot=function(t){var e;if(this.componentProto.encapsulation===P.ViewEncapsulation.Native){e=E.DOM.createShadowRoot(t),this._rootRenderer.sharedStylesHost.addHost(e);for(var n=0;no?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(6),s=n(211),a=n(216),u=function(){function t(t){this.browserDetails=t}return t.prototype.css=function(){return new s.CssAnimationBuilder(this.browserDetails)},t=r([o.Injectable(),i("design:paramtypes",[a.BrowserDetails])],t)}();e.AnimationBuilder=u},function(t,e,n){"use strict";var r=n(212),i=n(213),o=function(){function t(t){this.browserDetails=t,this.data=new r.CssAnimationOptions}return t.prototype.addAnimationClass=function(t){return this.data.animationClasses.push(t),this},t.prototype.addClass=function(t){return this.data.classesToAdd.push(t),this},t.prototype.removeClass=function(t){return this.data.classesToRemove.push(t),this},t.prototype.setDuration=function(t){return this.data.duration=t,this},t.prototype.setDelay=function(t){return this.data.delay=t,this},t.prototype.setStyles=function(t,e){return this.setFromStyles(t).setToStyles(e)},t.prototype.setFromStyles=function(t){return this.data.fromStyles=t,this},t.prototype.setToStyles=function(t){return this.data.toStyles=t,this},t.prototype.start=function(t){return new i.Animation(t,this.data,this.browserDetails)},t}();e.CssAnimationBuilder=o},function(t,e){"use strict";var n=function(){function t(){this.classesToAdd=[],this.classesToRemove=[],this.animationClasses=[]}return t}();e.CssAnimationOptions=n},function(t,e,n){"use strict";var r=n(5),i=n(214),o=n(215),s=n(15),a=n(200),u=function(){function t(t,e,n){var i=this;this.element=t,this.data=e,this.browserDetails=n,this.callbacks=[],this.eventClearFunctions=[],this.completed=!1,this._stringPrefix="",this.startTime=r.DateWrapper.toMillis(r.DateWrapper.now()),this._stringPrefix=a.DOM.getAnimationPrefix(),this.setup(),this.wait(function(t){return i.start()})}return Object.defineProperty(t.prototype,"totalTime",{get:function(){var t=null!=this.computedDelay?this.computedDelay:0,e=null!=this.computedDuration?this.computedDuration:0;return t+e},enumerable:!0,configurable:!0}),t.prototype.wait=function(t){this.browserDetails.raf(t,2)},t.prototype.setup=function(){null!=this.data.fromStyles&&this.applyStyles(this.data.fromStyles),null!=this.data.duration&&this.applyStyles({transitionDuration:this.data.duration.toString()+"ms"}),null!=this.data.delay&&this.applyStyles({transitionDelay:this.data.delay.toString()+"ms"})},t.prototype.start=function(){this.addClasses(this.data.classesToAdd),this.addClasses(this.data.animationClasses),this.removeClasses(this.data.classesToRemove),null!=this.data.toStyles&&this.applyStyles(this.data.toStyles);var t=a.DOM.getComputedStyle(this.element);this.computedDelay=i.Math.max(this.parseDurationString(t.getPropertyValue(this._stringPrefix+"transition-delay")),this.parseDurationString(this.element.style.getPropertyValue(this._stringPrefix+"transition-delay"))),this.computedDuration=i.Math.max(this.parseDurationString(t.getPropertyValue(this._stringPrefix+"transition-duration")),this.parseDurationString(this.element.style.getPropertyValue(this._stringPrefix+"transition-duration"))),this.addEvents()},t.prototype.applyStyles=function(t){var e=this;s.StringMapWrapper.forEach(t,function(t,n){var i=o.camelCaseToDashCase(n);r.isPresent(a.DOM.getStyle(e.element,i))?a.DOM.setStyle(e.element,i,t.toString()):a.DOM.setStyle(e.element,e._stringPrefix+i,t.toString())})},t.prototype.addClasses=function(t){for(var e=0,n=t.length;n>e;e++)a.DOM.addClass(this.element,t[e])},t.prototype.removeClasses=function(t){for(var e=0,n=t.length;n>e;e++)a.DOM.removeClass(this.element,t[e])},t.prototype.addEvents=function(){var t=this;this.totalTime>0?this.eventClearFunctions.push(a.DOM.onAndCancel(this.element,a.DOM.getTransitionEnd(),function(e){return t.handleAnimationEvent(e)})):this.handleAnimationCompleted()},t.prototype.handleAnimationEvent=function(t){var e=i.Math.round(1e3*t.elapsedTime);this.browserDetails.elapsedTimeIncludesDelay||(e+=this.computedDelay),t.stopPropagation(),e>=this.totalTime&&this.handleAnimationCompleted()},t.prototype.handleAnimationCompleted=function(){this.removeClasses(this.data.animationClasses),this.callbacks.forEach(function(t){return t()}),this.callbacks=[],this.eventClearFunctions.forEach(function(t){return t()}),this.eventClearFunctions=[],this.completed=!0},t.prototype.onComplete=function(t){return this.completed?t():this.callbacks.push(t),this},t.prototype.parseDurationString=function(t){var e=0;if(null==t||t.length<2)return e;if("ms"==t.substring(t.length-2)){var n=r.NumberWrapper.parseInt(this.stripLetters(t),10);n>e&&(e=n)}else if("s"==t.substring(t.length-1)){var o=1e3*r.NumberWrapper.parseFloat(this.stripLetters(t)),n=i.Math.floor(o);n>e&&(e=n)}return e},t.prototype.stripLetters=function(t){return r.StringWrapper.replaceAll(t,r.RegExpWrapper.create("[^0-9]+$",""),"")},t}();e.Animation=u},function(t,e,n){"use strict";var r=n(5);e.Math=r.global.Math,e.NaN=typeof e.NaN},function(t,e,n){"use strict";function r(t){return o.StringWrapper.replaceAllMapped(t,s,function(t){return"-"+t[1].toLowerCase()})}function i(t){return o.StringWrapper.replaceAllMapped(t,a,function(t){return t[1].toUpperCase()})}var o=n(5),s=/([A-Z])/g,a=/-([a-z])/g;e.camelCaseToDashCase=r,e.dashCaseToCamelCase=i},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(6),s=n(214),a=n(200),u=function(){function t(){this.elapsedTimeIncludesDelay=!1,this.doesElapsedTimeIncludesDelay()}return t.prototype.doesElapsedTimeIncludesDelay=function(){var t=this,e=a.DOM.createElement("div");a.DOM.setAttribute(e,"style","position: absolute; top: -9999px; left: -9999px; width: 1px;\n height: 1px; transition: all 1ms linear 1ms;"),this.raf(function(n){a.DOM.on(e,"transitionend",function(n){var r=s.Math.round(1e3*n.elapsedTime);t.elapsedTimeIncludesDelay=2==r,a.DOM.remove(e)}),a.DOM.setStyle(e,"width","2px")},2)},t.prototype.raf=function(t,e){void 0===e&&(e=1);var n=new c(t,e);return function(){return n.cancel()}},t=r([o.Injectable(),i("design:paramtypes",[])],t)}();e.BrowserDetails=u;var c=function(){function t(t,e){this.callback=t,this.frames=e,this._raf()}return t.prototype._raf=function(){var t=this;this.currentFrameId=a.DOM.requestAnimationFrame(function(e){return t._nextFrame(e)})},t.prototype._nextFrame=function(t){this.frames--,this.frames>0?this._raf():this.callback(t)},t.prototype.cancel=function(){a.DOM.cancelAnimationFrame(this.currentFrameId),this.currentFrameId=null},t}()},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},a=n(200),u=n(6),c=n(15),p=n(208),l=function(){function t(){this._styles=[],this._stylesSet=new Set}return t.prototype.addStyles=function(t){var e=this,n=[];t.forEach(function(t){c.SetWrapper.has(e._stylesSet,t)||(e._stylesSet.add(t),e._styles.push(t),n.push(t))}),this.onStylesAdded(n)},t.prototype.onStylesAdded=function(t){},t.prototype.getAllStyles=function(){return this._styles},t=i([u.Injectable(),o("design:paramtypes",[])],t)}();e.SharedStylesHost=l;var h=function(t){function e(e){t.call(this),this._hostNodes=new Set,this._hostNodes.add(e.head)}return r(e,t),e.prototype._addStylesToHost=function(t,e){for(var n=0;n0},e.prototype.tagName=function(t){return t.tagName},e.prototype.attributeMap=function(t){for(var e=new Map,n=t.attributes,r=0;r=200&&300>=i?e.resolve(r):e.reject("Failed to load "+t,null)},n.onerror=function(){e.reject("Failed to load "+t,null)},n.send(),e.promise},e}(s.XHR);e.XHRImpl=a},function(t,e,n){"use strict";var r=n(15),i=n(5),o=n(200),s=n(2),a=function(){function t(t){this._testability=t}return t.prototype.isStable=function(){return this._testability.isStable()},t.prototype.whenStable=function(t){this._testability.whenStable(t)},t.prototype.findBindings=function(t,e,n){return this.findProviders(t,e,n)},t.prototype.findProviders=function(t,e,n){return this._testability.findBindings(t,e,n)},t}(),u=function(){function t(){}return t.init=function(){s.setTestabilityGetter(new t)},t.prototype.addToWindow=function(t){i.global.getAngularTestability=function(e,n){void 0===n&&(n=!0);var r=t.findTestabilityInTree(e,n);if(null==r)throw new Error("Could not find testability for element.");return new a(r)},i.global.getAllAngularTestabilities=function(){var e=t.getAllTestabilities();return e.map(function(t){return new a(t)})},i.global.getAllAngularRootElements=function(){return t.getAllRootElements()};var e=function(t){var e=i.global.getAllAngularTestabilities(),n=e.length,r=!1,o=function(e){r=r||e,n--,0==n&&t(r)};e.forEach(function(t){t.whenStable(o)})};i.global.frameworkStabilizers||(i.global.frameworkStabilizers=r.ListWrapper.createGrowableSize(0)),i.global.frameworkStabilizers.push(e)},t.prototype.findTestabilityInTree=function(t,e,n){if(null==e)return null;var r=t.getTestability(e);return i.isPresent(r)?r:n?o.DOM.isShadowRoot(e)?this.findTestabilityInTree(t,o.DOM.getHost(e),!0):this.findTestabilityInTree(t,o.DOM.parentElement(e),!0):null},t}();e.BrowserGetTestability=u},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(184),o=n(12),s=n(5),a=n(41),u=function(t){function e(){if(t.call(this),this._cache=s.global.$templateCache,null==this._cache)throw new o.BaseException("CachedXHR: Template cache was not found in $templateCache.")}return r(e,t),e.prototype.get=function(t){return this._cache.hasOwnProperty(t)?a.PromiseWrapper.resolve(this._cache[t]):a.PromiseWrapper.reject("CachedXHR: Did not find cached template for "+t,null)},e}(i.XHR);e.CachedXHR=u},function(t,e){"use strict";function n(){}e.wtfInit=n},function(t,e,n){"use strict";function r(t){for(var n in t)e.hasOwnProperty(n)||(e[n]=t[n])}var i=n(200);e.DOM=i.DOM,e.setRootDomAdapter=i.setRootDomAdapter,e.DomAdapter=i.DomAdapter;var o=n(209);e.DomRenderer=o.DomRenderer;var s=n(208);e.DOCUMENT=s.DOCUMENT;var a=n(217);e.SharedStylesHost=a.SharedStylesHost,e.DomSharedStylesHost=a.DomSharedStylesHost;var u=n(203);e.DomEventsPlugin=u.DomEventsPlugin;var c=n(204);e.EVENT_MANAGER_PLUGINS=c.EVENT_MANAGER_PLUGINS,e.EventManager=c.EventManager,e.EventManagerPlugin=c.EventManagerPlugin,r(n(225)),r(n(226))},function(t,e,n){"use strict";var r=n(5),i=n(200),o=function(){function t(){}return t.all=function(){return function(t){return!0}},t.css=function(t){return function(e){return r.isPresent(e.nativeElement)?i.DOM.elementMatches(e.nativeElement,t):!1}},t.directive=function(t){return function(e){return-1!==e.providerTokens.indexOf(t)}},t}();e.By=o},function(t,e,n){"use strict";function r(t){return c.getDebugNode(t)}function i(t){return s.assertionsEnabled()?o(t):t}function o(t){return u.DOM.setGlobalVar(d,r),u.DOM.setGlobalVar(v,f),new h.DebugDomRootRenderer(t)}var s=n(5),a=n(6),u=n(200),c=n(83),p=n(209),l=n(2),h=n(227),f=s.CONST_EXPR({ApplicationRef:l.ApplicationRef,NgZone:l.NgZone}),d="ng.probe",v="ng.coreTokens";e.inspectNativeElement=r,e.ELEMENT_PROBE_PROVIDERS=s.CONST_EXPR([new a.Provider(l.RootRenderer,{useFactory:i,deps:[p.DomRootRenderer]})]),e.ELEMENT_PROBE_PROVIDERS_PROD_MODE=s.CONST_EXPR([new a.Provider(l.RootRenderer,{useFactory:o,deps:[p.DomRootRenderer]})])},function(t,e,n){"use strict";var r=n(5),i=n(83),o=function(){function t(t){this._delegate=t}return t.prototype.renderComponent=function(t){return new s(this._delegate.renderComponent(t))},t}();e.DebugDomRootRenderer=o;var s=function(){function t(t){this._delegate=t}return t.prototype.selectRootElement=function(t,e){var n=this._delegate.selectRootElement(t,e),r=new i.DebugElement(n,null,e);return i.indexDebugNode(r),n},t.prototype.createElement=function(t,e,n){var r=this._delegate.createElement(t,e,n),o=new i.DebugElement(r,i.getDebugNode(t),n);return o.name=e,i.indexDebugNode(o),r},t.prototype.createViewRoot=function(t){return this._delegate.createViewRoot(t)},t.prototype.createTemplateAnchor=function(t,e){var n=this._delegate.createTemplateAnchor(t,e),r=new i.DebugNode(n,i.getDebugNode(t),e);return i.indexDebugNode(r),n},t.prototype.createText=function(t,e,n){var r=this._delegate.createText(t,e,n),o=new i.DebugNode(r,i.getDebugNode(t),n);return i.indexDebugNode(o),r},t.prototype.projectNodes=function(t,e){var n=i.getDebugNode(t);if(r.isPresent(n)&&n instanceof i.DebugElement){var o=n;e.forEach(function(t){o.addChild(i.getDebugNode(t))})}this._delegate.projectNodes(t,e)},t.prototype.attachViewAfter=function(t,e){var n=i.getDebugNode(t);if(r.isPresent(n)){var o=n.parent;if(e.length>0&&r.isPresent(o)){var s=[];e.forEach(function(t){return s.push(i.getDebugNode(t))}),o.insertChildrenAfter(n,s)}}this._delegate.attachViewAfter(t,e)},t.prototype.detachView=function(t){t.forEach(function(t){var e=i.getDebugNode(t);r.isPresent(e)&&r.isPresent(e.parent)&&e.parent.removeChild(e)}),this._delegate.detachView(t)},t.prototype.destroyView=function(t,e){e.forEach(function(t){i.removeDebugNodeFromIndex(i.getDebugNode(t))}),this._delegate.destroyView(t,e)},t.prototype.listen=function(t,e,n){var o=i.getDebugNode(t);return r.isPresent(o)&&o.listeners.push(new i.EventListener(e,n)),this._delegate.listen(t,e,n)},t.prototype.listenGlobal=function(t,e,n){return this._delegate.listenGlobal(t,e,n)},t.prototype.setElementProperty=function(t,e,n){ -var o=i.getDebugNode(t);r.isPresent(o)&&o instanceof i.DebugElement&&(o.properties[e]=n),this._delegate.setElementProperty(t,e,n)},t.prototype.setElementAttribute=function(t,e,n){var o=i.getDebugNode(t);r.isPresent(o)&&o instanceof i.DebugElement&&(o.attributes[e]=n),this._delegate.setElementAttribute(t,e,n)},t.prototype.setBindingDebugInfo=function(t,e,n){this._delegate.setBindingDebugInfo(t,e,n)},t.prototype.setElementClass=function(t,e,n){this._delegate.setElementClass(t,e,n)},t.prototype.setElementStyle=function(t,e,n){this._delegate.setElementStyle(t,e,n)},t.prototype.invokeElementMethod=function(t,e,n){this._delegate.invokeElementMethod(t,e,n)},t.prototype.setText=function(t,e){this._delegate.setText(t,e)},t}();e.DebugDomRenderer=s},function(t,e,n){"use strict";var r=n(200),i=function(){function t(){}return t.prototype.getTitle=function(){return r.DOM.getTitle()},t.prototype.setTitle=function(t){r.DOM.setTitle(t)},t}();e.Title=i},function(t,e,n){"use strict";function r(t){a.ng=new s.AngularTools(t)}function i(){delete a.ng}var o=n(5),s=n(230),a=o.global;e.enableDebugTools=r,e.disableDebugTools=i},function(t,e,n){"use strict";var r=n(59),i=n(5),o=n(231),s=n(200),a=function(){function t(t,e){this.msPerTick=t,this.numTicks=e}return t}();e.ChangeDetectionPerfRecord=a;var u=function(){function t(t){this.profiler=new c(t)}return t}();e.AngularTools=u;var c=function(){function t(t){this.appRef=t.injector.get(r.ApplicationRef)}return t.prototype.timeChangeDetection=function(t){var e=i.isPresent(t)&&t.record,n="Change Detection",r=i.isPresent(o.window.console.profile);e&&r&&o.window.console.profile(n);for(var u=s.DOM.performanceNow(),c=0;5>c||s.DOM.performanceNow()-u<500;)this.appRef.tick(),c++;var p=s.DOM.performanceNow();e&&r&&o.window.console.profileEnd(n);var l=(p-u)/c;return o.window.console.log("ran "+c+" change detection cycles"),o.window.console.log(i.NumberWrapper.toFixed(l,2)+" ms per check"),new a(l,c)},t}();e.AngularProfiler=c},function(t,e){"use strict";var n=window;e.window=n,e.document=window.document,e.location=window.location,e.gc=window.gc?function(){return window.gc()}:function(){return null},e.performance=window.performance?window.performance:null,e.Event=window.Event,e.MouseEvent=window.MouseEvent,e.KeyboardEvent=window.KeyboardEvent,e.EventTarget=window.EventTarget,e.History=window.History,e.Location=window.Location,e.EventListener=window.EventListener},function(t,e,n){"use strict";var r=n(2),i=n(233),o=n(241),s=n(245),a=n(244),u=n(246),c=n(239),p=n(243),l=n(235);e.Request=l.Request;var h=n(242);e.Response=h.Response;var f=n(234);e.Connection=f.Connection,e.ConnectionBackend=f.ConnectionBackend;var d=n(244);e.BrowserXhr=d.BrowserXhr;var v=n(239);e.BaseRequestOptions=v.BaseRequestOptions,e.RequestOptions=v.RequestOptions;var y=n(243);e.BaseResponseOptions=y.BaseResponseOptions,e.ResponseOptions=y.ResponseOptions;var m=n(241);e.XHRBackend=m.XHRBackend,e.XHRConnection=m.XHRConnection;var g=n(245);e.JSONPBackend=g.JSONPBackend,e.JSONPConnection=g.JSONPConnection;var _=n(233);e.Http=_.Http,e.Jsonp=_.Jsonp;var b=n(236);e.Headers=b.Headers;var P=n(238);e.ResponseType=P.ResponseType,e.ReadyState=P.ReadyState,e.RequestMethod=P.RequestMethod;var E=n(240);e.URLSearchParams=E.URLSearchParams,e.HTTP_PROVIDERS=[r.provide(i.Http,{useFactory:function(t,e){return new i.Http(t,e)},deps:[o.XHRBackend,c.RequestOptions]}),a.BrowserXhr,r.provide(c.RequestOptions,{useClass:c.BaseRequestOptions}),r.provide(p.ResponseOptions,{useClass:p.BaseResponseOptions}),o.XHRBackend],e.HTTP_BINDINGS=e.HTTP_PROVIDERS,e.JSONP_PROVIDERS=[r.provide(i.Jsonp,{useFactory:function(t,e){return new i.Jsonp(t,e)},deps:[s.JSONPBackend,c.RequestOptions]}),u.BrowserJsonp,r.provide(c.RequestOptions,{useClass:c.BaseRequestOptions}),r.provide(p.ResponseOptions,{useClass:p.BaseResponseOptions}),r.provide(s.JSONPBackend,{useClass:s.JSONPBackend_})],e.JSON_BINDINGS=e.JSONP_PROVIDERS},function(t,e,n){"use strict";function r(t,e){return t.createConnection(e).response}function i(t,e,n,r){var i=t;return u.isPresent(e)?i.merge(new f.RequestOptions({method:e.method||n,url:e.url||r,search:e.search,headers:e.headers,body:e.body})):u.isPresent(n)?i.merge(new f.RequestOptions({method:n,url:r})):i.merge(new f.RequestOptions({url:r}))}var o=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},s=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},a=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},u=n(5),c=n(12),p=n(2),l=n(234),h=n(235),f=n(239),d=n(238),v=function(){function t(t,e){this._backend=t,this._defaultOptions=e}return t.prototype.request=function(t,e){var n;if(u.isString(t))n=r(this._backend,new h.Request(i(this._defaultOptions,e,d.RequestMethod.Get,t)));else{if(!(t instanceof h.Request))throw c.makeTypeError("First argument must be a url string or Request instance.");n=r(this._backend,t)}return n},t.prototype.get=function(t,e){return r(this._backend,new h.Request(i(this._defaultOptions,e,d.RequestMethod.Get,t)))},t.prototype.post=function(t,e,n){return r(this._backend,new h.Request(i(this._defaultOptions.merge(new f.RequestOptions({body:e})),n,d.RequestMethod.Post,t)))},t.prototype.put=function(t,e,n){return r(this._backend,new h.Request(i(this._defaultOptions.merge(new f.RequestOptions({body:e})),n,d.RequestMethod.Put,t)))},t.prototype["delete"]=function(t,e){return r(this._backend,new h.Request(i(this._defaultOptions,e,d.RequestMethod.Delete,t)))},t.prototype.patch=function(t,e,n){return r(this._backend,new h.Request(i(this._defaultOptions.merge(new f.RequestOptions({body:e})),n,d.RequestMethod.Patch,t)))},t.prototype.head=function(t,e){return r(this._backend,new h.Request(i(this._defaultOptions,e,d.RequestMethod.Head,t)))},t=s([p.Injectable(),a("design:paramtypes",[l.ConnectionBackend,f.RequestOptions])],t)}();e.Http=v;var y=function(t){function e(e,n){t.call(this,e,n)}return o(e,t),e.prototype.request=function(t,e){var n;if(u.isString(t)&&(t=new h.Request(i(this._defaultOptions,e,d.RequestMethod.Get,t))),!(t instanceof h.Request))throw c.makeTypeError("First argument must be a url string or Request instance.");return t.method!==d.RequestMethod.Get&&c.makeTypeError("JSONP requests must use GET request method."),n=r(this._backend,t)},e=s([p.Injectable(),a("design:paramtypes",[l.ConnectionBackend,f.RequestOptions])],e)}(v);e.Jsonp=y},function(t,e){"use strict";var n=function(){function t(){}return t}();e.ConnectionBackend=n;var r=function(){function t(){}return t}();e.Connection=r},function(t,e,n){"use strict";var r=n(236),i=n(237),o=n(5),s=function(){function t(t){var e=t.url;if(this.url=t.url,o.isPresent(t.search)){var n=t.search.toString();if(n.length>0){var s="?";o.StringWrapper.contains(this.url,"?")&&(s="&"==this.url[this.url.length-1]?"":"&"),this.url=e+s+n}}this._body=t.body,this.method=i.normalizeMethodName(t.method),this.headers=new r.Headers(t.headers)}return t.prototype.text=function(){return o.isPresent(this._body)?this._body.toString():""},t}();e.Request=s},function(t,e,n){"use strict";var r=n(5),i=n(12),o=n(15),s=function(){function t(e){var n=this;return e instanceof t?void(this._headersMap=e._headersMap):(this._headersMap=new o.Map,void(r.isBlank(e)||o.StringMapWrapper.forEach(e,function(t,e){n._headersMap.set(e,o.isListLikeIterable(t)?t:[t])})))}return t.fromResponseHeaderString=function(e){return e.trim().split("\n").map(function(t){return t.split(":")}).map(function(t){var e=t[0],n=t.slice(1);return[e.trim(),n.join(":").trim()]}).reduce(function(t,e){var n=e[0],r=e[1];return!t.set(n,r)&&t},new t)},t.prototype.append=function(t,e){var n=this._headersMap.get(t),r=o.isListLikeIterable(n)?n:[];r.push(e),this._headersMap.set(t,r)},t.prototype["delete"]=function(t){this._headersMap["delete"](t)},t.prototype.forEach=function(t){this._headersMap.forEach(t)},t.prototype.get=function(t){return o.ListWrapper.first(this._headersMap.get(t))},t.prototype.has=function(t){return this._headersMap.has(t)},t.prototype.keys=function(){return o.MapWrapper.keys(this._headersMap)},t.prototype.set=function(t,e){var n=[];if(o.isListLikeIterable(e)){var r=e.join(",");n.push(r)}else n.push(e);this._headersMap.set(t,n)},t.prototype.values=function(){return o.MapWrapper.values(this._headersMap)},t.prototype.toJSON=function(){var t={};return this._headersMap.forEach(function(e,n){var r=[];o.iterateListLike(e,function(t){return r=o.ListWrapper.concat(r,t.split(","))}),t[n]=r}),t},t.prototype.getAll=function(t){var e=this._headersMap.get(t);return o.isListLikeIterable(e)?e:[]},t.prototype.entries=function(){throw new i.BaseException('"entries" method is not implemented on Headers class')},t}();e.Headers=s},function(t,e,n){"use strict";function r(t){if(o.isString(t)){var e=t;if(t=t.replace(/(\w)(\w*)/g,function(t,e,n){return e.toUpperCase()+n.toLowerCase()}),t=s.RequestMethod[t],"number"!=typeof t)throw a.makeTypeError('Invalid request method. The method "'+e+'" is not supported.')}return t}function i(t){return"responseURL"in t?t.responseURL:/^X-Request-URL:/m.test(t.getAllResponseHeaders())?t.getResponseHeader("X-Request-URL"):void 0}var o=n(5),s=n(238),a=n(12);e.normalizeMethodName=r,e.isSuccess=function(t){return t>=200&&300>t},e.getResponseURL=i;var u=n(5);e.isJsObject=u.isJsObject},function(t,e){"use strict";!function(t){t[t.Get=0]="Get",t[t.Post=1]="Post",t[t.Put=2]="Put",t[t.Delete=3]="Delete",t[t.Options=4]="Options",t[t.Head=5]="Head",t[t.Patch=6]="Patch"}(e.RequestMethod||(e.RequestMethod={}));e.RequestMethod;!function(t){t[t.Unsent=0]="Unsent",t[t.Open=1]="Open",t[t.HeadersReceived=2]="HeadersReceived",t[t.Loading=3]="Loading",t[t.Done=4]="Done",t[t.Cancelled=5]="Cancelled"}(e.ReadyState||(e.ReadyState={}));e.ReadyState;!function(t){t[t.Basic=0]="Basic",t[t.Cors=1]="Cors",t[t.Default=2]="Default",t[t.Error=3]="Error",t[t.Opaque=4]="Opaque"}(e.ResponseType||(e.ResponseType={}));e.ResponseType},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(5),a=n(236),u=n(238),c=n(2),p=n(240),l=n(237),h=function(){function t(t){var e=void 0===t?{}:t,n=e.method,r=e.headers,i=e.body,o=e.url,a=e.search;this.method=s.isPresent(n)?l.normalizeMethodName(n):null,this.headers=s.isPresent(r)?r:null,this.body=s.isPresent(i)?i:null,this.url=s.isPresent(o)?o:null,this.search=s.isPresent(a)?s.isString(a)?new p.URLSearchParams(a):a:null}return t.prototype.merge=function(e){return new t({method:s.isPresent(e)&&s.isPresent(e.method)?e.method:this.method,headers:s.isPresent(e)&&s.isPresent(e.headers)?e.headers:this.headers,body:s.isPresent(e)&&s.isPresent(e.body)?e.body:this.body,url:s.isPresent(e)&&s.isPresent(e.url)?e.url:this.url,search:s.isPresent(e)&&s.isPresent(e.search)?s.isString(e.search)?new p.URLSearchParams(e.search):e.search.clone():this.search})},t}();e.RequestOptions=h;var f=function(t){function e(){t.call(this,{method:u.RequestMethod.Get,headers:new a.Headers})}return r(e,t),e=i([c.Injectable(),o("design:paramtypes",[])],e)}(h);e.BaseRequestOptions=f},function(t,e,n){"use strict";function r(t){void 0===t&&(t="");var e=new o.Map;if(t.length>0){var n=t.split("&");n.forEach(function(t){var n=t.split("="),r=n[0],o=n[1],s=i.isPresent(e.get(r))?e.get(r):[];s.push(o),e.set(r,s)})}return e}var i=n(5),o=n(15),s=function(){function t(t){void 0===t&&(t=""),this.rawParams=t,this.paramsMap=r(t)}return t.prototype.clone=function(){var e=new t;return e.appendAll(this),e},t.prototype.has=function(t){return this.paramsMap.has(t)},t.prototype.get=function(t){var e=this.paramsMap.get(t);return o.isListLikeIterable(e)?o.ListWrapper.first(e):null},t.prototype.getAll=function(t){var e=this.paramsMap.get(t);return i.isPresent(e)?e:[]},t.prototype.set=function(t,e){var n=this.paramsMap.get(t),r=i.isPresent(n)?n:[];o.ListWrapper.clear(r),r.push(e),this.paramsMap.set(t,r)},t.prototype.setAll=function(t){var e=this;t.paramsMap.forEach(function(t,n){var r=e.paramsMap.get(n),s=i.isPresent(r)?r:[];o.ListWrapper.clear(s),s.push(t[0]),e.paramsMap.set(n,s)})},t.prototype.append=function(t,e){var n=this.paramsMap.get(t),r=i.isPresent(n)?n:[];r.push(e),this.paramsMap.set(t,r)},t.prototype.appendAll=function(t){var e=this;t.paramsMap.forEach(function(t,n){for(var r=e.paramsMap.get(n),o=i.isPresent(r)?r:[],s=0;so?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(238),s=n(242),a=n(236),u=n(243),c=n(2),p=n(244),l=n(5),h=n(42),f=n(237),d=function(){function t(t,e,n){var r=this;this.request=t,this.response=new h.Observable(function(i){var c=e.build();c.open(o.RequestMethod[t.method].toUpperCase(),t.url);var p=function(){var t=l.isPresent(c.response)?c.response:c.responseText,e=a.Headers.fromResponseHeaderString(c.getAllResponseHeaders()),r=f.getResponseURL(c),o=1223===c.status?204:c.status;0===o&&(o=t?200:0);var p=new u.ResponseOptions({body:t,status:o,headers:e,url:r});l.isPresent(n)&&(p=n.merge(p));var h=new s.Response(p);return f.isSuccess(o)?(i.next(h),void i.complete()):void i.error(h)},h=function(t){var e=new u.ResponseOptions({body:t,type:o.ResponseType.Error});l.isPresent(n)&&(e=n.merge(e)),i.error(new s.Response(e))};return l.isPresent(t.headers)&&t.headers.forEach(function(t,e){return c.setRequestHeader(e,t.join(","))}),c.addEventListener("load",p),c.addEventListener("error",h),c.send(r.request.text()),function(){c.removeEventListener("load",p),c.removeEventListener("error",h),c.abort()}})}return t}();e.XHRConnection=d;var v=function(){function t(t,e){this._browserXHR=t,this._baseResponseOptions=e}return t.prototype.createConnection=function(t){return new d(t,this._browserXHR,this._baseResponseOptions)},t=r([c.Injectable(),i("design:paramtypes",[p.BrowserXhr,u.ResponseOptions])],t)}();e.XHRBackend=v},function(t,e,n){"use strict";var r=n(5),i=n(12),o=n(237),s=function(){function t(t){this._body=t.body,this.status=t.status,this.ok=this.status>=200&&this.status<=299,this.statusText=t.statusText,this.headers=t.headers,this.type=t.type,this.url=t.url}return t.prototype.blob=function(){throw new i.BaseException('"blob()" method not implemented on Response superclass')},t.prototype.json=function(){var t;return o.isJsObject(this._body)?t=this._body:r.isString(this._body)&&(t=r.Json.parse(this._body)),t},t.prototype.text=function(){return this._body.toString()},t.prototype.arrayBuffer=function(){throw new i.BaseException('"arrayBuffer()" method not implemented on Response superclass')},t}();e.Response=s},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(2),a=n(5),u=n(236),c=n(238),p=function(){function t(t){var e=void 0===t?{}:t,n=e.body,r=e.status,i=e.headers,o=e.statusText,s=e.type,u=e.url;this.body=a.isPresent(n)?n:null,this.status=a.isPresent(r)?r:null,this.headers=a.isPresent(i)?i:null,this.statusText=a.isPresent(o)?o:null,this.type=a.isPresent(s)?s:null,this.url=a.isPresent(u)?u:null}return t.prototype.merge=function(e){return new t({body:a.isPresent(e)&&a.isPresent(e.body)?e.body:this.body,status:a.isPresent(e)&&a.isPresent(e.status)?e.status:this.status,headers:a.isPresent(e)&&a.isPresent(e.headers)?e.headers:this.headers,statusText:a.isPresent(e)&&a.isPresent(e.statusText)?e.statusText:this.statusText,type:a.isPresent(e)&&a.isPresent(e.type)?e.type:this.type,url:a.isPresent(e)&&a.isPresent(e.url)?e.url:this.url})},t}();e.ResponseOptions=p;var l=function(t){function e(){t.call(this,{status:200,statusText:"Ok",type:c.ResponseType.Default,headers:new u.Headers})}return r(e,t),e=i([s.Injectable(),o("design:paramtypes",[])],e)}(p);e.BaseResponseOptions=l},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(2),s=function(){function t(){}return t.prototype.build=function(){return new XMLHttpRequest},t=r([o.Injectable(),i("design:paramtypes",[])],t)}();e.BrowserXhr=s},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(234),a=n(238),u=n(242),c=n(243),p=n(2),l=n(246),h=n(12),f=n(5),d=n(42),v="JSONP injected script did not invoke callback.",y="JSONP requests must use GET request method.",m=function(){function t(){}return t}();e.JSONPConnection=m;var g=function(t){function e(e,n,r){var i=this;if(t.call(this),this._dom=n,this.baseResponseOptions=r,this._finished=!1,e.method!==a.RequestMethod.Get)throw h.makeTypeError(y);this.request=e,this.response=new d.Observable(function(t){i.readyState=a.ReadyState.Loading;var o=i._id=n.nextRequestID();n.exposeConnection(o,i);var s=n.requestCallback(i._id),p=e.url;p.indexOf("=JSONP_CALLBACK&")>-1?p=f.StringWrapper.replace(p,"=JSONP_CALLBACK&","="+s+"&"):p.lastIndexOf("=JSONP_CALLBACK")===p.length-"=JSONP_CALLBACK".length&&(p=p.substring(0,p.length-"=JSONP_CALLBACK".length)+("="+s));var l=i._script=n.build(p),h=function(e){if(i.readyState!==a.ReadyState.Cancelled){if(i.readyState=a.ReadyState.Done,n.cleanup(l),!i._finished){var o=new c.ResponseOptions({body:v,type:a.ResponseType.Error,url:p});return f.isPresent(r)&&(o=r.merge(o)),void t.error(new u.Response(o))}var s=new c.ResponseOptions({body:i._responseData,url:p});f.isPresent(i.baseResponseOptions)&&(s=i.baseResponseOptions.merge(s)),t.next(new u.Response(s)),t.complete()}},d=function(e){if(i.readyState!==a.ReadyState.Cancelled){i.readyState=a.ReadyState.Done,n.cleanup(l);var o=new c.ResponseOptions({body:e.message,type:a.ResponseType.Error});f.isPresent(r)&&(o=r.merge(o)),t.error(new u.Response(o))}};return l.addEventListener("load",h),l.addEventListener("error",d),n.send(l),function(){i.readyState=a.ReadyState.Cancelled,l.removeEventListener("load",h),l.removeEventListener("error",d),f.isPresent(l)&&i._dom.cleanup(l)}})}return r(e,t),e.prototype.finished=function(t){this._finished=!0,this._dom.removeConnection(this._id),this.readyState!==a.ReadyState.Cancelled&&(this._responseData=t)},e}(m);e.JSONPConnection_=g;var _=function(t){function e(){t.apply(this,arguments)}return r(e,t),e}(s.ConnectionBackend);e.JSONPBackend=_;var b=function(t){function e(e,n){t.call(this),this._browserJSONP=e,this._baseResponseOptions=n}return r(e,t),e.prototype.createConnection=function(t){return new g(t,this._browserJSONP,this._baseResponseOptions)},e=i([p.Injectable(),o("design:paramtypes",[l.BrowserJsonp,c.ResponseOptions])],e)}(_);e.JSONPBackend_=b},function(t,e,n){"use strict";function r(){return null===c&&(c=a.global[e.JSONP_HOME]={}),c}var i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(2),a=n(5),u=0;e.JSONP_HOME="__ng_jsonp__";var c=null,p=function(){function t(){}return t.prototype.build=function(t){var e=document.createElement("script");return e.src=t,e},t.prototype.nextRequestID=function(){return"__req"+u++},t.prototype.requestCallback=function(t){return e.JSONP_HOME+"."+t+".finished"},t.prototype.exposeConnection=function(t,e){var n=r();n[t]=e},t.prototype.removeConnection=function(t){var e=r();e[t]=null},t.prototype.send=function(t){document.body.appendChild(t)},t.prototype.cleanup=function(t){t.parentNode&&t.parentNode.removeChild(t)},t=i([s.Injectable(),o("design:paramtypes",[])],t)}();e.BrowserJsonp=p},function(t,e,n){"use strict";function r(t){for(var n in t)e.hasOwnProperty(n)||(e[n]=t[n])}var i=n(248);e.Router=i.Router;var o=n(272);e.RouterOutlet=o.RouterOutlet;var s=n(274);e.RouterLink=s.RouterLink;var a=n(260);e.RouteParams=a.RouteParams,e.RouteData=a.RouteData;var u=n(256);e.RouteRegistry=u.RouteRegistry,e.ROUTER_PRIMARY_COMPONENT=u.ROUTER_PRIMARY_COMPONENT,r(n(269));var c=n(273);e.CanActivate=c.CanActivate;var p=n(260);e.Instruction=p.Instruction,e.ComponentInstruction=p.ComponentInstruction;var l=n(2);e.OpaqueToken=l.OpaqueToken;var h=n(275);e.ROUTER_PROVIDERS_COMMON=h.ROUTER_PROVIDERS_COMMON;var f=n(276);e.ROUTER_PROVIDERS=f.ROUTER_PROVIDERS,e.ROUTER_BINDINGS=f.ROUTER_BINDINGS;var d=n(272),v=n(274),y=n(5);e.ROUTER_DIRECTIVES=y.CONST_EXPR([d.RouterOutlet,v.RouterLink])},function(t,e,n){"use strict";function r(t,e){var n=y;return p.isBlank(t.component)?n:(p.isPresent(t.child)&&(n=r(t.child,p.isPresent(e)?e.child:null)),n.then(function(n){if(0==n)return!1;if(t.component.reuse)return!0;var r=v.getCanActivateHook(t.component.componentType);return p.isPresent(r)?r(t.component,p.isPresent(e)?e.component:null):!0}))}var i=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},o=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},s=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},a=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},u=n(40),c=n(15),p=n(5),l=n(12),h=n(249),f=n(2),d=n(256),v=n(270),y=u.PromiseWrapper.resolve(!0),m=u.PromiseWrapper.resolve(!1),g=function(){function t(t,e,n,r){this.registry=t,this.parent=e,this.hostComponent=n,this.root=r,this.navigating=!1,this.currentInstruction=null,this._currentNavigation=y,this._outlet=null,this._auxRouters=new c.Map,this._subject=new u.EventEmitter}return t.prototype.childRouter=function(t){return this._childRouter=new b(this,t)},t.prototype.auxRouter=function(t){return new b(this,t)},t.prototype.registerPrimaryOutlet=function(t){if(p.isPresent(t.name))throw new l.BaseException("registerPrimaryOutlet expects to be called with an unnamed outlet.");if(p.isPresent(this._outlet))throw new l.BaseException("Primary outlet is already registered.");return this._outlet=t,p.isPresent(this.currentInstruction)?this.commit(this.currentInstruction,!1):y},t.prototype.unregisterPrimaryOutlet=function(t){if(p.isPresent(t.name))throw new l.BaseException("registerPrimaryOutlet expects to be called with an unnamed outlet.");this._outlet=null},t.prototype.registerAuxOutlet=function(t){var e=t.name;if(p.isBlank(e))throw new l.BaseException("registerAuxOutlet expects to be called with an outlet with a name.");var n=this.auxRouter(this.hostComponent);this._auxRouters.set(e,n),n._outlet=t;var r;return p.isPresent(this.currentInstruction)&&p.isPresent(r=this.currentInstruction.auxInstruction[e])?n.commit(r):y},t.prototype.isRouteActive=function(t){var e=this,n=this;if(p.isBlank(this.currentInstruction))return!1;for(;p.isPresent(n.parent)&&p.isPresent(t.child);)n=n.parent,t=t.child;if(p.isBlank(t.component)||p.isBlank(this.currentInstruction.component)||this.currentInstruction.component.routeName!=t.component.routeName)return!1;var r=!0;return p.isPresent(this.currentInstruction.component.params)&&c.StringMapWrapper.forEach(t.component.params,function(t,n){e.currentInstruction.component.params[n]!==t&&(r=!1)}),r},t.prototype.config=function(t){var e=this;return t.forEach(function(t){e.registry.config(e.hostComponent,t)}),this.renavigate()},t.prototype.navigate=function(t){var e=this.generate(t);return this.navigateByInstruction(e,!1)},t.prototype.navigateByUrl=function(t,e){var n=this;return void 0===e&&(e=!1),this._currentNavigation=this._currentNavigation.then(function(r){return n.lastNavigationAttempt=t,n._startNavigating(),n._afterPromiseFinishNavigating(n.recognize(t).then(function(t){return p.isBlank(t)?!1:n._navigate(t,e)}))})},t.prototype.navigateByInstruction=function(t,e){var n=this;return void 0===e&&(e=!1),p.isBlank(t)?m:this._currentNavigation=this._currentNavigation.then(function(r){return n._startNavigating(),n._afterPromiseFinishNavigating(n._navigate(t,e))})},t.prototype._settleInstruction=function(t){var e=this;return t.resolveComponent().then(function(n){var r=[];return p.isPresent(t.component)&&(t.component.reuse=!1),p.isPresent(t.child)&&r.push(e._settleInstruction(t.child)),c.StringMapWrapper.forEach(t.auxInstruction,function(t,n){r.push(e._settleInstruction(t))}),u.PromiseWrapper.all(r)})},t.prototype._navigate=function(t,e){var n=this;return this._settleInstruction(t).then(function(e){return n._routerCanReuse(t)}).then(function(e){return n._canActivate(t)}).then(function(r){return r?n._routerCanDeactivate(t).then(function(r){return r?n.commit(t,e).then(function(e){return n._emitNavigationFinish(t.toRootUrl()),!0}):void 0}):!1})},t.prototype._emitNavigationFinish=function(t){u.ObservableWrapper.callEmit(this._subject,t)},t.prototype._emitNavigationFail=function(t){u.ObservableWrapper.callError(this._subject,t)},t.prototype._afterPromiseFinishNavigating=function(t){var e=this;return u.PromiseWrapper.catchError(t.then(function(t){return e._finishNavigating()}),function(t){throw e._finishNavigating(),t})},t.prototype._routerCanReuse=function(t){var e=this;return p.isBlank(this._outlet)?m:p.isBlank(t.component)?y:this._outlet.routerCanReuse(t.component).then(function(n){return t.component.reuse=n,n&&p.isPresent(e._childRouter)&&p.isPresent(t.child)?e._childRouter._routerCanReuse(t.child):void 0})},t.prototype._canActivate=function(t){return r(t,this.currentInstruction)},t.prototype._routerCanDeactivate=function(t){var e=this;if(p.isBlank(this._outlet))return y;var n,r=null,i=!1,o=null;return p.isPresent(t)&&(r=t.child,o=t.component,i=p.isBlank(t.component)||t.component.reuse),n=i?y:this._outlet.routerCanDeactivate(o),n.then(function(t){return 0==t?!1:p.isPresent(e._childRouter)?e._childRouter._routerCanDeactivate(r):!0})},t.prototype.commit=function(t,e){var n=this;void 0===e&&(e=!1),this.currentInstruction=t;var r=y;if(p.isPresent(this._outlet)&&p.isPresent(t.component)){var i=t.component;r=i.reuse?this._outlet.reuse(i):this.deactivate(t).then(function(t){return n._outlet.activate(i)}),p.isPresent(t.child)&&(r=r.then(function(e){return p.isPresent(n._childRouter)?n._childRouter.commit(t.child):void 0}))}var o=[];return this._auxRouters.forEach(function(e,n){p.isPresent(t.auxInstruction[n])&&o.push(e.commit(t.auxInstruction[n]))}),r.then(function(t){return u.PromiseWrapper.all(o)})},t.prototype._startNavigating=function(){this.navigating=!0},t.prototype._finishNavigating=function(){this.navigating=!1},t.prototype.subscribe=function(t,e){return u.ObservableWrapper.subscribe(this._subject,t,e)},t.prototype.deactivate=function(t){var e=this,n=null,r=null;p.isPresent(t)&&(n=t.child,r=t.component);var i=y;return p.isPresent(this._childRouter)&&(i=this._childRouter.deactivate(n)),p.isPresent(this._outlet)&&(i=i.then(function(t){return e._outlet.deactivate(r)})),i},t.prototype.recognize=function(t){var e=this._getAncestorInstructions();return this.registry.recognize(t,e)},t.prototype._getAncestorInstructions=function(){for(var t=[this.currentInstruction],e=this;p.isPresent(e=e.parent);)t.unshift(e.currentInstruction);return t},t.prototype.renavigate=function(){return p.isBlank(this.lastNavigationAttempt)?this._currentNavigation:this.navigateByUrl(this.lastNavigationAttempt)},t.prototype.generate=function(t){var e=this._getAncestorInstructions();return this.registry.generate(t,e)},t=o([f.Injectable(),s("design:paramtypes",[d.RouteRegistry,t,Object,t])],t)}();e.Router=g;var _=function(t){function e(e,n,r){var i=this;t.call(this,e,null,r),this.root=this,this._location=n,this._locationSub=this._location.subscribe(function(t){i.recognize(t.url).then(function(e){p.isPresent(e)?i.navigateByInstruction(e,p.isPresent(t.pop)).then(function(n){if(!p.isPresent(t.pop)||"hashchange"==t.type){var r=e.toUrlPath(),o=e.toUrlQuery();r.length>0&&"/"!=r[0]&&(r="/"+r),"hashchange"==t.type?e.toRootUrl()!=i._location.path()&&i._location.replaceState(r,o):i._location.go(r,o)}}):i._emitNavigationFail(t.url)})}),this.registry.configFromComponent(r),this.navigateByUrl(n.path())}return i(e,t),e.prototype.commit=function(e,n){var r=this;void 0===n&&(n=!1);var i=e.toUrlPath(),o=e.toUrlQuery();i.length>0&&"/"!=i[0]&&(i="/"+i);var s=t.prototype.commit.call(this,e);return n||(s=s.then(function(t){ -r._location.go(i,o)})),s},e.prototype.dispose=function(){p.isPresent(this._locationSub)&&(u.ObservableWrapper.dispose(this._locationSub),this._locationSub=null)},e=o([f.Injectable(),a(2,f.Inject(d.ROUTER_PRIMARY_COMPONENT)),s("design:paramtypes",[d.RouteRegistry,h.Location,p.Type])],e)}(g);e.RootRouter=_;var b=function(t){function e(e,n){t.call(this,e.registry,e,n,e.root),this.parent=e}return i(e,t),e.prototype.navigateByUrl=function(t,e){return void 0===e&&(e=!1),this.parent.navigateByUrl(t,e)},e.prototype.navigateByInstruction=function(t,e){return void 0===e&&(e=!1),this.parent.navigateByInstruction(t,e)},e}(g)},function(t,e,n){"use strict";function r(t){for(var n in t)e.hasOwnProperty(n)||(e[n]=t[n])}r(n(250))},function(t,e,n){"use strict";function r(t){for(var n in t)e.hasOwnProperty(n)||(e[n]=t[n])}r(n(251)),r(n(252)),r(n(253)),r(n(255)),r(n(254))},function(t,e){"use strict";var n=function(){function t(){}return Object.defineProperty(t.prototype,"pathname",{get:function(){return null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"search",{get:function(){return null},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"hash",{get:function(){return null},enumerable:!0,configurable:!0}),t}();e.PlatformLocation=n},function(t,e,n){"use strict";var r=n(5),i=n(2),o=function(){function t(){}return t}();e.LocationStrategy=o,e.APP_BASE_HREF=r.CONST_EXPR(new i.OpaqueToken("appBaseHref"))},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},a=n(2),u=n(252),c=n(254),p=n(251),l=n(5),h=function(t){function e(e,n){t.call(this),this._platformLocation=e,this._baseHref="",l.isPresent(n)&&(this._baseHref=n)}return r(e,t),e.prototype.onPopState=function(t){this._platformLocation.onPopState(t),this._platformLocation.onHashChange(t)},e.prototype.getBaseHref=function(){return this._baseHref},e.prototype.path=function(){var t=this._platformLocation.hash;return l.isPresent(t)||(t="#"),t.length>0?t.substring(1):t},e.prototype.prepareExternalUrl=function(t){var e=c.Location.joinWithSlash(this._baseHref,t);return e.length>0?"#"+e:e},e.prototype.pushState=function(t,e,n,r){var i=this.prepareExternalUrl(n+c.Location.normalizeQueryParams(r));0==i.length&&(i=this._platformLocation.pathname),this._platformLocation.pushState(t,e,i)},e.prototype.replaceState=function(t,e,n,r){var i=this.prepareExternalUrl(n+c.Location.normalizeQueryParams(r));0==i.length&&(i=this._platformLocation.pathname),this._platformLocation.replaceState(t,e,i)},e.prototype.forward=function(){this._platformLocation.forward()},e.prototype.back=function(){this._platformLocation.back()},e=i([a.Injectable(),s(1,a.Optional()),s(1,a.Inject(u.APP_BASE_HREF)),o("design:paramtypes",[p.PlatformLocation,String])],e)}(u.LocationStrategy);e.HashLocationStrategy=h},function(t,e,n){"use strict";function r(t,e){return t.length>0&&e.startsWith(t)?e.substring(t.length):e}function i(t){return/\/index.html$/g.test(t)?t.substring(0,t.length-11):t}var o=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},s=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},a=n(40),u=n(2),c=n(252),p=function(){function t(e){var n=this;this.platformStrategy=e,this._subject=new a.EventEmitter;var r=this.platformStrategy.getBaseHref();this._baseHref=t.stripTrailingSlash(i(r)),this.platformStrategy.onPopState(function(t){a.ObservableWrapper.callEmit(n._subject,{url:n.path(),pop:!0,type:t.type})})}return t.prototype.path=function(){return this.normalize(this.platformStrategy.path())},t.prototype.normalize=function(e){return t.stripTrailingSlash(r(this._baseHref,i(e)))},t.prototype.prepareExternalUrl=function(t){return t.length>0&&!t.startsWith("/")&&(t="/"+t),this.platformStrategy.prepareExternalUrl(t)},t.prototype.go=function(t,e){void 0===e&&(e=""),this.platformStrategy.pushState(null,"",t,e)},t.prototype.replaceState=function(t,e){void 0===e&&(e=""),this.platformStrategy.replaceState(null,"",t,e)},t.prototype.forward=function(){this.platformStrategy.forward()},t.prototype.back=function(){this.platformStrategy.back()},t.prototype.subscribe=function(t,e,n){return void 0===e&&(e=null),void 0===n&&(n=null),a.ObservableWrapper.subscribe(this._subject,t,e,n)},t.normalizeQueryParams=function(t){return t.length>0&&"?"!=t.substring(0,1)?"?"+t:t},t.joinWithSlash=function(t,e){if(0==t.length)return e;if(0==e.length)return t;var n=0;return t.endsWith("/")&&n++,e.startsWith("/")&&n++,2==n?t+e.substring(1):1==n?t+e:t+"/"+e},t.stripTrailingSlash=function(t){return/\/$/g.test(t)&&(t=t.substring(0,t.length-1)),t},t=o([u.Injectable(),s("design:paramtypes",[c.LocationStrategy])],t)}();e.Location=p},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},a=n(2),u=n(5),c=n(12),p=n(251),l=n(252),h=n(254),f=function(t){function e(e,n){if(t.call(this),this._platformLocation=e,u.isBlank(n)&&(n=this._platformLocation.getBaseHrefFromDOM()),u.isBlank(n))throw new c.BaseException("No base href set. Please provide a value for the APP_BASE_HREF token or add a base element to the document.");this._baseHref=n}return r(e,t),e.prototype.onPopState=function(t){this._platformLocation.onPopState(t),this._platformLocation.onHashChange(t)},e.prototype.getBaseHref=function(){return this._baseHref},e.prototype.prepareExternalUrl=function(t){return h.Location.joinWithSlash(this._baseHref,t)},e.prototype.path=function(){return this._platformLocation.pathname+h.Location.normalizeQueryParams(this._platformLocation.search)},e.prototype.pushState=function(t,e,n,r){var i=this.prepareExternalUrl(n+h.Location.normalizeQueryParams(r));this._platformLocation.pushState(t,e,i)},e.prototype.replaceState=function(t,e,n,r){var i=this.prepareExternalUrl(n+h.Location.normalizeQueryParams(r));this._platformLocation.replaceState(t,e,i)},e.prototype.forward=function(){this._platformLocation.forward()},e.prototype.back=function(){this._platformLocation.back()},e=i([a.Injectable(),s(1,a.Optional()),s(1,a.Inject(l.APP_BASE_HREF)),o("design:paramtypes",[p.PlatformLocation,String])],e)}(l.LocationStrategy);e.PathLocationStrategy=f},function(t,e,n){"use strict";function r(t){var e=[];return t.forEach(function(t){if(h.isString(t)){var n=t;e=e.concat(n.split("/"))}else e.push(t)}),e}function i(t){if(t=t.filter(function(t){return h.isPresent(t)}),0==t.length)return null;if(1==t.length)return t[0];var e=t[0],n=t.slice(1);return n.reduce(function(t,e){return-1==o(e.specificity,t.specificity)?e:t},e)}function o(t,e){for(var n=h.Math.min(t.length,e.length),r=0;n>r;r+=1){var i=h.StringWrapper.charCodeAt(t,r),o=h.StringWrapper.charCodeAt(e,r),s=o-i;if(0!=s)return s}return t.length-e.length}function s(t,e){if(h.isType(t)){var n=d.reflector.annotations(t);if(h.isPresent(n))for(var r=0;ro?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},u=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},c=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},p=n(15),l=n(40),h=n(5),f=n(12),d=n(18),v=n(2),y=n(257),m=n(258),g=n(261),_=n(260),b=n(268),P=n(259),E=l.PromiseWrapper.resolve(null);e.ROUTER_PRIMARY_COMPONENT=h.CONST_EXPR(new v.OpaqueToken("RouterPrimaryComponent"));var w=function(){function t(t){this._rootComponent=t,this._rules=new p.Map}return t.prototype.config=function(t,e){e=b.normalizeRouteConfig(e,this),e instanceof y.Route?b.assertComponentExists(e.component,e.path):e instanceof y.AuxRoute&&b.assertComponentExists(e.component,e.path);var n=this._rules.get(t);h.isBlank(n)&&(n=new g.RuleSet,this._rules.set(t,n));var r=n.config(e);e instanceof y.Route&&(r?s(e.component,e.path):this.configFromComponent(e.component))},t.prototype.configFromComponent=function(t){var e=this;if(h.isType(t)&&!this._rules.has(t)){var n=d.reflector.annotations(t);if(h.isPresent(n))for(var r=0;r0?[p.ListWrapper.last(e)]:[],i=r._auxRoutesToUnresolved(t.remainingAux,n),o=new _.ResolvedInstruction(t.instruction,null,i);if(h.isBlank(t.instruction)||t.instruction.terminal)return o;var s=e.concat([o]);return r._recognize(t.remaining,s).then(function(t){return h.isBlank(t)?null:t instanceof _.RedirectInstruction?t:(o.child=t,o)})}if(t instanceof m.RedirectMatch){var o=r.generate(t.redirectTo,e.concat([null]));return new _.RedirectInstruction(o.component,o.child,o.auxInstruction,t.specificity)}})});return!h.isBlank(t)&&""!=t.path||0!=u.length?l.PromiseWrapper.all(c).then(i):l.PromiseWrapper.resolve(this.generateDefault(s))},t.prototype._auxRoutesToUnresolved=function(t,e){var n=this,r={};return t.forEach(function(t){r[t.path]=new _.UnresolvedInstruction(function(){return n._recognize(t,e,!0)})}),r},t.prototype.generate=function(t,e,n){void 0===n&&(n=!1);var i,o=r(t);if(""==p.ListWrapper.first(o))o.shift(),i=p.ListWrapper.first(e),e=[];else if(i=e.length>0?e.pop():null,"."==p.ListWrapper.first(o))o.shift();else if(".."==p.ListWrapper.first(o))for(;".."==p.ListWrapper.first(o);){if(e.length<=0)throw new f.BaseException('Link "'+p.ListWrapper.toJSON(t)+'" has too many "../" segments.');i=e.pop(),o=p.ListWrapper.slice(o,1)}else{var s=p.ListWrapper.first(o),a=this._rootComponent,u=null;if(e.length>1){var c=e[e.length-1],l=e[e.length-2];a=c.component.componentType,u=l.component.componentType}else 1==e.length&&(a=e[0].component.componentType,u=this._rootComponent);var d=this.hasRoute(s,a),v=h.isPresent(u)&&this.hasRoute(s,u);if(v&&d){var y='Link "'+p.ListWrapper.toJSON(t)+'" is ambiguous, use "./" or "../" to disambiguate.';throw new f.BaseException(y)}v&&(i=e.pop())}if(""==o[o.length-1]&&o.pop(),o.length>0&&""==o[0]&&o.shift(),o.length<1){var y='Link "'+p.ListWrapper.toJSON(t)+'" must include a route name.';throw new f.BaseException(y)}for(var m=this._generate(o,e,i,n,t),g=e.length-1;g>=0;g--){var _=e[g];if(h.isBlank(_))break;m=_.replaceChild(m)}return m},t.prototype._generate=function(t,e,n,r,i){var o=this;void 0===r&&(r=!1);var s=this._rootComponent,a=null,u={},c=p.ListWrapper.last(e);if(h.isPresent(c)&&h.isPresent(c.component)&&(s=c.component.componentType),0==t.length){var l=this.generateDefault(s);if(h.isBlank(l))throw new f.BaseException('Link "'+p.ListWrapper.toJSON(i)+'" does not resolve to a terminal instruction.');return l}h.isPresent(n)&&!r&&(u=p.StringMapWrapper.merge(n.auxInstruction,u),a=n.component);var d=this._rules.get(s);if(h.isBlank(d))throw new f.BaseException('Component "'+h.getTypeNameForDebugging(s)+'" has no route config.');var v=0,y={};if(v=t.length;else{var O=e.concat([R]),T=t.slice(v);S=this._generate(T,O,null,!1,i)}R.child=S}return R},t.prototype.hasRoute=function(t,e){var n=this._rules.get(e);return h.isBlank(n)?!1:n.hasRoute(t)},t.prototype.generateDefault=function(t){var e=this;if(h.isBlank(t))return null;var n=this._rules.get(t);if(h.isBlank(n)||h.isBlank(n.defaultRule))return null;var r=null;if(h.isPresent(n.defaultRule.handler.componentType)){var i=n.defaultRule.generate({});return n.defaultRule.terminal||(r=this.generateDefault(n.defaultRule.handler.componentType)),new _.DefaultInstruction(i,r)}return new _.UnresolvedInstruction(function(){return n.defaultRule.handler.resolveComponentType().then(function(n){return e.generateDefault(t)})})},t=a([v.Injectable(),c(0,v.Inject(e.ROUTER_PRIMARY_COMPONENT)),u("design:paramtypes",[h.Type])],t)}();e.RouteRegistry=w},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(5),a=function(){function t(t){this.configs=t}return t=i([s.CONST(),o("design:paramtypes",[Array])],t)}();e.RouteConfig=a;var u=function(){function t(t){var e=t.name,n=t.useAsDefault,r=t.path,i=t.regex,o=t.serializer,s=t.data;this.name=e,this.useAsDefault=n,this.path=r,this.regex=i,this.serializer=o,this.data=s}return t=i([s.CONST(),o("design:paramtypes",[Object])],t)}();e.AbstractRoute=u;var c=function(t){function e(e){var n=e.name,r=e.useAsDefault,i=e.path,o=e.regex,s=e.serializer,a=e.data,u=e.component;t.call(this,{name:n,useAsDefault:r,path:i,regex:o,serializer:s,data:a}),this.aux=null,this.component=u}return r(e,t),e=i([s.CONST(),o("design:paramtypes",[Object])],e)}(u);e.Route=c;var p=function(t){function e(e){var n=e.name,r=e.useAsDefault,i=e.path,o=e.regex,s=e.serializer,a=e.data,u=e.component;t.call(this,{name:n,useAsDefault:r,path:i,regex:o,serializer:s,data:a}),this.component=u}return r(e,t),e=i([s.CONST(),o("design:paramtypes",[Object])],e)}(u);e.AuxRoute=p;var l=function(t){function e(e){var n=e.name,r=e.useAsDefault,i=e.path,o=e.regex,s=e.serializer,a=e.data,u=e.loader;t.call(this,{name:n,useAsDefault:r,path:i,regex:o,serializer:s,data:a}),this.aux=null,this.loader=u}return r(e,t),e=i([s.CONST(),o("design:paramtypes",[Object])],e)}(u);e.AsyncRoute=l;var h=function(t){function e(e){var n=e.name,r=e.useAsDefault,i=e.path,o=e.regex,s=e.serializer,a=e.data,u=e.redirectTo;t.call(this,{name:n,useAsDefault:r,path:i,regex:o,serializer:s,data:a}),this.redirectTo=u}return r(e,t),e=i([s.CONST(),o("design:paramtypes",[Object])],e)}(u);e.Redirect=h},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(5),o=n(12),s=n(41),a=n(15),u=n(259),c=n(260),p=function(){function t(){}return t}();e.RouteMatch=p;var l=function(t){function e(e,n,r){t.call(this),this.instruction=e,this.remaining=n,this.remainingAux=r}return r(e,t),e}(p);e.PathMatch=l;var h=function(t){function e(e,n){t.call(this),this.redirectTo=e,this.specificity=n}return r(e,t),e}(p);e.RedirectMatch=h;var f=function(){function t(t,e){this._pathRecognizer=t,this.redirectTo=e,this.hash=this._pathRecognizer.hash}return Object.defineProperty(t.prototype,"path",{get:function(){return this._pathRecognizer.toString()},set:function(t){throw new o.BaseException("you cannot set the path of a RedirectRule directly")},enumerable:!0,configurable:!0}),t.prototype.recognize=function(t){var e=null;return i.isPresent(this._pathRecognizer.matchUrl(t))&&(e=new h(this.redirectTo,this._pathRecognizer.specificity)),s.PromiseWrapper.resolve(e)},t.prototype.generate=function(t){throw new o.BaseException("Tried to generate a redirect.")},t}();e.RedirectRule=f;var d=function(){function t(t,e,n){this._routePath=t,this.handler=e,this._routeName=n,this._cache=new a.Map,this.specificity=this._routePath.specificity,this.hash=this._routePath.hash,this.terminal=this._routePath.terminal}return Object.defineProperty(t.prototype,"path",{get:function(){return this._routePath.toString()},set:function(t){throw new o.BaseException("you cannot set the path of a RouteRule directly")},enumerable:!0,configurable:!0}),t.prototype.recognize=function(t){var e=this,n=this._routePath.matchUrl(t);return i.isBlank(n)?null:this.handler.resolveComponentType().then(function(t){var r=e._getInstruction(n.urlPath,n.urlParams,n.allParams);return new l(r,n.rest,n.auxiliary)})},t.prototype.generate=function(t){var e=this._routePath.generateUrl(t),n=e.urlPath,r=e.urlParams;return this._getInstruction(n,u.convertUrlParamsToArray(r),t)},t.prototype.generateComponentPathValues=function(t){return this._routePath.generateUrl(t)},t.prototype._getInstruction=function(t,e,n){if(i.isBlank(this.handler.componentType))throw new o.BaseException("Tried to get instruction before the type was loaded.");var r=t+"?"+e.join("&");if(this._cache.has(r))return this._cache.get(r);var s=new c.ComponentInstruction(t,e,this.handler.data,this.handler.componentType,this.terminal,this.specificity,n,this._routeName);return this._cache.set(r,s),s},t}();e.RouteRule=d},function(t,e,n){"use strict";function r(t){var e=[];return p.isBlank(t)?[]:(c.StringMapWrapper.forEach(t,function(t,n){e.push(t===!0?n:n+"="+t)}),e)}function i(t,e){return void 0===e&&(e="&"),r(t).join(e)}function o(t){for(var e=new h(t[t.length-1]),n=t.length-2;n>=0;n-=1)e=new h(t[n],e);return e}function s(t){var e=p.RegExpWrapper.firstMatch(d,t);return p.isPresent(e)?e[0]:""}function a(t){var e=p.RegExpWrapper.firstMatch(v,t);return p.isPresent(e)?e[0]:""}var u=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},c=n(15),p=n(5),l=n(12);e.convertUrlParamsToArray=r,e.serializeParams=i;var h=function(){function t(t,e,n,r){void 0===e&&(e=null),void 0===n&&(n=p.CONST_EXPR([])),void 0===r&&(r=p.CONST_EXPR({})),this.path=t,this.child=e,this.auxiliary=n,this.params=r}return t.prototype.toString=function(){return this.path+this._matrixParamsToString()+this._auxToString()+this._childString()},t.prototype.segmentToString=function(){return this.path+this._matrixParamsToString()},t.prototype._auxToString=function(){return this.auxiliary.length>0?"("+this.auxiliary.map(function(t){return t.toString()}).join("//")+")":""},t.prototype._matrixParamsToString=function(){var t=i(this.params,";");return t.length>0?";"+t:""},t.prototype._childString=function(){return p.isPresent(this.child)?"/"+this.child.toString():""},t}();e.Url=h;var f=function(t){function e(e,n,r,i){void 0===n&&(n=null),void 0===r&&(r=p.CONST_EXPR([])),void 0===i&&(i=null),t.call(this,e,n,r,i)}return u(e,t),e.prototype.toString=function(){return this.path+this._auxToString()+this._childString()+this._queryParamsToString()},e.prototype.segmentToString=function(){return this.path+this._queryParamsToString()},e.prototype._queryParamsToString=function(){return p.isBlank(this.params)?"":"?"+i(this.params)},e}(h);e.RootUrl=f,e.pathSegmentsToUrl=o;var d=p.RegExpWrapper.create("^[^\\/\\(\\)\\?;=&#]+"),v=p.RegExpWrapper.create("^[^\\(\\)\\?;&#]+"),y=function(){function t(){}return t.prototype.peekStartsWith=function(t){return this._remaining.startsWith(t)},t.prototype.capture=function(t){if(!this._remaining.startsWith(t))throw new l.BaseException('Expected "'+t+'".');this._remaining=this._remaining.substring(t.length)},t.prototype.parse=function(t){return this._remaining=t,""==t||"/"==t?new h(""):this.parseRoot()},t.prototype.parseRoot=function(){this.peekStartsWith("/")&&this.capture("/");var t=s(this._remaining);this.capture(t);var e=[];this.peekStartsWith("(")&&(e=this.parseAuxiliaryRoutes()),this.peekStartsWith(";")&&this.parseMatrixParams();var n=null;this.peekStartsWith("/")&&!this.peekStartsWith("//")&&(this.capture("/"),n=this.parseSegment());var r=null;return this.peekStartsWith("?")&&(r=this.parseQueryParams()),new f(t,n,e,r)},t.prototype.parseSegment=function(){if(0==this._remaining.length)return null;this.peekStartsWith("/")&&this.capture("/");var t=s(this._remaining);this.capture(t);var e=null;this.peekStartsWith(";")&&(e=this.parseMatrixParams());var n=[];this.peekStartsWith("(")&&(n=this.parseAuxiliaryRoutes());var r=null;return this.peekStartsWith("/")&&!this.peekStartsWith("//")&&(this.capture("/"),r=this.parseSegment()),new h(t,r,n,e)},t.prototype.parseQueryParams=function(){var t={};for(this.capture("?"),this.parseQueryParam(t);this._remaining.length>0&&this.peekStartsWith("&");)this.capture("&"),this.parseQueryParam(t);return t},t.prototype.parseMatrixParams=function(){for(var t={};this._remaining.length>0&&this.peekStartsWith(";");)this.capture(";"),this.parseParam(t);return t},t.prototype.parseParam=function(t){var e=s(this._remaining);if(!p.isBlank(e)){this.capture(e);var n=!0;if(this.peekStartsWith("=")){this.capture("=");var r=s(this._remaining);p.isPresent(r)&&(n=r,this.capture(n))}t[e]=n}},t.prototype.parseQueryParam=function(t){var e=s(this._remaining);if(!p.isBlank(e)){this.capture(e);var n=!0;if(this.peekStartsWith("=")){this.capture("=");var r=a(this._remaining);p.isPresent(r)&&(n=r,this.capture(n))}t[e]=n}},t.prototype.parseAuxiliaryRoutes=function(){var t=[];for(this.capture("(");!this.peekStartsWith(")")&&this._remaining.length>0;)t.push(this.parseSegment()),this.peekStartsWith("//")&&this.capture("//");return this.capture(")"),t},t}();e.UrlParser=y,e.parser=new y},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=n(15),o=n(5),s=n(40),a=function(){function t(t){this.params=t}return t.prototype.get=function(t){return o.normalizeBlank(i.StringMapWrapper.get(this.params,t))},t}();e.RouteParams=a;var u=function(){function t(t){void 0===t&&(t=o.CONST_EXPR({})),this.data=t}return t.prototype.get=function(t){return o.normalizeBlank(i.StringMapWrapper.get(this.data,t))},t}();e.RouteData=u,e.BLANK_ROUTE_DATA=new u;var c=function(){function t(t,e,n){this.component=t,this.child=e,this.auxInstruction=n}return Object.defineProperty(t.prototype,"urlPath",{get:function(){return o.isPresent(this.component)?this.component.urlPath:""},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"urlParams",{get:function(){return o.isPresent(this.component)?this.component.urlParams:[]},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"specificity",{get:function(){var t="";return o.isPresent(this.component)&&(t+=this.component.specificity),o.isPresent(this.child)&&(t+=this.child.specificity),t},enumerable:!0,configurable:!0}),t.prototype.toRootUrl=function(){return this.toUrlPath()+this.toUrlQuery()},t.prototype._toNonRootUrl=function(){return this._stringifyPathMatrixAuxPrefixed()+(o.isPresent(this.child)?this.child._toNonRootUrl():"")},t.prototype.toUrlQuery=function(){return this.urlParams.length>0?"?"+this.urlParams.join("&"):""},t.prototype.replaceChild=function(t){return new p(this.component,t,this.auxInstruction)},t.prototype.toUrlPath=function(){return this.urlPath+this._stringifyAux()+(o.isPresent(this.child)?this.child._toNonRootUrl():"")},t.prototype.toLinkUrl=function(){return this.urlPath+this._stringifyAux()+(o.isPresent(this.child)?this.child._toLinkUrl():"")+this.toUrlQuery()},t.prototype._toLinkUrl=function(){return this._stringifyPathMatrixAuxPrefixed()+(o.isPresent(this.child)?this.child._toLinkUrl():"")},t.prototype._stringifyPathMatrixAuxPrefixed=function(){var t=this._stringifyPathMatrixAux();return t.length>0&&(t="/"+t),t},t.prototype._stringifyMatrixParams=function(){return this.urlParams.length>0?";"+this.urlParams.join(";"):""},t.prototype._stringifyPathMatrixAux=function(){return o.isBlank(this.component)?"":this.urlPath+this._stringifyMatrixParams()+this._stringifyAux()},t.prototype._stringifyAux=function(){var t=[];return i.StringMapWrapper.forEach(this.auxInstruction,function(e,n){t.push(e._stringifyPathMatrixAux())}),t.length>0?"("+t.join("//")+")":""},t}();e.Instruction=c;var p=function(t){function e(e,n,r){t.call(this,e,n,r)}return r(e,t),e.prototype.resolveComponent=function(){return s.PromiseWrapper.resolve(this.component)},e}(c);e.ResolvedInstruction=p;var l=function(t){function e(e,n){t.call(this,e,n,{})}return r(e,t),e.prototype.toLinkUrl=function(){return""},e.prototype._toLinkUrl=function(){return""},e}(p);e.DefaultInstruction=l;var h=function(t){function e(e,n,r){void 0===n&&(n=""),void 0===r&&(r=o.CONST_EXPR([])),t.call(this,null,null,{}),this._resolver=e,this._urlPath=n,this._urlParams=r}return r(e,t),Object.defineProperty(e.prototype,"urlPath",{get:function(){return o.isPresent(this.component)?this.component.urlPath:o.isPresent(this._urlPath)?this._urlPath:""},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"urlParams",{get:function(){return o.isPresent(this.component)?this.component.urlParams:o.isPresent(this._urlParams)?this._urlParams:[]},enumerable:!0,configurable:!0}),e.prototype.resolveComponent=function(){var t=this;return o.isPresent(this.component)?s.PromiseWrapper.resolve(this.component):this._resolver().then(function(e){return t.child=o.isPresent(e)?e.child:null,t.component=o.isPresent(e)?e.component:null})},e}(c);e.UnresolvedInstruction=h;var f=function(t){function e(e,n,r,i){t.call(this,e,n,r),this._specificity=i}return r(e,t),Object.defineProperty(e.prototype,"specificity",{get:function(){return this._specificity},enumerable:!0,configurable:!0}),e}(p);e.RedirectInstruction=f;var d=function(){function t(t,n,r,i,s,a,u,c){void 0===u&&(u=null),this.urlPath=t,this.urlParams=n,this.componentType=i,this.terminal=s,this.specificity=a,this.params=u,this.routeName=c,this.reuse=!1,this.routeData=o.isPresent(r)?r:e.BLANK_ROUTE_DATA}return t}();e.ComponentInstruction=d},function(t,e,n){"use strict";var r=n(5),i=n(12),o=n(15),s=n(40),a=n(258),u=n(257),c=n(262),p=n(263),l=n(264),h=n(267),f=function(){function t(){this.rulesByName=new o.Map,this.auxRulesByName=new o.Map,this.auxRulesByPath=new o.Map,this.rules=[],this.defaultRule=null}return t.prototype.config=function(t){var e;if(r.isPresent(t.name)&&t.name[0].toUpperCase()!=t.name[0]){var n=t.name[0].toUpperCase()+t.name.substring(1);throw new i.BaseException('Route "'+t.path+'" with name "'+t.name+'" does not begin with an uppercase letter. Route names should be CamelCase like "'+n+'".')}if(t instanceof u.AuxRoute){e=new p.SyncRouteHandler(t.component,t.data);var o=this._getRoutePath(t),s=new a.RouteRule(o,e,t.name);return this.auxRulesByPath.set(o.toString(),s),r.isPresent(t.name)&&this.auxRulesByName.set(t.name,s),s.terminal}var l=!1;if(t instanceof u.Redirect){var h=this._getRoutePath(t),f=new a.RedirectRule(h,t.redirectTo);return this._assertNoHashCollision(f.hash,t.path),this.rules.push(f),!0}t instanceof u.Route?(e=new p.SyncRouteHandler(t.component,t.data),l=r.isPresent(t.useAsDefault)&&t.useAsDefault):t instanceof u.AsyncRoute&&(e=new c.AsyncRouteHandler(t.loader,t.data),l=r.isPresent(t.useAsDefault)&&t.useAsDefault);var d=this._getRoutePath(t),v=new a.RouteRule(d,e,t.name);if(this._assertNoHashCollision(v.hash,t.path),l){if(r.isPresent(this.defaultRule))throw new i.BaseException("Only one route can be default");this.defaultRule=v}return this.rules.push(v),r.isPresent(t.name)&&this.rulesByName.set(t.name,v),v.terminal},t.prototype.recognize=function(t){var e=[];return this.rules.forEach(function(n){var i=n.recognize(t);r.isPresent(i)&&e.push(i)}),0==e.length&&r.isPresent(t)&&t.auxiliary.length>0?[s.PromiseWrapper.resolve(new a.PathMatch(null,null,t.auxiliary))]:e},t.prototype.recognizeAuxiliary=function(t){var e=this.auxRulesByPath.get(t.path);return r.isPresent(e)?[e.recognize(t)]:[s.PromiseWrapper.resolve(null)]},t.prototype.hasRoute=function(t){return this.rulesByName.has(t)},t.prototype.componentLoaded=function(t){return this.hasRoute(t)&&r.isPresent(this.rulesByName.get(t).handler.componentType)},t.prototype.loadComponent=function(t){return this.rulesByName.get(t).handler.resolveComponentType()},t.prototype.generate=function(t,e){var n=this.rulesByName.get(t);return r.isBlank(n)?null:n.generate(e)},t.prototype.generateAuxiliary=function(t,e){var n=this.auxRulesByName.get(t);return r.isBlank(n)?null:n.generate(e)},t.prototype._assertNoHashCollision=function(t,e){this.rules.forEach(function(n){if(t==n.hash)throw new i.BaseException("Configuration '"+e+"' conflicts with existing route '"+n.path+"'")})},t.prototype._getRoutePath=function(t){if(r.isPresent(t.regex)){if(r.isFunction(t.serializer))return new h.RegexRoutePath(t.regex,t.serializer);throw new i.BaseException("Route provides a regex property, '"+t.regex+"', but no serializer property")}if(r.isPresent(t.path)){var e=t instanceof u.AuxRoute&&t.path.startsWith("/")?t.path.substring(1):t.path;return new l.ParamRoutePath(e)}throw new i.BaseException("Route must provide either a path or regex property")},t}();e.RuleSet=f},function(t,e,n){"use strict";var r=n(5),i=n(260),o=function(){function t(t,e){void 0===e&&(e=null),this._loader=t,this._resolvedComponent=null,this.data=r.isPresent(e)?new i.RouteData(e):i.BLANK_ROUTE_DATA}return t.prototype.resolveComponentType=function(){ -var t=this;return r.isPresent(this._resolvedComponent)?this._resolvedComponent:this._resolvedComponent=this._loader().then(function(e){return t.componentType=e,e})},t}();e.AsyncRouteHandler=o},function(t,e,n){"use strict";var r=n(40),i=n(5),o=n(260),s=function(){function t(t,e){this.componentType=t,this._resolvedComponent=null,this._resolvedComponent=r.PromiseWrapper.resolve(t),this.data=i.isPresent(e)?new o.RouteData(e):o.BLANK_ROUTE_DATA}return t.prototype.resolveComponentType=function(){return this._resolvedComponent},t}();e.SyncRouteHandler=s},function(t,e,n){"use strict";function r(t){return o.isBlank(t)?null:(t=o.StringWrapper.replaceAll(t,y,"%25"),t=o.StringWrapper.replaceAll(t,m,"%2F"),t=o.StringWrapper.replaceAll(t,g,"%28"),t=o.StringWrapper.replaceAll(t,_,"%29"),t=o.StringWrapper.replaceAll(t,b,"%3B"))}function i(t){return o.isBlank(t)?null:(t=o.StringWrapper.replaceAll(t,P,";"),t=o.StringWrapper.replaceAll(t,E,")"),t=o.StringWrapper.replaceAll(t,w,"("),t=o.StringWrapper.replaceAll(t,C,"/"),t=o.StringWrapper.replaceAll(t,R,"%"))}var o=n(5),s=n(12),a=n(15),u=n(265),c=n(259),p=n(266),l=function(){function t(){this.name="",this.specificity="",this.hash="..."}return t.prototype.generate=function(t){return""},t.prototype.match=function(t){return!0},t}(),h=function(){function t(t){this.path=t,this.name="",this.specificity="2",this.hash=t}return t.prototype.match=function(t){return t==this.path},t.prototype.generate=function(t){return this.path},t}(),f=function(){function t(t){this.name=t,this.specificity="1",this.hash=":"}return t.prototype.match=function(t){return t.length>0},t.prototype.generate=function(t){if(!a.StringMapWrapper.contains(t.map,this.name))throw new s.BaseException("Route generator for '"+this.name+"' was not included in parameters passed.");return r(u.normalizeString(t.get(this.name)))},t.paramMatcher=/^:([^\/]+)$/g,t}(),d=function(){function t(t){this.name=t,this.specificity="0",this.hash="*"}return t.prototype.match=function(t){return!0},t.prototype.generate=function(t){return u.normalizeString(t.get(this.name))},t.wildcardMatcher=/^\*([^\/]+)$/g,t}(),v=function(){function t(t){this.routePath=t,this.terminal=!0,this._assertValidPath(t),this._parsePathString(t),this.specificity=this._calculateSpecificity(),this.hash=this._calculateHash();var e=this._segments[this._segments.length-1];this.terminal=!(e instanceof l)}return t.prototype.matchUrl=function(t){for(var e,n=t,r={},s=[],u=0;u=r;r++){var i,a=e[r];if(o.isPresent(i=o.RegExpWrapper.firstMatch(f.paramMatcher,a)))this._segments.push(new f(i[1]));else if(o.isPresent(i=o.RegExpWrapper.firstMatch(d.wildcardMatcher,a)))this._segments.push(new d(i[1]));else if("..."==a){if(n>r)throw new s.BaseException('Unexpected "..." before the end of the path for "'+t+'".');this._segments.push(new l)}else this._segments.push(new h(a))}},t.prototype._calculateSpecificity=function(){var t,e,n=this._segments.length;if(0==n)e+="2";else for(e="",t=0;n>t;t++)e+=this._segments[t].specificity;return e},t.prototype._calculateHash=function(){var t,e=this._segments.length,n=[];for(t=0;e>t;t++)n.push(this._segments[t].hash);return n.join("/")},t.prototype._assertValidPath=function(e){if(o.StringWrapper.contains(e,"#"))throw new s.BaseException('Path "'+e+'" should not include "#". Use "HashLocationStrategy" instead.');var n=o.RegExpWrapper.firstMatch(t.RESERVED_CHARS,e);if(o.isPresent(n))throw new s.BaseException('Path "'+e+'" contains "'+n[0]+'" which is not allowed in a route config.')},t.RESERVED_CHARS=o.RegExpWrapper.create("//|\\(|\\)|;|\\?|="),t}();e.ParamRoutePath=v;var y=/%/g,m=/\//g,g=/\(/g,_=/\)/g,b=/;/g,P=/%3B/gi,E=/%29/gi,w=/%28/gi,C=/%2F/gi,R=/%25/gi},function(t,e,n){"use strict";function r(t){return i.isBlank(t)?null:t.toString()}var i=n(5),o=n(15),s=function(){function t(t){var e=this;this.map={},this.keys={},i.isPresent(t)&&o.StringMapWrapper.forEach(t,function(t,n){e.map[n]=i.isPresent(t)?t.toString():null,e.keys[n]=!0})}return t.prototype.get=function(t){return o.StringMapWrapper["delete"](this.keys,t),this.map[t]},t.prototype.getUnused=function(){var t=this,e={},n=o.StringMapWrapper.keys(this.keys);return n.forEach(function(n){return e[n]=o.StringMapWrapper.get(t.map,n)}),e},t}();e.TouchMap=s,e.normalizeString=r},function(t,e){"use strict";var n=function(){function t(t,e,n,r,i){this.urlPath=t,this.urlParams=e,this.allParams=n,this.auxiliary=r,this.rest=i}return t}();e.MatchedUrl=n;var r=function(){function t(t,e){this.urlPath=t,this.urlParams=e}return t}();e.GeneratedUrl=r},function(t,e,n){"use strict";var r=n(5),i=n(266),o=function(){function t(t,e){this._reString=t,this._serializer=e,this.terminal=!0,this.specificity="2",this.hash=this._reString,this._regex=r.RegExpWrapper.create(this._reString)}return t.prototype.matchUrl=function(t){var e=t.toString(),n={},o=r.RegExpWrapper.matcher(this._regex,e),s=r.RegExpMatcherWrapper.next(o);if(r.isBlank(s))return null;for(var a=0;ao?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(5),s=function(){function t(t){this.name=t}return t=r([o.CONST(),i("design:paramtypes",[String])],t)}();e.RouteLifecycleHook=s;var a=function(){function t(t){this.fn=t}return t=r([o.CONST(),i("design:paramtypes",[Function])],t)}();e.CanActivate=a,e.routerCanReuse=o.CONST_EXPR(new s("routerCanReuse")),e.routerCanDeactivate=o.CONST_EXPR(new s("routerCanDeactivate")),e.routerOnActivate=o.CONST_EXPR(new s("routerOnActivate")),e.routerOnReuse=o.CONST_EXPR(new s("routerOnReuse")),e.routerOnDeactivate=o.CONST_EXPR(new s("routerOnDeactivate"))},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=this&&this.__param||function(t,e){return function(n,r){e(n,r,t)}},s=n(40),a=n(15),u=n(5),c=n(2),p=n(248),l=n(260),h=n(273),f=n(270),d=s.PromiseWrapper.resolve(!0),v=function(){function t(t,e,n,r){this._viewContainerRef=t,this._loader=e,this._parentRouter=n,this.name=null,this._componentRef=null,this._currentInstruction=null,this.activateEvents=new s.EventEmitter,u.isPresent(r)?(this.name=r,this._parentRouter.registerAuxOutlet(this)):this._parentRouter.registerPrimaryOutlet(this)}return t.prototype.activate=function(t){var e=this,n=this._currentInstruction;this._currentInstruction=t;var r=t.componentType,i=this._parentRouter.childRouter(r),o=c.ReflectiveInjector.resolve([c.provide(l.RouteData,{useValue:t.routeData}),c.provide(l.RouteParams,{useValue:new l.RouteParams(t.params)}),c.provide(p.Router,{useValue:i})]);return this._componentRef=this._loader.loadNextToLocation(r,this._viewContainerRef,o),this._componentRef.then(function(i){return e.activateEvents.emit(i.instance),f.hasLifecycleHook(h.routerOnActivate,r)?e._componentRef.then(function(e){return e.instance.routerOnActivate(t,n)}):i})},t.prototype.reuse=function(t){var e=this._currentInstruction;return this._currentInstruction=t,u.isBlank(this._componentRef)?this.activate(t):s.PromiseWrapper.resolve(f.hasLifecycleHook(h.routerOnReuse,this._currentInstruction.componentType)?this._componentRef.then(function(n){return n.instance.routerOnReuse(t,e)}):!0)},t.prototype.deactivate=function(t){var e=this,n=d;return u.isPresent(this._componentRef)&&u.isPresent(this._currentInstruction)&&f.hasLifecycleHook(h.routerOnDeactivate,this._currentInstruction.componentType)&&(n=this._componentRef.then(function(n){return n.instance.routerOnDeactivate(t,e._currentInstruction)})),n.then(function(t){if(u.isPresent(e._componentRef)){var n=e._componentRef.then(function(t){return t.destroy()});return e._componentRef=null,n}})},t.prototype.routerCanDeactivate=function(t){var e=this;return u.isBlank(this._currentInstruction)?d:f.hasLifecycleHook(h.routerCanDeactivate,this._currentInstruction.componentType)?this._componentRef.then(function(n){return n.instance.routerCanDeactivate(t,e._currentInstruction)}):d},t.prototype.routerCanReuse=function(t){var e,n=this;return e=u.isBlank(this._currentInstruction)||this._currentInstruction.componentType!=t.componentType?!1:f.hasLifecycleHook(h.routerCanReuse,this._currentInstruction.componentType)?this._componentRef.then(function(e){return e.instance.routerCanReuse(t,n._currentInstruction)}):t==this._currentInstruction||u.isPresent(t.params)&&u.isPresent(this._currentInstruction.params)&&a.StringMapWrapper.equals(t.params,this._currentInstruction.params),s.PromiseWrapper.resolve(e)},t.prototype.ngOnDestroy=function(){this._parentRouter.unregisterPrimaryOutlet(this)},r([c.Output("activate"),i("design:type",Object)],t.prototype,"activateEvents",void 0),t=r([c.Directive({selector:"router-outlet"}),o(3,c.Attribute("name")),i("design:paramtypes",[c.ViewContainerRef,c.DynamicComponentLoader,p.Router,String])],t)}();e.RouterOutlet=v},function(t,e,n){"use strict";var r=n(9),i=n(271),o=n(271);e.routerCanReuse=o.routerCanReuse,e.routerCanDeactivate=o.routerCanDeactivate,e.routerOnActivate=o.routerOnActivate,e.routerOnReuse=o.routerOnReuse,e.routerOnDeactivate=o.routerOnDeactivate,e.CanActivate=r.makeDecorator(i.CanActivate)},function(t,e,n){"use strict";var r=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},i=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},o=n(2),s=n(249),a=n(5),u=n(248),c=function(){function t(t,e){var n=this;this._router=t,this._location=e,this._router.subscribe(function(t){return n._updateLink()})}return t.prototype._updateLink=function(){this._navigationInstruction=this._router.generate(this._routeParams);var t=this._navigationInstruction.toLinkUrl();this.visibleHref=this._location.prepareExternalUrl(t)},Object.defineProperty(t.prototype,"isRouteActive",{get:function(){return this._router.isRouteActive(this._navigationInstruction)},enumerable:!0,configurable:!0}),Object.defineProperty(t.prototype,"routeParams",{set:function(t){this._routeParams=t,this._updateLink()},enumerable:!0,configurable:!0}),t.prototype.onClick=function(){return a.isString(this.target)&&"_self"!=this.target?!0:(this._router.navigateByInstruction(this._navigationInstruction),!1)},t=r([o.Directive({selector:"[routerLink]",inputs:["routeParams: routerLink","target: target"],host:{"(click)":"onClick()","[attr.href]":"visibleHref","[class.router-link-active]":"isRouteActive"}}),i("design:paramtypes",[u.Router,s.Location])],t)}();e.RouterLink=c},function(t,e,n){"use strict";function r(t,e,n,r){var i=new s.RootRouter(t,e,n);return r.registerDisposeListener(function(){return i.dispose()}),i}function i(t){if(0==t.componentTypes.length)throw new p.BaseException("Bootstrap at least one component before injecting Router.");return t.componentTypes[0]}var o=n(249),s=n(248),a=n(256),u=n(5),c=n(2),p=n(12);e.ROUTER_PROVIDERS_COMMON=u.CONST_EXPR([a.RouteRegistry,u.CONST_EXPR(new c.Provider(o.LocationStrategy,{useClass:o.PathLocationStrategy})),o.Location,u.CONST_EXPR(new c.Provider(s.Router,{useFactory:r,deps:u.CONST_EXPR([a.RouteRegistry,o.Location,a.ROUTER_PRIMARY_COMPONENT,c.ApplicationRef])})),u.CONST_EXPR(new c.Provider(a.ROUTER_PRIMARY_COMPONENT,{useFactory:i,deps:u.CONST_EXPR([c.ApplicationRef])}))])},function(t,e,n){"use strict";var r=n(275),i=n(2),o=n(277),s=n(249),a=n(5);e.ROUTER_PROVIDERS=a.CONST_EXPR([r.ROUTER_PROVIDERS_COMMON,a.CONST_EXPR(new i.Provider(s.PlatformLocation,{useClass:o.BrowserPlatformLocation}))]),e.ROUTER_BINDINGS=e.ROUTER_PROVIDERS},function(t,e,n){"use strict";var r=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},o=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},s=n(8),a=n(251),u=n(200),c=function(t){function e(){t.call(this),this._init()}return r(e,t),e.prototype._init=function(){this._location=u.DOM.getLocation(),this._history=u.DOM.getHistory()},Object.defineProperty(e.prototype,"location",{get:function(){return this._location},enumerable:!0,configurable:!0}),e.prototype.getBaseHrefFromDOM=function(){return u.DOM.getBaseHref()},e.prototype.onPopState=function(t){u.DOM.getGlobalEventTarget("window").addEventListener("popstate",t,!1)},e.prototype.onHashChange=function(t){u.DOM.getGlobalEventTarget("window").addEventListener("hashchange",t,!1)},Object.defineProperty(e.prototype,"pathname",{get:function(){return this._location.pathname},set:function(t){this._location.pathname=t},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"search",{get:function(){return this._location.search},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"hash",{get:function(){return this._location.hash},enumerable:!0,configurable:!0}),e.prototype.pushState=function(t,e,n){this._history.pushState(t,e,n)},e.prototype.replaceState=function(t,e,n){this._history.replaceState(t,e,n)},e.prototype.forward=function(){this._history.forward()},e.prototype.back=function(){this._history.back()},e=i([s.Injectable(),o("design:paramtypes",[])],e)}(a.PlatformLocation);e.BrowserPlatformLocation=c},function(t,e,n){"use strict";var r=n(137),i=n(2),o=n(279),s=n(5),a=n(279);e.RouterLinkTransform=a.RouterLinkTransform,e.ROUTER_LINK_DSL_PROVIDER=s.CONST_EXPR(new i.Provider(r.TEMPLATE_TRANSFORMS,{useClass:o.RouterLinkTransform,multi:!0}))},function(t,e,n){"use strict";function r(t,e){var n=new y(t,e.trim()).tokenize();return new m(n).generate()}var i=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},o=this&&this.__decorate||function(t,e,n,r){var i,o=arguments.length,s=3>o?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(s=(3>o?i(s):o>3?i(e,n,s):i(e,n))||s);return o>3&&s&&Object.defineProperty(e,n,s),s},s=this&&this.__metadata||function(t,e){return"object"==typeof Reflect&&"function"==typeof Reflect.metadata?Reflect.metadata(t,e):void 0},a=n(137),u=n(141),c=n(12),p=n(2),l=n(142),h=function(){function t(t){this.value=t}return t}(),f=function(){function t(){}return t}(),d=function(){function t(){}return t}(),v=function(){function t(t){this.ast=t}return t}(),y=function(){function t(t,e){this.parser=t,this.exp=e,this.index=0}return t.prototype.tokenize=function(){for(var t=[];this.indexn;n++)e.insertBefore(t[n],this.contentInsertionPoint)},t.prototype.setupOutputs=function(){for(var t=this,e=this.attrs,n=this.info.outputs,r=0;r1)throw new Error("Only support single directive definition for: "+this.name);var n=e[0];n.replace&&this.notSupported("replace"),n.terminal&&this.notSupported("terminal");var r=n.link;return"object"==typeof r&&r.post&&this.notSupported("link.post"),n},t.prototype.notSupported=function(t){throw new Error("Upgraded directive '"+this.name+"' does not support '"+t+"'.")},t.prototype.extractBindings=function(){var t="object"==typeof this.directive.bindToController;if(t&&Object.keys(this.directive.scope).length)throw new Error("Binding definitions on scope and controller at the same time are not supported.");var e=t?this.directive.bindToController:this.directive.scope;if("object"==typeof e)for(var n in e)if(e.hasOwnProperty(n)){var r=e[n],i=r.charAt(0);r=r.substr(1)||n;var o="output_"+n,s=o+": "+n,a=o+": "+n+"Change",u="input_"+n,c=u+": "+n;switch(i){case"=":this.propertyOutputs.push(o),this.checkProperties.push(r),this.outputs.push(o),this.outputsRename.push(a),this.propertyMap[o]=r;case"@":case"<":this.inputs.push(u),this.inputsRename.push(c),this.propertyMap[u]=r;break;case"&":this.outputs.push(o),this.outputsRename.push(s),this.propertyMap[o]=r;break;default:var p=JSON.stringify(e);throw new Error("Unexpected mapping '"+i+"' in '"+p+"' in '"+this.name+"' directive.")}}},t.prototype.compileTemplate=function(t,e,n){function r(e){var n=document.createElement("div");return n.innerHTML=e,t(n.childNodes)}var i=this;if(void 0!==this.directive.template)this.linkFn=r(this.directive.template);else{if(!this.directive.templateUrl)throw new Error("Directive '"+this.name+"' is not a component, it is missing template.");var o=this.directive.templateUrl,s=e.get(o);if(void 0===s)return new Promise(function(t,s){n("GET",o,null,function(n,a){200==n?t(i.linkFn=r(e.put(o,a))):s("GET "+o+" returned "+n+": "+a)})});this.linkFn=r(s)}return null},t.resolve=function(t,e){var n=[],r=e.get(i.NG1_COMPILE),o=e.get(i.NG1_TEMPLATE_CACHE),s=e.get(i.NG1_HTTP_BACKEND),a=e.get(i.NG1_CONTROLLER);for(var u in t)if(t.hasOwnProperty(u)){var c=t[u];c.directive=c.extractDirective(e),c.$controller=a,c.extractBindings();var p=c.compileTemplate(r,o,s);p&&n.push(p)}return Promise.all(n)},t}();e.UpgradeNg1ComponentAdapterBuilder=p;var l=function(){function t(t,e,n,i,a,p,l,h,f,d){this.linkFn=t,this.directive=n,this.inputs=p,this.outputs=l,this.propOuts=h,this.checkProperties=f,this.propertyMap=d,this.destinationObj=null,this.checkLastValues=[],this.element=i.nativeElement,this.componentScope=e.$new(!!n.scope);var v=s.element(this.element),y=n.controller,m=null;if(y){var g={$scope:this.componentScope,$element:v};m=a(y,g,null,n.controllerAs),v.data(o.controllerKey(n.name),m)}var _=n.link;if("object"==typeof _&&(_=_.pre),_){var b=c,P=c,E=this.resolveRequired(v,n.require);n.link(this.componentScope,v,b,E,P)}this.destinationObj=n.bindToController&&m?m:this.componentScope;for(var w=0;wr;r++)e.element.appendChild(t[r])},{parentBoundTranscludeFn:function(t,e){e(n)}}),this.destinationObj.$onInit&&this.destinationObj.$onInit()},t.prototype.ngOnChanges=function(t){for(var e in t)if(t.hasOwnProperty(e)){var n=t[e];this.setComponentProperty(e,n.currentValue)}},t.prototype.ngDoCheck=function(){for(var t=0,e=this.destinationObj,n=this.checkLastValues,r=this.checkProperties,i=0;i",this._properties=t&&t.properties||{},this._zoneDelegate=new d(this,this._parent&&this._parent._zoneDelegate,t)}return Object.defineProperty(e,"current",{get:function(){return m},enumerable:!0,configurable:!0}),Object.defineProperty(e,"currentTask",{get:function(){return T},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"parent",{get:function(){return this._parent},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"name",{get:function(){return this._name},enumerable:!0,configurable:!0}),e.prototype.get=function(e){for(var t=this;t;){if(t._properties.hasOwnProperty(e))return t._properties[e];t=t._parent}},e.prototype.fork=function(e){if(!e)throw new Error("ZoneSpec required!");return this._zoneDelegate.fork(this,e)},e.prototype.wrap=function(e,t){if("function"!=typeof e)throw new Error("Expecting function got: "+e);var n=this._zoneDelegate.intercept(this,e,t),r=this;return function(){return r.runGuarded(n,this,arguments,t)}},e.prototype.run=function(e,t,n,r){void 0===t&&(t=null),void 0===n&&(n=null),void 0===r&&(r=null);var o=m;m=this;try{return this._zoneDelegate.invoke(this,e,t,n,r)}finally{m=o}},e.prototype.runGuarded=function(e,t,n,r){void 0===t&&(t=null),void 0===n&&(n=null),void 0===r&&(r=null);var o=m;m=this;try{try{return this._zoneDelegate.invoke(this,e,t,n,r)}catch(a){if(this._zoneDelegate.handleError(this,a))throw a}}finally{m=o}},e.prototype.runTask=function(e,t,n){if(e.runCount++,e.zone!=this)throw new Error("A task can only be run in the zone which created it! (Creation: "+e.zone.name+"; Execution: "+this.name+")");var r=T;T=e;var o=m;m=this;try{"macroTask"==e.type&&e.data&&!e.data.isPeriodic&&(e.cancelFn=null);try{return this._zoneDelegate.invokeTask(this,e,t,n)}catch(a){if(this._zoneDelegate.handleError(this,a))throw a}}finally{m=o,T=r}},e.prototype.scheduleMicroTask=function(e,t,n,r){return this._zoneDelegate.scheduleTask(this,new v("microTask",this,e,t,n,r,null))},e.prototype.scheduleMacroTask=function(e,t,n,r,o){return this._zoneDelegate.scheduleTask(this,new v("macroTask",this,e,t,n,r,o))},e.prototype.scheduleEventTask=function(e,t,n,r,o){return this._zoneDelegate.scheduleTask(this,new v("eventTask",this,e,t,n,r,o))},e.prototype.cancelTask=function(e){var t=this._zoneDelegate.cancelTask(this,e);return e.runCount=-1,e.cancelFn=null,t},e.__symbol__=t,e}(),d=function(){function e(e,t,n){this._taskCounts={microTask:0,macroTask:0,eventTask:0},this.zone=e,this._parentDelegate=t,this._forkZS=n&&(n&&n.onFork?n:t._forkZS),this._forkDlgt=n&&(n.onFork?t:t._forkDlgt),this._interceptZS=n&&(n.onIntercept?n:t._interceptZS),this._interceptDlgt=n&&(n.onIntercept?t:t._interceptDlgt),this._invokeZS=n&&(n.onInvoke?n:t._invokeZS),this._invokeDlgt=n&&(n.onInvoke?t:t._invokeDlgt),this._handleErrorZS=n&&(n.onHandleError?n:t._handleErrorZS),this._handleErrorDlgt=n&&(n.onHandleError?t:t._handleErrorDlgt),this._scheduleTaskZS=n&&(n.onScheduleTask?n:t._scheduleTaskZS),this._scheduleTaskDlgt=n&&(n.onScheduleTask?t:t._scheduleTaskDlgt),this._invokeTaskZS=n&&(n.onInvokeTask?n:t._invokeTaskZS),this._invokeTaskDlgt=n&&(n.onInvokeTask?t:t._invokeTaskDlgt),this._cancelTaskZS=n&&(n.onCancelTask?n:t._cancelTaskZS),this._cancelTaskDlgt=n&&(n.onCancelTask?t:t._cancelTaskDlgt),this._hasTaskZS=n&&(n.onHasTask?n:t._hasTaskZS),this._hasTaskDlgt=n&&(n.onHasTask?t:t._hasTaskDlgt)}return e.prototype.fork=function(e,t){return this._forkZS?this._forkZS.onFork(this._forkDlgt,this.zone,e,t):new h(e,t)},e.prototype.intercept=function(e,t,n){return this._interceptZS?this._interceptZS.onIntercept(this._interceptDlgt,this.zone,e,t,n):t},e.prototype.invoke=function(e,t,n,r,o){return this._invokeZS?this._invokeZS.onInvoke(this._invokeDlgt,this.zone,e,t,n,r,o):t.apply(n,r)},e.prototype.handleError=function(e,t){return this._handleErrorZS?this._handleErrorZS.onHandleError(this._handleErrorDlgt,this.zone,e,t):!0},e.prototype.scheduleTask=function(e,t){try{if(this._scheduleTaskZS)return this._scheduleTaskZS.onScheduleTask(this._scheduleTaskDlgt,this.zone,e,t);if(t.scheduleFn)t.scheduleFn(t);else{if("microTask"!=t.type)throw new Error("Task is missing scheduleFn.");r(t)}return t}finally{e==this.zone&&this._updateTaskCount(t.type,1)}},e.prototype.invokeTask=function(e,t,n,r){try{return this._invokeTaskZS?this._invokeTaskZS.onInvokeTask(this._invokeTaskDlgt,this.zone,e,t,n,r):t.callback.apply(n,r)}finally{e!=this.zone||"eventTask"==t.type||t.data&&t.data.isPeriodic||this._updateTaskCount(t.type,-1)}},e.prototype.cancelTask=function(e,t){var n;if(this._cancelTaskZS)n=this._cancelTaskZS.onCancelTask(this._cancelTaskDlgt,this.zone,e,t);else{if(!t.cancelFn)throw new Error("Task does not support cancellation, or is already canceled.");n=t.cancelFn(t)}return e==this.zone&&this._updateTaskCount(t.type,-1),n},e.prototype.hasTask=function(e,t){return this._hasTaskZS&&this._hasTaskZS.onHasTask(this._hasTaskDlgt,this.zone,e,t)},e.prototype._updateTaskCount=function(e,t){var n=this._taskCounts,r=n[e],o=n[e]=r+t;if(0>o)throw new Error("More tasks executed then were scheduled.");if(0==r||0==o){var a={microTask:n.microTask>0,macroTask:n.macroTask>0,eventTask:n.eventTask>0,change:e};try{this.hasTask(this.zone,a)}finally{this._parentDelegate&&this._parentDelegate._updateTaskCount(e,t)}}},e}(),v=function(){function e(e,t,n,r,o,i,u){this.runCount=0,this.type=e,this.zone=t,this.source=n,this.data=o,this.scheduleFn=i,this.cancelFn=u,this.callback=r;var c=this;this.invoke=function(){try{return t.runTask(c,this,arguments)}finally{a()}}}return e}(),y=t("setTimeout"),g=t("Promise"),k=t("then"),m=new h(null,null),T=null,w=[],_=!1,b=[],E=!1,S=t("state"),O=t("value"),D="Promise.then",P=null,M=!0,z=!1,j=0,C=function(){function e(e){var t=this;t[S]=P,t[O]=[];try{e&&e(s(t,M),s(t,z))}catch(n){l(t,!1,n)}}return e.resolve=function(e){return l(new this(null),M,e)},e.reject=function(e){return l(new this(null),z,e)},e.race=function(e){function t(e){a&&(a=r(e))}function n(e){a&&(a=o(e))}for(var r,o,a=new this(function(e,t){r=e,o=t}),u=0,c=e;u=0;n--)"function"==typeof e[n]&&(e[n]=Zone.current.wrap(e[n],t+"_"+n));return e}function r(e,t){for(var r=e.constructor.name,o=function(o){var a=t[o],i=e[a];i&&(e[a]=function(e){return function(){return e.apply(this,n(arguments,r+"."+a))}}(i))},a=0;a1?new t(e,n):new t(e),i=Object.getOwnPropertyDescriptor(a,"onmessage");return i&&i.configurable===!1?(r=Object.create(a),["addEventListener","removeEventListener","send","close"].forEach(function(e){r[e]=function(){return a[e].apply(a,arguments)}})):r=a,o.patchOnProperties(r,["close","error","message","open"]),r};for(var n in t)e.WebSocket[n]=t[n]}var o=n(3);t.apply=r},function(e,t,n){"use strict";function r(e,t,n,r){function a(t){var n=t.data;return n.args[0]=t.invoke,n.handleId=u.apply(e,n.args),t}function i(e){return c(e.data.handleId)}var u=null,c=null;t+=r,n+=r,u=o.patchMethod(e,t,function(n){return function(o,u){if("function"==typeof u[0]){var c=Zone.current,s={handleId:null,isPeriodic:"Interval"===r,delay:"Timeout"===r||"Interval"===r?u[1]||0:null,args:u};return c.scheduleMacroTask(t,u[0],s,a,i)}return n.apply(e,u)}}),c=o.patchMethod(e,n,function(t){return function(n,r){var o=r[0];o&&"string"==typeof o.type?(o.cancelFn&&o.data.isPeriodic||0===o.runCount)&&o.zone.cancelTask(o):t.apply(e,r)}})}var o=n(3);t.patchTimer=r}]),function(e){function t(r){if(n[r])return n[r].exports;var o=n[r]={exports:{},id:r,loaded:!1};return e[r].call(o.exports,o,o.exports,t),o.loaded=!0,o.exports}var n={};return t.m=e,t.c=n,t.p="",t(0)}([function(e,t){"use strict";!function(){function e(){return new Error("STACKTRACE TRACKING")}function t(){try{throw e()}catch(t){return t}}function n(e){return e.stack?e.stack.split(u):[]}function r(e,t){for(var r=n(t),o=0;o0&&(e.push(n((new f).error)),a(e,t-1))}function i(){var e=[];a(e,2);for(var t=e[0],n=e[1],r=0;rthis.longStackTraceLimit&&(a.length=this.longStackTraceLimit),r.data||(r.data={}),r.data[l]=a,e.scheduleTask(n,r)},onHandleError:function(e,t,n,r){var a=Zone.currentTask;if(r instanceof Error&&a){var i=Object.getOwnPropertyDescriptor(r,"stack");if(i){var u=i.get,c=i.value;i={get:function(){return o(a.data&&a.data[l],u?u.apply(this):c)}},Object.defineProperty(r,"stack",i)}else r.stack=o(a.data&&a.data[l],r.stack)}return e.handleError(n,r)}},i()}()}]);var Reflect;!function(e){function t(e,t,n,r){if(_(r)){if(_(n)){if(!b(e))throw new TypeError;if(!S(t))throw new TypeError;return f(e,t)}if(!b(e))throw new TypeError;if(!E(t))throw new TypeError;return n=D(n),h(e,t,n)}if(!b(e))throw new TypeError;if(!E(t))throw new TypeError;if(_(n))throw new TypeError;if(!E(r))throw new TypeError;return n=D(n),p(e,t,n,r)}function n(e,t){function n(n,r){if(_(r)){if(!S(n))throw new TypeError;m(e,t,n,void 0)}else{if(!E(n))throw new TypeError;r=D(r),m(e,t,n,r)}}return n}function r(e,t,n,r){if(!E(n))throw new TypeError;return _(r)||(r=D(r)),m(e,t,n,r)}function o(e,t,n){if(!E(t))throw new TypeError;return _(n)||(n=D(n)),v(e,t,n)}function a(e,t,n){if(!E(t))throw new TypeError;return _(n)||(n=D(n)),y(e,t,n)}function i(e,t,n){if(!E(t))throw new TypeError;return _(n)||(n=D(n)),g(e,t,n)}function u(e,t,n){if(!E(t))throw new TypeError;return _(n)||(n=D(n)),k(e,t,n)}function c(e,t){if(!E(e))throw new TypeError;return _(t)||(t=D(t)),T(e,t)}function s(e,t){if(!E(e))throw new TypeError;return _(t)||(t=D(t)),w(e,t)}function l(e,t,n){if(!E(t))throw new TypeError;_(n)||(n=D(n));var r=d(t,n,!1);if(_(r))return!1;if(!r["delete"](e))return!1;if(r.size>0)return!0;var o=R.get(t);return o["delete"](n),o.size>0?!0:(R["delete"](t),!0)}function f(e,t){for(var n=e.length-1;n>=0;--n){var r=e[n],o=r(t);if(!_(o)){if(!S(o))throw new TypeError;t=o}}return t}function p(e,t,n,r){for(var o=e.length-1;o>=0;--o){var a=e[o],i=a(t,n,r);if(!_(i)){if(!E(i))throw new TypeError;r=i}}return r}function h(e,t,n){for(var r=e.length-1;r>=0;--r){var o=e[r];o(t,n)}}function d(e,t,n){var r=R.get(e);if(!r){if(!n)return;r=new Z,R.set(e,r)}var o=r.get(t);if(!o){if(!n)return;o=new Z,r.set(t,o)}return o}function v(e,t,n){var r=y(e,t,n);if(r)return!0;var o=P(t);return null!==o?v(e,o,n):!1}function y(e,t,n){var r=d(t,n,!1);return void 0===r?!1:Boolean(r.has(e))}function g(e,t,n){var r=y(e,t,n);if(r)return k(e,t,n);var o=P(t);return null!==o?g(e,o,n):void 0}function k(e,t,n){var r=d(t,n,!1);if(void 0!==r)return r.get(e)}function m(e,t,n,r){var o=d(n,r,!0);o.set(e,t)}function T(e,t){var n=w(e,t),r=P(e);if(null===r)return n;var o=T(r,t);if(o.length<=0)return n;if(n.length<=0)return o;for(var a=new I,i=[],u=0;u=0?(this._cache=e,!0):!1},get:function(e){var t=this._find(e);return t>=0?(this._cache=e,this._values[t]):void 0},set:function(e,t){return this["delete"](e),this._keys.push(e),this._values.push(t),this._cache=e,this},"delete":function(e){var n=this._find(e);return n>=0?(this._keys.splice(n,1),this._values.splice(n,1),this._cache=t,!0):!1},clear:function(){this._keys.length=0,this._values.length=0,this._cache=t},forEach:function(e,t){for(var n=this.size,r=0;n>r;++r){var o=this._keys[r],a=this._values[r];this._cache=o,e.call(this,a,o,this)}},_find:function(e){for(var t=this._keys,n=t.length,r=0;n>r;++r)if(t[r]===e)return r;return-1}},e}function z(){function e(){this._map=new Z}return e.prototype={get size(){return this._map.length},has:function(e){return this._map.has(e)},add:function(e){return this._map.set(e,e),this},"delete":function(e){return this._map["delete"](e)},clear:function(){this._map.clear()},forEach:function(e,t){this._map.forEach(e,t)}},e}function j(){function e(){this._key=o()}function t(e,t){for(var n=0;t>n;++n)e[n]=255*Math.random()|0}function n(e){if(c){var n=c.randomBytes(e);return n}if("function"==typeof Uint8Array){var n=new Uint8Array(e);return"undefined"!=typeof crypto?crypto.getRandomValues(n):"undefined"!=typeof msCrypto?msCrypto.getRandomValues(n):t(n,e),n}var n=new Array(e);return t(n,e),n}function r(){var e=n(i);e[6]=79&e[6]|64,e[8]=191&e[8]|128;for(var t="",r=0;i>r;++r){var o=e[r];(4===r||6===r||8===r)&&(t+="-"),16>o&&(t+="0"),t+=o.toString(16).toLowerCase()}return t}function o(){var e;do e="@@WeakMap@@"+r();while(s.call(l,e));return l[e]=!0,e}function a(e,t){if(!s.call(e,f)){if(!t)return;Object.defineProperty(e,f,{value:Object.create(null)})}return e[f]}var i=16,u="undefined"!=typeof global&&"[object process]"===Object.prototype.toString.call(global.process),c=u&&require("crypto"),s=Object.prototype.hasOwnProperty,l={},f=o();return e.prototype={has:function(e){var t=a(e,!1);return t?this._key in t:!1},get:function(e){var t=a(e,!1);return t?t[this._key]:void 0},set:function(e,t){var n=a(e,!0);return n[this._key]=t,this},"delete":function(e){var t=a(e,!1);return t&&this._key in t?delete t[this._key]:!1},clear:function(){this._key=o()}},e}var C=Object.getPrototypeOf(Function),Z="function"==typeof Map?Map:M(),I="function"==typeof Set?Set:z(),L="function"==typeof WeakMap?WeakMap:j(),R=new L;e.decorate=t,e.metadata=n,e.defineMetadata=r,e.hasMetadata=o,e.hasOwnMetadata=a,e.getMetadata=i,e.getOwnMetadata=u,e.getMetadataKeys=c,e.getOwnMetadataKeys=s,e.deleteMetadata=l,function(t){if("undefined"!=typeof t.Reflect){if(t.Reflect!==e)for(var n in e)t.Reflect[n]=e[n]}else t.Reflect=e}("undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope?self:"undefined"!=typeof global?global:Function("return this;")())}(Reflect||(Reflect={})); \ No newline at end of file diff --git a/accessible/libs/es6-shim.min.js b/accessible/libs/es6-shim.min.js deleted file mode 100644 index 9a11646fc88..00000000000 --- a/accessible/libs/es6-shim.min.js +++ /dev/null @@ -1,12 +0,0 @@ -/*! - * https://github.com/paulmillr/es6-shim - * @license es6-shim Copyright 2013-2016 by Paul Miller (http://paulmillr.com) - * and contributors, MIT License - * es6-shim: v0.35.1 - * see https://github.com/paulmillr/es6-shim/blob/0.35.1/LICENSE - * Details and documentation: - * https://github.com/paulmillr/es6-shim/ - */ -(function(e,t){if(typeof define==="function"&&define.amd){define(t)}else if(typeof exports==="object"){module.exports=t()}else{e.returnExports=t()}})(this,function(){"use strict";var e=Function.call.bind(Function.apply);var t=Function.call.bind(Function.call);var r=Array.isArray;var n=Object.keys;var o=function notThunker(t){return function notThunk(){return!e(t,this,arguments)}};var i=function(e){try{e();return false}catch(t){return true}};var a=function valueOrFalseIfThrows(e){try{return e()}catch(t){return false}};var u=o(i);var f=function(){return!i(function(){Object.defineProperty({},"x",{get:function(){}})})};var s=!!Object.defineProperty&&f();var c=function foo(){}.name==="foo";var l=Function.call.bind(Array.prototype.forEach);var p=Function.call.bind(Array.prototype.reduce);var v=Function.call.bind(Array.prototype.filter);var y=Function.call.bind(Array.prototype.some);var h=function(e,t,r,n){if(!n&&t in e){return}if(s){Object.defineProperty(e,t,{configurable:true,enumerable:false,writable:true,value:r})}else{e[t]=r}};var b=function(e,t,r){l(n(t),function(n){var o=t[n];h(e,n,o,!!r)})};var g=Function.call.bind(Object.prototype.toString);var d=typeof/abc/==="function"?function IsCallableSlow(e){return typeof e==="function"&&g(e)==="[object Function]"}:function IsCallableFast(e){return typeof e==="function"};var O={getter:function(e,t,r){if(!s){throw new TypeError("getters require true ES5 support")}Object.defineProperty(e,t,{configurable:true,enumerable:false,get:r})},proxy:function(e,t,r){if(!s){throw new TypeError("getters require true ES5 support")}var n=Object.getOwnPropertyDescriptor(e,t);Object.defineProperty(r,t,{configurable:n.configurable,enumerable:n.enumerable,get:function getKey(){return e[t]},set:function setKey(r){e[t]=r}})},redefine:function(e,t,r){if(s){var n=Object.getOwnPropertyDescriptor(e,t);n.value=r;Object.defineProperty(e,t,n)}else{e[t]=r}},defineByDescriptor:function(e,t,r){if(s){Object.defineProperty(e,t,r)}else if("value"in r){e[t]=r.value}},preserveToString:function(e,t){if(t&&d(t.toString)){h(e,"toString",t.toString.bind(t),true)}}};var m=Object.create||function(e,t){var r=function Prototype(){};r.prototype=e;var o=new r;if(typeof t!=="undefined"){n(t).forEach(function(e){O.defineByDescriptor(o,e,t[e])})}return o};var w=function(e,t){if(!Object.setPrototypeOf){return false}return a(function(){var r=function Subclass(t){var r=new e(t);Object.setPrototypeOf(r,Subclass.prototype);return r};Object.setPrototypeOf(r,e);r.prototype=m(e.prototype,{constructor:{value:r}});return t(r)})};var j=function(){if(typeof self!=="undefined"){return self}if(typeof window!=="undefined"){return window}if(typeof global!=="undefined"){return global}throw new Error("unable to locate global object")};var S=j();var T=S.isFinite;var I=Function.call.bind(String.prototype.indexOf);var E=Function.apply.bind(Array.prototype.indexOf);var P=Function.call.bind(Array.prototype.concat);var C=Function.call.bind(String.prototype.slice);var M=Function.call.bind(Array.prototype.push);var x=Function.apply.bind(Array.prototype.push);var N=Function.call.bind(Array.prototype.shift);var A=Math.max;var R=Math.min;var _=Math.floor;var k=Math.abs;var F=Math.exp;var L=Math.log;var D=Math.sqrt;var z=Function.call.bind(Object.prototype.hasOwnProperty);var q;var W=function(){};var G=S.Symbol||{};var H=G.species||"@@species";var V=Number.isNaN||function isNaN(e){return e!==e};var B=Number.isFinite||function isFinite(e){return typeof e==="number"&&T(e)};var $=d(Math.sign)?Math.sign:function sign(e){var t=Number(e);if(t===0){return t}if(V(t)){return t}return t<0?-1:1};var U=function isArguments(e){return g(e)==="[object Arguments]"};var J=function isArguments(e){return e!==null&&typeof e==="object"&&typeof e.length==="number"&&e.length>=0&&g(e)!=="[object Array]"&&g(e.callee)==="[object Function]"};var X=U(arguments)?U:J;var K={primitive:function(e){return e===null||typeof e!=="function"&&typeof e!=="object"},string:function(e){return g(e)==="[object String]"},regex:function(e){return g(e)==="[object RegExp]"},symbol:function(e){return typeof S.Symbol==="function"&&typeof e==="symbol"}};var Z=function overrideNative(e,t,r){var n=e[t];h(e,t,r,true);O.preserveToString(e[t],n)};var Y=typeof G==="function"&&typeof G["for"]==="function"&&K.symbol(G());var Q=K.symbol(G.iterator)?G.iterator:"_es6-shim iterator_";if(S.Set&&typeof(new S.Set)["@@iterator"]==="function"){Q="@@iterator"}if(!S.Reflect){h(S,"Reflect",{},true)}var ee=S.Reflect;var te=String;var re={Call:function Call(t,r){var n=arguments.length>2?arguments[2]:[];if(!re.IsCallable(t)){throw new TypeError(t+" is not a function")}return e(t,r,n)},RequireObjectCoercible:function(e,t){if(e==null){throw new TypeError(t||"Cannot call method on "+e)}return e},TypeIsObject:function(e){if(e===void 0||e===null||e===true||e===false){return false}return typeof e==="function"||typeof e==="object"},ToObject:function(e,t){return Object(re.RequireObjectCoercible(e,t))},IsCallable:d,IsConstructor:function(e){return re.IsCallable(e)},ToInt32:function(e){return re.ToNumber(e)>>0},ToUint32:function(e){return re.ToNumber(e)>>>0},ToNumber:function(e){if(g(e)==="[object Symbol]"){throw new TypeError("Cannot convert a Symbol value to a number")}return+e},ToInteger:function(e){var t=re.ToNumber(e);if(V(t)){return 0}if(t===0||!B(t)){return t}return(t>0?1:-1)*_(k(t))},ToLength:function(e){var t=re.ToInteger(e);if(t<=0){return 0}if(t>Number.MAX_SAFE_INTEGER){return Number.MAX_SAFE_INTEGER}return t},SameValue:function(e,t){if(e===t){if(e===0){return 1/e===1/t}return true}return V(e)&&V(t)},SameValueZero:function(e,t){return e===t||V(e)&&V(t)},IsIterable:function(e){return re.TypeIsObject(e)&&(typeof e[Q]!=="undefined"||X(e))},GetIterator:function(e){if(X(e)){return new q(e,"value")}var t=re.GetMethod(e,Q);if(!re.IsCallable(t)){throw new TypeError("value is not an iterable")}var r=re.Call(t,e);if(!re.TypeIsObject(r)){throw new TypeError("bad iterator")}return r},GetMethod:function(e,t){var r=re.ToObject(e)[t];if(r===void 0||r===null){return void 0}if(!re.IsCallable(r)){throw new TypeError("Method not callable: "+t)}return r},IteratorComplete:function(e){return!!e.done},IteratorClose:function(e,t){var r=re.GetMethod(e,"return");if(r===void 0){return}var n,o;try{n=re.Call(r,e)}catch(i){o=i}if(t){return}if(o){throw o}if(!re.TypeIsObject(n)){throw new TypeError("Iterator's return method returned a non-object.")}},IteratorNext:function(e){var t=arguments.length>1?e.next(arguments[1]):e.next();if(!re.TypeIsObject(t)){throw new TypeError("bad iterator")}return t},IteratorStep:function(e){var t=re.IteratorNext(e);var r=re.IteratorComplete(t);return r?false:t},Construct:function(e,t,r,n){var o=typeof r==="undefined"?e:r;if(!n&&ee.construct){return ee.construct(e,t,o)}var i=o.prototype;if(!re.TypeIsObject(i)){i=Object.prototype}var a=m(i);var u=re.Call(e,a,t);return re.TypeIsObject(u)?u:a},SpeciesConstructor:function(e,t){var r=e.constructor;if(r===void 0){return t}if(!re.TypeIsObject(r)){throw new TypeError("Bad constructor")}var n=r[H];if(n===void 0||n===null){return t}if(!re.IsConstructor(n)){throw new TypeError("Bad @@species")}return n},CreateHTML:function(e,t,r,n){var o=re.ToString(e);var i="<"+t;if(r!==""){var a=re.ToString(n);var u=a.replace(/"/g,""");i+=" "+r+'="'+u+'"'}var f=i+">";var s=f+o;return s+""},IsRegExp:function IsRegExp(e){if(!re.TypeIsObject(e)){return false}var t=e[G.match];if(typeof t!=="undefined"){return!!t}return K.regex(e)},ToString:function ToString(e){return te(e)}};if(s&&Y){var ne=function defineWellKnownSymbol(e){if(K.symbol(G[e])){return G[e]}var t=G["for"]("Symbol."+e);Object.defineProperty(G,e,{configurable:false,enumerable:false,writable:false,value:t});return t};if(!K.symbol(G.search)){var oe=ne("search");var ie=String.prototype.search;h(RegExp.prototype,oe,function search(e){return re.Call(ie,e,[this])});var ae=function search(e){var t=re.RequireObjectCoercible(this);if(e!==null&&typeof e!=="undefined"){var r=re.GetMethod(e,oe);if(typeof r!=="undefined"){return re.Call(r,e,[t])}}return re.Call(ie,t,[re.ToString(e)])};Z(String.prototype,"search",ae)}if(!K.symbol(G.replace)){var ue=ne("replace");var fe=String.prototype.replace;h(RegExp.prototype,ue,function replace(e,t){return re.Call(fe,e,[this,t])});var se=function replace(e,t){var r=re.RequireObjectCoercible(this);if(e!==null&&typeof e!=="undefined"){var n=re.GetMethod(e,ue);if(typeof n!=="undefined"){return re.Call(n,e,[r,t])}}return re.Call(fe,r,[re.ToString(e),t])};Z(String.prototype,"replace",se)}if(!K.symbol(G.split)){var ce=ne("split");var le=String.prototype.split;h(RegExp.prototype,ce,function split(e,t){return re.Call(le,e,[this,t])});var pe=function split(e,t){var r=re.RequireObjectCoercible(this);if(e!==null&&typeof e!=="undefined"){var n=re.GetMethod(e,ce);if(typeof n!=="undefined"){return re.Call(n,e,[r,t])}}return re.Call(le,r,[re.ToString(e),t])};Z(String.prototype,"split",pe)}var ve=K.symbol(G.match);var ye=ve&&function(){var e={};e[G.match]=function(){return 42};return"a".match(e)!==42}();if(!ve||ye){var he=ne("match");var be=String.prototype.match;h(RegExp.prototype,he,function match(e){return re.Call(be,e,[this])});var ge=function match(e){var t=re.RequireObjectCoercible(this);if(e!==null&&typeof e!=="undefined"){var r=re.GetMethod(e,he);if(typeof r!=="undefined"){return re.Call(r,e,[t])}}return re.Call(be,t,[re.ToString(e)])};Z(String.prototype,"match",ge)}}var de=function wrapConstructor(e,t,r){O.preserveToString(t,e);if(Object.setPrototypeOf){Object.setPrototypeOf(e,t)}if(s){l(Object.getOwnPropertyNames(e),function(n){if(n in W||r[n]){return}O.proxy(e,n,t)})}else{l(Object.keys(e),function(n){if(n in W||r[n]){return}t[n]=e[n]})}t.prototype=e.prototype;O.redefine(e.prototype,"constructor",t)};var Oe=function(){return this};var me=function(e){if(s&&!z(e,H)){O.getter(e,H,Oe)}};var we=function(e,t){var r=t||function iterator(){return this};h(e,Q,r);if(!e[Q]&&K.symbol(Q)){e[Q]=r}};var je=function createDataProperty(e,t,r){if(s){Object.defineProperty(e,t,{configurable:true,enumerable:true,writable:true,value:r})}else{e[t]=r}};var Se=function createDataPropertyOrThrow(e,t,r){je(e,t,r);if(!re.SameValue(e[t],r)){throw new TypeError("property is nonconfigurable")}};var Te=function(e,t,r,n){if(!re.TypeIsObject(e)){throw new TypeError("Constructor requires `new`: "+t.name)}var o=t.prototype;if(!re.TypeIsObject(o)){o=r}var i=m(o);for(var a in n){if(z(n,a)){var u=n[a];h(i,a,u,true)}}return i};if(String.fromCodePoint&&String.fromCodePoint.length!==1){var Ie=String.fromCodePoint;Z(String,"fromCodePoint",function fromCodePoint(e){return re.Call(Ie,this,arguments)})}var Ee={fromCodePoint:function fromCodePoint(e){var t=[];var r;for(var n=0,o=arguments.length;n1114111){throw new RangeError("Invalid code point "+r)}if(r<65536){M(t,String.fromCharCode(r))}else{r-=65536;M(t,String.fromCharCode((r>>10)+55296));M(t,String.fromCharCode(r%1024+56320))}}return t.join("")},raw:function raw(e){var t=re.ToObject(e,"bad callSite");var r=re.ToObject(t.raw,"bad raw value");var n=r.length;var o=re.ToLength(n);if(o<=0){return""}var i=[];var a=0;var u,f,s,c;while(a=o){break}f=a+1=Ce){throw new RangeError("repeat count must be less than infinity and not overflow maximum string size")}return Pe(t,r)},startsWith:function startsWith(e){var t=re.ToString(re.RequireObjectCoercible(this));if(re.IsRegExp(e)){throw new TypeError('Cannot call method "startsWith" with a regex')}var r=re.ToString(e);var n;if(arguments.length>1){n=arguments[1]}var o=A(re.ToInteger(n),0);return C(t,o,o+r.length)===r},endsWith:function endsWith(e){var t=re.ToString(re.RequireObjectCoercible(this));if(re.IsRegExp(e)){throw new TypeError('Cannot call method "endsWith" with a regex')}var r=re.ToString(e);var n=t.length;var o;if(arguments.length>1){o=arguments[1]}var i=typeof o==="undefined"?n:re.ToInteger(o);var a=R(A(i,0),n);return C(t,a-r.length,a)===r},includes:function includes(e){if(re.IsRegExp(e)){throw new TypeError('"includes" does not accept a RegExp')}var t=re.ToString(e);var r;if(arguments.length>1){r=arguments[1]}return I(this,t,r)!==-1},codePointAt:function codePointAt(e){var t=re.ToString(re.RequireObjectCoercible(this));var r=re.ToInteger(e);var n=t.length;if(r>=0&&r56319||i){return o}var a=t.charCodeAt(r+1);if(a<56320||a>57343){return o}return(o-55296)*1024+(a-56320)+65536}}};if(String.prototype.includes&&"a".includes("a",Infinity)!==false){Z(String.prototype,"includes",Me.includes)}if(String.prototype.startsWith&&String.prototype.endsWith){var xe=i(function(){"/a/".startsWith(/a/)});var Ne=a(function(){return"abc".startsWith("a",Infinity)===false});if(!xe||!Ne){Z(String.prototype,"startsWith",Me.startsWith);Z(String.prototype,"endsWith",Me.endsWith)}}if(Y){var Ae=a(function(){var e=/a/;e[G.match]=false;return"/a/".startsWith(e)});if(!Ae){Z(String.prototype,"startsWith",Me.startsWith)}var Re=a(function(){var e=/a/;e[G.match]=false;return"/a/".endsWith(e)});if(!Re){Z(String.prototype,"endsWith",Me.endsWith)}var _e=a(function(){var e=/a/;e[G.match]=false;return"/a/".includes(e)});if(!_e){Z(String.prototype,"includes",Me.includes)}}b(String.prototype,Me);var ke=[" \n\x0B\f\r \xa0\u1680\u180e\u2000\u2001\u2002\u2003","\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028","\u2029\ufeff"].join("");var Fe=new RegExp("(^["+ke+"]+)|(["+ke+"]+$)","g");var Le=function trim(){return re.ToString(re.RequireObjectCoercible(this)).replace(Fe,"")};var De=["\x85","\u200b","\ufffe"].join("");var ze=new RegExp("["+De+"]","g");var qe=/^[\-+]0x[0-9a-f]+$/i;var We=De.trim().length!==De.length;h(String.prototype,"trim",Le,We);var Ge=function(e){return{value:e,done:arguments.length===0}};var He=function(e){re.RequireObjectCoercible(e);this._s=re.ToString(e);this._i=0};He.prototype.next=function(){var e=this._s;var t=this._i;if(typeof e==="undefined"||t>=e.length){this._s=void 0;return Ge()}var r=e.charCodeAt(t);var n,o;if(r<55296||r>56319||t+1===e.length){o=1}else{n=e.charCodeAt(t+1);o=n<56320||n>57343?1:2}this._i=t+o;return Ge(e.substr(t,o))};we(He.prototype);we(String.prototype,function(){return new He(this)});var Ve={from:function from(e){var r=this;var n;if(arguments.length>1){n=arguments[1]}var o,i;if(typeof n==="undefined"){o=false}else{if(!re.IsCallable(n)){throw new TypeError("Array.from: when provided, the second argument must be a function")}if(arguments.length>2){i=arguments[2]}o=true}var a=typeof(X(e)||re.GetMethod(e,Q))!=="undefined";var u,f,s;if(a){f=re.IsConstructor(r)?Object(new r):[];var c=re.GetIterator(e);var l,p;s=0;while(true){l=re.IteratorStep(c);if(l===false){break}p=l.value;try{if(o){p=typeof i==="undefined"?n(p,s):t(n,i,p,s)}f[s]=p}catch(v){re.IteratorClose(c,true);throw v}s+=1}u=s}else{var y=re.ToObject(e);u=re.ToLength(y.length);f=re.IsConstructor(r)?Object(new r(u)):new Array(u);var h;for(s=0;s2){f=arguments[2]}var s=typeof f==="undefined"?n:re.ToInteger(f);var c=s<0?A(n+s,0):R(s,n);var l=R(c-u,n-a);var p=1;if(u0){if(u in r){r[a]=r[u]}else{delete r[a]}u+=p;a+=p;l-=1}return r},fill:function fill(e){var t;if(arguments.length>1){t=arguments[1]}var r;if(arguments.length>2){r=arguments[2]}var n=re.ToObject(this);var o=re.ToLength(n.length);t=re.ToInteger(typeof t==="undefined"?0:t);r=re.ToInteger(typeof r==="undefined"?o:r);var i=t<0?A(o+t,0):R(t,o);var a=r<0?o+r:r;for(var u=i;u1?arguments[1]:null;for(var i=0,a;i1?arguments[1]:null;for(var i=0;i1&&typeof arguments[1]!=="undefined"){return re.Call(Ze,this,arguments)}else{return t(Ze,this,e)}})}var Ye=-(Math.pow(2,32)-1);var Qe=function(e,r){var n={length:Ye};n[r?(n.length>>>0)-1:0]=true;return a(function(){t(e,n,function(){throw new RangeError("should not reach here")},[]);return true})};if(!Qe(Array.prototype.forEach)){var et=Array.prototype.forEach;Z(Array.prototype,"forEach",function forEach(e){return re.Call(et,this.length>=0?this:[],arguments)},true)}if(!Qe(Array.prototype.map)){var tt=Array.prototype.map;Z(Array.prototype,"map",function map(e){return re.Call(tt,this.length>=0?this:[],arguments)},true)}if(!Qe(Array.prototype.filter)){var rt=Array.prototype.filter;Z(Array.prototype,"filter",function filter(e){return re.Call(rt,this.length>=0?this:[],arguments)},true)}if(!Qe(Array.prototype.some)){var nt=Array.prototype.some;Z(Array.prototype,"some",function some(e){return re.Call(nt,this.length>=0?this:[],arguments)},true)}if(!Qe(Array.prototype.every)){var ot=Array.prototype.every;Z(Array.prototype,"every",function every(e){return re.Call(ot,this.length>=0?this:[],arguments)},true)}if(!Qe(Array.prototype.reduce)){var it=Array.prototype.reduce;Z(Array.prototype,"reduce",function reduce(e){return re.Call(it,this.length>=0?this:[],arguments)},true)}if(!Qe(Array.prototype.reduceRight,true)){var at=Array.prototype.reduceRight;Z(Array.prototype,"reduceRight",function reduceRight(e){return re.Call(at,this.length>=0?this:[],arguments)},true)}var ut=Number("0o10")!==8;var ft=Number("0b10")!==2;var st=y(De,function(e){return Number(e+0+e)===0});if(ut||ft||st){var ct=Number;var lt=/^0b[01]+$/i;var pt=/^0o[0-7]+$/i;var vt=lt.test.bind(lt);var yt=pt.test.bind(pt);var ht=function(e){var t;if(typeof e.valueOf==="function"){t=e.valueOf();if(K.primitive(t)){return t}}if(typeof e.toString==="function"){t=e.toString();if(K.primitive(t)){return t}}throw new TypeError("No default value")};var bt=ze.test.bind(ze);var gt=qe.test.bind(qe);var dt=function(){var e=function Number(t){var r;if(arguments.length>0){r=K.primitive(t)?t:ht(t,"number")}else{r=0}if(typeof r==="string"){r=re.Call(Le,r);if(vt(r)){r=parseInt(C(r,2),2)}else if(yt(r)){r=parseInt(C(r,2),8)}else if(bt(r)||gt(r)){r=NaN}}var n=this;var o=a(function(){ct.prototype.valueOf.call(n);return true});if(n instanceof e&&!o){return new ct(r)}return ct(r)};return e}();de(ct,dt,{});b(dt,{NaN:ct.NaN,MAX_VALUE:ct.MAX_VALUE,MIN_VALUE:ct.MIN_VALUE,NEGATIVE_INFINITY:ct.NEGATIVE_INFINITY,POSITIVE_INFINITY:ct.POSITIVE_INFINITY});Number=dt;O.redefine(S,"Number",dt)}var Ot=Math.pow(2,53)-1;b(Number,{MAX_SAFE_INTEGER:Ot,MIN_SAFE_INTEGER:-Ot,EPSILON:2.220446049250313e-16,parseInt:S.parseInt,parseFloat:S.parseFloat,isFinite:B,isInteger:function isInteger(e){return B(e)&&re.ToInteger(e)===e},isSafeInteger:function isSafeInteger(e){return Number.isInteger(e)&&k(e)<=Number.MAX_SAFE_INTEGER},isNaN:V});h(Number,"parseInt",S.parseInt,Number.parseInt!==S.parseInt);if(![,1].find(function(e,t){return t===0})){Z(Array.prototype,"find",$e.find)}if([,1].findIndex(function(e,t){return t===0})!==0){Z(Array.prototype,"findIndex",$e.findIndex)}var mt=Function.bind.call(Function.bind,Object.prototype.propertyIsEnumerable);var wt=function ensureEnumerable(e,t){if(s&&mt(e,t)){Object.defineProperty(e,t,{enumerable:false})}};var jt=function sliceArgs(){var e=Number(this);var t=arguments.length;var r=t-e;var n=new Array(r<0?0:r);for(var o=e;o1){return NaN}if(t===-1){return-Infinity}if(t===1){return Infinity}if(t===0){return t}return.5*L((1+t)/(1-t))},cbrt:function cbrt(e){var t=Number(e);if(t===0){return t}var r=t<0;var n;if(r){t=-t}if(t===Infinity){n=Infinity}else{n=F(L(t)/3);n=(t/(n*n)+2*n)/3}return r?-n:n},clz32:function clz32(e){var t=Number(e);var r=re.ToUint32(t);if(r===0){return 32}return Or?re.Call(Or,r):31-_(L(r+.5)*gr)},cosh:function cosh(e){var t=Number(e);if(t===0){return 1}if(V(t)){return NaN}if(!T(t)){return Infinity}if(t<0){t=-t}if(t>21){return F(t)/2}return(F(t)+F(-t))/2},expm1:function expm1(e){var t=Number(e);if(t===-Infinity){return-1}if(!T(t)||t===0){return t}if(k(t)>.5){return F(t)-1}var r=t;var n=0;var o=1;while(n+r!==n){n+=r;o+=1;r*=t/o}return n},hypot:function hypot(e,t){var r=0;var n=0;for(var o=0;o0?i/n*(i/n):i}}return n===Infinity?Infinity:n*D(r)},log2:function log2(e){return L(e)*gr},log10:function log10(e){return L(e)*dr},log1p:function log1p(e){var t=Number(e);if(t<-1||V(t)){return NaN}if(t===0||t===Infinity){return t}if(t===-1){return-Infinity}return 1+t-1===0?t:t*(L(1+t)/(1+t-1))},sign:$,sinh:function sinh(e){var t=Number(e);if(!T(t)||t===0){return t}if(k(t)<1){return(Math.expm1(t)-Math.expm1(-t))/2}return(F(t-1)-F(-t-1))*br/2},tanh:function tanh(e){var t=Number(e);if(V(t)||t===0){return t}if(t>=20){return 1}if(t<=-20){return-1}return(Math.expm1(t)-Math.expm1(-t))/(F(t)+F(-t))},trunc:function trunc(e){var t=Number(e);return t<0?-_(-t):_(t)},imul:function imul(e,t){var r=re.ToUint32(e);var n=re.ToUint32(t);var o=r>>>16&65535;var i=r&65535;var a=n>>>16&65535;var u=n&65535;return i*u+(o*u+i*a<<16>>>0)|0},fround:function fround(e){var t=Number(e);if(t===0||t===Infinity||t===-Infinity||V(t)){return t}var r=$(t);var n=k(t);if(nyr||V(i)){return r*Infinity}return r*i}};b(Math,mr);h(Math,"log1p",mr.log1p,Math.log1p(-1e-17)!==-1e-17);h(Math,"asinh",mr.asinh,Math.asinh(-1e7)!==-Math.asinh(1e7));h(Math,"tanh",mr.tanh,Math.tanh(-2e-17)!==-2e-17);h(Math,"acosh",mr.acosh,Math.acosh(Number.MAX_VALUE)===Infinity);h(Math,"cbrt",mr.cbrt,Math.abs(1-Math.cbrt(1e-300)/1e-100)/Number.EPSILON>8);h(Math,"sinh",mr.sinh,Math.sinh(-2e-17)!==-2e-17);var wr=Math.expm1(10);h(Math,"expm1",mr.expm1,wr>22025.465794806718||wr<22025.465794806718);var jr=Math.round;var Sr=Math.round(.5-Number.EPSILON/4)===0&&Math.round(-.5+Number.EPSILON/3.99)===1;var Tr=lr+1;var Ir=2*lr-1;var Er=[Tr,Ir].every(function(e){return Math.round(e)===e});h(Math,"round",function round(e){var t=_(e);var r=t===-1?-0:t+1;return e-t<.5?t:r},!Sr||!Er);O.preserveToString(Math.round,jr);var Pr=Math.imul;if(Math.imul(4294967295,5)!==-5){Math.imul=mr.imul;O.preserveToString(Math.imul,Pr)}if(Math.imul.length!==2){Z(Math,"imul",function imul(e,t){return re.Call(Pr,Math,arguments); -})}var Cr=function(){var e=S.setTimeout;if(typeof e!=="function"&&typeof e!=="object"){return}re.IsPromise=function(e){if(!re.TypeIsObject(e)){return false}if(typeof e._promise==="undefined"){return false}return true};var r=function(e){if(!re.IsConstructor(e)){throw new TypeError("Bad promise constructor")}var t=this;var r=function(e,r){if(t.resolve!==void 0||t.reject!==void 0){throw new TypeError("Bad Promise implementation!")}t.resolve=e;t.reject=r};t.resolve=void 0;t.reject=void 0;t.promise=new e(r);if(!(re.IsCallable(t.resolve)&&re.IsCallable(t.reject))){throw new TypeError("Bad promise constructor")}};var n;if(typeof window!=="undefined"&&re.IsCallable(window.postMessage)){n=function(){var e=[];var t="zero-timeout-message";var r=function(r){M(e,r);window.postMessage(t,"*")};var n=function(r){if(r.source===window&&r.data===t){r.stopPropagation();if(e.length===0){return}var n=N(e);n()}};window.addEventListener("message",n,true);return r}}var o=function(){var e=S.Promise;var t=e&&e.resolve&&e.resolve();return t&&function(e){return t.then(e)}};var i=re.IsCallable(S.setImmediate)?S.setImmediate:typeof process==="object"&&process.nextTick?process.nextTick:o()||(re.IsCallable(n)?n():function(t){e(t,0)});var a=function(e){return e};var u=function(e){throw e};var f=0;var s=1;var c=2;var l=0;var p=1;var v=2;var y={};var h=function(e,t,r){i(function(){g(e,t,r)})};var g=function(e,t,r){var n,o;if(t===y){return e(r)}try{n=e(r);o=t.resolve}catch(i){n=i;o=t.reject}o(n)};var d=function(e,t){var r=e._promise;var n=r.reactionLength;if(n>0){h(r.fulfillReactionHandler0,r.reactionCapability0,t);r.fulfillReactionHandler0=void 0;r.rejectReactions0=void 0;r.reactionCapability0=void 0;if(n>1){for(var o=1,i=0;o0){h(r.rejectReactionHandler0,r.reactionCapability0,t);r.fulfillReactionHandler0=void 0;r.rejectReactions0=void 0;r.reactionCapability0=void 0;if(n>1){for(var o=1,i=0;o2&&arguments[2]===y;if(b&&o===E){i=y}else{i=new r(o)}var g=re.IsCallable(e)?e:a;var d=re.IsCallable(t)?t:u;var O=n._promise;var m;if(O.state===f){if(O.reactionLength===0){O.fulfillReactionHandler0=g;O.rejectReactionHandler0=d;O.reactionCapability0=i}else{var w=3*(O.reactionLength-1);O[w+l]=g;O[w+p]=d;O[w+v]=i}O.reactionLength+=1}else if(O.state===s){m=O.result;h(g,i,m)}else if(O.state===c){m=O.result;h(d,i,m)}else{throw new TypeError("unexpected Promise state")}return i.promise}});y=new r(E);I=T.then;return E}();if(S.Promise){delete S.Promise.accept;delete S.Promise.defer;delete S.Promise.prototype.chain}if(typeof Cr==="function"){b(S,{Promise:Cr});var Mr=w(S.Promise,function(e){return e.resolve(42).then(function(){})instanceof e});var xr=!i(function(){S.Promise.reject(42).then(null,5).then(null,W)});var Nr=i(function(){S.Promise.call(3,W)});var Ar=function(e){var t=e.resolve(5);t.constructor={};var r=e.resolve(t);try{r.then(null,W).then(null,W)}catch(n){return true}return t===r}(S.Promise);var Rr=s&&function(){var e=0;var t=Object.defineProperty({},"then",{get:function(){e+=1}});Promise.resolve(t);return e===1}();var _r=function BadResolverPromise(e){var t=new Promise(e);e(3,function(){});this.then=t.then;this.constructor=BadResolverPromise};_r.prototype=Promise.prototype;_r.all=Promise.all;var kr=a(function(){return!!_r.all([1,2])});if(!Mr||!xr||!Nr||Ar||!Rr||kr){Promise=Cr;Z(S,"Promise",Cr)}if(Promise.all.length!==1){var Fr=Promise.all;Z(Promise,"all",function all(e){return re.Call(Fr,this,arguments)})}if(Promise.race.length!==1){var Lr=Promise.race;Z(Promise,"race",function race(e){return re.Call(Lr,this,arguments)})}if(Promise.resolve.length!==1){var Dr=Promise.resolve;Z(Promise,"resolve",function resolve(e){return re.Call(Dr,this,arguments)})}if(Promise.reject.length!==1){var zr=Promise.reject;Z(Promise,"reject",function reject(e){return re.Call(zr,this,arguments)})}wt(Promise,"all");wt(Promise,"race");wt(Promise,"resolve");wt(Promise,"reject");me(Promise)}var qr=function(e){var t=n(p(e,function(e,t){e[t]=true;return e},{}));return e.join(":")===t.join(":")};var Wr=qr(["z","a","bb"]);var Gr=qr(["z",1,"a","3",2]);if(s){var Hr=function fastkey(e){if(!Wr){return null}if(typeof e==="undefined"||e===null){return"^"+re.ToString(e)}else if(typeof e==="string"){return"$"+e}else if(typeof e==="number"){if(!Gr){return"n"+e}return e}else if(typeof e==="boolean"){return"b"+e}return null};var Vr=function emptyObject(){return Object.create?Object.create(null):{}};var Br=function addIterableToMap(e,n,o){if(r(o)||K.string(o)){l(o,function(e){if(!re.TypeIsObject(e)){throw new TypeError("Iterator value "+e+" is not an entry object")}n.set(e[0],e[1])})}else if(o instanceof e){t(e.prototype.forEach,o,function(e,t){n.set(t,e)})}else{var i,a;if(o!==null&&typeof o!=="undefined"){a=n.set;if(!re.IsCallable(a)){throw new TypeError("bad map")}i=re.GetIterator(o)}if(typeof i!=="undefined"){while(true){var u=re.IteratorStep(i);if(u===false){break}var f=u.value;try{if(!re.TypeIsObject(f)){throw new TypeError("Iterator value "+f+" is not an entry object")}t(a,n,f[0],f[1])}catch(s){re.IteratorClose(i,true);throw s}}}}};var $r=function addIterableToSet(e,n,o){if(r(o)||K.string(o)){l(o,function(e){n.add(e)})}else if(o instanceof e){t(e.prototype.forEach,o,function(e){n.add(e)})}else{var i,a;if(o!==null&&typeof o!=="undefined"){a=n.add;if(!re.IsCallable(a)){throw new TypeError("bad set")}i=re.GetIterator(o)}if(typeof i!=="undefined"){while(true){var u=re.IteratorStep(i);if(u===false){break}var f=u.value;try{t(a,n,f)}catch(s){re.IteratorClose(i,true);throw s}}}}};var Ur={Map:function(){var e={};var r=function MapEntry(e,t){this.key=e;this.value=t;this.next=null;this.prev=null};r.prototype.isRemoved=function isRemoved(){return this.key===e};var n=function isMap(e){return!!e._es6map};var o=function requireMapSlot(e,t){if(!re.TypeIsObject(e)||!n(e)){throw new TypeError("Method Map.prototype."+t+" called on incompatible receiver "+re.ToString(e))}};var i=function MapIterator(e,t){o(e,"[[MapIterator]]");this.head=e._head;this.i=this.head;this.kind=t};i.prototype={next:function next(){var e=this.i;var t=this.kind;var r=this.head;if(typeof this.i==="undefined"){return Ge()}while(e.isRemoved()&&e!==r){e=e.prev}var n;while(e.next!==r){e=e.next;if(!e.isRemoved()){if(t==="key"){n=e.key}else if(t==="value"){n=e.value}else{n=[e.key,e.value]}this.i=e;return Ge(n)}}this.i=void 0;return Ge()}};we(i.prototype);var a;var u=function Map(){if(!(this instanceof Map)){throw new TypeError('Constructor Map requires "new"')}if(this&&this._es6map){throw new TypeError("Bad construction")}var e=Te(this,Map,a,{_es6map:true,_head:null,_storage:Vr(),_size:0});var t=new r(null,null);t.next=t.prev=t;e._head=t;if(arguments.length>0){Br(Map,e,arguments[0])}return e};a=u.prototype;O.getter(a,"size",function(){if(typeof this._size==="undefined"){throw new TypeError("size method called on incompatible Map")}return this._size});b(a,{get:function get(e){o(this,"get");var t=Hr(e);if(t!==null){var r=this._storage[t];if(r){return r.value}else{return}}var n=this._head;var i=n;while((i=i.next)!==n){if(re.SameValueZero(i.key,e)){return i.value}}},has:function has(e){o(this,"has");var t=Hr(e);if(t!==null){return typeof this._storage[t]!=="undefined"}var r=this._head;var n=r;while((n=n.next)!==r){if(re.SameValueZero(n.key,e)){return true}}return false},set:function set(e,t){o(this,"set");var n=this._head;var i=n;var a;var u=Hr(e);if(u!==null){if(typeof this._storage[u]!=="undefined"){this._storage[u].value=t;return this}else{a=this._storage[u]=new r(e,t);i=n.prev}}while((i=i.next)!==n){if(re.SameValueZero(i.key,e)){i.value=t;return this}}a=a||new r(e,t);if(re.SameValue(-0,e)){a.key=+0}a.next=this._head;a.prev=this._head.prev;a.prev.next=a;a.next.prev=a;this._size+=1;return this},"delete":function(t){o(this,"delete");var r=this._head;var n=r;var i=Hr(t);if(i!==null){if(typeof this._storage[i]==="undefined"){return false}n=this._storage[i].prev;delete this._storage[i]}while((n=n.next)!==r){if(re.SameValueZero(n.key,t)){n.key=n.value=e;n.prev.next=n.next;n.next.prev=n.prev;this._size-=1;return true}}return false},clear:function clear(){o(this,"clear");this._size=0;this._storage=Vr();var t=this._head;var r=t;var n=r.next;while((r=n)!==t){r.key=r.value=e;n=r.next;r.next=r.prev=t}t.next=t.prev=t},keys:function keys(){o(this,"keys");return new i(this,"key")},values:function values(){o(this,"values");return new i(this,"value")},entries:function entries(){o(this,"entries");return new i(this,"key+value")},forEach:function forEach(e){o(this,"forEach");var r=arguments.length>1?arguments[1]:null;var n=this.entries();for(var i=n.next();!i.done;i=n.next()){if(r){t(e,r,i.value[1],i.value[0],this)}else{e(i.value[1],i.value[0],this)}}}});we(a,a.entries);return u}(),Set:function(){var e=function isSet(e){return e._es6set&&typeof e._storage!=="undefined"};var r=function requireSetSlot(t,r){if(!re.TypeIsObject(t)||!e(t)){throw new TypeError("Set.prototype."+r+" called on incompatible receiver "+re.ToString(t))}};var o;var i=function Set(){if(!(this instanceof Set)){throw new TypeError('Constructor Set requires "new"')}if(this&&this._es6set){throw new TypeError("Bad construction")}var e=Te(this,Set,o,{_es6set:true,"[[SetData]]":null,_storage:Vr()});if(!e._es6set){throw new TypeError("bad set")}if(arguments.length>0){$r(Set,e,arguments[0])}return e};o=i.prototype;var a=function(e){var t=e;if(t==="^null"){return null}else if(t==="^undefined"){return void 0}else{var r=t.charAt(0);if(r==="$"){return C(t,1)}else if(r==="n"){return+C(t,1)}else if(r==="b"){return t==="btrue"}}return+t};var u=function ensureMap(e){if(!e["[[SetData]]"]){var t=e["[[SetData]]"]=new Ur.Map;l(n(e._storage),function(e){var r=a(e);t.set(r,r)});e["[[SetData]]"]=t}e._storage=null};O.getter(i.prototype,"size",function(){r(this,"size");if(this._storage){return n(this._storage).length}u(this);return this["[[SetData]]"].size});b(i.prototype,{has:function has(e){r(this,"has");var t;if(this._storage&&(t=Hr(e))!==null){return!!this._storage[t]}u(this);return this["[[SetData]]"].has(e)},add:function add(e){r(this,"add");var t;if(this._storage&&(t=Hr(e))!==null){this._storage[t]=true;return this}u(this);this["[[SetData]]"].set(e,e);return this},"delete":function(e){r(this,"delete");var t;if(this._storage&&(t=Hr(e))!==null){var n=z(this._storage,t);return delete this._storage[t]&&n}u(this);return this["[[SetData]]"]["delete"](e)},clear:function clear(){r(this,"clear");if(this._storage){this._storage=Vr()}if(this["[[SetData]]"]){this["[[SetData]]"].clear()}},values:function values(){r(this,"values");u(this);return this["[[SetData]]"].values()},entries:function entries(){r(this,"entries");u(this);return this["[[SetData]]"].entries()},forEach:function forEach(e){r(this,"forEach");var n=arguments.length>1?arguments[1]:null;var o=this;u(o);this["[[SetData]]"].forEach(function(r,i){if(n){t(e,n,i,i,o)}else{e(i,i,o)}})}});h(i.prototype,"keys",i.prototype.values,true);we(i.prototype,i.prototype.values);return i}()};if(S.Map||S.Set){var Jr=a(function(){return new Map([[1,2]]).get(1)===2});if(!Jr){var Xr=S.Map;S.Map=function Map(){if(!(this instanceof Map)){throw new TypeError('Constructor Map requires "new"')}var e=new Xr;if(arguments.length>0){Br(Map,e,arguments[0])}delete e.constructor;Object.setPrototypeOf(e,S.Map.prototype);return e};S.Map.prototype=m(Xr.prototype);h(S.Map.prototype,"constructor",S.Map,true);O.preserveToString(S.Map,Xr)}var Kr=new Map;var Zr=function(){var e=new Map([[1,0],[2,0],[3,0],[4,0]]);e.set(-0,e);return e.get(0)===e&&e.get(-0)===e&&e.has(0)&&e.has(-0)}();var Yr=Kr.set(1,2)===Kr;if(!Zr||!Yr){var Qr=Map.prototype.set;Z(Map.prototype,"set",function set(e,r){t(Qr,this,e===0?0:e,r);return this})}if(!Zr){var en=Map.prototype.get;var tn=Map.prototype.has;b(Map.prototype,{get:function get(e){return t(en,this,e===0?0:e)},has:function has(e){return t(tn,this,e===0?0:e)}},true);O.preserveToString(Map.prototype.get,en);O.preserveToString(Map.prototype.has,tn)}var rn=new Set;var nn=function(e){e["delete"](0);e.add(-0);return!e.has(0)}(rn);var on=rn.add(1)===rn;if(!nn||!on){var an=Set.prototype.add;Set.prototype.add=function add(e){t(an,this,e===0?0:e);return this};O.preserveToString(Set.prototype.add,an)}if(!nn){var un=Set.prototype.has;Set.prototype.has=function has(e){return t(un,this,e===0?0:e)};O.preserveToString(Set.prototype.has,un);var fn=Set.prototype["delete"];Set.prototype["delete"]=function SetDelete(e){return t(fn,this,e===0?0:e)};O.preserveToString(Set.prototype["delete"],fn)}var sn=w(S.Map,function(e){var t=new e([]);t.set(42,42);return t instanceof e});var cn=Object.setPrototypeOf&&!sn;var ln=function(){try{return!(S.Map()instanceof S.Map)}catch(e){return e instanceof TypeError}}();if(S.Map.length!==0||cn||!ln){var pn=S.Map;S.Map=function Map(){if(!(this instanceof Map)){throw new TypeError('Constructor Map requires "new"')}var e=new pn;if(arguments.length>0){Br(Map,e,arguments[0])}delete e.constructor;Object.setPrototypeOf(e,Map.prototype);return e};S.Map.prototype=pn.prototype;h(S.Map.prototype,"constructor",S.Map,true);O.preserveToString(S.Map,pn)}var vn=w(S.Set,function(e){var t=new e([]);t.add(42,42);return t instanceof e});var yn=Object.setPrototypeOf&&!vn;var hn=function(){try{return!(S.Set()instanceof S.Set)}catch(e){return e instanceof TypeError}}();if(S.Set.length!==0||yn||!hn){var bn=S.Set;S.Set=function Set(){if(!(this instanceof Set)){throw new TypeError('Constructor Set requires "new"')}var e=new bn;if(arguments.length>0){$r(Set,e,arguments[0])}delete e.constructor;Object.setPrototypeOf(e,Set.prototype);return e};S.Set.prototype=bn.prototype;h(S.Set.prototype,"constructor",S.Set,true);O.preserveToString(S.Set,bn)}var gn=new S.Map;var dn=!a(function(){return gn.keys().next().done});if(typeof S.Map.prototype.clear!=="function"||(new S.Set).size!==0||gn.size!==0||typeof S.Map.prototype.keys!=="function"||typeof S.Set.prototype.keys!=="function"||typeof S.Map.prototype.forEach!=="function"||typeof S.Set.prototype.forEach!=="function"||u(S.Map)||u(S.Set)||typeof gn.keys().next!=="function"||dn||!sn){b(S,{Map:Ur.Map,Set:Ur.Set},true)}if(S.Set.prototype.keys!==S.Set.prototype.values){h(S.Set.prototype,"keys",S.Set.prototype.values,true)}we(Object.getPrototypeOf((new S.Map).keys()));we(Object.getPrototypeOf((new S.Set).keys()));if(c&&S.Set.prototype.has.name!=="has"){var On=S.Set.prototype.has;Z(S.Set.prototype,"has",function has(e){return t(On,this,e)})}}b(S,Ur);me(S.Map);me(S.Set)}var mn=function throwUnlessTargetIsObject(e){if(!re.TypeIsObject(e)){throw new TypeError("target must be an object")}};var wn={apply:function apply(){return re.Call(re.Call,null,arguments)},construct:function construct(e,t){if(!re.IsConstructor(e)){throw new TypeError("First argument must be a constructor.")}var r=arguments.length>2?arguments[2]:e;if(!re.IsConstructor(r)){throw new TypeError("new.target must be a constructor.")}return re.Construct(e,t,r,"internal")},deleteProperty:function deleteProperty(e,t){mn(e);if(s){var r=Object.getOwnPropertyDescriptor(e,t);if(r&&!r.configurable){return false}}return delete e[t]},has:function has(e,t){mn(e);return t in e}};if(Object.getOwnPropertyNames){Object.assign(wn,{ownKeys:function ownKeys(e){mn(e);var t=Object.getOwnPropertyNames(e);if(re.IsCallable(Object.getOwnPropertySymbols)){x(t,Object.getOwnPropertySymbols(e))}return t}})}var jn=function ConvertExceptionToBoolean(e){return!i(e)};if(Object.preventExtensions){Object.assign(wn,{isExtensible:function isExtensible(e){mn(e);return Object.isExtensible(e)},preventExtensions:function preventExtensions(e){mn(e);return jn(function(){Object.preventExtensions(e)})}})}if(s){var Sn=function get(e,t,r){var n=Object.getOwnPropertyDescriptor(e,t);if(!n){var o=Object.getPrototypeOf(e);if(o===null){return void 0}return Sn(o,t,r)}if("value"in n){return n.value}if(n.get){return re.Call(n.get,r)}return void 0};var Tn=function set(e,r,n,o){var i=Object.getOwnPropertyDescriptor(e,r);if(!i){var a=Object.getPrototypeOf(e);if(a!==null){return Tn(a,r,n,o)}i={value:void 0,writable:true,enumerable:true,configurable:true}}if("value"in i){if(!i.writable){return false}if(!re.TypeIsObject(o)){return false}var u=Object.getOwnPropertyDescriptor(o,r);if(u){return ee.defineProperty(o,r,{value:n})}else{return ee.defineProperty(o,r,{value:n,writable:true,enumerable:true,configurable:true})}}if(i.set){t(i.set,o,n);return true}return false};Object.assign(wn,{defineProperty:function defineProperty(e,t,r){mn(e);return jn(function(){Object.defineProperty(e,t,r)})},getOwnPropertyDescriptor:function getOwnPropertyDescriptor(e,t){mn(e);return Object.getOwnPropertyDescriptor(e,t)},get:function get(e,t){mn(e);var r=arguments.length>2?arguments[2]:e;return Sn(e,t,r)},set:function set(e,t,r){mn(e);var n=arguments.length>3?arguments[3]:e;return Tn(e,t,r,n)}})}if(Object.getPrototypeOf){var In=Object.getPrototypeOf;wn.getPrototypeOf=function getPrototypeOf(e){mn(e);return In(e)}}if(Object.setPrototypeOf&&wn.getPrototypeOf){var En=function(e,t){var r=t;while(r){if(e===r){return true}r=wn.getPrototypeOf(r)}return false};Object.assign(wn,{setPrototypeOf:function setPrototypeOf(e,t){mn(e);if(t!==null&&!re.TypeIsObject(t)){throw new TypeError("proto must be an object or null")}if(t===ee.getPrototypeOf(e)){return true}if(ee.isExtensible&&!ee.isExtensible(e)){return false}if(En(e,t)){return false}Object.setPrototypeOf(e,t);return true}})}var Pn=function(e,t){if(!re.IsCallable(S.Reflect[e])){h(S.Reflect,e,t)}else{var r=a(function(){S.Reflect[e](1);S.Reflect[e](NaN);S.Reflect[e](true);return true});if(r){Z(S.Reflect,e,t)}}};Object.keys(wn).forEach(function(e){Pn(e,wn[e])});var Cn=S.Reflect.getPrototypeOf;if(c&&Cn&&Cn.name!=="getPrototypeOf"){Z(S.Reflect,"getPrototypeOf",function getPrototypeOf(e){return t(Cn,S.Reflect,e)})}if(S.Reflect.setPrototypeOf){if(a(function(){S.Reflect.setPrototypeOf(1,{});return true})){Z(S.Reflect,"setPrototypeOf",wn.setPrototypeOf)}}if(S.Reflect.defineProperty){if(!a(function(){var e=!S.Reflect.defineProperty(1,"test",{value:1});var t=typeof Object.preventExtensions!=="function"||!S.Reflect.defineProperty(Object.preventExtensions({}),"test",{});return e&&t})){Z(S.Reflect,"defineProperty",wn.defineProperty)}}if(S.Reflect.construct){if(!a(function(){var e=function F(){};return S.Reflect.construct(function(){},[],e)instanceof e})){Z(S.Reflect,"construct",wn.construct)}}if(String(new Date(NaN))!=="Invalid Date"){var Mn=Date.prototype.toString;var xn=function toString(){var e=+this;if(e!==e){return"Invalid Date"}return re.Call(Mn,this)};Z(Date.prototype,"toString",xn)}var Nn={anchor:function anchor(e){return re.CreateHTML(this,"a","name",e)},big:function big(){return re.CreateHTML(this,"big","","")},blink:function blink(){return re.CreateHTML(this,"blink","","")},bold:function bold(){return re.CreateHTML(this,"b","","")},fixed:function fixed(){return re.CreateHTML(this,"tt","","")},fontcolor:function fontcolor(e){return re.CreateHTML(this,"font","color",e)},fontsize:function fontsize(e){return re.CreateHTML(this,"font","size",e)},italics:function italics(){return re.CreateHTML(this,"i","","")},link:function link(e){return re.CreateHTML(this,"a","href",e)},small:function small(){return re.CreateHTML(this,"small","","")},strike:function strike(){return re.CreateHTML(this,"strike","","")},sub:function sub(){return re.CreateHTML(this,"sub","","")},sup:function sub(){return re.CreateHTML(this,"sup","","")}};l(Object.keys(Nn),function(e){var r=String.prototype[e];var n=false;if(re.IsCallable(r)){var o=t(r,"",' " ');var i=P([],o.match(/"/g)).length;n=o!==o.toLowerCase()||i>2}else{n=true}if(n){Z(String.prototype,e,Nn[e])}});var An=function(){if(!Y){return false}var e=typeof JSON==="object"&&typeof JSON.stringify==="function"?JSON.stringify:null;if(!e){return false}if(typeof e(G())!=="undefined"){return true}if(e([G()])!=="[null]"){return true}var t={a:G()};t[G()]=true;if(e(t)!=="{}"){return true}return false}();var Rn=a(function(){if(!Y){return true}return JSON.stringify(Object(G()))==="{}"&&JSON.stringify([Object(G())])==="[{}]"});if(An||!Rn){var _n=JSON.stringify;Z(JSON,"stringify",function stringify(e){if(typeof e==="symbol"){return}var n;if(arguments.length>1){n=arguments[1]}var o=[e];if(!r(n)){var i=re.IsCallable(n)?n:null;var a=function(e,r){var n=i?t(i,this,e,r):r;if(typeof n!=="symbol"){if(K.symbol(n)){return St({})(n)}else{return n}}};o.push(a)}else{o.push(n)}if(arguments.length>2){o.push(arguments[2])}return _n.apply(this,o)})}return S}); -//# sourceMappingURL=es6-shim.map diff --git a/accessible/media/accessible.css b/accessible/media/accessible.css deleted file mode 100644 index 500ee73231a..00000000000 --- a/accessible/media/accessible.css +++ /dev/null @@ -1,80 +0,0 @@ -.blocklyWorkspaceColumn { - float: left; - margin-right: 20px; - width: 800px; -} -.blocklySidebarColumn { - border-left: 1px solid #888; - float: left; - padding-left: 20px; - margin-top: 20px; - min-height: 700px; - width: 200px; -} - -.blocklySidebarButton { - background-color: #fff; - border: 1px solid #333; - border-radius: 4px; - color: #000; - font-size: 1em; - margin: 10px 0 10px 30px; - padding: 10px; - text-align: center; - vertical-align: middle; - white-space: nowrap; -} -.blocklySidebarButton[disabled] { - border: 1px solid #ccc; - opacity: 0.5; -} - -.blocklyAriaLiveStatus { - background: #c8f7be; - border-radius: 10px; - bottom: 80px; - left: 20px; - max-width: 275px; - padding: 10px; - position: fixed; -} - -.blocklyTree .blocklyActiveDescendant > label, -.blocklyTree .blocklyActiveDescendant > div > label, -.blocklyActiveDescendant > button, -.blocklyActiveDescendant > input, -.blocklyActiveDescendant > select, -.blocklyActiveDescendant > blockly-field-segment > label, -.blocklyActiveDescendant > blockly-field-segment > input, -.blocklyActiveDescendant > blockly-field-segment > select { - outline: 2px dotted #00f; -} - -.blocklyDropdownListItem[aria-selected="true"] button { - font-weight: bold; -} - -.blocklyModalCurtain { - background-color: rgba(0,0,0,0.4); - height: 100%; - left: 0; - overflow: auto; - position: fixed; - top: 0; - width: 100%; - z-index: 1; -} -.blocklyModal { - background-color: #fefefe; - border: 1px solid #888; - margin: 10% auto; - max-width: 600px; - padding: 20px; - width: 60%; -} -.blocklyModalButtonContainer { - margin: 10px 0; -} -.blocklyModal .activeButton { - border: 1px solid blue; -} diff --git a/accessible/media/click.mp3 b/accessible/media/click.mp3 deleted file mode 100644 index 4534b0ddca7..00000000000 Binary files a/accessible/media/click.mp3 and /dev/null differ diff --git a/accessible/media/click.ogg b/accessible/media/click.ogg deleted file mode 100644 index e8ae42a6106..00000000000 Binary files a/accessible/media/click.ogg and /dev/null differ diff --git a/accessible/media/click.wav b/accessible/media/click.wav deleted file mode 100644 index 41a50cd76f5..00000000000 Binary files a/accessible/media/click.wav and /dev/null differ diff --git a/accessible/media/delete.mp3 b/accessible/media/delete.mp3 deleted file mode 100644 index 1e71bdcf498..00000000000 Binary files a/accessible/media/delete.mp3 and /dev/null differ diff --git a/accessible/media/delete.ogg b/accessible/media/delete.ogg deleted file mode 100644 index a65b1122839..00000000000 Binary files a/accessible/media/delete.ogg and /dev/null differ diff --git a/accessible/media/delete.wav b/accessible/media/delete.wav deleted file mode 100644 index 455bcd3bb11..00000000000 Binary files a/accessible/media/delete.wav and /dev/null differ diff --git a/accessible/media/oops.mp3 b/accessible/media/oops.mp3 deleted file mode 100644 index 0c9507140b5..00000000000 Binary files a/accessible/media/oops.mp3 and /dev/null differ diff --git a/accessible/media/oops.ogg b/accessible/media/oops.ogg deleted file mode 100644 index 7bac05d97ed..00000000000 Binary files a/accessible/media/oops.ogg and /dev/null differ diff --git a/accessible/media/oops.wav b/accessible/media/oops.wav deleted file mode 100644 index 163df4f1cf7..00000000000 Binary files a/accessible/media/oops.wav and /dev/null differ diff --git a/accessible/messages.js b/accessible/messages.js deleted file mode 100644 index 60c51d34415..00000000000 --- a/accessible/messages.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @license - * Visual Blocks Language - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Translatable string constants for Accessible Blockly. - * @author madeeha@google.com (Madeeha Ghori) - */ -'use strict'; - -Blockly.Msg.WORKSPACE = 'Workspace'; -Blockly.Msg.WORKSPACE_BLOCK = - 'workspace block. Move right to edit. Press Enter for more options.'; - -Blockly.Msg.ATTACH_NEW_BLOCK_TO_LINK = 'Attach new block to link...'; -Blockly.Msg.CREATE_NEW_BLOCK_GROUP = 'Create new block group...'; -Blockly.Msg.ERASE_WORKSPACE = 'Erase Workspace'; -Blockly.Msg.NO_BLOCKS_IN_WORKSPACE = 'There are no blocks in the workspace.'; - -Blockly.Msg.COPY_BLOCK = 'Copy block'; -Blockly.Msg.DELETE = 'Delete block'; -Blockly.Msg.MARK_SPOT_BEFORE = 'Add link before'; -Blockly.Msg.MARK_SPOT_AFTER = 'Add link after'; -Blockly.Msg.MARK_THIS_SPOT = 'Add link inside'; -Blockly.Msg.MOVE_TO_MARKED_SPOT = 'Move to existing link'; -Blockly.Msg.PASTE_AFTER = 'Paste after'; -Blockly.Msg.PASTE_BEFORE = 'Paste before'; -Blockly.Msg.PASTE_INSIDE = 'Paste inside'; - -Blockly.Msg.BLOCK_OPTIONS = 'Block Options'; -Blockly.Msg.SELECT_A_BLOCK = 'Select a block...'; -Blockly.Msg.CANCEL = 'Cancel'; - -Blockly.Msg.ANY = 'any'; -Blockly.Msg.BLOCK = 'block'; -Blockly.Msg.BUTTON = 'Button.'; -Blockly.Msg.FOR = 'for'; -Blockly.Msg.VALUE = 'value'; - -Blockly.Msg.ADDED_LINK_MSG = 'Added link.'; -Blockly.Msg.ATTACHED_BLOCK_TO_LINK_MSG = 'attached to link. '; -Blockly.Msg.COPIED_BLOCK_MSG = 'copied. '; -Blockly.Msg.PASTED_BLOCK_FROM_CLIPBOARD_MSG = 'pasted. '; - -Blockly.Msg.PRESS_ENTER_TO_EDIT_NUMBER = 'Press Enter to edit number. '; -Blockly.Msg.PRESS_ENTER_TO_EDIT_TEXT = 'Press Enter to edit text. '; diff --git a/accessible/notifications.service.js b/accessible/notifications.service.js deleted file mode 100644 index b1ce22b27e6..00000000000 --- a/accessible/notifications.service.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Service for updating the ARIA live region that - * allows screenreaders to notify the user about actions that they have taken. - * @author sll@google.com (Sean Lip) - */ - -goog.provide('blocklyApp.NotificationsService'); - - -blocklyApp.NotificationsService = ng.core.Class({ - constructor: [function() { - this.currentMessage = ''; - this.timeouts = []; - }], - setDisplayedMessage_: function(newMessage) { - this.currentMessage = newMessage; - }, - getDisplayedMessage: function() { - return this.currentMessage; - }, - speak: function(newMessage) { - // Clear and reset any existing timeouts. - this.timeouts.forEach(function(timeout) { - clearTimeout(timeout); - }); - this.timeouts.length = 0; - - // Clear the current message, so that if, e.g., two operations of the same - // type are performed, both messages will be read in succession. - this.setDisplayedMessage_(''); - - // We need a non-zero timeout here, otherwise NVDA does not read the - // notification messages properly. - var that = this; - this.timeouts.push(setTimeout(function() { - that.setDisplayedMessage_(newMessage); - }, 20)); - this.timeouts.push(setTimeout(function() { - that.setDisplayedMessage_(''); - }, 5000)); - } -}); diff --git a/accessible/sidebar.component.js b/accessible/sidebar.component.js deleted file mode 100644 index 66f735eda26..00000000000 --- a/accessible/sidebar.component.js +++ /dev/null @@ -1,132 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Component representing the sidebar that is shown next - * to the workspace. - * - * @author sll@google.com (Sean Lip) - */ - -goog.provide('blocklyApp.SidebarComponent'); - -goog.require('blocklyApp.UtilsService'); - -goog.require('blocklyApp.BlockConnectionService'); -goog.require('blocklyApp.ToolboxModalService'); -goog.require('blocklyApp.TranslatePipe'); -goog.require('blocklyApp.TreeService'); -goog.require('blocklyApp.VariableModalService'); - - -blocklyApp.SidebarComponent = ng.core.Component({ - selector: 'blockly-sidebar', - template: ` -
- - - - - -
- `, - pipes: [blocklyApp.TranslatePipe] -}) -.Class({ - constructor: [ - blocklyApp.BlockConnectionService, - blocklyApp.ToolboxModalService, - blocklyApp.TreeService, - blocklyApp.UtilsService, - blocklyApp.VariableModalService, - function( - blockConnectionService, toolboxModalService, treeService, - utilsService, variableService) { - // ACCESSIBLE_GLOBALS is a global variable defined by the containing - // page. It should contain a key, customSidebarButtons, describing - // additional buttons that should be displayed after the default ones. - // See README.md for details. - this.customSidebarButtons = - ACCESSIBLE_GLOBALS && ACCESSIBLE_GLOBALS.customSidebarButtons ? - ACCESSIBLE_GLOBALS.customSidebarButtons : []; - - this.blockConnectionService = blockConnectionService; - this.toolboxModalService = toolboxModalService; - this.treeService = treeService; - this.utilsService = utilsService; - this.variableModalService = variableService; - - this.ID_FOR_ATTACH_TO_LINK_BUTTON = 'blocklyAttachToLinkBtn'; - this.ID_FOR_CREATE_NEW_GROUP_BUTTON = 'blocklyCreateNewGroupBtn'; - } - ], - isAnyConnectionMarked: function() { - return this.blockConnectionService.isAnyConnectionMarked(); - }, - isWorkspaceEmpty: function() { - return this.utilsService.isWorkspaceEmpty(); - }, - hasVariableCategory: function() { - return this.toolboxModalService.toolboxHasVariableCategory(); - }, - clearWorkspace: function() { - blocklyApp.workspace.clear(); - this.treeService.clearAllActiveDescs(); - // The timeout is needed in order to give the blocks time to be cleared - // from the workspace, and for the 'workspace is empty' button to show up. - setTimeout(function() { - document.getElementById(blocklyApp.ID_FOR_EMPTY_WORKSPACE_BTN).focus(); - }, 50); - }, - showToolboxModalForAttachToMarkedConnection: function() { - this.toolboxModalService.showToolboxModalForAttachToMarkedConnection( - this.ID_FOR_ATTACH_TO_LINK_BUTTON); - }, - showToolboxModalForCreateNewGroup: function() { - this.toolboxModalService.showToolboxModalForCreateNewGroup( - this.ID_FOR_CREATE_NEW_GROUP_BUTTON); - }, - showAddVariableModal: function() { - this.variableModalService.showAddModal_("item"); - } -}); diff --git a/accessible/toolbox-modal.component.js b/accessible/toolbox-modal.component.js deleted file mode 100644 index 25358e82eef..00000000000 --- a/accessible/toolbox-modal.component.js +++ /dev/null @@ -1,188 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Component representing the toolbox modal. - * - * @author sll@google.com (Sean Lip) - */ - -goog.provide('blocklyApp.ToolboxModalComponent'); - -goog.require('Blockly.CommonModal'); -goog.require('blocklyApp.AudioService'); -goog.require('blocklyApp.KeyboardInputService'); -goog.require('blocklyApp.ToolboxModalService'); -goog.require('blocklyApp.TranslatePipe'); -goog.require('blocklyApp.TreeService'); -goog.require('blocklyApp.UtilsService'); - - -blocklyApp.ToolboxModalComponent = ng.core.Component({ - selector: 'blockly-toolbox-modal', - template: ` -
- -
-

{{'SELECT_A_BLOCK'|translate}}

- -
-

{{toolboxCategory.categoryName}}

-
- -
-
-
-
- -
-
-
- `, - pipes: [blocklyApp.TranslatePipe] -}) -.Class({ - constructor: [ - blocklyApp.ToolboxModalService, blocklyApp.KeyboardInputService, - blocklyApp.AudioService, blocklyApp.UtilsService, blocklyApp.TreeService, - function( - toolboxModalService_, keyboardInputService_, audioService_, - utilsService_, treeService_) { - this.toolboxModalService = toolboxModalService_; - this.keyboardInputService = keyboardInputService_; - this.audioService = audioService_; - this.utilsService = utilsService_; - this.treeService = treeService_; - - this.modalIsVisible = false; - this.toolboxCategories = []; - this.onSelectBlockCallback = null; - this.onDismissCallback = null; - - this.firstBlockIndexes = []; - this.activeButtonIndex = -1; - this.totalNumBlocks = 0; - - var that = this; - this.toolboxModalService.registerPreShowHook( - function( - toolboxCategories, onSelectBlockCallback, onDismissCallback) { - that.modalIsVisible = true; - that.toolboxCategories = toolboxCategories; - that.onSelectBlockCallback = onSelectBlockCallback; - that.onDismissCallback = onDismissCallback; - - // The indexes of the buttons corresponding to the first block in - // each category, as well as the 'cancel' button at the end. - that.firstBlockIndexes = []; - that.activeButtonIndex = -1; - that.totalNumBlocks = 0; - - var cumulativeIndex = 0; - that.toolboxCategories.forEach(function(category) { - that.firstBlockIndexes.push(cumulativeIndex); - cumulativeIndex += category.blocks.length; - }); - that.firstBlockIndexes.push(cumulativeIndex); - that.totalNumBlocks = cumulativeIndex; - - Blockly.CommonModal.setupKeyboardOverrides(that); - that.keyboardInputService.addOverride('13', function(evt) { - evt.preventDefault(); - evt.stopPropagation(); - - if (that.activeButtonIndex == -1) { - return; - } - - var button = document.getElementById( - that.getOptionId(that.activeButtonIndex)); - - for (var i = 0; i < that.toolboxCategories.length; i++) { - if (that.firstBlockIndexes[i + 1] > that.activeButtonIndex) { - var categoryIndex = i; - var blockIndex = - that.activeButtonIndex - that.firstBlockIndexes[i]; - var block = that.getBlock(categoryIndex, blockIndex); - that.selectBlock(block); - return; - } - } - - // The 'Cancel' button has been pressed. - that.dismissModal(); - }); - - setTimeout(function() { - document.getElementById('toolboxModal').focus(); - }, 150); - } - ); - } - ], - // Closes the modal (on both success and failure). - hideModal_: Blockly.CommonModal.hideModal, - // Focuses on the button represented by the given index. - focusOnOption: function(index) { - var button = document.getElementById(this.getOptionId(index)); - button.focus(); - }, - // Counts the number of interactive elements for the modal. - numInteractiveElements: function() { - return this.totalNumBlocks + 1; - }, - getOverallIndex: function(categoryIndex, blockIndex) { - return this.firstBlockIndexes[categoryIndex] + blockIndex; - }, - getBlock: function(categoryIndex, blockIndex) { - return this.toolboxCategories[categoryIndex].blocks[blockIndex]; - }, - getBlockDescription: function(block) { - return this.utilsService.getBlockDescription(block); - }, - // Returns the ID for the corresponding option button. - getOptionId: function(index) { - return 'toolbox-modal-option-' + index; - }, - // Returns the ID for the "cancel" option button. - getCancelOptionId: function() { - return 'toolbox-modal-option-' + this.totalNumBlocks; - }, - selectBlock: function(block) { - this.onSelectBlockCallback(block); - this.hideModal_(); - }, - // Dismisses and closes the modal. - dismissModal: function() { - this.hideModal_(); - this.onDismissCallback(); - } -}); diff --git a/accessible/toolbox-modal.service.js b/accessible/toolbox-modal.service.js deleted file mode 100644 index fa860d58dcb..00000000000 --- a/accessible/toolbox-modal.service.js +++ /dev/null @@ -1,234 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Service for the toolbox modal. - * - * @author sll@google.com (Sean Lip) - */ - -goog.provide('blocklyApp.ToolboxModalService'); - -goog.require('blocklyApp.UtilsService'); - -goog.require('blocklyApp.BlockConnectionService'); -goog.require('blocklyApp.NotificationsService'); -goog.require('blocklyApp.TreeService'); - - -blocklyApp.ToolboxModalService = ng.core.Class({ - constructor: [ - blocklyApp.BlockConnectionService, - blocklyApp.NotificationsService, - blocklyApp.TreeService, - blocklyApp.UtilsService, - function( - blockConnectionService, notificationsService, treeService, - utilsService) { - this.blockConnectionService = blockConnectionService; - this.notificationsService = notificationsService; - this.treeService = treeService; - this.utilsService = utilsService; - - this.modalIsShown = false; - - this.selectedToolboxCategories = null; - this.onSelectBlockCallback = null; - this.onDismissCallback = null; - this.hasVariableCategory = null; - // The aim of the pre-show hook is to populate the modal component with - // the information it needs to display the modal (e.g., which categories - // and blocks to display). - this.preShowHook = function() { - throw Error( - 'A pre-show hook must be defined for the toolbox modal before it ' + - 'can be shown.'); - }; - } - ], - populateToolbox_: function() { - // Populate the toolbox categories. - this.allToolboxCategories = []; - var toolboxXmlElt = document.getElementById('blockly-toolbox-xml'); - var toolboxCategoryElts = toolboxXmlElt.getElementsByTagName('category'); - if (toolboxCategoryElts.length) { - this.allToolboxCategories = Array.from(toolboxCategoryElts).map( - function(categoryElt) { - var tmpWorkspace = new Blockly.Workspace(); - var custom = categoryElt.attributes.custom - // TODO (corydiers): Implement custom flyouts once #1153 is solved. - if (custom && custom.value == Blockly.VARIABLE_CATEGORY_NAME) { - var varBlocks = - Blockly.Variables.flyoutCategoryBlocks(blocklyApp.workspace); - varBlocks.forEach(function(block) { - Blockly.Xml.domToBlock(block, tmpWorkspace); - }); - } else { - Blockly.Xml.domToWorkspace(categoryElt, tmpWorkspace); - } - return { - categoryName: categoryElt.attributes.name.value, - blocks: tmpWorkspace.topBlocks_ - }; - } - ); - this.computeCategoriesForCreateNewGroupModal_(); - } else { - // A timeout seems to be needed in order for the .children accessor to - // work correctly. - var that = this; - setTimeout(function() { - // If there are no top-level categories, we create a single category - // containing all the top-level blocks. - var tmpWorkspace = new Blockly.Workspace(); - Array.from(toolboxXmlElt.children).forEach(function(topLevelNode) { - Blockly.Xml.domToBlock(tmpWorkspace, topLevelNode); - }); - - that.allToolboxCategories = [{ - categoryName: '', - blocks: tmpWorkspace.topBlocks_ - }]; - - that.computeCategoriesForCreateNewGroupModal_(); - }); - } - }, - computeCategoriesForCreateNewGroupModal_: function() { - // Precompute toolbox categories for blocks that have no output - // connection (and that can therefore be used as the base block of a - // "create new block group" action). - this.toolboxCategoriesForNewGroup = []; - var that = this; - this.allToolboxCategories.forEach(function(toolboxCategory) { - var baseBlocks = toolboxCategory.blocks.filter(function(block) { - return !block.outputConnection; - }); - - if (baseBlocks.length > 0) { - that.toolboxCategoriesForNewGroup.push({ - categoryName: toolboxCategory.categoryName, - blocks: baseBlocks - }); - } - }); - }, - registerPreShowHook: function(preShowHook) { - var that = this; - this.preShowHook = function() { - preShowHook( - that.selectedToolboxCategories, that.onSelectBlockCallback, - that.onDismissCallback); - }; - }, - isModalShown: function() { - return this.modalIsShown; - }, - toolboxHasVariableCategory: function() { - if (this.hasVariableCategory === null) { - var toolboxXmlElt = document.getElementById('blockly-toolbox-xml'); - var toolboxCategoryElts = toolboxXmlElt.getElementsByTagName('category'); - var that = this; - Array.from(toolboxCategoryElts).forEach( - function(categoryElt) { - var custom = categoryElt.attributes.custom; - if (custom && custom.value == Blockly.VARIABLE_CATEGORY_NAME) { - that.hasVariableCategory = true; - } - }); - - if (this.hasVariableCategory === null) { - this.hasVariableCategory = false; - } - } - - return this.hasVariableCategory; - }, - showModal_: function( - selectedToolboxCategories, onSelectBlockCallback, onDismissCallback) { - this.selectedToolboxCategories = selectedToolboxCategories; - this.onSelectBlockCallback = onSelectBlockCallback; - this.onDismissCallback = onDismissCallback; - - this.preShowHook(); - this.modalIsShown = true; - }, - hideModal: function() { - this.modalIsShown = false; - }, - showToolboxModalForAttachToMarkedConnection: function(sourceButtonId) { - var that = this; - - var selectedToolboxCategories = []; - this.populateToolbox_(); - this.allToolboxCategories.forEach(function(toolboxCategory) { - var selectedBlocks = toolboxCategory.blocks.filter(function(block) { - return that.blockConnectionService.canBeAttachedToMarkedConnection( - block); - }); - - if (selectedBlocks.length > 0) { - selectedToolboxCategories.push({ - categoryName: toolboxCategory.categoryName, - blocks: selectedBlocks - }); - } - }); - - this.showModal_(selectedToolboxCategories, function(block) { - var blockDescription = that.utilsService.getBlockDescription(block); - - // Clear the active desc for the destination tree, so that it can be - // cleanly reinstated after the new block is attached. - var destinationTreeId = that.treeService.getTreeIdForBlock( - that.blockConnectionService.getMarkedConnectionSourceBlock().id); - that.treeService.clearActiveDesc(destinationTreeId); - var newBlockId = that.blockConnectionService.attachToMarkedConnection( - block); - - // Invoke a digest cycle, so that the DOM settles. - setTimeout(function() { - that.treeService.focusOnBlock(newBlockId); - that.notificationsService.speak( - 'Attached. Now on, ' + blockDescription + ', block in workspace.'); - }); - }, function() { - document.getElementById(sourceButtonId).focus(); - }); - }, - showToolboxModalForCreateNewGroup: function(sourceButtonId) { - var that = this; - this.populateToolbox_(); - this.showModal_(this.toolboxCategoriesForNewGroup, function(block) { - var blockDescription = that.utilsService.getBlockDescription(block); - var xml = Blockly.Xml.blockToDom(block); - var newBlockId = Blockly.Xml.domToBlock(blocklyApp.workspace, xml).id; - - // Invoke a digest cycle, so that the DOM settles. - setTimeout(function() { - that.treeService.focusOnBlock(newBlockId); - that.notificationsService.speak( - 'Created new group in workspace. Now on, ' + blockDescription + - ', block in workspace.'); - }); - }, function() { - document.getElementById(sourceButtonId).focus(); - }); - } -}); diff --git a/accessible/translate.pipe.js b/accessible/translate.pipe.js deleted file mode 100644 index fef1940cd1f..00000000000 --- a/accessible/translate.pipe.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Pipe for internationalizing Blockly message strings. - * @author sll@google.com (Sean Lip) - */ - -goog.provide('blocklyApp.TranslatePipe'); - - -blocklyApp.TranslatePipe = ng.core.Pipe({ - name: 'translate' -}) -.Class({ - constructor: function() {}, - transform: function(messageId) { - return Blockly.Msg[messageId]; - } -}); diff --git a/accessible/tree.service.js b/accessible/tree.service.js deleted file mode 100644 index c5a73ac4c4a..00000000000 --- a/accessible/tree.service.js +++ /dev/null @@ -1,609 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Service that handles keyboard navigation on workspace - * block groups (internally represented as trees). This is a singleton service - * for the entire application. - * - * @author madeeha@google.com (Madeeha Ghori) - */ - -goog.provide('blocklyApp.TreeService'); - -goog.require('blocklyApp.UtilsService'); - -goog.require('blocklyApp.AudioService'); -goog.require('blocklyApp.BlockConnectionService'); -goog.require('blocklyApp.BlockOptionsModalService'); -goog.require('blocklyApp.NotificationsService'); -goog.require('blocklyApp.VariableModalService'); - - -blocklyApp.TreeService = ng.core.Class({ - constructor: [ - blocklyApp.AudioService, - blocklyApp.BlockConnectionService, - blocklyApp.BlockOptionsModalService, - blocklyApp.NotificationsService, - blocklyApp.UtilsService, - blocklyApp.VariableModalService, - function( - audioService, blockConnectionService, blockOptionsModalService, - notificationsService, utilsService, variableModalService) { - this.audioService = audioService; - this.blockConnectionService = blockConnectionService; - this.blockOptionsModalService = blockOptionsModalService; - this.notificationsService = notificationsService; - this.utilsService = utilsService; - this.variableModalService = variableModalService; - - // The suffix used for all IDs of block root elements. - this.BLOCK_ROOT_ID_SUFFIX_ = blocklyApp.BLOCK_ROOT_ID_SUFFIX; - // Maps tree IDs to the IDs of their active descendants. - this.activeDescendantIds_ = {}; - // Array containing all the sidebar button elements. - this.sidebarButtonElements_ = Array.from( - document.querySelectorAll('button.blocklySidebarButton')); - } - ], - scrollToElement_: function(elementId) { - var element = document.getElementById(elementId); - var documentElement = document.body || document.documentElement; - if (element.offsetTop < documentElement.scrollTop || - element.offsetTop > documentElement.scrollTop + window.innerHeight) { - window.scrollTo(0, element.offsetTop - 10); - } - }, - - isLi_: function(node) { - return node.tagName == 'LI'; - }, - getParentLi_: function(element) { - var nextNode = element.parentNode; - while (nextNode && !this.isLi_(nextNode)) { - nextNode = nextNode.parentNode; - } - return nextNode; - }, - getFirstChildLi_: function(element) { - var childList = element.children; - for (var i = 0; i < childList.length; i++) { - if (this.isLi_(childList[i])) { - return childList[i]; - } else { - var potentialElement = this.getFirstChildLi_(childList[i]); - if (potentialElement) { - return potentialElement; - } - } - } - return null; - }, - getLastChildLi_: function(element) { - var childList = element.children; - for (var i = childList.length - 1; i >= 0; i--) { - if (this.isLi_(childList[i])) { - return childList[i]; - } else { - var potentialElement = this.getLastChildLi_(childList[i]); - if (potentialElement) { - return potentialElement; - } - } - } - return null; - }, - getInitialSiblingLi_: function(element) { - while (true) { - var previousSibling = this.getPreviousSiblingLi_(element); - if (previousSibling && previousSibling.id != element.id) { - element = previousSibling; - } else { - return element; - } - } - }, - getPreviousSiblingLi_: function(element) { - if (element.previousElementSibling) { - var sibling = element.previousElementSibling; - return this.isLi_(sibling) ? sibling : this.getLastChildLi_(sibling); - } else { - var parent = element.parentNode; - while (parent && parent.tagName != 'OL') { - if (parent.previousElementSibling) { - var node = parent.previousElementSibling; - return this.isLi_(node) ? node : this.getLastChildLi_(node); - } else { - parent = parent.parentNode; - } - } - return null; - } - }, - getNextSiblingLi_: function(element) { - if (element.nextElementSibling) { - var sibling = element.nextElementSibling; - return this.isLi_(sibling) ? sibling : this.getFirstChildLi_(sibling); - } else { - var parent = element.parentNode; - while (parent && parent.tagName != 'OL') { - if (parent.nextElementSibling) { - var node = parent.nextElementSibling; - return this.isLi_(node) ? node : this.getFirstChildLi_(node); - } else { - parent = parent.parentNode; - } - } - return null; - } - }, - getFinalSiblingLi_: function(element) { - while (true) { - var nextSibling = this.getNextSiblingLi_(element); - if (nextSibling && nextSibling.id != element.id) { - element = nextSibling; - } else { - return element; - } - } - }, - - // Returns a list of all focus targets in the workspace, including the - // "Create new group" button that appears when no blocks are present. - getWorkspaceFocusTargets_: function() { - return Array.from( - document.querySelectorAll('.blocklyWorkspaceFocusTarget')); - }, - getAllFocusTargets_: function() { - return this.getWorkspaceFocusTargets_().concat(this.sidebarButtonElements_); - }, - getNextFocusTargetId_: function(treeId) { - var trees = this.getAllFocusTargets_(); - for (var i = 0; i < trees.length - 1; i++) { - if (trees[i].id == treeId) { - return trees[i + 1].id; - } - } - return null; - }, - getPreviousFocusTargetId_: function(treeId) { - var trees = this.getAllFocusTargets_(); - for (var i = trees.length - 1; i > 0; i--) { - if (trees[i].id == treeId) { - return trees[i - 1].id; - } - } - return null; - }, - - getActiveDescId: function(treeId) { - return this.activeDescendantIds_[treeId] || ''; - }, - // Set the active desc for this tree to its first child. - initActiveDesc: function(treeId) { - var tree = document.getElementById(treeId); - this.setActiveDesc(this.getFirstChildLi_(tree).id, treeId); - }, - // Make a given element the active descendant of a given tree. - setActiveDesc: function(newActiveDescId, treeId) { - if (this.getActiveDescId(treeId)) { - this.clearActiveDesc(treeId); - } - document.getElementById(newActiveDescId).classList.add( - 'blocklyActiveDescendant'); - this.activeDescendantIds_[treeId] = newActiveDescId; - - // Scroll the new active desc into view, if needed. This has no effect - // for blind users, but is helpful for sighted onlookers. - this.scrollToElement_(newActiveDescId); - }, - // This clears the active descendant of the given tree. It is used just - // before the tree is deleted. - clearActiveDesc: function(treeId) { - var activeDesc = document.getElementById(this.getActiveDescId(treeId)); - if (activeDesc) { - activeDesc.classList.remove('blocklyActiveDescendant'); - } - - if (this.activeDescendantIds_[treeId]) { - delete this.activeDescendantIds_[treeId]; - } - }, - clearAllActiveDescs: function() { - for (var treeId in this.activeDescendantIds_) { - var activeDesc = document.getElementById(this.getActiveDescId(treeId)); - if (activeDesc) { - activeDesc.classList.remove('blocklyActiveDescendant'); - } - } - - this.activeDescendantIds_ = {}; - }, - - isTreeRoot_: function(element) { - return element.classList.contains('blocklyTree'); - }, - getBlockRootId_: function(blockId) { - return blockId + this.BLOCK_ROOT_ID_SUFFIX_; - }, - // Return the 'lowest' Blockly block in the DOM tree that contains the given - // DOM element. - getContainingBlock_: function(domElement) { - var potentialBlockRoot = domElement; - while (potentialBlockRoot.id.indexOf(this.BLOCK_ROOT_ID_SUFFIX_) === -1) { - potentialBlockRoot = potentialBlockRoot.parentNode; - } - - var blockRootId = potentialBlockRoot.id; - var blockId = blockRootId.substring( - 0, blockRootId.length - this.BLOCK_ROOT_ID_SUFFIX_.length); - return blocklyApp.workspace.getBlockById(blockId); - }, - isTopLevelBlock_: function(block) { - return !block.getParent(); - }, - // Returns whether the given block is at the top level, and has no siblings. - isIsolatedTopLevelBlock_: function(block) { - var blockHasNoSiblings = ( - (!block.nextConnection || - !block.nextConnection.targetConnection) && - (!block.previousConnection || - !block.previousConnection.targetConnection)); - return this.isTopLevelBlock_(block) && blockHasNoSiblings; - }, - safelyRemoveBlock_: function(block, deleteBlockFunc, areNextBlocksRemoved) { - // Runs the given deleteBlockFunc (which should have the effect of deleting - // the given block, and possibly others after it if `areNextBlocksRemoved` - // is true) and then does one of two things: - // - If the deleted block was an isolated top-level block, or it is a top- - // level block and the next blocks are going to be removed, this means - // the current tree has no more blocks after the deletion. So, pick a new - // tree to focus on. - // - Otherwise, set the correct new active desc for the current tree. - var treeId = this.getTreeIdForBlock(block.id); - - var treeCeasesToExist = areNextBlocksRemoved ? - this.isTopLevelBlock_(block) : this.isIsolatedTopLevelBlock_(block); - - if (treeCeasesToExist) { - // Find the node to focus on after the deletion happens. - var nextElementToFocusOn = null; - var focusTargets = this.getWorkspaceFocusTargets_(); - for (var i = 0; i < focusTargets.length; i++) { - if (focusTargets[i].id == treeId) { - if (i + 1 < focusTargets.length) { - nextElementToFocusOn = focusTargets[i + 1]; - } else if (i > 0) { - nextElementToFocusOn = focusTargets[i - 1]; - } - break; - } - } - - this.clearActiveDesc(treeId); - deleteBlockFunc(); - // Invoke a digest cycle, so that the DOM settles (and the "Create new - // group" button in the workspace shows up, if applicable). - setTimeout(function() { - if (nextElementToFocusOn) { - nextElementToFocusOn.focus(); - } else { - document.getElementById( - blocklyApp.ID_FOR_EMPTY_WORKSPACE_BTN).focus(); - } - }); - } else { - var blockRootId = this.getBlockRootId_(block.id); - var blockRootElement = document.getElementById(blockRootId); - - // Find the new active desc for the current tree by trying the following - // possibilities in order: the parent, the next sibling, and the previous - // sibling. (If `areNextBlocksRemoved` is true, the next sibling would be - // moved together with the moved block, so we don't check it.) - if (areNextBlocksRemoved) { - var newActiveDesc = - this.getParentLi_(blockRootElement) || - this.getPreviousSiblingLi_(blockRootElement); - } else { - var newActiveDesc = - this.getParentLi_(blockRootElement) || - this.getNextSiblingLi_(blockRootElement) || - this.getPreviousSiblingLi_(blockRootElement); - } - - this.clearActiveDesc(treeId); - deleteBlockFunc(); - // Invoke a digest cycle, so that the DOM settles. - var that = this; - setTimeout(function() { - that.setActiveDesc(newActiveDesc.id, treeId); - document.getElementById(treeId).focus(); - }); - } - }, - getTreeIdForBlock: function(blockId) { - // Walk up the DOM until we get to the root element of the tree. - var potentialRoot = document.getElementById(this.getBlockRootId_(blockId)); - while (!this.isTreeRoot_(potentialRoot)) { - potentialRoot = potentialRoot.parentNode; - } - return potentialRoot.id; - }, - // Set focus to the tree containing the given block, and set the tree's - // active desc to the root element of the given block. - focusOnBlock: function(blockId) { - // Invoke a digest cycle, in order to allow the ID of the newly-created - // tree to be set in the DOM. - var that = this; - setTimeout(function() { - var treeId = that.getTreeIdForBlock(blockId); - document.getElementById(treeId).focus(); - that.setActiveDesc(that.getBlockRootId_(blockId), treeId); - }); - }, - showBlockOptionsModal: function(block) { - var that = this; - var actionButtonsInfo = []; - - if (block.previousConnection) { - actionButtonsInfo.push({ - action: function() { - that.blockConnectionService.markConnection(block.previousConnection); - that.focusOnBlock(block.id); - }, - translationIdForText: 'MARK_SPOT_BEFORE' - }); - } - - if (block.nextConnection) { - actionButtonsInfo.push({ - action: function() { - that.blockConnectionService.markConnection(block.nextConnection); - that.focusOnBlock(block.id); - }, - translationIdForText: 'MARK_SPOT_AFTER' - }); - } - - if (this.blockConnectionService.canBeMovedToMarkedConnection(block)) { - actionButtonsInfo.push({ - action: function() { - var blockDescription = that.utilsService.getBlockDescription(block); - var oldDestinationTreeId = that.getTreeIdForBlock( - that.blockConnectionService.getMarkedConnectionSourceBlock().id); - that.clearActiveDesc(oldDestinationTreeId); - - var newBlockId = that.blockConnectionService.attachToMarkedConnection( - block); - that.safelyRemoveBlock_(block, function() { - block.dispose(false); - }, true); - - // Invoke a digest cycle, so that the DOM settles. - setTimeout(function() { - that.focusOnBlock(newBlockId); - var newDestinationTreeId = that.getTreeIdForBlock(newBlockId); - - if (newDestinationTreeId != oldDestinationTreeId) { - // The tree ID for a moved block does not seem to behave - // predictably. E.g. start with two separate groups of one block - // each, add a link before the block in the second group, and - // move the block in the first group to that link. The tree ID of - // the resulting group ends up being the tree ID for the group - // that was originally first, not second as might be expected. - // Here, we double-check to ensure that all affected trees have - // an active desc set. - if (document.getElementById(oldDestinationTreeId)) { - var activeDescId = that.getActiveDescId(oldDestinationTreeId); - var activeDescTreeId = null; - if (activeDescId) { - var oldDestinationBlock = that.getContainingBlock_( - document.getElementById(activeDescId)); - activeDescTreeId = that.getTreeIdForBlock( - oldDestinationBlock); - if (activeDescTreeId != oldDestinationTreeId) { - that.clearActiveDesc(oldDestinationTreeId); - } - } - that.initActiveDesc(oldDestinationTreeId); - } - } - - that.notificationsService.speak( - blockDescription + ' ' + - Blockly.Msg.ATTACHED_BLOCK_TO_LINK_MSG + - '. Now on attached block in workspace.'); - }); - }, - translationIdForText: 'MOVE_TO_MARKED_SPOT' - }); - } - - actionButtonsInfo.push({ - action: function() { - var blockDescription = that.utilsService.getBlockDescription(block); - - that.safelyRemoveBlock_(block, function() { - block.dispose(true); - that.audioService.playDeleteSound(); - }, false); - - setTimeout(function() { - var message = blockDescription + ' deleted. ' + ( - that.utilsService.isWorkspaceEmpty() ? - 'Workspace is empty.' : 'Now on workspace.'); - that.notificationsService.speak(message); - }); - }, - translationIdForText: 'DELETE' - }); - - this.blockOptionsModalService.showModal(actionButtonsInfo, function() { - that.focusOnBlock(block.id); - }); - }, - - moveUpOneLevel_: function(treeId) { - var activeDesc = document.getElementById(this.getActiveDescId(treeId)); - var nextNode = this.getParentLi_(activeDesc); - if (nextNode) { - this.setActiveDesc(nextNode.id, treeId); - } else { - this.audioService.playOopsSound(); - } - }, - onKeypress: function(e, tree) { - // TODO(sll): Instead of this, have a common ActiveContextService which - // returns true if at least one modal is shown, and false otherwise. - if (this.blockOptionsModalService.isModalShown() || - this.variableModalService.isModalShown()) { - return; - } - - var treeId = tree.id; - var activeDesc = document.getElementById(this.getActiveDescId(treeId)); - if (!activeDesc) { - // The underlying Blockly instance may have decided blocks needed to - // be deleted. This is not necessarily an error, but needs to be repaired. - this.initActiveDesc(treeId); - activeDesc = document.getElementById(this.getActiveDescId(treeId)); - } - - if (e.altKey || e.ctrlKey) { - // Do not intercept combinations such as Alt+Home. - return; - } - - if (document.activeElement.tagName == 'INPUT' || - document.activeElement.tagName == 'SELECT') { - // For input fields, Esc, Enter, and Tab keystrokes are handled specially. - if (e.keyCode == 9 || e.keyCode == 13 || e.keyCode == 27) { - // Return the focus to the workspace tree containing the input field. - document.getElementById(treeId).focus(); - - // Note that Tab and Enter events stop propagating, this behavior is - // handled on other listeners. - if (e.keyCode == 27 || e.keyCode == 13) { - e.preventDefault(); - e.stopPropagation(); - } - } - } else { - // Outside an input field, Enter, Tab, Esc and navigation keys are all - // recognized. - if (e.keyCode == 13) { - // Enter key. The user wants to interact with a button, interact with - // an input field, or open the block options modal. - // Algorithm to find the field: do a DFS through the children until - // we find an INPUT, BUTTON or SELECT element (in which case we use it). - // Truncate the search at child LI elements. - e.stopPropagation(); - - var found = false; - var dfsStack = Array.from(activeDesc.children); - while (dfsStack.length) { - var currentNode = dfsStack.shift(); - if (currentNode.tagName == 'BUTTON') { - currentNode.click(); - found = true; - break; - } else if (currentNode.tagName == 'INPUT') { - currentNode.focus(); - currentNode.select(); - this.notificationsService.speak( - 'Type a value, then press Escape to exit'); - found = true; - break; - } else if (currentNode.tagName == 'SELECT') { - currentNode.focus(); - found = true; - return; - } else if (currentNode.tagName == 'LI') { - continue; - } - - if (currentNode.children) { - var reversedChildren = Array.from(currentNode.children).reverse(); - reversedChildren.forEach(function(childNode) { - dfsStack.unshift(childNode); - }); - } - } - - // If we cannot find a field to interact with, we open the modal for - // the current block instead. - if (!found) { - var block = this.getContainingBlock_(activeDesc); - this.showBlockOptionsModal(block); - } - } else if (e.keyCode == 9) { - // Tab key. The event is allowed to propagate through. - } else if ([27, 35, 36, 37, 38, 39, 40].indexOf(e.keyCode) !== -1) { - if (e.keyCode == 27 || e.keyCode == 37) { - // Esc or left arrow key. Go up a level, if possible. - this.moveUpOneLevel_(treeId); - } else if (e.keyCode == 35) { - // End key. Go to the last sibling in the subtree. - var potentialFinalSibling = this.getFinalSiblingLi_(activeDesc); - if (potentialFinalSibling) { - this.setActiveDesc(potentialFinalSibling.id, treeId); - } - } else if (e.keyCode == 36) { - // Home key. Go to the first sibling in the subtree. - var potentialInitialSibling = this.getInitialSiblingLi_(activeDesc); - if (potentialInitialSibling) { - this.setActiveDesc(potentialInitialSibling.id, treeId); - } - } else if (e.keyCode == 38) { - // Up arrow key. Go to the previous sibling, if possible. - var potentialPrevSibling = this.getPreviousSiblingLi_(activeDesc); - if (potentialPrevSibling) { - this.setActiveDesc(potentialPrevSibling.id, treeId); - } else { - var statusMessage = 'Reached top of list.'; - if (this.getParentLi_(activeDesc)) { - statusMessage += ' Press left to go to parent list.'; - } - this.audioService.playOopsSound(statusMessage); - } - } else if (e.keyCode == 39) { - // Right arrow key. Go down a level, if possible. - var potentialFirstChild = this.getFirstChildLi_(activeDesc); - if (potentialFirstChild) { - this.setActiveDesc(potentialFirstChild.id, treeId); - } else { - this.audioService.playOopsSound(); - } - } else if (e.keyCode == 40) { - // Down arrow key. Go to the next sibling, if possible. - var potentialNextSibling = this.getNextSiblingLi_(activeDesc); - if (potentialNextSibling) { - this.setActiveDesc(potentialNextSibling.id, treeId); - } else { - this.audioService.playOopsSound('Reached bottom of list.'); - } - } - - e.preventDefault(); - e.stopPropagation(); - } - } - } -}); diff --git a/accessible/utils.service.js b/accessible/utils.service.js deleted file mode 100644 index d78472dc8e1..00000000000 --- a/accessible/utils.service.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 utility service for multiple components. This is a - * singleton service that is used for the entire application. In general, it - * should only be used as a stateless adapter for native Blockly functions. - * - * @author madeeha@google.com (Madeeha Ghori) - */ - -goog.provide('blocklyApp.UtilsService'); - - -blocklyApp.ID_FOR_EMPTY_WORKSPACE_BTN = 'blocklyEmptyWorkspaceBtn'; -blocklyApp.BLOCK_ROOT_ID_SUFFIX = '-blockRoot'; - -blocklyApp.UtilsService = ng.core.Class({ - constructor: [function() {}], - getBlockDescription: function(block) { - // We use 'BLANK' instead of the default '?' so that the string is read - // out. (By default, screen readers tend to ignore punctuation.) - return block.toString(undefined, 'BLANK'); - }, - isWorkspaceEmpty: function() { - return !blocklyApp.workspace.topBlocks_.length; - } -}); diff --git a/accessible/variable-add-modal.component.js b/accessible/variable-add-modal.component.js deleted file mode 100644 index 1965e4e5e91..00000000000 --- a/accessible/variable-add-modal.component.js +++ /dev/null @@ -1,118 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Component representing the variable rename modal. - * - * @author corydiers@google.com (Cory Diers) - */ - -goog.provide('blocklyApp.VariableAddModalComponent'); - -goog.require('blocklyApp.AudioService'); -goog.require('blocklyApp.KeyboardInputService'); -goog.require('blocklyApp.TranslatePipe'); -goog.require('blocklyApp.VariableModalService'); - -goog.require('Blockly.CommonModal'); - - -blocklyApp.VariableAddModalComponent = ng.core.Component({ - selector: 'blockly-add-variable-modal', - template: ` -
- -
-

Add a variable...

- - -

New Variable Name: - -

-
- - - -
-
- `, - pipes: [blocklyApp.TranslatePipe] -}) -.Class({ - constructor: [ - blocklyApp.AudioService, blocklyApp.KeyboardInputService, blocklyApp.VariableModalService, - function(audioService, keyboardService, variableService) { - this.workspace = blocklyApp.workspace; - this.variableModalService = variableService; - this.audioService = audioService; - this.keyboardInputService = keyboardService - this.modalIsVisible = false; - this.activeButtonIndex = -1; - - var that = this; - this.variableModalService.registerPreAddShowHook( - function() { - that.modalIsVisible = true; - - Blockly.CommonModal.setupKeyboardOverrides(that); - - setTimeout(function() { - document.getElementById('varModal').focus(); - }, 150); - } - ); - } - ], - // Caches the current text variable as the user types. - setTextValue: function(newValue) { - this.variableName = newValue; - }, - // Closes the modal (on both success and failure). - hideModal_: Blockly.CommonModal.hideModal, - // Focuses on the button represented by the given index. - focusOnOption: Blockly.CommonModal.focusOnOption, - // Counts the number of interactive elements for the modal. - numInteractiveElements: Blockly.CommonModal.numInteractiveElements, - // Gets all the interactive elements for the modal. - getInteractiveElements: Blockly.CommonModal.getInteractiveElements, - // Gets the container with interactive elements. - getInteractiveContainer: function() { - return document.getElementById("varForm"); - }, - // Submits the name change for the variable. - submit: function() { - this.workspace.createVariable(this.variableName); - this.dismissModal(); - }, - // Dismisses and closes the modal. - dismissModal: function() { - this.variableModalService.hideModal(); - this.hideModal_(); - } -}) diff --git a/accessible/variable-modal.service.js b/accessible/variable-modal.service.js deleted file mode 100644 index df5555fae4f..00000000000 --- a/accessible/variable-modal.service.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Service for the variable modal. - * - * @author corydiers@google.com (Cory Diers) - */ - -goog.provide('blocklyApp.VariableModalService'); - -blocklyApp.VariableModalService = ng.core.Class({ - constructor: [ - function() { - this.modalIsShown = false; - } - ], - // Registers a hook to be called before the add modal is shown. - registerPreAddShowHook: function(preShowHook) { - this.preAddShowHook = function() { - preShowHook(); - }; - }, - // Registers a hook to be called before the rename modal is shown. - registerPreRenameShowHook: function(preShowHook) { - this.preRenameShowHook = function(oldName) { - preShowHook(oldName); - }; - }, - // Registers a hook to be called before the remove modal is shown. - registerPreRemoveShowHook: function(preShowHook) { - this.preRemoveShowHook = function(oldName, count) { - preShowHook(oldName, count); - }; - }, - // Returns true if the variable modal is shown. - isModalShown: function() { - return this.modalIsShown; - }, - // Show the add variable modal. - showAddModal_: function() { - this.preAddShowHook(); - this.modalIsShown = true; - }, - // Show the rename variable modal. - showRenameModal_: function(oldName) { - this.preRenameShowHook(oldName); - this.modalIsShown = true; - }, - // Show the remove variable modal. - showRemoveModal_: function(oldName) { - var count = this.getNumVariables(oldName); - this.modalIsShown = true; - if (count > 1) { - this.preRemoveShowHook(oldName, count); - } else { - var variable = blocklyApp.workspace.getVariable(oldName); - blocklyApp.workspace.deleteVariableInternal_(variable); - // Allow the execution loop to finish before "closing" the modal. While - // the modal never opens, its being "open" should prevent other keypresses - // anyway. - var that = this; - setTimeout(function() { - that.modalIsShown = false; - }); - } - }, - getNumVariables: function(oldName) { - return blocklyApp.workspace.getVariableUses(oldName).length; - }, - // Hide the variable modal. - hideModal: function() { - this.modalIsShown = false; - } -}); diff --git a/accessible/variable-remove-modal.component.js b/accessible/variable-remove-modal.component.js deleted file mode 100644 index b542e88a5fb..00000000000 --- a/accessible/variable-remove-modal.component.js +++ /dev/null @@ -1,125 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Component representing the variable remove modal. - * - * @author corydiers@google.com (Cory Diers) - */ - -goog.provide('blocklyApp.VariableRemoveModalComponent'); - -goog.require('blocklyApp.AudioService'); -goog.require('blocklyApp.KeyboardInputService'); -goog.require('blocklyApp.TranslatePipe'); -goog.require('blocklyApp.TreeService'); -goog.require('blocklyApp.VariableModalService'); - -goog.require('Blockly.CommonModal'); - - -blocklyApp.VariableRemoveModalComponent = ng.core.Component({ - selector: 'blockly-remove-variable-modal', - template: ` -
- -
-

- Delete {{getNumVariables()}} uses of the "{{currentVariableName}}" - variable? -

- -
-
- - -
-
-
- `, - pipes: [blocklyApp.TranslatePipe] -}) -.Class({ - constructor: [ - blocklyApp.AudioService, - blocklyApp.KeyboardInputService, - blocklyApp.TreeService, - blocklyApp.VariableModalService, - function(audioService, keyboardService, treeService, variableService) { - this.workspace = blocklyApp.workspace; - this.treeService = treeService; - this.variableModalService = variableService; - this.audioService = audioService; - this.keyboardInputService = keyboardService - this.modalIsVisible = false; - this.activeButtonIndex = -1; - this.currentVariableName = ""; - this.count = 0; - - var that = this; - this.variableModalService.registerPreRemoveShowHook( - function(name, count) { - that.currentVariableName = name; - that.count = count - that.modalIsVisible = true; - - Blockly.CommonModal.setupKeyboardOverrides(that); - - setTimeout(function() { - document.getElementById('varModal').focus(); - }, 150); - } - ); - } - ], - // Closes the modal (on both success and failure). - hideModal_: Blockly.CommonModal.hideModal, - // Focuses on the button represented by the given index. - focusOnOption: Blockly.CommonModal.focusOnOption, - // Counts the number of interactive elements for the modal. - numInteractiveElements: Blockly.CommonModal.numInteractiveElements, - // Gets all the interactive elements for the modal. - getInteractiveElements: Blockly.CommonModal.getInteractiveElements, - // Gets the container with interactive elements. - getInteractiveContainer: function() { - return document.getElementById("varForm"); - }, - getNumVariables: function() { - return this.variableModalService.getNumVariables(this.currentVariableName); - }, - // Submits the name change for the variable. - submit: function() { - var variable = blocklyApp.workspace.getVariable(this.currentVariableName); - blocklyApp.workspace.deleteVariableInternal_(variable); - this.dismissModal(); - }, - // Dismisses and closes the modal. - dismissModal: function() { - this.variableModalService.hideModal(); - this.hideModal_(); - } -}) diff --git a/accessible/variable-rename-modal.component.js b/accessible/variable-rename-modal.component.js deleted file mode 100644 index e276e739cef..00000000000 --- a/accessible/variable-rename-modal.component.js +++ /dev/null @@ -1,121 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Component representing the variable rename modal. - * - * @author corydiers@google.com (Cory Diers) - */ - -goog.provide('blocklyApp.VariableRenameModalComponent'); - -goog.require('Blockly.CommonModal'); -goog.require('blocklyApp.AudioService'); -goog.require('blocklyApp.KeyboardInputService'); -goog.require('blocklyApp.TranslatePipe'); -goog.require('blocklyApp.VariableModalService'); - - -blocklyApp.VariableRenameModalComponent = ng.core.Component({ - selector: 'blockly-rename-variable-modal', - template: ` -
- -
-

- Rename the "{{currentVariableName}}" variable... -

- -
-

New Variable Name: - -

-
- - -
-
-
- `, - pipes: [blocklyApp.TranslatePipe] -}) -.Class({ - constructor: [ - blocklyApp.AudioService, blocklyApp.KeyboardInputService, blocklyApp.VariableModalService, - function(audioService, keyboardService, variableService) { - this.workspace = blocklyApp.workspace; - this.variableModalService = variableService; - this.audioService = audioService; - this.keyboardInputService = keyboardService - this.modalIsVisible = false; - this.activeButtonIndex = -1; - this.currentVariableName = ""; - - var that = this; - this.variableModalService.registerPreRenameShowHook( - function(oldName) { - that.currentVariableName = oldName; - that.modalIsVisible = true; - - Blockly.CommonModal.setupKeyboardOverrides(that); - - setTimeout(function() { - document.getElementById('varModal').focus(); - }, 150); - } - ); - } - ], - // Caches the current text variable as the user types. - setTextValue: function(newValue) { - this.variableName = newValue; - }, - // Closes the modal (on both success and failure). - hideModal_: Blockly.CommonModal.hideModal, - // Focuses on the button represented by the given index. - focusOnOption: Blockly.CommonModal.focusOnOption, - // Counts the number of interactive elements for the modal. - numInteractiveElements: Blockly.CommonModal.numInteractiveElements, - // Gets all the interactive elements for the modal. - getInteractiveElements: Blockly.CommonModal.getInteractiveElements, - // Gets the container with interactive elements. - getInteractiveContainer: function() { - return document.getElementById("varForm"); - }, - // Submits the name change for the variable. - submit: function() { - this.workspace.renameVariable(this.currentVariableName, this.variableName); - this.dismissModal(); - }, - // Dismisses and closes the modal. - dismissModal: function() { - this.variableModalService.hideModal(); - this.hideModal_(); - } -}) diff --git a/accessible/workspace-block.component.js b/accessible/workspace-block.component.js deleted file mode 100644 index ca20b7fde33..00000000000 --- a/accessible/workspace-block.component.js +++ /dev/null @@ -1,216 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Component representing a Blockly.Block in the - * workspace. - * @author madeeha@google.com (Madeeha Ghori) - */ - -goog.provide('blocklyApp.WorkspaceBlockComponent'); - -goog.require('blocklyApp.UtilsService'); - -goog.require('blocklyApp.AudioService'); -goog.require('blocklyApp.BlockConnectionService'); -goog.require('blocklyApp.FieldSegmentComponent'); -goog.require('blocklyApp.TranslatePipe'); -goog.require('blocklyApp.TreeService'); - - -blocklyApp.WorkspaceBlockComponent = ng.core.Component({ - selector: 'blockly-workspace-block', - template: ` -
  • - - -
      - -
    -
  • - - - - `, - directives: [blocklyApp.FieldSegmentComponent, ng.core.forwardRef(function() { - return blocklyApp.WorkspaceBlockComponent; - })], - inputs: ['block', 'level', 'tree'], - pipes: [blocklyApp.TranslatePipe] -}) -.Class({ - constructor: [ - blocklyApp.AudioService, - blocklyApp.BlockConnectionService, - blocklyApp.TreeService, - blocklyApp.UtilsService, - function(audioService, blockConnectionService, treeService, utilsService) { - this.audioService = audioService; - this.blockConnectionService = blockConnectionService; - this.treeService = treeService; - this.utilsService = utilsService; - this.cachedBlockId = null; - } - ], - ngDoCheck: function() { - // The block ID can change if, for example, a block is spliced between two - // linked blocks. We need to refresh the fields and component IDs when this - // happens. - if (this.cachedBlockId != this.block.id) { - this.cachedBlockId = this.block.id; - - var SUPPORTED_FIELDS = [Blockly.FieldTextInput, Blockly.FieldDropdown]; - this.inputListAsFieldSegments = this.block.inputList.map(function(input) { - // Converts the input list to an array of field segments. Each field - // segment represents a user-editable field, prefixed by an arbitrary - // number of non-editable fields. - var fieldSegments = []; - - var bufferedFields = []; - input.fieldRow.forEach(function(field) { - var fieldIsSupported = SUPPORTED_FIELDS.some(function(fieldType) { - return (field instanceof fieldType); - }); - - if (fieldIsSupported) { - var fieldSegment = { - prefixFields: [], - mainField: field - }; - bufferedFields.forEach(function(bufferedField) { - fieldSegment.prefixFields.push(bufferedField); - }); - fieldSegments.push(fieldSegment); - bufferedFields = []; - } else { - bufferedFields.push(field); - } - }); - - // Handle leftover text at the end. - if (bufferedFields.length) { - fieldSegments.push({ - prefixFields: bufferedFields, - mainField: null - }); - } - - return fieldSegments; - }); - - // Generate unique IDs for elements in this component. - this.componentIds = {}; - this.componentIds.blockRoot = - this.block.id + blocklyApp.BLOCK_ROOT_ID_SUFFIX; - this.componentIds.blockSummary = this.block.id + '-blockSummary'; - - var that = this; - this.componentIds.inputs = this.block.inputList.map(function(input, i) { - var idsToGenerate = ['inputLi', 'fieldLabel']; - if (input.connection && !input.connection.targetBlock()) { - idsToGenerate.push('actionButtonLi', 'actionButton', 'buttonLabel'); - } - - var inputIds = {}; - idsToGenerate.forEach(function(idBaseString) { - inputIds[idBaseString] = [that.block.id, i, idBaseString].join('-'); - }); - - return inputIds; - }); - } - }, - ngAfterViewInit: function() { - // If this is a top-level tree in the workspace, ensure that it has an - // active descendant. (Note that a timeout is needed here in order to - // trigger Angular change detection.) - var that = this; - setTimeout(function() { - if (that.level === 0 && !that.treeService.getActiveDescId(that.tree.id)) { - that.treeService.setActiveDesc( - that.componentIds.blockRoot, that.tree.id); - } - }); - }, - addInteriorLink: function(connection) { - this.blockConnectionService.markConnection(connection); - }, - getBlockDescription: function() { - var blockDescription = this.utilsService.getBlockDescription(this.block); - - var parentBlock = this.block.getSurroundParent(); - if (parentBlock) { - var fullDescription = blockDescription + ' inside ' + - this.utilsService.getBlockDescription(parentBlock); - return fullDescription; - } else { - return blockDescription; - } - }, - getBlockNeededLabel: function(blockInput) { - // The input type name, or 'any' if any official input type qualifies. - var inputTypeLabel = ( - blockInput.connection.check_ ? - blockInput.connection.check_.join(', ') : Blockly.Msg.ANY); - var blockTypeLabel = ( - blockInput.type == Blockly.NEXT_STATEMENT ? - Blockly.Msg.BLOCK : Blockly.Msg.VALUE); - return inputTypeLabel + ' ' + blockTypeLabel + ' needed:'; - }, - generateAriaLabelledByAttr: function(mainLabel, secondLabel) { - return mainLabel + (secondLabel ? ' ' + secondLabel : ''); - } -}); diff --git a/accessible/workspace.component.js b/accessible/workspace.component.js deleted file mode 100644 index 08446011765..00000000000 --- a/accessible/workspace.component.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * AccessibleBlockly - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angular2 Component that details how a Blockly.Workspace is - * rendered in AccessibleBlockly. - * - * @author madeeha@google.com (Madeeha Ghori) - */ - -goog.provide('blocklyApp.WorkspaceComponent'); - -goog.require('blocklyApp.NotificationsService'); -goog.require('blocklyApp.ToolboxModalService'); -goog.require('blocklyApp.TranslatePipe'); -goog.require('blocklyApp.TreeService'); - -goog.require('blocklyApp.WorkspaceBlockComponent'); - - -blocklyApp.WorkspaceComponent = ng.core.Component({ - selector: 'blockly-workspace', - template: ` -
    -

    {{'WORKSPACE'|translate}}

    - -
    -
      - - -
    - - -

    - {{'NO_BLOCKS_IN_WORKSPACE'|translate}} - -

    -
    -
    -
    - `, - directives: [blocklyApp.WorkspaceBlockComponent], - pipes: [blocklyApp.TranslatePipe] -}) -.Class({ - constructor: [ - blocklyApp.NotificationsService, - blocklyApp.ToolboxModalService, - blocklyApp.TreeService, - function(notificationsService, toolboxModalService, treeService) { - this.notificationsService = notificationsService; - this.toolboxModalService = toolboxModalService; - this.treeService = treeService; - - this.ID_FOR_EMPTY_WORKSPACE_BTN = blocklyApp.ID_FOR_EMPTY_WORKSPACE_BTN; - this.workspace = blocklyApp.workspace; - this.currentTreeId = 0; - } - ], - getNewTreeId: function() { - this.currentTreeId++; - return 'blockly-tree-' + this.currentTreeId; - }, - getActiveDescId: function(treeId) { - return this.treeService.getActiveDescId(treeId); - }, - onKeypress: function(e, tree) { - this.treeService.onKeypress(e, tree); - }, - showToolboxModalForCreateNewGroup: function() { - this.toolboxModalService.showToolboxModalForCreateNewGroup( - this.ID_FOR_EMPTY_WORKSPACE_BTN); - }, - speakLocation: function(groupIndex, treeId) { - this.notificationsService.speak( - 'Now in workspace group ' + (groupIndex + 1) + ' of ' + - this.workspace.topBlocks_.length); - } -}); diff --git a/api-extractor.json b/api-extractor.json new file mode 100644 index 00000000000..66414503a29 --- /dev/null +++ b/api-extractor.json @@ -0,0 +1,390 @@ +/** + * Config file for API Extractor. For more info, please visit: https://api-extractor.com + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + /** + * Optionally specifies another JSON config file that this file extends from. This provides a way for + * standard settings to be shared across multiple projects. + * + * If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains + * the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be + * resolved using NodeJS require(). + * + * SUPPORTED TOKENS: none + * DEFAULT VALUE: "" + */ + // "extends": "./shared/api-extractor-base.json" + // "extends": "my-package/include/api-extractor-base.json" + + /** + * Determines the "" token that can be used with other config file settings. The project folder + * typically contains the tsconfig.json and package.json config files, but the path is user-defined. + * + * The path is resolved relative to the folder of the config file that contains the setting. + * + * The default value for "projectFolder" is the token "", which means the folder is determined by traversing + * parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder + * that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error + * will be reported. + * + * SUPPORTED TOKENS: + * DEFAULT VALUE: "" + */ + // "projectFolder": "..", + + /** + * (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor + * analyzes the symbols exported by this module. + * + * The file extension must be ".d.ts" and not ".ts". + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + */ + "mainEntryPointFilePath": "dist/index.d.ts", + + /** + * A list of NPM package names whose exports should be treated as part of this package. + * + * For example, suppose that Webpack is used to generate a distributed bundle for the project "library1", + * and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part + * of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly + * imports library2. To avoid this, we can specify: + * + * "bundledPackages": [ "library2" ], + * + * This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been + * local files for library1. + */ + "bundledPackages": [], + + /** + * Determines how the TypeScript compiler engine will be invoked by API Extractor. + */ + "compiler": { + /** + * Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * Note: This setting will be ignored if "overrideTsconfig" is used. + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/tsconfig.json" + */ + // "tsconfigFilePath": "/tsconfig.json", + /** + * Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk. + * The object must conform to the TypeScript tsconfig schema: + * + * http://json.schemastore.org/tsconfig + * + * If omitted, then the tsconfig.json file will be read from the "projectFolder". + * + * DEFAULT VALUE: no overrideTsconfig section + */ + // "overrideTsconfig": { + // . . . + // } + /** + * This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended + * and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when + * dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses + * for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck. + * + * DEFAULT VALUE: false + */ + // "skipLibCheck": true, + }, + + /** + * Configures how the API report file (*.api.md) will be generated. + */ + "apiReport": { + /** + * (REQUIRED) Whether to generate an API report. + */ + "enabled": false + + /** + * The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce + * a full file path. + * + * The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/". + * + * SUPPORTED TOKENS: , + * DEFAULT VALUE: ".api.md" + */ + // "reportFileName": ".api.md", + + /** + * Specifies the folder where the API report file is written. The file name portion is determined by + * the "reportFileName" setting. + * + * The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy, + * e.g. for an API review. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/temp/" + */ + // "reportFolder": "/temp/", + + /** + * Specifies the folder where the temporary report file is written. The file name portion is determined by + * the "reportFileName" setting. + * + * After the temporary file is written to disk, it is compared with the file in the "reportFolder". + * If they are different, a production build will fail. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/temp/" + */ + // "reportTempFolder": "/temp/" + }, + + /** + * Configures how the doc model file (*.api.json) will be generated. + */ + "docModel": { + /** + * (REQUIRED) Whether to generate a doc model file. + */ + "enabled": true + + /** + * The output path for the doc model file. The file extension should be ".api.json". + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/temp/.api.json" + */ + // "apiJsonFilePath": "/temp/.api.json" + }, + + /** + * Configures how the .d.ts rollup file will be generated. + */ + "dtsRollup": { + /** + * (REQUIRED) Whether to generate the .d.ts rollup file. + */ + "enabled": true, + + /** + * Specifies the output path for a .d.ts rollup file to be generated without any trimming. + * This file will include all declarations that are exported by the main entry point. + * + * If the path is an empty string, then this file will not be written. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/dist/.d.ts" + */ + "untrimmedFilePath": "/dist/_rollup.d.ts" + + /** + * Specifies the output path for a .d.ts rollup file to be generated with trimming for an "alpha" release. + * This file will include only declarations that are marked as "@public", "@beta", or "@alpha". + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "" + */ + // "alphaTrimmedFilePath": "/dist/-alpha.d.ts", + + /** + * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release. + * This file will include only declarations that are marked as "@public" or "@beta". + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "" + */ + // "betaTrimmedFilePath": "/dist/-beta.d.ts", + + /** + * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release. + * This file will include only declarations that are marked as "@public". + * + * If the path is an empty string, then this file will not be written. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "" + */ + // "publicTrimmedFilePath": "/dist/-public.d.ts", + + /** + * When a declaration is trimmed, by default it will be replaced by a code comment such as + * "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the + * declaration completely. + * + * DEFAULT VALUE: false + */ + // "omitTrimmingComments": true + }, + + /** + * Configures how the tsdoc-metadata.json file will be generated. + */ + "tsdocMetadata": { + /** + * Whether to generate the tsdoc-metadata.json file. + * + * DEFAULT VALUE: true + */ + // "enabled": true, + /** + * Specifies where the TSDoc metadata file should be written. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * The default value is "", which causes the path to be automatically inferred from the "tsdocMetadata", + * "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup + * falls back to "tsdoc-metadata.json" in the package folder. + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "" + */ + // "tsdocMetadataFilePath": "/dist/tsdoc-metadata.json" + }, + + /** + * Specifies what type of newlines API Extractor should use when writing output files. By default, the output files + * will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead. + * To use the OS's default newline kind, specify "os". + * + * DEFAULT VALUE: "crlf" + */ + // "newlineKind": "crlf", + + /** + * Configures how API Extractor reports error and warning messages produced during analysis. + * + * There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages. + */ + "messages": { + /** + * Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing + * the input .d.ts files. + * + * TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551" + * + * DEFAULT VALUE: A single "default" entry with logLevel=warning. + */ + "compilerMessageReporting": { + /** + * Configures the default routing for messages that don't match an explicit rule in this table. + */ + "default": { + /** + * Specifies whether the message should be written to the the tool's output log. Note that + * the "addToApiReportFile" property may supersede this option. + * + * Possible values: "error", "warning", "none" + * + * Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail + * and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes + * the "--local" option), the warning is displayed but the build will not fail. + * + * DEFAULT VALUE: "warning" + */ + "logLevel": "warning" + + /** + * When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md), + * then the message will be written inside that file; otherwise, the message is instead logged according to + * the "logLevel" option. + * + * DEFAULT VALUE: false + */ + // "addToApiReportFile": false + } + + // "TS2551": { + // "logLevel": "warning", + // "addToApiReportFile": true + // }, + // + // . . . + }, + + /** + * Configures handling of messages reported by API Extractor during its analysis. + * + * API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag" + * + * DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings + */ + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + // "addToApiReportFile": false + }, + + // We don't use `@public`, that's just the default. + "ae-missing-release-tag": { + "logLevel": "none" + }, + + // Needs investigation. + "ae-forgotten-export": { + "logLevel": "none" + }, + + // We don't prefix our internal APIs with underscores. + "ae-internal-missing-underscore": { + "logLevel": "none" + } + }, + + /** + * Configures handling of messages reported by the TSDoc parser when analyzing code comments. + * + * TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text" + * + * DEFAULT VALUE: A single "default" entry with logLevel=warning. + */ + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + // "addToApiReportFile": false + }, + + "tsdoc-param-tag-missing-hyphen": { + "logLevel": "none" + }, + + // These two are due to "type-like" tags in JsDoc like + // `@suppress {warningName}`. The braces are unexpected in TsDoc. + "tsdoc-malformed-inline-tag": { + "logLevel": "none" + }, + "tsdoc-escape-right-brace": { + "logLevel": "none" + } + } + } +} diff --git a/appengine/.gcloudignore b/appengine/.gcloudignore new file mode 100644 index 00000000000..00d3868b177 --- /dev/null +++ b/appengine/.gcloudignore @@ -0,0 +1,20 @@ +# Do not upload these files. +.* +*.soy +*.komodoproject +deploy +/static/appengine/ +/static/demos/plane/soy/*.jar +/static/demos/plane/xlf/ +/static/externs/ +/static/msg/json/ +/static/scripts/ +/static/typings/ + +/static/eslintrc.json +/static/gulpfile.js +/static/jsconfig.json +/static/LICENSE +/static/package-lock.json +/static/package.json +/static/README.md diff --git a/appengine/README.txt b/appengine/README.txt index 6ba262bba75..caaa8e7b763 100644 --- a/appengine/README.txt +++ b/appengine/README.txt @@ -11,12 +11,13 @@ structure: blockly/ |- app.yaml + |- deploy |- index.yaml - |- index_redirect.py + |- main.py |- README.txt + |- requirements.txt |- storage.js |- storage.py - |- closure-library/ (Optional) `- static/ |- blocks/ |- core/ @@ -26,7 +27,7 @@ blockly/ |- msg/ |- tests/ |- blockly_compressed.js - |- blockly_uncompressed.js (Optional) + |- blockly_uncompressed.js |- blocks_compressed.js |- dart_compressed.js |- javascript_compressed.js @@ -34,11 +35,8 @@ blockly/ |- php_compressed.js `- python_compressed.js -Instructions for fetching the optional Closure library may be found here: - https://developers.google.com/blockly/guides/modify/web/closure - Go to https://appengine.google.com/ and create your App Engine application. -Modify the 'application' name of app.yaml to your App Engine application name. +Modify the 'PROJECT' name in the 'deploy' file to your App Engine application name. Finally, upload this directory structure to your App Engine account, -wait a minute, then go to http://YOURAPPNAME.appspot.com/ +then go to http://YOURAPPNAME.appspot.com/ diff --git a/appengine/add_timestamps.py b/appengine/add_timestamps.py new file mode 100644 index 00000000000..05ff7b92814 --- /dev/null +++ b/appengine/add_timestamps.py @@ -0,0 +1,69 @@ +"""Blockly Demo: Add timestamps + +Copyright 2020 Google LLC + +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. +""" + +"""A script to get all Xml entries in the datastore for Blockly demos +and reinsert any that do not have a last_accessed time. + +This script should only need to be run once, but may take a long time +to complete. + +NDB does not provide a way to query for all entities that are missing a +given property, so we have to get all of them and discard any that +already have a last_accessed time. + +Auth: `gcloud auth login` + +Set the correct project: `gcloud config set project blockly-demo` + +See the current project: `gcloud config get-value project` + +Start a venv: `python3 -m venv venv && source venv/bin/activate` +Inside your vm run `pip install google-cloud-ndb` +Run the script: `python3 add_timestamps.py` +""" + +__author__ = "fenichel@google.com (Rachel Fenichel)" + + +from google.cloud import ndb +from storage import Xml +import datetime + +PAGE_SIZE = 1000 + +def handle_results(results): + for x in results: + if (x.last_accessed is None): + x.put() + +def run_query(): + client = ndb.Client() + with client.context(): + query = Xml.query() + print(f'Total entries: {query.count()}') + cursor = None + more = True + page_count = 0 + result_count = 0 + while more: + results, cursor, more = query.fetch_page(PAGE_SIZE, start_cursor=cursor) + handle_results(results) + page_count += 1 + result_count += len(results) + print(f'{datetime.datetime.now().strftime("%I:%M:%S %p")} : page {page_count} : {result_count}') + +run_query() diff --git a/appengine/app.yaml b/appengine/app.yaml index 8938830f849..563af8b24d0 100644 --- a/appengine/app.yaml +++ b/appengine/app.yaml @@ -1,8 +1,4 @@ -application: blockly-demo -version: 1 -runtime: python27 -api_version: 1 -threadsafe: no +runtime: python312 handlers: # Redirect obsolete URLs. @@ -18,30 +14,70 @@ handlers: - url: /static/apps/.* static_files: redirect.html upload: redirect.html - - -# Storage API. -- url: /storage - script: storage.py - secure: always -- url: /storage\.js - static_files: storage.js - upload: storage\.js - secure: always +# Certain demos were moved on 25 Nov 2022. +- url: /static/demos/fixed/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/resizable/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/toolbox/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/maxBlocks/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/generator/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/headless/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/interpreter/step-execution.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/interpreter/async-execution.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/graph/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/rtl/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/custom-dialogs/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/custom-fields/turtle/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/custom-fields/pitch/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/mirror/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/plane/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/keyboard_nav/.* + static_files: redirect.html + upload: redirect.html +- url: /static/demos/custom-fields/.* + static_files: redirect.html + upload: redirect.html # Blockly files. - url: /static static_dir: static + http_headers: + Access-Control-Allow-Origin: "*" secure: always -# Closure library for uncompressed Blockly. -- url: /closure-library - static_dir: closure-library - secure: always - -# Redirect for root directory. -- url: / - script: index_redirect.py +# Storage API. +- url: /storage\.js + static_files: storage.js + upload: storage\.js secure: always # Favicon. @@ -64,24 +100,7 @@ handlers: upload: robots\.txt secure: always - -skip_files: -# App Engine default patterns. -- ^(.*/)?#.*#$ -- ^(.*/)?.*~$ -- ^(.*/)?.*\.py[co]$ -- ^(.*/)?.*/RCS/.*$ -- ^(.*/)?\..*$ -# Custom skip patterns. -- ^static/appengine/.*$ -- ^static/demos/plane/soy/.+\.jar$ -- ^static/demos/plane/template.soy$ -- ^static/demos/plane/xlf/.*$ -- ^static/i18n/.*$ -- ^static/msg/json/.*$ -- ^.+\.soy$ -- ^closure-library/.*_test.html$ -- ^closure-library/.*_test.js$ -- ^closure-library/closure/bin/.*$ -- ^closure-library/doc/.*$ -- ^closure-library/scripts/.*$ +# Dynamic content. +- url: /.* + script: auto + secure: always diff --git a/appengine/apple-touch-icon.png b/appengine/apple-touch-icon.png index 455abac2d71..38dc7ba1d44 100644 Binary files a/appengine/apple-touch-icon.png and b/appengine/apple-touch-icon.png differ diff --git a/appengine/blockly_compressed.js b/appengine/blockly_compressed.js new file mode 100644 index 00000000000..837d6bb3580 --- /dev/null +++ b/appengine/blockly_compressed.js @@ -0,0 +1,11 @@ +// Added November 2022 after discovering that a number of orgs were hot-linking +// their Blockly applications to https://blockly-demo.appspot.com/ +// Delete this file in early 2024. +var msg = 'Compiled Blockly files should be loaded from https://unpkg.com/blockly/\n' + + 'For help, contact https://groups.google.com/g/blockly'; +console.log(msg); +try { + alert(msg); +} catch { + // Can't alert? Probably node.js. +} diff --git a/appengine/expiration.py b/appengine/expiration.py new file mode 100644 index 00000000000..89d4a7a8d5d --- /dev/null +++ b/appengine/expiration.py @@ -0,0 +1,52 @@ +""" +Copyright 2020 Google LLC + +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. +""" + +"""Delete expired XML. +""" + +__author__ = "fenichel@google.com (Rachel Fenichel)" + + +import storage +import datetime + +from google.cloud import ndb + + +EXPIRATION_DAYS = 365 +# Limit the query to avoid timeouts. +QUERY_LIMIT = 1000 + +def delete_expired(): + """Deletes entries that have not been accessed in more than a year.""" + bestBefore = datetime.datetime.utcnow() - datetime.timedelta(days=EXPIRATION_DAYS) + client = ndb.Client() + with client.context(): + query = storage.Xml.query(storage.Xml.last_accessed < bestBefore) + results = query.fetch(limit=QUERY_LIMIT, keys_only=True) + for x in results: + x.delete() + return len(results) + + +def app(environ, start_response): + headers = [ + ("Content-Type", "text/plain") + ] + start_response("200 OK", headers) + n = delete_expired() + out = "%d records deleted." % n + return [out.encode("utf-8")] diff --git a/appengine/index_redirect.py b/appengine/index_redirect.py deleted file mode 100644 index 286a8e87cd1..00000000000 --- a/appengine/index_redirect.py +++ /dev/null @@ -1,2 +0,0 @@ -print("Status: 302") -print("Location: /static/demos/index.html") diff --git a/appengine/main.py b/appengine/main.py new file mode 100644 index 00000000000..765087c612c --- /dev/null +++ b/appengine/main.py @@ -0,0 +1,39 @@ +""" +Copyright 2020 Google LLC + +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. +""" + +import storage +import expiration + + +# Route to requested handler. +def app(environ, start_response): + if environ["PATH_INFO"] == "/": + return redirect(environ, start_response) + if environ["PATH_INFO"] == "/storage": + return storage.app(environ, start_response) + if environ["PATH_INFO"] == "/expiration": + return expiration.app(environ, start_response) + start_response("404 Not Found", []) + return [b"Page not found."] + + +# Redirect for root directory. +def redirect(environ, start_response): + headers = [ + ("Location", "static/demos/index.html") + ] + start_response("301 Found", headers) + return [] diff --git a/appengine/redirect.html b/appengine/redirect.html index 9c29bac59f9..cc7cf301848 100644 --- a/appengine/redirect.html +++ b/appengine/redirect.html @@ -47,20 +47,59 @@ } } -if (loc.match('/apps/puzzle/')) { +if (loc.includes('/apps/puzzle/')) { // Puzzle moved to Blockly Games on 15 Oct 2014. - loc = 'https://blockly-games.appspot.com/puzzle'; -} else if (loc.match('/apps/maze/')) { + loc = 'https://blockly.games/puzzle'; +} else if (loc.includes('/apps/maze/')) { // Maze moved to Blockly Games on 10 Nov 2014. - loc = 'https://blockly-games.appspot.com/maze'; -} else if (loc.match('/apps/turtle/')) { + loc = 'https://blockly.games/maze'; +} else if (loc.includes('/apps/turtle/')) { // Turtle moved to Blockly Games on 10 Nov 2014. - loc = 'https://blockly-games.appspot.com/turtle'; -} else if (loc.match('/apps/')) { + loc = 'https://blockly.games/turtle'; +} else if (loc.includes('/apps/')) { // Remaining apps moved to demos on 20 Nov 2014. loc = loc.replace('/apps/', '/demos/'); } +// Demos without saved data were moved to Blockly Samples in 2021. +if (loc.includes('/demos/fixed/')) { + loc = 'https://google.github.io/blockly-samples/examples/fixed-demo/'; +} else if (loc.includes('/demos/resizable/overlay')) { + loc = 'https://google.github.io/blockly-samples/examples/resizable-demo/overlay.html'; +} else if (loc.includes('/demos/resizable/')) { + loc = 'https://google.github.io/blockly-samples/examples/resizable-demo/'; +} else if (loc.includes('/demos/toolbox/')) { + loc = 'https://google.github.io/blockly-samples/examples/toolbox-demo/'; +} else if (loc.includes('/demos/maxBlocks/')) { + loc = 'https://google.github.io/blockly-samples/examples/max-blocks-demo/'; +} else if (loc.includes('/demos/generator/')) { + loc = 'https://google.github.io/blockly-samples/examples/generator-demo/'; +} else if (loc.includes('/demos/headless/')) { + loc = 'https://google.github.io/blockly-samples/examples/headless-demo/'; +} else if (loc.includes('/demos/interpreter/step-execution')) { + loc = 'https://google.github.io/blockly-samples/examples/interpreter-demo/step-execution.html'; +} else if (loc.includes('/demos/interpreter/async-execution')) { + loc = 'https://google.github.io/blockly-samples/examples/interpreter-demo/async-execution.html'; +} else if (loc.includes('/demos/graph/')) { + loc = 'https://google.github.io/blockly-samples/examples/graph-demo/'; +} else if (loc.includes('/demos/rtl/')) { + loc = 'https://google.github.io/blockly-samples/examples/rtl-demo/'; +} else if (loc.includes('/demos/custom-dialogs/')) { + loc = 'https://google.github.io/blockly-samples/examples/custom-dialogs-demo'; +} else if (loc.includes('/demos/custom-fields/turtle/')) { + loc = 'https://google.github.io/blockly-samples/examples/turtle-field-demo/'; +} else if (loc.includes('/demos/custom-fields/pitch/')) { + loc = 'https://google.github.io/blockly-samples/examples/pitch-field-demo/'; +} else if (loc.includes('/demos/mirror/')) { + loc = 'https://google.github.io/blockly-samples/examples/mirror-demo/'; +} else if (loc.includes('/demos/plane/')) { + loc = 'https://google.github.io/blockly-samples/examples/plane-demo/'; +} else if (loc.includes('/demos/keyboard_nav/')) { + loc = 'https://google.github.io/blockly-samples/plugins/keyboard-navigation/test/'; +} else if (loc.includes('/demos/custom-fields/')) { + loc = 'https://google.github.io/blockly-samples/examples/pitch-field-demo/'; +} + location = loc; diff --git a/appengine/requirements.txt b/appengine/requirements.txt new file mode 100644 index 00000000000..99d5d110e57 --- /dev/null +++ b/appengine/requirements.txt @@ -0,0 +1 @@ +google-cloud-ndb diff --git a/appengine/storage.js b/appengine/storage.js index 8141806d524..c1157177133 100644 --- a/appengine/storage.js +++ b/appengine/storage.js @@ -1,26 +1,11 @@ /** * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Loading and saving blocks with localStorage and cloud storage. - * @author q.neutron@gmail.com (Quynh Neutron) */ 'use strict'; @@ -59,7 +44,7 @@ BlocklyStorage.restoreBlocks = function(opt_workspace) { var url = window.location.href.split('#')[0]; if ('localStorage' in window && window.localStorage[url]) { var workspace = opt_workspace || Blockly.getMainWorkspace(); - var xml = Blockly.Xml.textToDom(window.localStorage[url]); + var xml = Blockly.utils.xml.textToDom(window.localStorage[url]); Blockly.Xml.domToWorkspace(xml, workspace); } }; @@ -70,7 +55,16 @@ BlocklyStorage.restoreBlocks = function(opt_workspace) { */ BlocklyStorage.link = function(opt_workspace) { var workspace = opt_workspace || Blockly.getMainWorkspace(); - var xml = Blockly.Xml.workspaceToDom(workspace); + var xml = Blockly.Xml.workspaceToDom(workspace, true); + // Remove x/y coordinates from XML if there's only one block stack. + // There's no reason to store this, removing it helps with anonymity. + if (workspace.getTopBlocks(false).length === 1 && xml.querySelector) { + var block = xml.querySelector('block'); + if (block) { + block.removeAttribute('x'); + block.removeAttribute('y'); + } + } var data = Blockly.Xml.domToText(xml); BlocklyStorage.makeRequest_('/storage', 'xml', data, workspace); }; @@ -121,21 +115,23 @@ BlocklyStorage.makeRequest_ = function(url, name, content, workspace) { * @private */ BlocklyStorage.handleRequest_ = function() { - if (BlocklyStorage.httpRequest_.readyState == 4) { - if (BlocklyStorage.httpRequest_.status != 200) { + if (BlocklyStorage.httpRequest_.readyState === 4) { + if (BlocklyStorage.httpRequest_.status !== 200) { BlocklyStorage.alert(BlocklyStorage.HTTPREQUEST_ERROR + '\n' + 'httpRequest_.status: ' + BlocklyStorage.httpRequest_.status); } else { var data = BlocklyStorage.httpRequest_.responseText.trim(); - if (BlocklyStorage.httpRequest_.name == 'xml') { + if (BlocklyStorage.httpRequest_.name === 'xml') { window.location.hash = data; BlocklyStorage.alert(BlocklyStorage.LINK_ALERT.replace('%1', window.location.href)); - } else if (BlocklyStorage.httpRequest_.name == 'key') { + } else if (BlocklyStorage.httpRequest_.name === 'key') { if (!data.length) { BlocklyStorage.alert(BlocklyStorage.HASH_ERROR.replace('%1', window.location.hash)); } else { + // Remove poison line to prevent raw content from being served. + data = data.replace(/^\{\[\(\< UNTRUSTED CONTENT \>\)\]\}\n/, ''); BlocklyStorage.loadXml_(data, BlocklyStorage.httpRequest_.workspace); } } @@ -158,12 +154,12 @@ BlocklyStorage.monitorChanges_ = function(workspace) { function change() { var xmlDom = Blockly.Xml.workspaceToDom(workspace); var xmlText = Blockly.Xml.domToText(xmlDom); - if (startXmlText != xmlText) { + if (startXmlText !== xmlText) { window.location.hash = ''; - workspace.removeChangeListener(bindData); + workspace.removeChangeListener(change); } } - var bindData = workspace.addChangeListener(change); + workspace.addChangeListener(change); }; /** @@ -174,7 +170,7 @@ BlocklyStorage.monitorChanges_ = function(workspace) { */ BlocklyStorage.loadXml_ = function(xml, workspace) { try { - xml = Blockly.Xml.textToDom(xml); + xml = Blockly.utils.xml.textToDom(xml); } catch (e) { BlocklyStorage.alert(BlocklyStorage.XML_ERROR + '\nXML: ' + xml); return; diff --git a/appengine/storage.py b/appengine/storage.py index 4a572073f4e..34db68b29ac 100644 --- a/appengine/storage.py +++ b/appengine/storage.py @@ -1,7 +1,6 @@ """Blockly Demo: Storage -Copyright 2012 Google Inc. -https://developers.google.com/blockly/ +Copyright 2012 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,70 +15,111 @@ limitations under the License. """ -"""Store and retrieve XML with App Engine. +"""Store and retrieve Blockly XML/JSON with App Engine. """ __author__ = "q.neutron@gmail.com (Quynh Neutron)" -import cgi +import hashlib +from google.cloud import ndb from random import randint -from google.appengine.ext import db -from google.appengine.api import memcache -import logging +from urllib.parse import unquote + + +class Xml(ndb.Model): + # A row in the database. + xml_hash = ndb.IntegerProperty() + xml_content = ndb.TextProperty() + last_accessed = ndb.DateTimeProperty(auto_now=True) -print "Content-Type: text/plain\n" def keyGen(): # Generate a random string of length KEY_LEN. KEY_LEN = 6 - CHARS = "abcdefghijkmnopqrstuvwxyz23456789" # Exclude l, 0, 1. + CHARS = "abcdefghijkmnopqrstuvwxyz23456789" # Exclude l, 0, 1. max_index = len(CHARS) - 1 return "".join([CHARS[randint(0, max_index)] for x in range(KEY_LEN)]) -class Xml(db.Model): - # A row in the database. - xml_hash = db.IntegerProperty() - xml_content = db.TextProperty() - -forms = cgi.FieldStorage() -if "xml" in forms: - # Store XML and return a generated key. - xml_content = forms["xml"].value - xml_hash = hash(xml_content) - lookup_query = db.Query(Xml) - lookup_query.filter("xml_hash =", xml_hash) - lookup_result = lookup_query.get() - if lookup_result: - xml_key = lookup_result.key().name() - else: - trials = 0 - result = True - while result: - trials += 1 - if trials == 100: - raise Exception("Sorry, the generator failed to get a key for you.") - xml_key = keyGen() - result = db.get(db.Key.from_path("Xml", xml_key)) - xml = db.Text(xml_content, encoding="utf_8") - row = Xml(key_name = xml_key, xml_hash = xml_hash, xml_content = xml) - row.put() - print xml_key - -if "key" in forms: - # Retrieve stored XML based on the provided key. - key_provided = forms["key"].value + +# Parse POST data (e.g. a=1&b=2) into a dictionary (e.g. {"a": 1, "b": 2}). +# Very minimal parser. Does not combine repeated names (a=1&a=2), ignores +# valueless names (a&b), does not support isindex or multipart/form-data. +def parse_post(environ): + fp = environ["wsgi.input"] + data = fp.read().decode() + parts = data.split("&") + dict = {} + for part in parts: + tuple = part.split("=", 1) + if len(tuple) == 2: + dict[tuple[0]] = unquote(tuple[1]) + return dict + + +def xmlToKey(xml_content): + # Store XML/JSON and return a generated key. + xml_hash = int(hashlib.sha1(xml_content.encode("utf-8")).hexdigest(), 16) + xml_hash = int(xml_hash % (2 ** 64) - (2 ** 63)) + client = ndb.Client() + with client.context(): + lookup_query = Xml.query(Xml.xml_hash == xml_hash) + lookup_result = lookup_query.get() + if lookup_result: + xml_key = lookup_result.key.string_id() + else: + trials = 0 + result = True + while result: + trials += 1 + if trials == 100: + raise Exception("Sorry, the generator failed to get a key for you.") + xml_key = keyGen() + result = Xml.get_by_id(xml_key) + row = Xml(id = xml_key, xml_hash = xml_hash, xml_content = xml_content) + row.put() + return xml_key + + +def keyToXml(key_provided): + # Retrieve stored XML/JSON based on the provided key. # Normalize the string. key_provided = key_provided.lower().strip() - # Check memcache for a quick match. - xml = memcache.get("XML_" + key_provided) - if xml is None: - # Check datastore for a definitive match. - result = db.get(db.Key.from_path("Xml", key_provided)) - if not result: - xml = "" - else: - xml = result.xml_content - # Save to memcache for next hit. - if not memcache.add("XML_" + key_provided, xml, 3600): - logging.error("Memcache set failed.") - print xml.encode("utf-8") + # Check datastore for a match. + client = ndb.Client() + with client.context(): + result = Xml.get_by_id(key_provided) + if not result: + xml = "" + else: + # Put it back into the datastore immediately, which updates the last + # accessed time. + with client.context(): + result.put() + xml = result.xml_content + # Add a poison line to prevent raw content from being served. + xml = "{[(< UNTRUSTED CONTENT >)]}\n" + xml + return xml + + +def app(environ, start_response): + headers = [ + ("Content-Type", "text/plain") + ] + if environ["REQUEST_METHOD"] != "POST": + start_response("405 Method Not Allowed", headers) + return ["Storage only accepts POST".encode("utf-8")] + if ("CONTENT_TYPE" in environ and + environ["CONTENT_TYPE"] != "application/x-www-form-urlencoded"): + start_response("405 Method Not Allowed", headers) + return ["Storage only accepts application/x-www-form-urlencoded".encode("utf-8")] + + forms = parse_post(environ) + if "xml" in forms: + out = xmlToKey(forms["xml"]) + elif "key" in forms: + out = keyToXml(forms["key"]) + else: + out = "" + + start_response("200 OK", headers) + return [out.encode("utf-8")] diff --git a/blockly_accessible_compressed.js b/blockly_accessible_compressed.js deleted file mode 100644 index 8c2c13769e8..00000000000 --- a/blockly_accessible_compressed.js +++ /dev/null @@ -1,1650 +0,0 @@ -// Do not edit this file; automatically generated by build.py. -'use strict'; - -var COMPILED=!0,goog=goog||{};goog.global=this;goog.isDef=function(a){return void 0!==a};goog.isString=function(a){return"string"==typeof a};goog.isBoolean=function(a){return"boolean"==typeof a};goog.isNumber=function(a){return"number"==typeof a};goog.exportPath_=function(a,b,c){a=a.split(".");c=c||goog.global;a[0]in c||!c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)!a.length&&goog.isDef(b)?c[d]=b:c=c[d]&&c[d]!==Object.prototype[d]?c[d]:c[d]={}}; -goog.define=function(a,b){COMPILED||(goog.global.CLOSURE_UNCOMPILED_DEFINES&&void 0===goog.global.CLOSURE_UNCOMPILED_DEFINES.nodeType&&Object.prototype.hasOwnProperty.call(goog.global.CLOSURE_UNCOMPILED_DEFINES,a)?b=goog.global.CLOSURE_UNCOMPILED_DEFINES[a]:goog.global.CLOSURE_DEFINES&&void 0===goog.global.CLOSURE_DEFINES.nodeType&&Object.prototype.hasOwnProperty.call(goog.global.CLOSURE_DEFINES,a)&&(b=goog.global.CLOSURE_DEFINES[a]));goog.exportPath_(a,b)};goog.DEBUG=!1;goog.LOCALE="en"; -goog.TRUSTED_SITE=!0;goog.STRICT_MODE_COMPATIBLE=!1;goog.DISALLOW_TEST_ONLY_CODE=COMPILED&&!goog.DEBUG;goog.ENABLE_CHROME_APP_SAFE_SCRIPT_LOADING=!1;goog.provide=function(a){if(goog.isInModuleLoader_())throw Error("goog.provide can not be used within a goog.module.");if(!COMPILED&&goog.isProvided_(a))throw Error('Namespace "'+a+'" already declared.');goog.constructNamespace_(a)}; -goog.constructNamespace_=function(a,b){if(!COMPILED){delete goog.implicitNamespaces_[a];for(var c=a;(c=c.substring(0,c.lastIndexOf(".")))&&!goog.getObjectByName(c);)goog.implicitNamespaces_[c]=!0}goog.exportPath_(a,b)};goog.VALID_MODULE_RE_=/^[a-zA-Z_$][a-zA-Z0-9._$]*$/; -goog.module=function(a){if(!goog.isString(a)||!a||-1==a.search(goog.VALID_MODULE_RE_))throw Error("Invalid module identifier");if(!goog.isInModuleLoader_())throw Error("Module "+a+" has been loaded incorrectly. Note, modules cannot be loaded as normal scripts. They require some kind of pre-processing step. You're likely trying to load a module via a script tag or as a part of a concatenated bundle without rewriting the module. For more info see: https://github.com/google/closure-library/wiki/goog.module:-an-ES6-module-like-alternative-to-goog.provide.");if(goog.moduleLoaderState_.moduleName)throw Error("goog.module may only be called once per module."); -goog.moduleLoaderState_.moduleName=a;if(!COMPILED){if(goog.isProvided_(a))throw Error('Namespace "'+a+'" already declared.');delete goog.implicitNamespaces_[a]}};goog.module.get=function(a){return goog.module.getInternal_(a)};goog.module.getInternal_=function(a){if(!COMPILED){if(a in goog.loadedModules_)return goog.loadedModules_[a];if(!goog.implicitNamespaces_[a])return a=goog.getObjectByName(a),null!=a?a:null}return null};goog.moduleLoaderState_=null; -goog.isInModuleLoader_=function(){return null!=goog.moduleLoaderState_};goog.module.declareLegacyNamespace=function(){if(!COMPILED&&!goog.isInModuleLoader_())throw Error("goog.module.declareLegacyNamespace must be called from within a goog.module");if(!COMPILED&&!goog.moduleLoaderState_.moduleName)throw Error("goog.module must be called prior to goog.module.declareLegacyNamespace.");goog.moduleLoaderState_.declareLegacyNamespace=!0}; -goog.setTestOnly=function(a){if(goog.DISALLOW_TEST_ONLY_CODE)throw a=a||"",Error("Importing test-only code into non-debug environment"+(a?": "+a:"."));};goog.forwardDeclare=function(a){};COMPILED||(goog.isProvided_=function(a){return a in goog.loadedModules_||!goog.implicitNamespaces_[a]&&goog.isDefAndNotNull(goog.getObjectByName(a))},goog.implicitNamespaces_={"goog.module":!0}); -goog.getObjectByName=function(a,b){a=a.split(".");b=b||goog.global;for(var c;c=a.shift();)if(goog.isDefAndNotNull(b[c]))b=b[c];else return null;return b};goog.globalize=function(a,b){b=b||goog.global;for(var c in a)b[c]=a[c]}; -goog.addDependency=function(a,b,c,d){if(goog.DEPENDENCIES_ENABLED){var e;a=a.replace(/\\/g,"/");var f=goog.dependencies_;d&&"boolean"!==typeof d||(d=d?{module:"goog"}:{});for(var g=0;e=b[g];g++)f.nameToPath[e]=a,f.loadFlags[a]=d;for(d=0;b=c[d];d++)a in f.requires||(f.requires[a]={}),f.requires[a][b]=!0}};goog.ENABLE_DEBUG_LOADER=!0;goog.logToConsole_=function(a){goog.global.console&&goog.global.console.error(a)}; -goog.require=function(a){if(!COMPILED){goog.ENABLE_DEBUG_LOADER&&goog.IS_OLD_IE_&&goog.maybeProcessDeferredDep_(a);if(goog.isProvided_(a)){if(goog.isInModuleLoader_())return goog.module.getInternal_(a)}else if(goog.ENABLE_DEBUG_LOADER){var b=goog.getPathFromDeps_(a);if(b)goog.writeScripts_(b);else throw a="goog.require could not find: "+a,goog.logToConsole_(a),Error(a);}return null}};goog.basePath="";goog.nullFunction=function(){}; -goog.abstractMethod=function(){throw Error("unimplemented abstract method");};goog.addSingletonGetter=function(a){a.instance_=void 0;a.getInstance=function(){if(a.instance_)return a.instance_;goog.DEBUG&&(goog.instantiatedSingletons_[goog.instantiatedSingletons_.length]=a);return a.instance_=new a}};goog.instantiatedSingletons_=[];goog.LOAD_MODULE_USING_EVAL=!0;goog.SEAL_MODULE_EXPORTS=goog.DEBUG;goog.loadedModules_={};goog.DEPENDENCIES_ENABLED=!COMPILED&&goog.ENABLE_DEBUG_LOADER;goog.TRANSPILE="detect"; -goog.TRANSPILER="transpile.js"; -goog.DEPENDENCIES_ENABLED&&(goog.dependencies_={loadFlags:{},nameToPath:{},requires:{},visited:{},written:{},deferred:{}},goog.inHtmlDocument_=function(){var a=goog.global.document;return null!=a&&"write"in a},goog.findBasePath_=function(){if(goog.isDef(goog.global.CLOSURE_BASE_PATH)&&goog.isString(goog.global.CLOSURE_BASE_PATH))goog.basePath=goog.global.CLOSURE_BASE_PATH;else if(goog.inHtmlDocument_()){var a=goog.global.document;var b=a.currentScript;a=b?[b]:a.getElementsByTagName("SCRIPT");for(b= -a.length-1;0<=b;--b){var c=a[b].src,d=c.lastIndexOf("?"),d=-1==d?c.length:d;if("base.js"==c.substr(d-7,7)){goog.basePath=c.substr(0,d-7);break}}}},goog.importScript_=function(a,b){(goog.global.CLOSURE_IMPORT_SCRIPT||goog.writeScriptTag_)(a,b)&&(goog.dependencies_.written[a]=!0)},goog.IS_OLD_IE_=!(goog.global.atob||!goog.global.document||!goog.global.document.all),goog.oldIeWaiting_=!1,goog.importProcessedScript_=function(a,b,c){goog.importScript_("",'goog.retrieveAndExec_("'+a+'", '+b+", "+c+");")}, -goog.queuedModules_=[],goog.wrapModule_=function(a,b){return goog.LOAD_MODULE_USING_EVAL&&goog.isDef(goog.global.JSON)?"goog.loadModule("+goog.global.JSON.stringify(b+"\n//# sourceURL="+a+"\n")+");":'goog.loadModule(function(exports) {"use strict";'+b+"\n;return exports});\n//# sourceURL="+a+"\n"},goog.loadQueuedModules_=function(){var a=goog.queuedModules_.length;if(0\x3c/script>')},goog.appendScriptSrcNode_=function(a){var b=goog.global.document,c=b.createElement("script"); -c.type="text/javascript";c.src=a;c.defer=!1;c.async=!1;b.head.appendChild(c)},goog.writeScriptTag_=function(a,b){if(goog.inHtmlDocument_()){var c=goog.global.document;if(!goog.ENABLE_CHROME_APP_SAFE_SCRIPT_LOADING&&"complete"==c.readyState){if(/\bdeps.js$/.test(a))return!1;throw Error('Cannot write "'+a+'" after document load');}void 0===b?goog.IS_OLD_IE_?(goog.oldIeWaiting_=!0,b=" onreadystatechange='goog.onScriptLoad_(this, "+ ++goog.lastNonModuleScriptIndex_+")' ",c.write(''); - // Load fresh Closure Library. - document.write(''); - document.write(''); -} diff --git a/blockly_compressed.js b/blockly_compressed.js deleted file mode 100644 index 1b9939ddfd9..00000000000 --- a/blockly_compressed.js +++ /dev/null @@ -1,1586 +0,0 @@ -// Do not edit this file; automatically generated by build.py. -'use strict'; - -var COMPILED=!0,goog=goog||{};goog.global=this;goog.isDef=function(a){return void 0!==a};goog.isString=function(a){return"string"==typeof a};goog.isBoolean=function(a){return"boolean"==typeof a};goog.isNumber=function(a){return"number"==typeof a};goog.exportPath_=function(a,b,c){a=a.split(".");c=c||goog.global;a[0]in c||!c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)!a.length&&goog.isDef(b)?c[d]=b:c=c[d]&&c[d]!==Object.prototype[d]?c[d]:c[d]={}}; -goog.define=function(a,b){var c=b;COMPILED||(goog.global.CLOSURE_UNCOMPILED_DEFINES&&void 0===goog.global.CLOSURE_UNCOMPILED_DEFINES.nodeType&&Object.prototype.hasOwnProperty.call(goog.global.CLOSURE_UNCOMPILED_DEFINES,a)?c=goog.global.CLOSURE_UNCOMPILED_DEFINES[a]:goog.global.CLOSURE_DEFINES&&void 0===goog.global.CLOSURE_DEFINES.nodeType&&Object.prototype.hasOwnProperty.call(goog.global.CLOSURE_DEFINES,a)&&(c=goog.global.CLOSURE_DEFINES[a]));goog.exportPath_(a,c)};goog.DEBUG=!1;goog.LOCALE="en"; -goog.TRUSTED_SITE=!0;goog.STRICT_MODE_COMPATIBLE=!1;goog.DISALLOW_TEST_ONLY_CODE=COMPILED&&!goog.DEBUG;goog.ENABLE_CHROME_APP_SAFE_SCRIPT_LOADING=!1;goog.provide=function(a){if(goog.isInModuleLoader_())throw Error("goog.provide can not be used within a goog.module.");if(!COMPILED&&goog.isProvided_(a))throw Error('Namespace "'+a+'" already declared.');goog.constructNamespace_(a)}; -goog.constructNamespace_=function(a,b){if(!COMPILED){delete goog.implicitNamespaces_[a];for(var c=a;(c=c.substring(0,c.lastIndexOf(".")))&&!goog.getObjectByName(c);)goog.implicitNamespaces_[c]=!0}goog.exportPath_(a,b)};goog.VALID_MODULE_RE_=/^[a-zA-Z_$][a-zA-Z0-9._$]*$/; -goog.module=function(a){if(!goog.isString(a)||!a||-1==a.search(goog.VALID_MODULE_RE_))throw Error("Invalid module identifier");if(!goog.isInModuleLoader_())throw Error("Module "+a+" has been loaded incorrectly. Note, modules cannot be loaded as normal scripts. They require some kind of pre-processing step. You're likely trying to load a module via a script tag or as a part of a concatenated bundle without rewriting the module. For more info see: https://github.com/google/closure-library/wiki/goog.module:-an-ES6-module-like-alternative-to-goog.provide.");if(goog.moduleLoaderState_.moduleName)throw Error("goog.module may only be called once per module."); -goog.moduleLoaderState_.moduleName=a;if(!COMPILED){if(goog.isProvided_(a))throw Error('Namespace "'+a+'" already declared.');delete goog.implicitNamespaces_[a]}};goog.module.get=function(a){return goog.module.getInternal_(a)};goog.module.getInternal_=function(a){if(!COMPILED){if(a in goog.loadedModules_)return goog.loadedModules_[a];if(!goog.implicitNamespaces_[a])return a=goog.getObjectByName(a),null!=a?a:null}return null};goog.moduleLoaderState_=null; -goog.isInModuleLoader_=function(){return null!=goog.moduleLoaderState_};goog.module.declareLegacyNamespace=function(){if(!COMPILED&&!goog.isInModuleLoader_())throw Error("goog.module.declareLegacyNamespace must be called from within a goog.module");if(!COMPILED&&!goog.moduleLoaderState_.moduleName)throw Error("goog.module must be called prior to goog.module.declareLegacyNamespace.");goog.moduleLoaderState_.declareLegacyNamespace=!0}; -goog.setTestOnly=function(a){if(goog.DISALLOW_TEST_ONLY_CODE)throw a=a||"",Error("Importing test-only code into non-debug environment"+(a?": "+a:"."));};goog.forwardDeclare=function(a){};COMPILED||(goog.isProvided_=function(a){return a in goog.loadedModules_||!goog.implicitNamespaces_[a]&&goog.isDefAndNotNull(goog.getObjectByName(a))},goog.implicitNamespaces_={"goog.module":!0}); -goog.getObjectByName=function(a,b){for(var c=a.split("."),d=b||goog.global,e;e=c.shift();)if(goog.isDefAndNotNull(d[e]))d=d[e];else return null;return d};goog.globalize=function(a,b){var c=b||goog.global,d;for(d in a)c[d]=a[d]}; -goog.addDependency=function(a,b,c,d){if(goog.DEPENDENCIES_ENABLED){var e;a=a.replace(/\\/g,"/");var f=goog.dependencies_;d&&"boolean"!==typeof d||(d=d?{module:"goog"}:{});for(var g=0;e=b[g];g++)f.nameToPath[e]=a,f.loadFlags[a]=d;for(d=0;b=c[d];d++)a in f.requires||(f.requires[a]={}),f.requires[a][b]=!0}};goog.ENABLE_DEBUG_LOADER=!0;goog.logToConsole_=function(a){goog.global.console&&goog.global.console.error(a)}; -goog.require=function(a){if(!COMPILED){goog.ENABLE_DEBUG_LOADER&&goog.IS_OLD_IE_&&goog.maybeProcessDeferredDep_(a);if(goog.isProvided_(a)){if(goog.isInModuleLoader_())return goog.module.getInternal_(a)}else if(goog.ENABLE_DEBUG_LOADER){var b=goog.getPathFromDeps_(a);if(b)goog.writeScripts_(b);else throw a="goog.require could not find: "+a,goog.logToConsole_(a),Error(a);}return null}};goog.basePath="";goog.nullFunction=function(){}; -goog.abstractMethod=function(){throw Error("unimplemented abstract method");};goog.addSingletonGetter=function(a){a.instance_=void 0;a.getInstance=function(){if(a.instance_)return a.instance_;goog.DEBUG&&(goog.instantiatedSingletons_[goog.instantiatedSingletons_.length]=a);return a.instance_=new a}};goog.instantiatedSingletons_=[];goog.LOAD_MODULE_USING_EVAL=!0;goog.SEAL_MODULE_EXPORTS=goog.DEBUG;goog.loadedModules_={};goog.DEPENDENCIES_ENABLED=!COMPILED&&goog.ENABLE_DEBUG_LOADER;goog.TRANSPILE="detect"; -goog.TRANSPILER="transpile.js"; -goog.DEPENDENCIES_ENABLED&&(goog.dependencies_={loadFlags:{},nameToPath:{},requires:{},visited:{},written:{},deferred:{}},goog.inHtmlDocument_=function(){var a=goog.global.document;return null!=a&&"write"in a},goog.findBasePath_=function(){if(goog.isDef(goog.global.CLOSURE_BASE_PATH)&&goog.isString(goog.global.CLOSURE_BASE_PATH))goog.basePath=goog.global.CLOSURE_BASE_PATH;else if(goog.inHtmlDocument_()){var a=goog.global.document;var b=a.currentScript;a=b?[b]:a.getElementsByTagName("SCRIPT");for(b= -a.length-1;0<=b;--b){var c=a[b].src,d=c.lastIndexOf("?"),d=-1==d?c.length:d;if("base.js"==c.substr(d-7,7)){goog.basePath=c.substr(0,d-7);break}}}},goog.importScript_=function(a,b){(goog.global.CLOSURE_IMPORT_SCRIPT||goog.writeScriptTag_)(a,b)&&(goog.dependencies_.written[a]=!0)},goog.IS_OLD_IE_=!(goog.global.atob||!goog.global.document||!goog.global.document.all),goog.oldIeWaiting_=!1,goog.importProcessedScript_=function(a,b,c){goog.importScript_("",'goog.retrieveAndExec_("'+a+'", '+b+", "+c+");")}, -goog.queuedModules_=[],goog.wrapModule_=function(a,b){return goog.LOAD_MODULE_USING_EVAL&&goog.isDef(goog.global.JSON)?"goog.loadModule("+goog.global.JSON.stringify(b+"\n//# sourceURL="+a+"\n")+");":'goog.loadModule(function(exports) {"use strict";'+b+"\n;return exports});\n//# sourceURL="+a+"\n"},goog.loadQueuedModules_=function(){var a=goog.queuedModules_.length;if(0\x3c/script>')},goog.appendScriptSrcNode_=function(a){var b=goog.global.document,c=b.createElement("script"); -c.type="text/javascript";c.src=a;c.defer=!1;c.async=!1;b.head.appendChild(c)},goog.writeScriptTag_=function(a,b){if(goog.inHtmlDocument_()){var c=goog.global.document;if(!goog.ENABLE_CHROME_APP_SAFE_SCRIPT_LOADING&&"complete"==c.readyState){if(/\bdeps.js$/.test(a))return!1;throw Error('Cannot write "'+a+'" after document load');}if(void 0===b)if(goog.IS_OLD_IE_){goog.oldIeWaiting_=!0;var d=" onreadystatechange='goog.onScriptLoad_(this, "+ ++goog.lastNonModuleScriptIndex_+")' ";c.write(''); - // Load fresh Closure Library. - document.write(''); - document.write(''); -} diff --git a/blocks/blocks.ts b/blocks/blocks.ts new file mode 100644 index 00000000000..dc6ca386cd2 --- /dev/null +++ b/blocks/blocks.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.libraryBlocks + +import type {BlockDefinition} from '../core/blocks.js'; +import * as lists from './lists.js'; +import * as logic from './logic.js'; +import * as loops from './loops.js'; +import * as math from './math.js'; +import * as procedures from './procedures.js'; +import * as texts from './text.js'; +import * as variables from './variables.js'; +import * as variablesDynamic from './variables_dynamic.js'; + +export { + lists, + logic, + loops, + math, + procedures, + texts, + variables, + variablesDynamic, +}; + +/** + * A dictionary of the block definitions provided by all the + * Blockly.libraryBlocks.* modules. + */ +export const blocks: {[key: string]: BlockDefinition} = Object.assign( + {}, + lists.blocks, + logic.blocks, + loops.blocks, + math.blocks, + procedures.blocks, + texts.blocks, + variables.blocks, + variablesDynamic.blocks, +); diff --git a/blocks/colour.js b/blocks/colour.js deleted file mode 100644 index 99e5aacdcaf..00000000000 --- a/blocks/colour.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Colour blocks for Blockly. - * - * This file is scraped to extract a .json file of block definitions. The array - * passed to defineBlocksWithJsonArray(..) must be strict JSON: double quotes - * only, no outside references, no functions, no trailing commas, etc. The one - * exception is end-of-line comments, which the scraper will remove. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Blocks.colour'); // Deprecated -goog.provide('Blockly.Constants.Colour'); - -goog.require('Blockly.Blocks'); - - -/** - * Common HSV hue for all blocks in this category. - * This should be the same as Blockly.Msg.COLOUR_HUE. - * @readonly - */ -Blockly.Constants.Colour.HUE = 20; -/** @deprecated Use Blockly.Constants.Colour.HUE */ -Blockly.Blocks.colour.HUE = Blockly.Constants.Colour.HUE; - -Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT - // Block for colour picker. - { - "type": "colour_picker", - "message0": "%1", - "args0": [ - { - "type": "field_colour", - "name": "COLOUR", - "colour": "#ff0000" - } - ], - "output": "Colour", - "colour": "%{BKY_COLOUR_HUE}", - "helpUrl": "%{BKY_COLOUR_PICKER_HELPURL}", - "tooltip": "%{BKY_COLOUR_PICKER_TOOLTIP}", - "extensions": ["parent_tooltip_when_inline"] - }, - - // Block for random colour. - { - "type": "colour_random", - "message0": "%{BKY_COLOUR_RANDOM_TITLE}", - "output": "Colour", - "colour": "%{BKY_COLOUR_HUE}", - "helpUrl": "%{BKY_COLOUR_RANDOM_HELPURL}", - "tooltip": "%{BKY_COLOUR_RANDOM_TOOLTIP}" - }, - - // Block for composing a colour from RGB components. - { - "type": "colour_rgb", - "message0": "%{BKY_COLOUR_RGB_TITLE} %{BKY_COLOUR_RGB_RED} %1 %{BKY_COLOUR_RGB_GREEN} %2 %{BKY_COLOUR_RGB_BLUE} %3", - "args0": [ - { - "type": "input_value", - "name": "RED", - "check": "Number", - "align": "RIGHT" - }, - { - "type": "input_value", - "name": "GREEN", - "check": "Number", - "align": "RIGHT" - }, - { - "type": "input_value", - "name": "BLUE", - "check": "Number", - "align": "RIGHT" - } - ], - "output": "Colour", - "colour": "%{BKY_COLOUR_HUE}", - "helpUrl": "%{BKY_COLOUR_RGB_HELPURL}", - "tooltip": "%{BKY_COLOUR_RGB_TOOLTIP}" - }, - - // Block for blending two colours together. - { - "type": "colour_blend", - "message0": "%{BKY_COLOUR_BLEND_TITLE} %{BKY_COLOUR_BLEND_COLOUR1} %1 %{BKY_COLOUR_BLEND_COLOUR2} %2 %{BKY_COLOUR_BLEND_RATIO} %3", - "args0": [ - { - "type": "input_value", - "name": "COLOUR1", - "check": "Colour", - "align": "RIGHT" - }, - { - "type": "input_value", - "name": "COLOUR2", - "check": "Colour", - "align": "RIGHT" - }, - { - "type": "input_value", - "name": "RATIO", - "check": "Number", - "align": "RIGHT" - } - ], - "output": "Colour", - "colour": "%{BKY_COLOUR_HUE}", - "helpUrl": "%{BKY_COLOUR_BLEND_HELPURL}", - "tooltip": "%{BKY_COLOUR_BLEND_TOOLTIP}" - } -]); // END JSON EXTRACT (Do not delete this comment.) diff --git a/blocks/lists.js b/blocks/lists.js deleted file mode 100644 index 3c7ef341dc4..00000000000 --- a/blocks/lists.js +++ /dev/null @@ -1,846 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 List blocks for Blockly. - * - * This file is scraped to extract a .json file of block definitions. The array - * passed to defineBlocksWithJsonArray(..) must be strict JSON: double quotes - * only, no outside references, no functions, no trailing commas, etc. The one - * exception is end-of-line comments, which the scraper will remove. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Blocks.lists'); // Deprecated -goog.provide('Blockly.Constants.Lists'); - -goog.require('Blockly.Blocks'); - - -/** - * Common HSV hue for all blocks in this category. - * This should be the same as Blockly.Msg.LISTS_HUE. - * @readonly - */ -Blockly.Constants.Lists.HUE = 260; -/** @deprecated Use Blockly.Constants.Lists.HUE */ -Blockly.Blocks.lists.HUE = Blockly.Constants.Lists.HUE; - - -Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT - // Block for creating an empty list - // The 'list_create_with' block is preferred as it is more flexible. - // - // - // - { - "type": "lists_create_empty", - "message0": "%{BKY_LISTS_CREATE_EMPTY_TITLE}", - "output": "Array", - "colour": "%{BKY_LISTS_HUE}", - "tooltip": "%{BKY_LISTS_CREATE_EMPTY_TOOLTIP}", - "helpUrl": "%{BKY_LISTS_CREATE_EMPTY_HELPURL}" - }, - // Block for creating a list with one element repeated. - { - "type": "lists_repeat", - "message0": "%{BKY_LISTS_REPEAT_TITLE}", - "args0": [ - { - "type": "input_value", - "name": "ITEM" - }, - { - "type": "input_value", - "name": "NUM", - "check": "Number" - } - ], - "output": "Array", - "colour": "%{BKY_LISTS_HUE}", - "tooltip": "%{BKY_LISTS_REPEAT_TOOLTIP}", - "helpUrl": "%{BKY_LISTS_REPEAT_HELPURL}" - }, - // Block for reversing a list. - { - "type": "lists_reverse", - "message0": "%{BKY_LISTS_REVERSE_MESSAGE0}", - "args0": [ - { - "type": "input_value", - "name": "LIST", - "check": "Array" - } - ], - "output": "Array", - "inputsInline": true, - "colour": "%{BKY_LISTS_HUE}", - "tooltip": "%{BKY_LISTS_REVERSE_TOOLTIP}", - "helpUrl": "%{BKY_LISTS_REVERSE_HELPURL}" - }, - // Block for checking if a list is empty - { - "type": "lists_isEmpty", - "message0": "%{BKY_LISTS_ISEMPTY_TITLE}", - "args0": [ - { - "type": "input_value", - "name": "VALUE", - "check": ["String", "Array"] - } - ], - "output": "Boolean", - "colour": "%{BKY_LISTS_HUE}", - "tooltip": "%{BKY_LISTS_ISEMPTY_TOOLTIP}", - "helpUrl": "%{BKY_LISTS_ISEMPTY_HELPURL}" - }, - // Block for getting the list length - { - "type": "lists_length", - "message0": "%{BKY_LISTS_LENGTH_TITLE}", - "args0": [ - { - "type": "input_value", - "name": "VALUE", - "check": ["String", "Array"] - } - ], - "output": "Number", - "colour": "%{BKY_LISTS_HUE}", - "tooltip": "%{BKY_LISTS_LENGTH_TOOLTIP}", - "helpUrl": "%{BKY_LISTS_LENGTH_HELPURL}" - } -]); // END JSON EXTRACT (Do not delete this comment.) - -Blockly.Blocks['lists_create_with'] = { - /** - * Block for creating a list with any number of elements of any type. - * @this Blockly.Block - */ - init: function() { - this.setHelpUrl(Blockly.Msg.LISTS_CREATE_WITH_HELPURL); - this.setColour(Blockly.Blocks.lists.HUE); - this.itemCount_ = 3; - this.updateShape_(); - this.setOutput(true, 'Array'); - this.setMutator(new Blockly.Mutator(['lists_create_with_item'])); - this.setTooltip(Blockly.Msg.LISTS_CREATE_WITH_TOOLTIP); - }, - /** - * Create XML to represent list inputs. - * @return {!Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - container.setAttribute('items', this.itemCount_); - return container; - }, - /** - * Parse XML to restore the list inputs. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - this.itemCount_ = parseInt(xmlElement.getAttribute('items'), 10); - this.updateShape_(); - }, - /** - * Populate the mutator's dialog with this block's components. - * @param {!Blockly.Workspace} workspace Mutator's workspace. - * @return {!Blockly.Block} Root block in mutator. - * @this Blockly.Block - */ - decompose: function(workspace) { - var containerBlock = workspace.newBlock('lists_create_with_container'); - containerBlock.initSvg(); - var connection = containerBlock.getInput('STACK').connection; - for (var i = 0; i < this.itemCount_; i++) { - var itemBlock = workspace.newBlock('lists_create_with_item'); - itemBlock.initSvg(); - connection.connect(itemBlock.previousConnection); - connection = itemBlock.nextConnection; - } - return containerBlock; - }, - /** - * Reconfigure this block based on the mutator dialog's components. - * @param {!Blockly.Block} containerBlock Root block in mutator. - * @this Blockly.Block - */ - compose: function(containerBlock) { - var itemBlock = containerBlock.getInputTargetBlock('STACK'); - // Count number of inputs. - var connections = []; - while (itemBlock) { - connections.push(itemBlock.valueConnection_); - itemBlock = itemBlock.nextConnection && - itemBlock.nextConnection.targetBlock(); - } - // Disconnect any children that don't belong. - for (var i = 0; i < this.itemCount_; i++) { - var connection = this.getInput('ADD' + i).connection.targetConnection; - if (connection && connections.indexOf(connection) == -1) { - connection.disconnect(); - } - } - this.itemCount_ = connections.length; - this.updateShape_(); - // Reconnect any child blocks. - for (var i = 0; i < this.itemCount_; i++) { - Blockly.Mutator.reconnect(connections[i], this, 'ADD' + i); - } - }, - /** - * Store pointers to any connected child blocks. - * @param {!Blockly.Block} containerBlock Root block in mutator. - * @this Blockly.Block - */ - saveConnections: function(containerBlock) { - var itemBlock = containerBlock.getInputTargetBlock('STACK'); - var i = 0; - while (itemBlock) { - var input = this.getInput('ADD' + i); - itemBlock.valueConnection_ = input && input.connection.targetConnection; - i++; - itemBlock = itemBlock.nextConnection && - itemBlock.nextConnection.targetBlock(); - } - }, - /** - * Modify this block to have the correct number of inputs. - * @private - * @this Blockly.Block - */ - updateShape_: function() { - if (this.itemCount_ && this.getInput('EMPTY')) { - this.removeInput('EMPTY'); - } else if (!this.itemCount_ && !this.getInput('EMPTY')) { - this.appendDummyInput('EMPTY') - .appendField(Blockly.Msg.LISTS_CREATE_EMPTY_TITLE); - } - // Add new inputs. - for (var i = 0; i < this.itemCount_; i++) { - if (!this.getInput('ADD' + i)) { - var input = this.appendValueInput('ADD' + i); - if (i == 0) { - input.appendField(Blockly.Msg.LISTS_CREATE_WITH_INPUT_WITH); - } - } - } - // Remove deleted inputs. - while (this.getInput('ADD' + i)) { - this.removeInput('ADD' + i); - i++; - } - } -}; - -Blockly.Blocks['lists_create_with_container'] = { - /** - * Mutator block for list container. - * @this Blockly.Block - */ - init: function() { - this.setColour(Blockly.Blocks.lists.HUE); - this.appendDummyInput() - .appendField(Blockly.Msg.LISTS_CREATE_WITH_CONTAINER_TITLE_ADD); - this.appendStatementInput('STACK'); - this.setTooltip(Blockly.Msg.LISTS_CREATE_WITH_CONTAINER_TOOLTIP); - this.contextMenu = false; - } -}; - -Blockly.Blocks['lists_create_with_item'] = { - /** - * Mutator block for adding items. - * @this Blockly.Block - */ - init: function() { - this.setColour(Blockly.Blocks.lists.HUE); - this.appendDummyInput() - .appendField(Blockly.Msg.LISTS_CREATE_WITH_ITEM_TITLE); - this.setPreviousStatement(true); - this.setNextStatement(true); - this.setTooltip(Blockly.Msg.LISTS_CREATE_WITH_ITEM_TOOLTIP); - this.contextMenu = false; - } -}; - -Blockly.Blocks['lists_indexOf'] = { - /** - * Block for finding an item in the list. - * @this Blockly.Block - */ - init: function() { - var OPERATORS = - [[Blockly.Msg.LISTS_INDEX_OF_FIRST, 'FIRST'], - [Blockly.Msg.LISTS_INDEX_OF_LAST, 'LAST']]; - this.setHelpUrl(Blockly.Msg.LISTS_INDEX_OF_HELPURL); - this.setColour(Blockly.Blocks.lists.HUE); - this.setOutput(true, 'Number'); - this.appendValueInput('VALUE') - .setCheck('Array') - .appendField(Blockly.Msg.LISTS_INDEX_OF_INPUT_IN_LIST); - this.appendValueInput('FIND') - .appendField(new Blockly.FieldDropdown(OPERATORS), 'END'); - this.setInputsInline(true); - // Assign 'this' to a variable for use in the tooltip closure below. - var thisBlock = this; - this.setTooltip(function() { - return Blockly.Msg.LISTS_INDEX_OF_TOOLTIP.replace('%1', - thisBlock.workspace.options.oneBasedIndex ? '0' : '-1'); - }); - } -}; - -Blockly.Blocks['lists_getIndex'] = { - /** - * Block for getting element at index. - * @this Blockly.Block - */ - init: function() { - var MODE = - [[Blockly.Msg.LISTS_GET_INDEX_GET, 'GET'], - [Blockly.Msg.LISTS_GET_INDEX_GET_REMOVE, 'GET_REMOVE'], - [Blockly.Msg.LISTS_GET_INDEX_REMOVE, 'REMOVE']]; - this.WHERE_OPTIONS = - [[Blockly.Msg.LISTS_GET_INDEX_FROM_START, 'FROM_START'], - [Blockly.Msg.LISTS_GET_INDEX_FROM_END, 'FROM_END'], - [Blockly.Msg.LISTS_GET_INDEX_FIRST, 'FIRST'], - [Blockly.Msg.LISTS_GET_INDEX_LAST, 'LAST'], - [Blockly.Msg.LISTS_GET_INDEX_RANDOM, 'RANDOM']]; - this.setHelpUrl(Blockly.Msg.LISTS_GET_INDEX_HELPURL); - this.setColour(Blockly.Blocks.lists.HUE); - var modeMenu = new Blockly.FieldDropdown(MODE, function(value) { - var isStatement = (value == 'REMOVE'); - this.sourceBlock_.updateStatement_(isStatement); - }); - this.appendValueInput('VALUE') - .setCheck('Array') - .appendField(Blockly.Msg.LISTS_GET_INDEX_INPUT_IN_LIST); - this.appendDummyInput() - .appendField(modeMenu, 'MODE') - .appendField('', 'SPACE'); - this.appendDummyInput('AT'); - if (Blockly.Msg.LISTS_GET_INDEX_TAIL) { - this.appendDummyInput('TAIL') - .appendField(Blockly.Msg.LISTS_GET_INDEX_TAIL); - } - this.setInputsInline(true); - this.setOutput(true); - this.updateAt_(true); - // Assign 'this' to a variable for use in the tooltip closure below. - var thisBlock = this; - this.setTooltip(function() { - var mode = thisBlock.getFieldValue('MODE'); - var where = thisBlock.getFieldValue('WHERE'); - var tooltip = ''; - switch (mode + ' ' + where) { - case 'GET FROM_START': - case 'GET FROM_END': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_GET_FROM; - break; - case 'GET FIRST': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_GET_FIRST; - break; - case 'GET LAST': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_GET_LAST; - break; - case 'GET RANDOM': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_GET_RANDOM; - break; - case 'GET_REMOVE FROM_START': - case 'GET_REMOVE FROM_END': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_GET_REMOVE_FROM; - break; - case 'GET_REMOVE FIRST': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_GET_REMOVE_FIRST; - break; - case 'GET_REMOVE LAST': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_GET_REMOVE_LAST; - break; - case 'GET_REMOVE RANDOM': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_GET_REMOVE_RANDOM; - break; - case 'REMOVE FROM_START': - case 'REMOVE FROM_END': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_REMOVE_FROM; - break; - case 'REMOVE FIRST': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_REMOVE_FIRST; - break; - case 'REMOVE LAST': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_REMOVE_LAST; - break; - case 'REMOVE RANDOM': - tooltip = Blockly.Msg.LISTS_GET_INDEX_TOOLTIP_REMOVE_RANDOM; - break; - } - if (where == 'FROM_START' || where == 'FROM_END') { - var msg = (where == 'FROM_START') ? - Blockly.Msg.LISTS_INDEX_FROM_START_TOOLTIP : - Blockly.Msg.LISTS_INDEX_FROM_END_TOOLTIP; - tooltip += ' ' + msg.replace('%1', - thisBlock.workspace.options.oneBasedIndex ? '#1' : '#0'); - } - return tooltip; - }); - }, - /** - * Create XML to represent whether the block is a statement or a value. - * Also represent whether there is an 'AT' input. - * @return {Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - var isStatement = !this.outputConnection; - container.setAttribute('statement', isStatement); - var isAt = this.getInput('AT').type == Blockly.INPUT_VALUE; - container.setAttribute('at', isAt); - return container; - }, - /** - * Parse XML to restore the 'AT' input. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - // Note: Until January 2013 this block did not have mutations, - // so 'statement' defaults to false and 'at' defaults to true. - var isStatement = (xmlElement.getAttribute('statement') == 'true'); - this.updateStatement_(isStatement); - var isAt = (xmlElement.getAttribute('at') != 'false'); - this.updateAt_(isAt); - }, - /** - * Switch between a value block and a statement block. - * @param {boolean} newStatement True if the block should be a statement. - * False if the block should be a value. - * @private - * @this Blockly.Block - */ - updateStatement_: function(newStatement) { - var oldStatement = !this.outputConnection; - if (newStatement != oldStatement) { - this.unplug(true, true); - if (newStatement) { - this.setOutput(false); - this.setPreviousStatement(true); - this.setNextStatement(true); - } else { - this.setPreviousStatement(false); - this.setNextStatement(false); - this.setOutput(true); - } - } - }, - /** - * Create or delete an input for the numeric index. - * @param {boolean} isAt True if the input should exist. - * @private - * @this Blockly.Block - */ - updateAt_: function(isAt) { - // Destroy old 'AT' and 'ORDINAL' inputs. - this.removeInput('AT'); - this.removeInput('ORDINAL', true); - // Create either a value 'AT' input or a dummy input. - if (isAt) { - this.appendValueInput('AT').setCheck('Number'); - if (Blockly.Msg.ORDINAL_NUMBER_SUFFIX) { - this.appendDummyInput('ORDINAL') - .appendField(Blockly.Msg.ORDINAL_NUMBER_SUFFIX); - } - } else { - this.appendDummyInput('AT'); - } - var menu = new Blockly.FieldDropdown(this.WHERE_OPTIONS, function(value) { - var newAt = (value == 'FROM_START') || (value == 'FROM_END'); - // The 'isAt' variable is available due to this function being a closure. - if (newAt != isAt) { - var block = this.sourceBlock_; - block.updateAt_(newAt); - // This menu has been destroyed and replaced. Update the replacement. - block.setFieldValue(value, 'WHERE'); - return null; - } - return undefined; - }); - this.getInput('AT').appendField(menu, 'WHERE'); - if (Blockly.Msg.LISTS_GET_INDEX_TAIL) { - this.moveInputBefore('TAIL', null); - } - } -}; - -Blockly.Blocks['lists_setIndex'] = { - /** - * Block for setting the element at index. - * @this Blockly.Block - */ - init: function() { - var MODE = - [[Blockly.Msg.LISTS_SET_INDEX_SET, 'SET'], - [Blockly.Msg.LISTS_SET_INDEX_INSERT, 'INSERT']]; - this.WHERE_OPTIONS = - [[Blockly.Msg.LISTS_GET_INDEX_FROM_START, 'FROM_START'], - [Blockly.Msg.LISTS_GET_INDEX_FROM_END, 'FROM_END'], - [Blockly.Msg.LISTS_GET_INDEX_FIRST, 'FIRST'], - [Blockly.Msg.LISTS_GET_INDEX_LAST, 'LAST'], - [Blockly.Msg.LISTS_GET_INDEX_RANDOM, 'RANDOM']]; - this.setHelpUrl(Blockly.Msg.LISTS_SET_INDEX_HELPURL); - this.setColour(Blockly.Blocks.lists.HUE); - this.appendValueInput('LIST') - .setCheck('Array') - .appendField(Blockly.Msg.LISTS_SET_INDEX_INPUT_IN_LIST); - this.appendDummyInput() - .appendField(new Blockly.FieldDropdown(MODE), 'MODE') - .appendField('', 'SPACE'); - this.appendDummyInput('AT'); - this.appendValueInput('TO') - .appendField(Blockly.Msg.LISTS_SET_INDEX_INPUT_TO); - this.setInputsInline(true); - this.setPreviousStatement(true); - this.setNextStatement(true); - this.setTooltip(Blockly.Msg.LISTS_SET_INDEX_TOOLTIP); - this.updateAt_(true); - // Assign 'this' to a variable for use in the tooltip closure below. - var thisBlock = this; - this.setTooltip(function() { - var mode = thisBlock.getFieldValue('MODE'); - var where = thisBlock.getFieldValue('WHERE'); - var tooltip = ''; - switch (mode + ' ' + where) { - case 'SET FROM_START': - case 'SET FROM_END': - tooltip = Blockly.Msg.LISTS_SET_INDEX_TOOLTIP_SET_FROM; - break; - case 'SET FIRST': - tooltip = Blockly.Msg.LISTS_SET_INDEX_TOOLTIP_SET_FIRST; - break; - case 'SET LAST': - tooltip = Blockly.Msg.LISTS_SET_INDEX_TOOLTIP_SET_LAST; - break; - case 'SET RANDOM': - tooltip = Blockly.Msg.LISTS_SET_INDEX_TOOLTIP_SET_RANDOM; - break; - case 'INSERT FROM_START': - case 'INSERT FROM_END': - tooltip = Blockly.Msg.LISTS_SET_INDEX_TOOLTIP_INSERT_FROM; - break; - case 'INSERT FIRST': - tooltip = Blockly.Msg.LISTS_SET_INDEX_TOOLTIP_INSERT_FIRST; - break; - case 'INSERT LAST': - tooltip = Blockly.Msg.LISTS_SET_INDEX_TOOLTIP_INSERT_LAST; - break; - case 'INSERT RANDOM': - tooltip = Blockly.Msg.LISTS_SET_INDEX_TOOLTIP_INSERT_RANDOM; - break; - } - if (where == 'FROM_START' || where == 'FROM_END') { - tooltip += ' ' + Blockly.Msg.LISTS_INDEX_FROM_START_TOOLTIP - .replace('%1', - thisBlock.workspace.options.oneBasedIndex ? '#1' : '#0'); - } - return tooltip; - }); - }, - /** - * Create XML to represent whether there is an 'AT' input. - * @return {Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - var isAt = this.getInput('AT').type == Blockly.INPUT_VALUE; - container.setAttribute('at', isAt); - return container; - }, - /** - * Parse XML to restore the 'AT' input. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - // Note: Until January 2013 this block did not have mutations, - // so 'at' defaults to true. - var isAt = (xmlElement.getAttribute('at') != 'false'); - this.updateAt_(isAt); - }, - /** - * Create or delete an input for the numeric index. - * @param {boolean} isAt True if the input should exist. - * @private - * @this Blockly.Block - */ - updateAt_: function(isAt) { - // Destroy old 'AT' and 'ORDINAL' input. - this.removeInput('AT'); - this.removeInput('ORDINAL', true); - // Create either a value 'AT' input or a dummy input. - if (isAt) { - this.appendValueInput('AT').setCheck('Number'); - if (Blockly.Msg.ORDINAL_NUMBER_SUFFIX) { - this.appendDummyInput('ORDINAL') - .appendField(Blockly.Msg.ORDINAL_NUMBER_SUFFIX); - } - } else { - this.appendDummyInput('AT'); - } - var menu = new Blockly.FieldDropdown(this.WHERE_OPTIONS, function(value) { - var newAt = (value == 'FROM_START') || (value == 'FROM_END'); - // The 'isAt' variable is available due to this function being a closure. - if (newAt != isAt) { - var block = this.sourceBlock_; - block.updateAt_(newAt); - // This menu has been destroyed and replaced. Update the replacement. - block.setFieldValue(value, 'WHERE'); - return null; - } - return undefined; - }); - this.moveInputBefore('AT', 'TO'); - if (this.getInput('ORDINAL')) { - this.moveInputBefore('ORDINAL', 'TO'); - } - - this.getInput('AT').appendField(menu, 'WHERE'); - } -}; - -Blockly.Blocks['lists_getSublist'] = { - /** - * Block for getting sublist. - * @this Blockly.Block - */ - init: function() { - this['WHERE_OPTIONS_1'] = - [[Blockly.Msg.LISTS_GET_SUBLIST_START_FROM_START, 'FROM_START'], - [Blockly.Msg.LISTS_GET_SUBLIST_START_FROM_END, 'FROM_END'], - [Blockly.Msg.LISTS_GET_SUBLIST_START_FIRST, 'FIRST']]; - this['WHERE_OPTIONS_2'] = - [[Blockly.Msg.LISTS_GET_SUBLIST_END_FROM_START, 'FROM_START'], - [Blockly.Msg.LISTS_GET_SUBLIST_END_FROM_END, 'FROM_END'], - [Blockly.Msg.LISTS_GET_SUBLIST_END_LAST, 'LAST']]; - this.setHelpUrl(Blockly.Msg.LISTS_GET_SUBLIST_HELPURL); - this.setColour(Blockly.Blocks.lists.HUE); - this.appendValueInput('LIST') - .setCheck('Array') - .appendField(Blockly.Msg.LISTS_GET_SUBLIST_INPUT_IN_LIST); - this.appendDummyInput('AT1'); - this.appendDummyInput('AT2'); - if (Blockly.Msg.LISTS_GET_SUBLIST_TAIL) { - this.appendDummyInput('TAIL') - .appendField(Blockly.Msg.LISTS_GET_SUBLIST_TAIL); - } - this.setInputsInline(true); - this.setOutput(true, 'Array'); - this.updateAt_(1, true); - this.updateAt_(2, true); - this.setTooltip(Blockly.Msg.LISTS_GET_SUBLIST_TOOLTIP); - }, - /** - * Create XML to represent whether there are 'AT' inputs. - * @return {Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - var isAt1 = this.getInput('AT1').type == Blockly.INPUT_VALUE; - container.setAttribute('at1', isAt1); - var isAt2 = this.getInput('AT2').type == Blockly.INPUT_VALUE; - container.setAttribute('at2', isAt2); - return container; - }, - /** - * Parse XML to restore the 'AT' inputs. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - var isAt1 = (xmlElement.getAttribute('at1') == 'true'); - var isAt2 = (xmlElement.getAttribute('at2') == 'true'); - this.updateAt_(1, isAt1); - this.updateAt_(2, isAt2); - }, - /** - * Create or delete an input for a numeric index. - * This block has two such inputs, independant of each other. - * @param {number} n Specify first or second input (1 or 2). - * @param {boolean} isAt True if the input should exist. - * @private - * @this Blockly.Block - */ - updateAt_: function(n, isAt) { - // Create or delete an input for the numeric index. - // Destroy old 'AT' and 'ORDINAL' inputs. - this.removeInput('AT' + n); - this.removeInput('ORDINAL' + n, true); - // Create either a value 'AT' input or a dummy input. - if (isAt) { - this.appendValueInput('AT' + n).setCheck('Number'); - if (Blockly.Msg.ORDINAL_NUMBER_SUFFIX) { - this.appendDummyInput('ORDINAL' + n) - .appendField(Blockly.Msg.ORDINAL_NUMBER_SUFFIX); - } - } else { - this.appendDummyInput('AT' + n); - } - var menu = new Blockly.FieldDropdown(this['WHERE_OPTIONS_' + n], - function(value) { - var newAt = (value == 'FROM_START') || (value == 'FROM_END'); - // The 'isAt' variable is available due to this function being a - // closure. - if (newAt != isAt) { - var block = this.sourceBlock_; - block.updateAt_(n, newAt); - // This menu has been destroyed and replaced. - // Update the replacement. - block.setFieldValue(value, 'WHERE' + n); - return null; - } - return undefined; - }); - this.getInput('AT' + n) - .appendField(menu, 'WHERE' + n); - if (n == 1) { - this.moveInputBefore('AT1', 'AT2'); - if (this.getInput('ORDINAL1')) { - this.moveInputBefore('ORDINAL1', 'AT2'); - } - } - if (Blockly.Msg.LISTS_GET_SUBLIST_TAIL) { - this.moveInputBefore('TAIL', null); - } - } -}; - -Blockly.Blocks['lists_sort'] = { - /** - * Block for sorting a list. - * @this Blockly.Block - */ - init: function() { - this.jsonInit({ - "message0": Blockly.Msg.LISTS_SORT_TITLE, - "args0": [ - { - "type": "field_dropdown", - "name": "TYPE", - "options": [ - [Blockly.Msg.LISTS_SORT_TYPE_NUMERIC, "NUMERIC"], - [Blockly.Msg.LISTS_SORT_TYPE_TEXT, "TEXT"], - [Blockly.Msg.LISTS_SORT_TYPE_IGNORECASE, "IGNORE_CASE"] - ] - }, - { - "type": "field_dropdown", - "name": "DIRECTION", - "options": [ - [Blockly.Msg.LISTS_SORT_ORDER_ASCENDING, "1"], - [Blockly.Msg.LISTS_SORT_ORDER_DESCENDING, "-1"] - ] - }, - { - "type": "input_value", - "name": "LIST", - "check": "Array" - } - ], - "output": "Array", - "colour": Blockly.Blocks.lists.HUE, - "tooltip": Blockly.Msg.LISTS_SORT_TOOLTIP, - "helpUrl": Blockly.Msg.LISTS_SORT_HELPURL - }); - } -}; - -Blockly.Blocks['lists_split'] = { - /** - * Block for splitting text into a list, or joining a list into text. - * @this Blockly.Block - */ - init: function() { - // Assign 'this' to a variable for use in the closures below. - var thisBlock = this; - var dropdown = new Blockly.FieldDropdown( - [[Blockly.Msg.LISTS_SPLIT_LIST_FROM_TEXT, 'SPLIT'], - [Blockly.Msg.LISTS_SPLIT_TEXT_FROM_LIST, 'JOIN']], - function(newMode) { - thisBlock.updateType_(newMode); - }); - this.setHelpUrl(Blockly.Msg.LISTS_SPLIT_HELPURL); - this.setColour(Blockly.Blocks.lists.HUE); - this.appendValueInput('INPUT') - .setCheck('String') - .appendField(dropdown, 'MODE'); - this.appendValueInput('DELIM') - .setCheck('String') - .appendField(Blockly.Msg.LISTS_SPLIT_WITH_DELIMITER); - this.setInputsInline(true); - this.setOutput(true, 'Array'); - this.setTooltip(function() { - var mode = thisBlock.getFieldValue('MODE'); - if (mode == 'SPLIT') { - return Blockly.Msg.LISTS_SPLIT_TOOLTIP_SPLIT; - } else if (mode == 'JOIN') { - return Blockly.Msg.LISTS_SPLIT_TOOLTIP_JOIN; - } - throw 'Unknown mode: ' + mode; - }); - }, - /** - * Modify this block to have the correct input and output types. - * @param {string} newMode Either 'SPLIT' or 'JOIN'. - * @private - * @this Blockly.Block - */ - updateType_: function(newMode) { - if (newMode == 'SPLIT') { - this.outputConnection.setCheck('Array'); - this.getInput('INPUT').setCheck('String'); - } else { - this.outputConnection.setCheck('String'); - this.getInput('INPUT').setCheck('Array'); - } - }, - /** - * Create XML to represent the input and output types. - * @return {!Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - container.setAttribute('mode', this.getFieldValue('MODE')); - return container; - }, - /** - * Parse XML to restore the input and output types. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - this.updateType_(xmlElement.getAttribute('mode')); - } -}; diff --git a/blocks/lists.ts b/blocks/lists.ts new file mode 100644 index 00000000000..864803b937c --- /dev/null +++ b/blocks/lists.ts @@ -0,0 +1,1065 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.libraryBlocks.lists + +import type {Block} from '../core/block.js'; +import type {BlockSvg} from '../core/block_svg.js'; +import { + createBlockDefinitionsFromJsonArray, + defineBlocks, +} from '../core/common.js'; +import type {Connection} from '../core/connection.js'; +import '../core/field_dropdown.js'; +import type {FieldDropdown} from '../core/field_dropdown.js'; +import * as fieldRegistry from '../core/field_registry.js'; +import {MutatorIcon} from '../core/icons/mutator_icon.js'; +import {Align} from '../core/inputs/align.js'; +import {ValueInput} from '../core/inputs/value_input.js'; +import {Msg} from '../core/msg.js'; +import * as xmlUtils from '../core/utils/xml.js'; +import type {Workspace} from '../core/workspace.js'; + +/** + * A dictionary of the block definitions provided by this module. + */ +export const blocks = createBlockDefinitionsFromJsonArray([ + // Block for creating an empty list + // The 'list_create_with' block is preferred as it is more flexible. + // + // + // + { + 'type': 'lists_create_empty', + 'message0': '%{BKY_LISTS_CREATE_EMPTY_TITLE}', + 'output': 'Array', + 'style': 'list_blocks', + 'tooltip': '%{BKY_LISTS_CREATE_EMPTY_TOOLTIP}', + 'helpUrl': '%{BKY_LISTS_CREATE_EMPTY_HELPURL}', + }, + // Block for creating a list with one element repeated. + { + 'type': 'lists_repeat', + 'message0': '%{BKY_LISTS_REPEAT_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'ITEM', + }, + { + 'type': 'input_value', + 'name': 'NUM', + 'check': 'Number', + }, + ], + 'output': 'Array', + 'style': 'list_blocks', + 'tooltip': '%{BKY_LISTS_REPEAT_TOOLTIP}', + 'helpUrl': '%{BKY_LISTS_REPEAT_HELPURL}', + }, + // Block for reversing a list. + { + 'type': 'lists_reverse', + 'message0': '%{BKY_LISTS_REVERSE_MESSAGE0}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'LIST', + 'check': 'Array', + }, + ], + 'output': 'Array', + 'inputsInline': true, + 'style': 'list_blocks', + 'tooltip': '%{BKY_LISTS_REVERSE_TOOLTIP}', + 'helpUrl': '%{BKY_LISTS_REVERSE_HELPURL}', + }, + // Block for checking if a list is empty + { + 'type': 'lists_isEmpty', + 'message0': '%{BKY_LISTS_ISEMPTY_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'VALUE', + 'check': ['String', 'Array'], + }, + ], + 'output': 'Boolean', + 'style': 'list_blocks', + 'tooltip': '%{BKY_LISTS_ISEMPTY_TOOLTIP}', + 'helpUrl': '%{BKY_LISTS_ISEMPTY_HELPURL}', + }, + // Block for getting the list length + { + 'type': 'lists_length', + 'message0': '%{BKY_LISTS_LENGTH_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'VALUE', + 'check': ['String', 'Array'], + }, + ], + 'output': 'Number', + 'style': 'list_blocks', + 'tooltip': '%{BKY_LISTS_LENGTH_TOOLTIP}', + 'helpUrl': '%{BKY_LISTS_LENGTH_HELPURL}', + }, +]); + +/** + * Type of a 'lists_create_with' block. + * + * @internal + */ +export type CreateWithBlock = Block & ListCreateWithMixin; +interface ListCreateWithMixin extends ListCreateWithMixinType { + itemCount_: number; +} +type ListCreateWithMixinType = typeof LISTS_CREATE_WITH; + +const LISTS_CREATE_WITH = { + /** + * Block for creating a list with any number of elements of any type. + */ + init: function (this: CreateWithBlock) { + this.setHelpUrl(Msg['LISTS_CREATE_WITH_HELPURL']); + this.setStyle('list_blocks'); + this.itemCount_ = 3; + this.updateShape_(); + this.setOutput(true, 'Array'); + this.setMutator( + new MutatorIcon(['lists_create_with_item'], this as unknown as BlockSvg), + ); // BUG(#6905) + this.setTooltip(Msg['LISTS_CREATE_WITH_TOOLTIP']); + }, + /** + * Create XML to represent list inputs. + * Backwards compatible serialization implementation. + */ + mutationToDom: function (this: CreateWithBlock): Element { + const container = xmlUtils.createElement('mutation'); + container.setAttribute('items', String(this.itemCount_)); + return container; + }, + /** + * Parse XML to restore the list inputs. + * Backwards compatible serialization implementation. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: CreateWithBlock, xmlElement: Element) { + const items = xmlElement.getAttribute('items'); + if (!items) throw new TypeError('element did not have items'); + this.itemCount_ = parseInt(items, 10); + this.updateShape_(); + }, + /** + * Returns the state of this block as a JSON serializable object. + * + * @returns The state of this block, ie the item count. + */ + saveExtraState: function (this: CreateWithBlock): {itemCount: number} { + return { + 'itemCount': this.itemCount_, + }; + }, + /** + * Applies the given state to this block. + * + * @param state The state to apply to this block, ie the item count. + */ + loadExtraState: function (this: CreateWithBlock, state: AnyDuringMigration) { + this.itemCount_ = state['itemCount']; + this.updateShape_(); + }, + /** + * Populate the mutator's dialog with this block's components. + * + * @param workspace Mutator's workspace. + * @returns Root block in mutator. + */ + decompose: function ( + this: CreateWithBlock, + workspace: Workspace, + ): ContainerBlock { + const containerBlock = workspace.newBlock( + 'lists_create_with_container', + ) as ContainerBlock; + (containerBlock as BlockSvg).initSvg(); + let connection = containerBlock.getInput('STACK')!.connection; + for (let i = 0; i < this.itemCount_; i++) { + const itemBlock = workspace.newBlock( + 'lists_create_with_item', + ) as ItemBlock; + (itemBlock as BlockSvg).initSvg(); + if (!itemBlock.previousConnection) { + throw new Error('itemBlock has no previousConnection'); + } + connection!.connect(itemBlock.previousConnection); + connection = itemBlock.nextConnection; + } + return containerBlock; + }, + /** + * Reconfigure this block based on the mutator dialog's components. + * + * @param containerBlock Root block in mutator. + */ + compose: function (this: CreateWithBlock, containerBlock: Block) { + let itemBlock: ItemBlock | null = containerBlock.getInputTargetBlock( + 'STACK', + ) as ItemBlock; + // Count number of inputs. + const connections: Connection[] = []; + while (itemBlock) { + if (itemBlock.isInsertionMarker()) { + itemBlock = itemBlock.getNextBlock() as ItemBlock | null; + continue; + } + connections.push(itemBlock.valueConnection_ as Connection); + itemBlock = itemBlock.getNextBlock() as ItemBlock | null; + } + // Disconnect any children that don't belong. + for (let i = 0; i < this.itemCount_; i++) { + const connection = this.getInput('ADD' + i)!.connection!.targetConnection; + if (connection && !connections.includes(connection)) { + connection.disconnect(); + } + } + this.itemCount_ = connections.length; + this.updateShape_(); + // Reconnect any child blocks. + for (let i = 0; i < this.itemCount_; i++) { + connections[i]?.reconnect(this, 'ADD' + i); + } + }, + /** + * Store pointers to any connected child blocks. + * + * @param containerBlock Root block in mutator. + */ + saveConnections: function (this: CreateWithBlock, containerBlock: Block) { + let itemBlock: ItemBlock | null = containerBlock.getInputTargetBlock( + 'STACK', + ) as ItemBlock; + let i = 0; + while (itemBlock) { + if (itemBlock.isInsertionMarker()) { + itemBlock = itemBlock.getNextBlock() as ItemBlock | null; + continue; + } + const input = this.getInput('ADD' + i); + itemBlock.valueConnection_ = input?.connection! + .targetConnection as Connection; + itemBlock = itemBlock.getNextBlock() as ItemBlock | null; + i++; + } + }, + /** + * Modify this block to have the correct number of inputs. + */ + updateShape_: function (this: CreateWithBlock) { + if (this.itemCount_ && this.getInput('EMPTY')) { + this.removeInput('EMPTY'); + } else if (!this.itemCount_ && !this.getInput('EMPTY')) { + this.appendDummyInput('EMPTY').appendField( + Msg['LISTS_CREATE_EMPTY_TITLE'], + ); + } + // Add new inputs. + for (let i = 0; i < this.itemCount_; i++) { + if (!this.getInput('ADD' + i)) { + const input = this.appendValueInput('ADD' + i).setAlign(Align.RIGHT); + if (i === 0) { + input.appendField(Msg['LISTS_CREATE_WITH_INPUT_WITH']); + } + } + } + // Remove deleted inputs. + for (let i = this.itemCount_; this.getInput('ADD' + i); i++) { + this.removeInput('ADD' + i); + } + }, +}; +blocks['lists_create_with'] = LISTS_CREATE_WITH; + +/** Type for a 'lists_create_with_container' block. */ +type ContainerBlock = Block & ContainerMutator; +interface ContainerMutator extends ContainerMutatorType {} +type ContainerMutatorType = typeof LISTS_CREATE_WITH_CONTAINER; + +const LISTS_CREATE_WITH_CONTAINER = { + /** + * Mutator block for list container. + */ + init: function (this: ContainerBlock) { + this.setStyle('list_blocks'); + this.appendDummyInput().appendField( + Msg['LISTS_CREATE_WITH_CONTAINER_TITLE_ADD'], + ); + this.appendStatementInput('STACK'); + this.setTooltip(Msg['LISTS_CREATE_WITH_CONTAINER_TOOLTIP']); + this.contextMenu = false; + }, +}; +blocks['lists_create_with_container'] = LISTS_CREATE_WITH_CONTAINER; + +/** Type for a 'lists_create_with_item' block. */ +type ItemBlock = Block & ItemMutator; +interface ItemMutator extends ItemMutatorType { + valueConnection_?: Connection; +} +type ItemMutatorType = typeof LISTS_CREATE_WITH_ITEM; + +const LISTS_CREATE_WITH_ITEM = { + /** + * Mutator block for adding items. + */ + init: function (this: ItemBlock) { + this.setStyle('list_blocks'); + this.appendDummyInput().appendField(Msg['LISTS_CREATE_WITH_ITEM_TITLE']); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip(Msg['LISTS_CREATE_WITH_ITEM_TOOLTIP']); + this.contextMenu = false; + }, +}; +blocks['lists_create_with_item'] = LISTS_CREATE_WITH_ITEM; + +/** Type for a 'lists_indexOf' block. */ +type IndexOfBlock = Block & IndexOfMutator; +interface IndexOfMutator extends IndexOfMutatorType {} +type IndexOfMutatorType = typeof LISTS_INDEXOF; + +const LISTS_INDEXOF = { + /** + * Block for finding an item in the list. + */ + init: function (this: IndexOfBlock) { + const OPERATORS = [ + [Msg['LISTS_INDEX_OF_FIRST'], 'FIRST'], + [Msg['LISTS_INDEX_OF_LAST'], 'LAST'], + ]; + this.setHelpUrl(Msg['LISTS_INDEX_OF_HELPURL']); + this.setStyle('list_blocks'); + this.setOutput(true, 'Number'); + this.appendValueInput('VALUE') + .setCheck('Array') + .appendField(Msg['LISTS_INDEX_OF_INPUT_IN_LIST']); + const operatorsDropdown = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: OPERATORS, + }); + if (!operatorsDropdown) throw new Error('field_dropdown not found'); + this.appendValueInput('FIND').appendField(operatorsDropdown, 'END'); + this.setInputsInline(true); + this.setTooltip(() => { + return Msg['LISTS_INDEX_OF_TOOLTIP'].replace( + '%1', + this.workspace.options.oneBasedIndex ? '0' : '-1', + ); + }); + }, +}; +blocks['lists_indexOf'] = LISTS_INDEXOF; + +/** Type for a 'lists_getIndex' block. */ +type GetIndexBlock = Block & GetIndexMutator; +interface GetIndexMutator extends GetIndexMutatorType { + WHERE_OPTIONS: Array<[string, string]>; +} +type GetIndexMutatorType = typeof LISTS_GETINDEX; + +const LISTS_GETINDEX = { + /** + * Block for getting element at index. + */ + init: function (this: GetIndexBlock) { + const MODE = [ + [Msg['LISTS_GET_INDEX_GET'], 'GET'], + [Msg['LISTS_GET_INDEX_GET_REMOVE'], 'GET_REMOVE'], + [Msg['LISTS_GET_INDEX_REMOVE'], 'REMOVE'], + ]; + this.WHERE_OPTIONS = [ + [Msg['LISTS_GET_INDEX_FROM_START'], 'FROM_START'], + [Msg['LISTS_GET_INDEX_FROM_END'], 'FROM_END'], + [Msg['LISTS_GET_INDEX_FIRST'], 'FIRST'], + [Msg['LISTS_GET_INDEX_LAST'], 'LAST'], + [Msg['LISTS_GET_INDEX_RANDOM'], 'RANDOM'], + ]; + this.setHelpUrl(Msg['LISTS_GET_INDEX_HELPURL']); + this.setStyle('list_blocks'); + const modeMenu = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: MODE, + }) as FieldDropdown; + modeMenu.setValidator( + /** @param value The input value. */ + function (this: FieldDropdown, value: string) { + const isStatement = value === 'REMOVE'; + (this.getSourceBlock() as GetIndexBlock).updateStatement_(isStatement); + return undefined; + }, + ); + this.appendValueInput('VALUE') + .setCheck('Array') + .appendField(Msg['LISTS_GET_INDEX_INPUT_IN_LIST']); + this.appendDummyInput() + .appendField(modeMenu, 'MODE') + .appendField('', 'SPACE'); + const menu = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: this.WHERE_OPTIONS, + }) as FieldDropdown; + menu.setValidator( + /** @param value The input value. */ + function (this: FieldDropdown, value: string) { + const oldValue: string | null = this.getValue(); + const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END'; + const newAt = value === 'FROM_START' || value === 'FROM_END'; + if (newAt !== oldAt) { + const block = this.getSourceBlock() as GetIndexBlock; + block.updateAt_(newAt); + } + return undefined; + }, + ); + this.appendDummyInput().appendField(menu, 'WHERE'); + this.appendDummyInput('AT'); + if (Msg['LISTS_GET_INDEX_TAIL']) { + this.appendDummyInput('TAIL').appendField(Msg['LISTS_GET_INDEX_TAIL']); + } + this.setInputsInline(true); + this.setOutput(true); + this.updateAt_(true); + this.setTooltip(() => { + const mode = this.getFieldValue('MODE'); + const where = this.getFieldValue('WHERE'); + let tooltip = ''; + switch (mode + ' ' + where) { + case 'GET FROM_START': + case 'GET FROM_END': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_GET_FROM']; + break; + case 'GET FIRST': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_GET_FIRST']; + break; + case 'GET LAST': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_GET_LAST']; + break; + case 'GET RANDOM': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_GET_RANDOM']; + break; + case 'GET_REMOVE FROM_START': + case 'GET_REMOVE FROM_END': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_GET_REMOVE_FROM']; + break; + case 'GET_REMOVE FIRST': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_GET_REMOVE_FIRST']; + break; + case 'GET_REMOVE LAST': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_GET_REMOVE_LAST']; + break; + case 'GET_REMOVE RANDOM': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_GET_REMOVE_RANDOM']; + break; + case 'REMOVE FROM_START': + case 'REMOVE FROM_END': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_REMOVE_FROM']; + break; + case 'REMOVE FIRST': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_REMOVE_FIRST']; + break; + case 'REMOVE LAST': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_REMOVE_LAST']; + break; + case 'REMOVE RANDOM': + tooltip = Msg['LISTS_GET_INDEX_TOOLTIP_REMOVE_RANDOM']; + break; + } + if (where === 'FROM_START' || where === 'FROM_END') { + const msg = + where === 'FROM_START' + ? Msg['LISTS_INDEX_FROM_START_TOOLTIP'] + : Msg['LISTS_INDEX_FROM_END_TOOLTIP']; + tooltip += + ' ' + + msg.replace('%1', this.workspace.options.oneBasedIndex ? '#1' : '#0'); + } + return tooltip; + }); + }, + /** + * Create XML to represent whether the block is a statement or a value. + * Also represent whether there is an 'AT' input. + * + * @returns XML storage element. + */ + mutationToDom: function (this: GetIndexBlock): Element { + const container = xmlUtils.createElement('mutation'); + const isStatement = !this.outputConnection; + container.setAttribute('statement', String(isStatement)); + const isAt = this.getInput('AT') instanceof ValueInput; + container.setAttribute('at', String(isAt)); + return container; + }, + /** + * Parse XML to restore the 'AT' input. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: GetIndexBlock, xmlElement: Element) { + // Note: Until January 2013 this block did not have mutations, + // so 'statement' defaults to false and 'at' defaults to true. + const isStatement = xmlElement.getAttribute('statement') === 'true'; + this.updateStatement_(isStatement); + const isAt = xmlElement.getAttribute('at') !== 'false'; + this.updateAt_(isAt); + }, + /** + * Returns the state of this block as a JSON serializable object. + * Returns null for efficiency if no state is needed (not a statement) + * + * @returns The state of this block, ie whether it's a statement. + */ + saveExtraState: function (this: GetIndexBlock): { + isStatement: boolean; + } | null { + if (!this.outputConnection) { + return { + isStatement: true, + }; + } + return null; + }, + + /** + * Applies the given state to this block. + * + * @param state The state to apply to this block, ie whether it's a + * statement. + */ + loadExtraState: function (this: GetIndexBlock, state: AnyDuringMigration) { + if (state['isStatement']) { + this.updateStatement_(true); + } else if (typeof state === 'string') { + // backward compatible for json serialised mutations + this.domToMutation(xmlUtils.textToDom(state)); + } + }, + + /** + * Switch between a value block and a statement block. + * + * @param newStatement True if the block should be a statement. + * False if the block should be a value. + */ + updateStatement_: function (this: GetIndexBlock, newStatement: boolean) { + const oldStatement = !this.outputConnection; + if (newStatement !== oldStatement) { + // TODO(#6920): The .unplug only has one parameter. + (this.unplug as (arg0?: boolean, arg1?: boolean) => void)(true, true); + if (newStatement) { + this.setOutput(false); + this.setPreviousStatement(true); + this.setNextStatement(true); + } else { + this.setPreviousStatement(false); + this.setNextStatement(false); + this.setOutput(true); + } + } + }, + /** + * Create or delete an input for the numeric index. + * + * @param isAt True if the input should exist. + */ + updateAt_: function (this: GetIndexBlock, isAt: boolean) { + // Destroy old 'AT' and 'ORDINAL' inputs. + this.removeInput('AT'); + this.removeInput('ORDINAL', true); + // Create either a value 'AT' input or a dummy input. + if (isAt) { + this.appendValueInput('AT').setCheck('Number'); + if (Msg['ORDINAL_NUMBER_SUFFIX']) { + this.appendDummyInput('ORDINAL').appendField( + Msg['ORDINAL_NUMBER_SUFFIX'], + ); + } + } else { + this.appendDummyInput('AT'); + } + if (Msg['LISTS_GET_INDEX_TAIL']) { + this.moveInputBefore('TAIL', null); + } + }, +}; +blocks['lists_getIndex'] = LISTS_GETINDEX; + +/** Type for a 'lists_setIndex' block. */ +type SetIndexBlock = Block & SetIndexMutator; +interface SetIndexMutator extends SetIndexMutatorType { + WHERE_OPTIONS: Array<[string, string]>; +} +type SetIndexMutatorType = typeof LISTS_SETINDEX; + +const LISTS_SETINDEX = { + /** + * Block for setting the element at index. + */ + init: function (this: SetIndexBlock) { + const MODE = [ + [Msg['LISTS_SET_INDEX_SET'], 'SET'], + [Msg['LISTS_SET_INDEX_INSERT'], 'INSERT'], + ]; + this.WHERE_OPTIONS = [ + [Msg['LISTS_GET_INDEX_FROM_START'], 'FROM_START'], + [Msg['LISTS_GET_INDEX_FROM_END'], 'FROM_END'], + [Msg['LISTS_GET_INDEX_FIRST'], 'FIRST'], + [Msg['LISTS_GET_INDEX_LAST'], 'LAST'], + [Msg['LISTS_GET_INDEX_RANDOM'], 'RANDOM'], + ]; + this.setHelpUrl(Msg['LISTS_SET_INDEX_HELPURL']); + this.setStyle('list_blocks'); + this.appendValueInput('LIST') + .setCheck('Array') + .appendField(Msg['LISTS_SET_INDEX_INPUT_IN_LIST']); + const operationDropdown = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: MODE, + }) as FieldDropdown; + this.appendDummyInput() + .appendField(operationDropdown, 'MODE') + .appendField('', 'SPACE'); + const menu = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: this.WHERE_OPTIONS, + }) as FieldDropdown; + menu.setValidator( + /** @param value The input value. */ + function (this: FieldDropdown, value: string) { + const oldValue: string | null = this.getValue(); + const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END'; + const newAt = value === 'FROM_START' || value === 'FROM_END'; + if (newAt !== oldAt) { + const block = this.getSourceBlock() as SetIndexBlock; + block.updateAt_(newAt); + } + return undefined; + }, + ); + this.appendDummyInput().appendField(menu, 'WHERE'); + this.appendDummyInput('AT'); + this.appendValueInput('TO').appendField(Msg['LISTS_SET_INDEX_INPUT_TO']); + this.setInputsInline(true); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip(Msg['LISTS_SET_INDEX_TOOLTIP']); + this.updateAt_(true); + this.setTooltip(() => { + const mode = this.getFieldValue('MODE'); + const where = this.getFieldValue('WHERE'); + let tooltip = ''; + switch (mode + ' ' + where) { + case 'SET FROM_START': + case 'SET FROM_END': + tooltip = Msg['LISTS_SET_INDEX_TOOLTIP_SET_FROM']; + break; + case 'SET FIRST': + tooltip = Msg['LISTS_SET_INDEX_TOOLTIP_SET_FIRST']; + break; + case 'SET LAST': + tooltip = Msg['LISTS_SET_INDEX_TOOLTIP_SET_LAST']; + break; + case 'SET RANDOM': + tooltip = Msg['LISTS_SET_INDEX_TOOLTIP_SET_RANDOM']; + break; + case 'INSERT FROM_START': + case 'INSERT FROM_END': + tooltip = Msg['LISTS_SET_INDEX_TOOLTIP_INSERT_FROM']; + break; + case 'INSERT FIRST': + tooltip = Msg['LISTS_SET_INDEX_TOOLTIP_INSERT_FIRST']; + break; + case 'INSERT LAST': + tooltip = Msg['LISTS_SET_INDEX_TOOLTIP_INSERT_LAST']; + break; + case 'INSERT RANDOM': + tooltip = Msg['LISTS_SET_INDEX_TOOLTIP_INSERT_RANDOM']; + break; + } + if (where === 'FROM_START' || where === 'FROM_END') { + tooltip += + ' ' + + Msg['LISTS_INDEX_FROM_START_TOOLTIP'].replace( + '%1', + this.workspace.options.oneBasedIndex ? '#1' : '#0', + ); + } + return tooltip; + }); + }, + /** + * Create XML to represent whether there is an 'AT' input. + * + * @returns XML storage element. + */ + mutationToDom: function (this: SetIndexBlock): Element { + const container = xmlUtils.createElement('mutation'); + const isAt = this.getInput('AT') instanceof ValueInput; + container.setAttribute('at', String(isAt)); + return container; + }, + /** + * Parse XML to restore the 'AT' input. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: SetIndexBlock, xmlElement: Element) { + // Note: Until January 2013 this block did not have mutations, + // so 'at' defaults to true. + const isAt = xmlElement.getAttribute('at') !== 'false'; + this.updateAt_(isAt); + }, + + /** + * Returns the state of this block as a JSON serializable object. + * This block does not need to serialize any specific state as it is already + * encoded in the dropdown values, but must have an implementation to avoid + * the backward compatible XML mutations being serialized. + * + * @returns The state of this block. + */ + saveExtraState: function (this: SetIndexBlock): null { + return null; + }, + + /** + * Applies the given state to this block. + * No extra state is needed or expected as it is already encoded in the + * dropdown values. + */ + loadExtraState: function (this: SetIndexBlock) {}, + + /** + * Create or delete an input for the numeric index. + * + * @param isAt True if the input should exist. + */ + updateAt_: function (this: SetIndexBlock, isAt: boolean) { + // Destroy old 'AT' and 'ORDINAL' input. + this.removeInput('AT'); + this.removeInput('ORDINAL', true); + // Create either a value 'AT' input or a dummy input. + if (isAt) { + this.appendValueInput('AT').setCheck('Number'); + if (Msg['ORDINAL_NUMBER_SUFFIX']) { + this.appendDummyInput('ORDINAL').appendField( + Msg['ORDINAL_NUMBER_SUFFIX'], + ); + } + } else { + this.appendDummyInput('AT'); + } + this.moveInputBefore('AT', 'TO'); + if (this.getInput('ORDINAL')) { + this.moveInputBefore('ORDINAL', 'TO'); + } + }, +}; +blocks['lists_setIndex'] = LISTS_SETINDEX; + +/** Type for a 'lists_getSublist' block. */ +type GetSublistBlock = Block & GetSublistMutator; +interface GetSublistMutator extends GetSublistMutatorType { + WHERE_OPTIONS_1: Array<[string, string]>; + WHERE_OPTIONS_2: Array<[string, string]>; +} +type GetSublistMutatorType = typeof LISTS_GETSUBLIST; + +const LISTS_GETSUBLIST = { + /** + * Block for getting sublist. + */ + init: function (this: GetSublistBlock) { + this['WHERE_OPTIONS_1'] = [ + [Msg['LISTS_GET_SUBLIST_START_FROM_START'], 'FROM_START'], + [Msg['LISTS_GET_SUBLIST_START_FROM_END'], 'FROM_END'], + [Msg['LISTS_GET_SUBLIST_START_FIRST'], 'FIRST'], + ]; + this['WHERE_OPTIONS_2'] = [ + [Msg['LISTS_GET_SUBLIST_END_FROM_START'], 'FROM_START'], + [Msg['LISTS_GET_SUBLIST_END_FROM_END'], 'FROM_END'], + [Msg['LISTS_GET_SUBLIST_END_LAST'], 'LAST'], + ]; + this.setHelpUrl(Msg['LISTS_GET_SUBLIST_HELPURL']); + this.setStyle('list_blocks'); + this.appendValueInput('LIST') + .setCheck('Array') + .appendField(Msg['LISTS_GET_SUBLIST_INPUT_IN_LIST']); + const createMenu = (n: 1 | 2): FieldDropdown => { + const menu = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: + this[('WHERE_OPTIONS_' + n) as 'WHERE_OPTIONS_1' | 'WHERE_OPTIONS_2'], + }) as FieldDropdown; + menu.setValidator( + /** @param value The input value. */ + function (this: FieldDropdown, value: string) { + const oldValue: string | null = this.getValue(); + const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END'; + const newAt = value === 'FROM_START' || value === 'FROM_END'; + if (newAt !== oldAt) { + const block = this.getSourceBlock() as GetSublistBlock; + block.updateAt_(n, newAt); + } + return undefined; + }, + ); + return menu; + }; + this.appendDummyInput('WHERE1_INPUT').appendField(createMenu(1), 'WHERE1'); + this.appendDummyInput('AT1'); + this.appendDummyInput('WHERE2_INPUT').appendField(createMenu(2), 'WHERE2'); + this.appendDummyInput('AT2'); + if (Msg['LISTS_GET_SUBLIST_TAIL']) { + this.appendDummyInput('TAIL').appendField(Msg['LISTS_GET_SUBLIST_TAIL']); + } + this.setInputsInline(true); + this.setOutput(true, 'Array'); + this.updateAt_(1, true); + this.updateAt_(2, true); + this.setTooltip(Msg['LISTS_GET_SUBLIST_TOOLTIP']); + }, + /** + * Create XML to represent whether there are 'AT' inputs. + * + * @returns XML storage element. + */ + mutationToDom: function (this: GetSublistBlock): Element { + const container = xmlUtils.createElement('mutation'); + const isAt1 = this.getInput('AT1') instanceof ValueInput; + container.setAttribute('at1', String(isAt1)); + const isAt2 = this.getInput('AT2') instanceof ValueInput; + container.setAttribute('at2', String(isAt2)); + return container; + }, + /** + * Parse XML to restore the 'AT' inputs. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: GetSublistBlock, xmlElement: Element) { + const isAt1 = xmlElement.getAttribute('at1') === 'true'; + const isAt2 = xmlElement.getAttribute('at2') === 'true'; + this.updateAt_(1, isAt1); + this.updateAt_(2, isAt2); + }, + + /** + * Returns the state of this block as a JSON serializable object. + * This block does not need to serialize any specific state as it is already + * encoded in the dropdown values, but must have an implementation to avoid + * the backward compatible XML mutations being serialized. + * + * @returns The state of this block. + */ + saveExtraState: function (this: GetSublistBlock): null { + return null; + }, + + /** + * Applies the given state to this block. + * No extra state is needed or expected as it is already encoded in the + * dropdown values. + */ + loadExtraState: function (this: GetSublistBlock) {}, + + /** + * Create or delete an input for a numeric index. + * This block has two such inputs, independent of each other. + * + * @param n Specify first or second input (1 or 2). + * @param isAt True if the input should exist. + */ + updateAt_: function (this: GetSublistBlock, n: 1 | 2, isAt: boolean) { + // Create or delete an input for the numeric index. + // Destroy old 'AT' and 'ORDINAL' inputs. + this.removeInput('AT' + n); + this.removeInput('ORDINAL' + n, true); + // Create either a value 'AT' input or a dummy input. + if (isAt) { + this.appendValueInput('AT' + n).setCheck('Number'); + if (Msg['ORDINAL_NUMBER_SUFFIX']) { + this.appendDummyInput('ORDINAL' + n).appendField( + Msg['ORDINAL_NUMBER_SUFFIX'], + ); + } + } else { + this.appendDummyInput('AT' + n); + } + if (n === 1) { + this.moveInputBefore('AT1', 'WHERE2_INPUT'); + if (this.getInput('ORDINAL1')) { + this.moveInputBefore('ORDINAL1', 'WHERE2_INPUT'); + } + } + if (Msg['LISTS_GET_SUBLIST_TAIL']) { + this.moveInputBefore('TAIL', null); + } + }, +}; +blocks['lists_getSublist'] = LISTS_GETSUBLIST; + +type SortBlock = Block | (typeof blocks)['lists_sort']; + +blocks['lists_sort'] = { + /** + * Block for sorting a list. + */ + init: function (this: SortBlock) { + this.jsonInit({ + 'message0': '%{BKY_LISTS_SORT_TITLE}', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'TYPE', + 'options': [ + ['%{BKY_LISTS_SORT_TYPE_NUMERIC}', 'NUMERIC'], + ['%{BKY_LISTS_SORT_TYPE_TEXT}', 'TEXT'], + ['%{BKY_LISTS_SORT_TYPE_IGNORECASE}', 'IGNORE_CASE'], + ], + }, + { + 'type': 'field_dropdown', + 'name': 'DIRECTION', + 'options': [ + ['%{BKY_LISTS_SORT_ORDER_ASCENDING}', '1'], + ['%{BKY_LISTS_SORT_ORDER_DESCENDING}', '-1'], + ], + }, + { + 'type': 'input_value', + 'name': 'LIST', + 'check': 'Array', + }, + ], + 'output': 'Array', + 'style': 'list_blocks', + 'tooltip': '%{BKY_LISTS_SORT_TOOLTIP}', + 'helpUrl': '%{BKY_LISTS_SORT_HELPURL}', + }); + }, +}; + +type SplitBlock = Block | (typeof blocks)['lists_split']; + +blocks['lists_split'] = { + /** + * Block for splitting text into a list, or joining a list into text. + */ + init: function (this: SplitBlock) { + const dropdown = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: [ + [Msg['LISTS_SPLIT_LIST_FROM_TEXT'], 'SPLIT'], + [Msg['LISTS_SPLIT_TEXT_FROM_LIST'], 'JOIN'], + ], + }); + if (!dropdown) throw new Error('field_dropdown not found'); + dropdown.setValidator((newMode) => { + this.updateType_(newMode); + }); + this.setHelpUrl(Msg['LISTS_SPLIT_HELPURL']); + this.setStyle('list_blocks'); + this.appendValueInput('INPUT') + .setCheck('String') + .appendField(dropdown, 'MODE'); + this.appendValueInput('DELIM') + .setCheck('String') + .appendField(Msg['LISTS_SPLIT_WITH_DELIMITER']); + this.setInputsInline(true); + this.setOutput(true, 'Array'); + this.setTooltip(() => { + const mode = this.getFieldValue('MODE'); + if (mode === 'SPLIT') { + return Msg['LISTS_SPLIT_TOOLTIP_SPLIT']; + } else if (mode === 'JOIN') { + return Msg['LISTS_SPLIT_TOOLTIP_JOIN']; + } + throw Error('Unknown mode: ' + mode); + }); + }, + /** + * Modify this block to have the correct input and output types. + * + * @param newMode Either 'SPLIT' or 'JOIN'. + */ + updateType_: function (this: SplitBlock, newMode: string) { + const mode = this.getFieldValue('MODE'); + if (mode !== newMode) { + const inputConnection = this.getInput('INPUT')!.connection; + inputConnection!.setShadowDom(null); + const inputBlock = inputConnection!.targetBlock(); + // TODO(#6920): This is probably not needed; see details in bug. + if (inputBlock) { + inputConnection!.disconnect(); + if (inputBlock.isShadow()) { + inputBlock.dispose(false); + } else { + this.bumpNeighbours(); + } + } + } + if (newMode === 'SPLIT') { + this.outputConnection!.setCheck('Array'); + this.getInput('INPUT')!.setCheck('String'); + } else { + this.outputConnection!.setCheck('String'); + this.getInput('INPUT')!.setCheck('Array'); + } + }, + /** + * Create XML to represent the input and output types. + * + * @returns XML storage element. + */ + mutationToDom: function (this: SplitBlock): Element { + const container = xmlUtils.createElement('mutation'); + container.setAttribute('mode', this.getFieldValue('MODE')); + return container; + }, + /** + * Parse XML to restore the input and output types. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: SplitBlock, xmlElement: Element) { + this.updateType_(xmlElement.getAttribute('mode')); + }, + + /** + * Returns the state of this block as a JSON serializable object. + * + * @returns The state of this block. + */ + saveExtraState: function (this: SplitBlock): {mode: string} { + return {'mode': this.getFieldValue('MODE')}; + }, + + /** + * Applies the given state to this block. + */ + loadExtraState: function (this: SplitBlock, state: {mode: string}) { + this.updateType_(state['mode']); + }, +}; + +// Register provided blocks. +defineBlocks(blocks); diff --git a/blocks/logic.js b/blocks/logic.js deleted file mode 100644 index f27c4e76928..00000000000 --- a/blocks/logic.js +++ /dev/null @@ -1,621 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Logic blocks for Blockly. - * - * This file is scraped to extract a .json file of block definitions. The array - * passed to defineBlocksWithJsonArray(..) must be strict JSON: double quotes - * only, no outside references, no functions, no trailing commas, etc. The one - * exception is end-of-line comments, which the scraper will remove. - * @author q.neutron@gmail.com (Quynh Neutron) - */ -'use strict'; - -goog.provide('Blockly.Blocks.logic'); // Deprecated -goog.provide('Blockly.Constants.Logic'); - -goog.require('Blockly.Blocks'); - - -/** - * Common HSV hue for all blocks in this category. - * Should be the same as Blockly.Msg.LOGIC_HUE. - * @readonly - */ -Blockly.Constants.Logic.HUE = 210; -/** @deprecated Use Blockly.Constants.Logic.HUE */ -Blockly.Blocks.logic.HUE = Blockly.Constants.Logic.HUE; - -Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT - // Block for boolean data type: true and false. - { - "type": "logic_boolean", - "message0": "%1", - "args0": [ - { - "type": "field_dropdown", - "name": "BOOL", - "options": [ - ["%{BKY_LOGIC_BOOLEAN_TRUE}", "TRUE"], - ["%{BKY_LOGIC_BOOLEAN_FALSE}", "FALSE"] - ] - } - ], - "output": "Boolean", - "colour": "%{BKY_LOGIC_HUE}", - "tooltip": "%{BKY_LOGIC_BOOLEAN_TOOLTIP}", - "helpUrl": "%{BKY_LOGIC_BOOLEAN_HELPURL}" - }, - // Block for if/elseif/else condition. - { - "type": "controls_if", - "message0": "%{BKY_CONTROLS_IF_MSG_IF} %1", - "args0": [ - { - "type": "input_value", - "name": "IF0", - "check": "Boolean" - } - ], - "message1": "%{BKY_CONTROLS_IF_MSG_THEN} %1", - "args1": [ - { - "type": "input_statement", - "name": "DO0" - } - ], - "previousStatement": null, - "nextStatement": null, - "colour": "%{BKY_LOGIC_HUE}", - "helpUrl": "%{BKY_CONTROLS_IF_HELPURL}", - "mutator": "controls_if_mutator", - "extensions": ["controls_if_tooltip"] - }, - // If/else block that does not use a mutator. - { - "type": "controls_ifelse", - "message0": "%{BKY_CONTROLS_IF_MSG_IF} %1", - "args0": [ - { - "type": "input_value", - "name": "IF0", - "check": "Boolean" - } - ], - "message1": "%{BKY_CONTROLS_IF_MSG_THEN} %1", - "args1": [ - { - "type": "input_statement", - "name": "DO0" - } - ], - "message2": "%{BKY_CONTROLS_IF_MSG_ELSE} %1", - "args2": [ - { - "type": "input_statement", - "name": "ELSE" - } - ], - "previousStatement": null, - "nextStatement": null, - "colour": "%{BKY_LOGIC_HUE}", - "tooltip": "%{BKYCONTROLS_IF_TOOLTIP_2}", - "helpUrl": "%{BKY_CONTROLS_IF_HELPURL}", - "extensions": ["controls_if_tooltip"] - }, - // Block for comparison operator. - { - "type": "logic_compare", - "message0": "%1 %2 %3", - "args0": [ - { - "type": "input_value", - "name": "A" - }, - { - "type": "field_dropdown", - "name": "OP", - "options": [ - ["=", "EQ"], - ["\u2260", "NEQ"], - ["<", "LT"], - ["\u2264", "LTE"], - [">", "GT"], - ["\u2265", "GTE"] - ] - }, - { - "type": "input_value", - "name": "B" - } - ], - "inputsInline": true, - "output": "Boolean", - "colour": "%{BKY_LOGIC_HUE}", - "helpUrl": "%{BKY_LOGIC_COMPARE_HELPURL}", - "extensions": ["logic_compare", "logic_op_tooltip"] - }, - // Block for logical operations: 'and', 'or'. - { - "type": "logic_operation", - "message0": "%1 %2 %3", - "args0": [ - { - "type": "input_value", - "name": "A", - "check": "Boolean" - }, - { - "type": "field_dropdown", - "name": "OP", - "options": [ - ["%{BKY_LOGIC_OPERATION_AND}", "AND"], - ["%{BKY_LOGIC_OPERATION_OR}", "OR"] - ] - }, - { - "type": "input_value", - "name": "B", - "check": "Boolean" - } - ], - "inputsInline": true, - "output": "Boolean", - "colour": "%{BKY_LOGIC_HUE}", - "helpUrl": "%{BKY_LOGIC_OPERATION_HELPURL}", - "extensions": ["logic_op_tooltip"] - }, - // Block for negation. - { - "type": "logic_negate", - "message0": "%{BKY_LOGIC_NEGATE_TITLE}", - "args0": [ - { - "type": "input_value", - "name": "BOOL", - "check": "Boolean" - } - ], - "output": "Boolean", - "colour": "%{BKY_LOGIC_HUE}", - "tooltip": "%{BKY_LOGIC_NEGATE_TOOLTIP}", - "helpUrl": "%{BKY_LOGIC_NEGATE_HELPURL}" - }, - // Block for null data type. - { - "type": "logic_null", - "message0": "%{BKY_LOGIC_NULL}", - "output": null, - "colour": "%{BKY_LOGIC_HUE}", - "tooltip": "%{BKY_LOGIC_NULL_TOOLTIP}", - "helpUrl": "%{BKY_LOGIC_NULL_HELPURL}" - }, - // Block for ternary operator. - { - "type": "logic_ternary", - "message0": "%{BKY_LOGIC_TERNARY_CONDITION} %1", - "args0": [ - { - "type": "input_value", - "name": "IF", - "check": "Boolean" - } - ], - "message1": "%{BKY_LOGIC_TERNARY_IF_TRUE} %1", - "args1": [ - { - "type": "input_value", - "name": "THEN" - } - ], - "message2": "%{BKY_LOGIC_TERNARY_IF_FALSE} %1", - "args2": [ - { - "type": "input_value", - "name": "ELSE" - } - ], - "output": null, - "colour": "%{BKY_LOGIC_HUE}", - "tooltip": "%{BKY_LOGIC_TERNARY_TOOLTIP}", - "helpUrl": "%{BKY_LOGIC_TERNARY_HELPURL}", - "extensions": ["logic_ternary"] - } -]); // END JSON EXTRACT (Do not delete this comment.) - -Blockly.defineBlocksWithJsonArray([ // Mutator blocks. Do not extract. - // Block representing the if statement in the controls_if mutator. - { - "type": "controls_if_if", - "message0": "%{BKY_CONTROLS_IF_IF_TITLE_IF}", - "nextStatement": null, - "enableContextMenu": false, - "colour": "%{BKY_LOGIC_HUE}", - "tooltip": "%{BKY_CONTROLS_IF_IF_TOOLTIP}" - }, - // Block representing the else-if statement in the controls_if mutator. - { - "type": "controls_if_elseif", - "message0": "%{BKY_CONTROLS_IF_ELSEIF_TITLE_ELSEIF}", - "previousStatement": null, - "nextStatement": null, - "enableContextMenu": false, - "colour": "%{BKY_LOGIC_HUE}", - "tooltip": "%{BKY_CONTROLS_IF_ELSEIF_TOOLTIP}" - }, - // Block representing the else statement in the controls_if mutator. - { - "type": "controls_if_else", - "message0": "%{BKY_CONTROLS_IF_ELSE_TITLE_ELSE}", - "previousStatement": null, - "enableContextMenu": false, - "colour": "%{BKY_LOGIC_HUE}", - "tooltip": "%{BKY_CONTROLS_IF_ELSE_TOOLTIP}" - } -]); - -/** - * Tooltip text, keyed by block OP value. Used by logic_compare and - * logic_operation blocks. - * @see {Blockly.Extensions#buildTooltipForDropdown} - * @package - * @readonly - */ -Blockly.Constants.Logic.TOOLTIPS_BY_OP = { - // logic_compare - 'EQ': '%{BKY_LOGIC_COMPARE_TOOLTIP_EQ}', - 'NEQ': '%{BKY_LOGIC_COMPARE_TOOLTIP_NEQ}', - 'LT': '%{BKY_LOGIC_COMPARE_TOOLTIP_LT}', - 'LTE': '%{BKY_LOGIC_COMPARE_TOOLTIP_LTE}', - 'GT': '%{BKY_LOGIC_COMPARE_TOOLTIP_GT}', - 'GTE': '%{BKY_LOGIC_COMPARE_TOOLTIP_GTE}', - - // logic_operation - 'AND': '%{BKY_LOGIC_OPERATION_TOOLTIP_AND}', - 'OR': '%{BKY_LOGIC_OPERATION_TOOLTIP_OR}' -}; - -Blockly.Extensions.register('logic_op_tooltip', - Blockly.Extensions.buildTooltipForDropdown( - 'OP', Blockly.Constants.Logic.TOOLTIPS_BY_OP)); - -/** - * Mutator methods added to controls_if blocks. - * @mixin - * @augments Blockly.Block - * @package - * @readonly - */ -Blockly.Constants.Logic.CONTROLS_IF_MUTATOR_MIXIN = { - elseifCount_: 0, - elseCount_: 0, - - /** - * Create XML to represent the number of else-if and else inputs. - * @return {Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - if (!this.elseifCount_ && !this.elseCount_) { - return null; - } - var container = document.createElement('mutation'); - if (this.elseifCount_) { - container.setAttribute('elseif', this.elseifCount_); - } - if (this.elseCount_) { - container.setAttribute('else', 1); - } - return container; - }, - /** - * Parse XML to restore the else-if and else inputs. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - this.elseifCount_ = parseInt(xmlElement.getAttribute('elseif'), 10) || 0; - this.elseCount_ = parseInt(xmlElement.getAttribute('else'), 10) || 0; - this.updateShape_(); - }, - /** - * Populate the mutator's dialog with this block's components. - * @param {!Blockly.Workspace} workspace Mutator's workspace. - * @return {!Blockly.Block} Root block in mutator. - * @this Blockly.Block - */ - decompose: function(workspace) { - var containerBlock = workspace.newBlock('controls_if_if'); - containerBlock.initSvg(); - var connection = containerBlock.nextConnection; - for (var i = 1; i <= this.elseifCount_; i++) { - var elseifBlock = workspace.newBlock('controls_if_elseif'); - elseifBlock.initSvg(); - connection.connect(elseifBlock.previousConnection); - connection = elseifBlock.nextConnection; - } - if (this.elseCount_) { - var elseBlock = workspace.newBlock('controls_if_else'); - elseBlock.initSvg(); - connection.connect(elseBlock.previousConnection); - } - return containerBlock; - }, - /** - * Reconfigure this block based on the mutator dialog's components. - * @param {!Blockly.Block} containerBlock Root block in mutator. - * @this Blockly.Block - */ - compose: function(containerBlock) { - var clauseBlock = containerBlock.nextConnection.targetBlock(); - // Count number of inputs. - this.elseifCount_ = 0; - this.elseCount_ = 0; - var valueConnections = [null]; - var statementConnections = [null]; - var elseStatementConnection = null; - while (clauseBlock) { - switch (clauseBlock.type) { - case 'controls_if_elseif': - this.elseifCount_++; - valueConnections.push(clauseBlock.valueConnection_); - statementConnections.push(clauseBlock.statementConnection_); - break; - case 'controls_if_else': - this.elseCount_++; - elseStatementConnection = clauseBlock.statementConnection_; - break; - default: - throw 'Unknown block type.'; - } - clauseBlock = clauseBlock.nextConnection && - clauseBlock.nextConnection.targetBlock(); - } - this.updateShape_(); - // Reconnect any child blocks. - for (var i = 1; i <= this.elseifCount_; i++) { - Blockly.Mutator.reconnect(valueConnections[i], this, 'IF' + i); - Blockly.Mutator.reconnect(statementConnections[i], this, 'DO' + i); - } - Blockly.Mutator.reconnect(elseStatementConnection, this, 'ELSE'); - }, - /** - * Store pointers to any connected child blocks. - * @param {!Blockly.Block} containerBlock Root block in mutator. - * @this Blockly.Block - */ - saveConnections: function(containerBlock) { - var clauseBlock = containerBlock.nextConnection.targetBlock(); - var i = 1; - while (clauseBlock) { - switch (clauseBlock.type) { - case 'controls_if_elseif': - var inputIf = this.getInput('IF' + i); - var inputDo = this.getInput('DO' + i); - clauseBlock.valueConnection_ = - inputIf && inputIf.connection.targetConnection; - clauseBlock.statementConnection_ = - inputDo && inputDo.connection.targetConnection; - i++; - break; - case 'controls_if_else': - var inputDo = this.getInput('ELSE'); - clauseBlock.statementConnection_ = - inputDo && inputDo.connection.targetConnection; - break; - default: - throw 'Unknown block type.'; - } - clauseBlock = clauseBlock.nextConnection && - clauseBlock.nextConnection.targetBlock(); - } - }, - /** - * Modify this block to have the correct number of inputs. - * @this Blockly.Block - * @private - */ - updateShape_: function() { - // Delete everything. - if (this.getInput('ELSE')) { - this.removeInput('ELSE'); - } - var i = 1; - while (this.getInput('IF' + i)) { - this.removeInput('IF' + i); - this.removeInput('DO' + i); - i++; - } - // Rebuild block. - for (var i = 1; i <= this.elseifCount_; i++) { - this.appendValueInput('IF' + i) - .setCheck('Boolean') - .appendField(Blockly.Msg.CONTROLS_IF_MSG_ELSEIF); - this.appendStatementInput('DO' + i) - .appendField(Blockly.Msg.CONTROLS_IF_MSG_THEN); - } - if (this.elseCount_) { - this.appendStatementInput('ELSE') - .appendField(Blockly.Msg.CONTROLS_IF_MSG_ELSE); - } - } -}; - -Blockly.Extensions.registerMutator('controls_if_mutator', - Blockly.Constants.Logic.CONTROLS_IF_MUTATOR_MIXIN, null, - ['controls_if_elseif', 'controls_if_else']); -/** - * "controls_if" extension function. Adds mutator, shape updating methods, and - * dynamic tooltip to "controls_if" blocks. - * @this Blockly.Block - * @package - */ -Blockly.Constants.Logic.CONTROLS_IF_TOOLTIP_EXTENSION = function() { - - this.setTooltip(function() { - if (!this.elseifCount_ && !this.elseCount_) { - return Blockly.Msg.CONTROLS_IF_TOOLTIP_1; - } else if (!this.elseifCount_ && this.elseCount_) { - return Blockly.Msg.CONTROLS_IF_TOOLTIP_2; - } else if (this.elseifCount_ && !this.elseCount_) { - return Blockly.Msg.CONTROLS_IF_TOOLTIP_3; - } else if (this.elseifCount_ && this.elseCount_) { - return Blockly.Msg.CONTROLS_IF_TOOLTIP_4; - } - return ''; - }.bind(this)); -}; - -Blockly.Extensions.register('controls_if_tooltip', - Blockly.Constants.Logic.CONTROLS_IF_TOOLTIP_EXTENSION); - -/** - * Corrects the logic_compare dropdown label with respect to language direction. - * @this Blockly.Block - * @package - */ -Blockly.Constants.Logic.fixLogicCompareRtlOpLabels = - function() { - var rtlOpLabels = { - 'LT': '\u200F<\u200F', - 'LTE': '\u200F\u2264\u200F', - 'GT': '\u200F>\u200F', - 'GTE': '\u200F\u2265\u200F' - }; - var opDropdown = this.getField('OP'); - if (opDropdown) { - var options = opDropdown.getOptions(); - for (var i = 0; i < options.length; ++i) { - var tuple = options[i]; - var op = tuple[1]; - var rtlLabel = rtlOpLabels[op]; - if (goog.isString(tuple[0]) && rtlLabel) { - // Replace LTR text label - tuple[0] = rtlLabel; - } - } - } - }; - -/** - * Adds dynamic type validation for the left and right sides of a logic_compare block. - * @mixin - * @augments Blockly.Block - * @package - * @readonly - */ -Blockly.Constants.Logic.LOGIC_COMPARE_ONCHANGE_MIXIN = { - prevBlocks_: [null, null], - - /** - * Called whenever anything on the workspace changes. - * Prevent mismatched types from being compared. - * @param {!Blockly.Events.Abstract} e Change event. - * @this Blockly.Block - */ - onchange: function(e) { - var blockA = this.getInputTargetBlock('A'); - var blockB = this.getInputTargetBlock('B'); - // Disconnect blocks that existed prior to this change if they don't match. - if (blockA && blockB && - !blockA.outputConnection.checkType_(blockB.outputConnection)) { - // Mismatch between two inputs. Disconnect previous and bump it away. - // Ensure that any disconnections are grouped with the causing event. - Blockly.Events.setGroup(e.group); - for (var i = 0; i < this.prevBlocks_.length; i++) { - var block = this.prevBlocks_[i]; - if (block === blockA || block === blockB) { - block.unplug(); - block.bumpNeighbours_(); - } - } - Blockly.Events.setGroup(false); - } - this.prevBlocks_[0] = blockA; - this.prevBlocks_[1] = blockB; - } -}; - -/** - * "logic_compare" extension function. Corrects direction of operators in the - * dropdown labels, and adds type left and right side type checking to - * "logic_compare" blocks. - * @this Blockly.Block - * @package - * @readonly - */ -Blockly.Constants.Logic.LOGIC_COMPARE_EXTENSION = function() { - // Fix operator labels in RTL - if (this.RTL) { - Blockly.Constants.Logic.fixLogicCompareRtlOpLabels.apply(this); - } - - // Add onchange handler to ensure types are compatable. - this.mixin(Blockly.Constants.Logic.LOGIC_COMPARE_ONCHANGE_MIXIN); -}; - -Blockly.Extensions.register('logic_compare', - Blockly.Constants.Logic.LOGIC_COMPARE_EXTENSION); - -/** - * Adds type coordination between inputs and output. - * @mixin - * @augments Blockly.Block - * @package - * @readonly - */ -Blockly.Constants.Logic.LOGIC_TERNARY_ONCHANGE_MIXIN = { - prevParentConnection_: null, - - /** - * Called whenever anything on the workspace changes. - * Prevent mismatched types. - * @param {!Blockly.Events.Abstract} e Change event. - * @this Blockly.Block - */ - onchange: function(e) { - var blockA = this.getInputTargetBlock('THEN'); - var blockB = this.getInputTargetBlock('ELSE'); - var parentConnection = this.outputConnection.targetConnection; - // Disconnect blocks that existed prior to this change if they don't match. - if ((blockA || blockB) && parentConnection) { - for (var i = 0; i < 2; i++) { - var block = (i == 1) ? blockA : blockB; - if (block && !block.outputConnection.checkType_(parentConnection)) { - // Ensure that any disconnections are grouped with the causing event. - Blockly.Events.setGroup(e.group); - if (parentConnection === this.prevParentConnection_) { - this.unplug(); - parentConnection.getSourceBlock().bumpNeighbours_(); - } else { - block.unplug(); - block.bumpNeighbours_(); - } - Blockly.Events.setGroup(false); - } - } - } - this.prevParentConnection_ = parentConnection; - } -}; - -Blockly.Extensions.registerMixin('logic_ternary', - Blockly.Constants.Logic.LOGIC_TERNARY_ONCHANGE_MIXIN); diff --git a/blocks/logic.ts b/blocks/logic.ts new file mode 100644 index 00000000000..d2a7405fffa --- /dev/null +++ b/blocks/logic.ts @@ -0,0 +1,712 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.libraryBlocks.logic + +import type {Block} from '../core/block.js'; +import type {BlockSvg} from '../core/block_svg.js'; +import { + createBlockDefinitionsFromJsonArray, + defineBlocks, +} from '../core/common.js'; +import type {Connection} from '../core/connection.js'; +import * as Events from '../core/events/events.js'; +import type {Abstract as AbstractEvent} from '../core/events/events_abstract.js'; +import * as Extensions from '../core/extensions.js'; +import '../core/field_dropdown.js'; +import '../core/field_label.js'; +import '../core/icons/mutator_icon.js'; +import {Msg} from '../core/msg.js'; +import * as xmlUtils from '../core/utils/xml.js'; +import type {Workspace} from '../core/workspace.js'; + +/** + * A dictionary of the block definitions provided by this module. + */ +export const blocks = createBlockDefinitionsFromJsonArray([ + // Block for boolean data type: true and false. + { + 'type': 'logic_boolean', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'BOOL', + 'options': [ + ['%{BKY_LOGIC_BOOLEAN_TRUE}', 'TRUE'], + ['%{BKY_LOGIC_BOOLEAN_FALSE}', 'FALSE'], + ], + }, + ], + 'output': 'Boolean', + 'style': 'logic_blocks', + 'tooltip': '%{BKY_LOGIC_BOOLEAN_TOOLTIP}', + 'helpUrl': '%{BKY_LOGIC_BOOLEAN_HELPURL}', + }, + // Block for if/elseif/else condition. + { + 'type': 'controls_if', + 'message0': '%{BKY_CONTROLS_IF_MSG_IF} %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'IF0', + 'check': 'Boolean', + }, + ], + 'message1': '%{BKY_CONTROLS_IF_MSG_THEN} %1', + 'args1': [ + { + 'type': 'input_statement', + 'name': 'DO0', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'style': 'logic_blocks', + 'helpUrl': '%{BKY_CONTROLS_IF_HELPURL}', + 'suppressPrefixSuffix': true, + 'mutator': 'controls_if_mutator', + 'extensions': ['controls_if_tooltip'], + }, + // If/else block that does not use a mutator. + { + 'type': 'controls_ifelse', + 'message0': '%{BKY_CONTROLS_IF_MSG_IF} %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'IF0', + 'check': 'Boolean', + }, + ], + 'message1': '%{BKY_CONTROLS_IF_MSG_THEN} %1', + 'args1': [ + { + 'type': 'input_statement', + 'name': 'DO0', + }, + ], + 'message2': '%{BKY_CONTROLS_IF_MSG_ELSE} %1', + 'args2': [ + { + 'type': 'input_statement', + 'name': 'ELSE', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'style': 'logic_blocks', + 'tooltip': '%{BKYCONTROLS_IF_TOOLTIP_2}', + 'helpUrl': '%{BKY_CONTROLS_IF_HELPURL}', + 'suppressPrefixSuffix': true, + 'extensions': ['controls_if_tooltip'], + }, + // Block for comparison operator. + { + 'type': 'logic_compare', + 'message0': '%1 %2 %3', + 'args0': [ + { + 'type': 'input_value', + 'name': 'A', + }, + { + 'type': 'field_dropdown', + 'name': 'OP', + 'options': [ + ['=', 'EQ'], + ['\u2260', 'NEQ'], + ['\u200F<', 'LT'], + ['\u200F\u2264', 'LTE'], + ['\u200F>', 'GT'], + ['\u200F\u2265', 'GTE'], + ], + }, + { + 'type': 'input_value', + 'name': 'B', + }, + ], + 'inputsInline': true, + 'output': 'Boolean', + 'style': 'logic_blocks', + 'helpUrl': '%{BKY_LOGIC_COMPARE_HELPURL}', + 'extensions': ['logic_compare', 'logic_op_tooltip'], + }, + // Block for logical operations: 'and', 'or'. + { + 'type': 'logic_operation', + 'message0': '%1 %2 %3', + 'args0': [ + { + 'type': 'input_value', + 'name': 'A', + 'check': 'Boolean', + }, + { + 'type': 'field_dropdown', + 'name': 'OP', + 'options': [ + ['%{BKY_LOGIC_OPERATION_AND}', 'AND'], + ['%{BKY_LOGIC_OPERATION_OR}', 'OR'], + ], + }, + { + 'type': 'input_value', + 'name': 'B', + 'check': 'Boolean', + }, + ], + 'inputsInline': true, + 'output': 'Boolean', + 'style': 'logic_blocks', + 'helpUrl': '%{BKY_LOGIC_OPERATION_HELPURL}', + 'extensions': ['logic_op_tooltip'], + }, + // Block for negation. + { + 'type': 'logic_negate', + 'message0': '%{BKY_LOGIC_NEGATE_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'BOOL', + 'check': 'Boolean', + }, + ], + 'output': 'Boolean', + 'style': 'logic_blocks', + 'tooltip': '%{BKY_LOGIC_NEGATE_TOOLTIP}', + 'helpUrl': '%{BKY_LOGIC_NEGATE_HELPURL}', + }, + // Block for null data type. + { + 'type': 'logic_null', + 'message0': '%{BKY_LOGIC_NULL}', + 'output': null, + 'style': 'logic_blocks', + 'tooltip': '%{BKY_LOGIC_NULL_TOOLTIP}', + 'helpUrl': '%{BKY_LOGIC_NULL_HELPURL}', + }, + // Block for ternary operator. + { + 'type': 'logic_ternary', + 'message0': '%{BKY_LOGIC_TERNARY_CONDITION} %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'IF', + 'check': 'Boolean', + }, + ], + 'message1': '%{BKY_LOGIC_TERNARY_IF_TRUE} %1', + 'args1': [ + { + 'type': 'input_value', + 'name': 'THEN', + }, + ], + 'message2': '%{BKY_LOGIC_TERNARY_IF_FALSE} %1', + 'args2': [ + { + 'type': 'input_value', + 'name': 'ELSE', + }, + ], + 'output': null, + 'style': 'logic_blocks', + 'tooltip': '%{BKY_LOGIC_TERNARY_TOOLTIP}', + 'helpUrl': '%{BKY_LOGIC_TERNARY_HELPURL}', + 'extensions': ['logic_ternary'], + }, + // Block representing the if statement in the controls_if mutator. + { + 'type': 'controls_if_if', + 'message0': '%{BKY_CONTROLS_IF_IF_TITLE_IF}', + 'nextStatement': null, + 'enableContextMenu': false, + 'style': 'logic_blocks', + 'tooltip': '%{BKY_CONTROLS_IF_IF_TOOLTIP}', + }, + // Block representing the else-if statement in the controls_if mutator. + { + 'type': 'controls_if_elseif', + 'message0': '%{BKY_CONTROLS_IF_ELSEIF_TITLE_ELSEIF}', + 'previousStatement': null, + 'nextStatement': null, + 'enableContextMenu': false, + 'style': 'logic_blocks', + 'tooltip': '%{BKY_CONTROLS_IF_ELSEIF_TOOLTIP}', + }, + // Block representing the else statement in the controls_if mutator. + { + 'type': 'controls_if_else', + 'message0': '%{BKY_CONTROLS_IF_ELSE_TITLE_ELSE}', + 'previousStatement': null, + 'enableContextMenu': false, + 'style': 'logic_blocks', + 'tooltip': '%{BKY_CONTROLS_IF_ELSE_TOOLTIP}', + }, +]); + +/** + * Tooltip text, keyed by block OP value. Used by logic_compare and + * logic_operation blocks. + * + * @see {Extensions#buildTooltipForDropdown} + */ +const TOOLTIPS_BY_OP = { + // logic_compare + 'EQ': '%{BKY_LOGIC_COMPARE_TOOLTIP_EQ}', + 'NEQ': '%{BKY_LOGIC_COMPARE_TOOLTIP_NEQ}', + 'LT': '%{BKY_LOGIC_COMPARE_TOOLTIP_LT}', + 'LTE': '%{BKY_LOGIC_COMPARE_TOOLTIP_LTE}', + 'GT': '%{BKY_LOGIC_COMPARE_TOOLTIP_GT}', + 'GTE': '%{BKY_LOGIC_COMPARE_TOOLTIP_GTE}', + + // logic_operation + 'AND': '%{BKY_LOGIC_OPERATION_TOOLTIP_AND}', + 'OR': '%{BKY_LOGIC_OPERATION_TOOLTIP_OR}', +}; + +Extensions.register( + 'logic_op_tooltip', + Extensions.buildTooltipForDropdown('OP', TOOLTIPS_BY_OP), +); + +/** Type of a block that has CONTROLS_IF_MUTATOR_MIXIN */ +type IfBlock = Block & IfMixin; +interface IfMixin extends IfMixinType {} +type IfMixinType = typeof CONTROLS_IF_MUTATOR_MIXIN; + +// Types for quarks defined in JSON. +/** Type of a controls_if_if (if mutator container) block. */ +interface ContainerBlock extends Block {} + +/** Type of a controls_if_elseif or controls_if_else block. */ +interface ClauseBlock extends Block { + valueConnection_?: Connection | null; + statementConnection_?: Connection | null; +} + +/** Extra state for serialising controls_if blocks. */ +type IfExtraState = { + elseIfCount?: number; + hasElse?: boolean; +}; + +/** + * Mutator methods added to controls_if blocks. + */ +const CONTROLS_IF_MUTATOR_MIXIN = { + elseifCount_: 0, + elseCount_: 0, + + /** + * Create XML to represent the number of else-if and else inputs. + * Backwards compatible serialization implementation. + * + * @returns XML storage element. + */ + mutationToDom: function (this: IfBlock): Element | null { + if (!this.elseifCount_ && !this.elseCount_) { + return null; + } + const container = xmlUtils.createElement('mutation'); + if (this.elseifCount_) { + container.setAttribute('elseif', String(this.elseifCount_)); + } + if (this.elseCount_) { + container.setAttribute('else', '1'); + } + return container; + }, + /** + * Parse XML to restore the else-if and else inputs. + * Backwards compatible serialization implementation. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: IfBlock, xmlElement: Element) { + this.elseifCount_ = parseInt(xmlElement.getAttribute('elseif')!, 10) || 0; + this.elseCount_ = parseInt(xmlElement.getAttribute('else')!, 10) || 0; + this.rebuildShape_(); + }, + /** + * Returns the state of this block as a JSON serializable object. + * + * @returns The state of this block, ie the else if count and else state. + */ + saveExtraState: function (this: IfBlock): IfExtraState | null { + if (!this.elseifCount_ && !this.elseCount_) { + return null; + } + const state = Object.create(null); + if (this.elseifCount_) { + state['elseIfCount'] = this.elseifCount_; + } + if (this.elseCount_) { + state['hasElse'] = true; + } + return state; + }, + /** + * Applies the given state to this block. + * + * @param state The state to apply to this block, ie the else if count + and + * else state. + */ + loadExtraState: function (this: IfBlock, state: IfExtraState) { + this.elseifCount_ = state['elseIfCount'] || 0; + this.elseCount_ = state['hasElse'] ? 1 : 0; + this.updateShape_(); + }, + /** + * Populate the mutator's dialog with this block's components. + * + * @param workspace MutatorIcon's workspace. + * @returns Root block in mutator. + */ + decompose: function (this: IfBlock, workspace: Workspace): ContainerBlock { + const containerBlock = workspace.newBlock('controls_if_if'); + (containerBlock as BlockSvg).initSvg(); + let connection = containerBlock.nextConnection!; + for (let i = 1; i <= this.elseifCount_; i++) { + const elseifBlock = workspace.newBlock('controls_if_elseif'); + (elseifBlock as BlockSvg).initSvg(); + connection.connect(elseifBlock.previousConnection!); + connection = elseifBlock.nextConnection!; + } + if (this.elseCount_) { + const elseBlock = workspace.newBlock('controls_if_else'); + (elseBlock as BlockSvg).initSvg(); + connection.connect(elseBlock.previousConnection!); + } + return containerBlock; + }, + /** + * Reconfigure this block based on the mutator dialog's components. + * + * @param containerBlock Root block in mutator. + */ + compose: function (this: IfBlock, containerBlock: ContainerBlock) { + let clauseBlock = + containerBlock.nextConnection!.targetBlock() as ClauseBlock | null; + // Count number of inputs. + this.elseifCount_ = 0; + this.elseCount_ = 0; + // Connections arrays are passed to .reconnectChildBlocks_() which + // takes 1-based arrays, so are initialised with a dummy value at + // index 0 for convenience. + const valueConnections: Array = [null]; + const statementConnections: Array = [null]; + let elseStatementConnection: Connection | null = null; + while (clauseBlock) { + if (clauseBlock.isInsertionMarker()) { + clauseBlock = clauseBlock.getNextBlock() as ClauseBlock | null; + continue; + } + switch (clauseBlock.type) { + case 'controls_if_elseif': + this.elseifCount_++; + // TODO(#6920): null valid, undefined not. + valueConnections.push( + clauseBlock.valueConnection_ as Connection | null, + ); + statementConnections.push( + clauseBlock.statementConnection_ as Connection | null, + ); + break; + case 'controls_if_else': + this.elseCount_++; + elseStatementConnection = + clauseBlock.statementConnection_ as Connection | null; + break; + default: + throw TypeError('Unknown block type: ' + clauseBlock.type); + } + clauseBlock = clauseBlock.getNextBlock() as ClauseBlock | null; + } + this.updateShape_(); + // Reconnect any child blocks. + this.reconnectChildBlocks_( + valueConnections, + statementConnections, + elseStatementConnection, + ); + }, + /** + * Store pointers to any connected child blocks. + * + * @param containerBlock Root block in mutator. + */ + saveConnections: function (this: IfBlock, containerBlock: ContainerBlock) { + let clauseBlock = + containerBlock!.nextConnection!.targetBlock() as ClauseBlock | null; + let i = 1; + while (clauseBlock) { + if (clauseBlock.isInsertionMarker()) { + clauseBlock = clauseBlock.getNextBlock() as ClauseBlock | null; + continue; + } + switch (clauseBlock.type) { + case 'controls_if_elseif': { + const inputIf = this.getInput('IF' + i); + const inputDo = this.getInput('DO' + i); + clauseBlock.valueConnection_ = + inputIf && inputIf.connection!.targetConnection; + clauseBlock.statementConnection_ = + inputDo && inputDo.connection!.targetConnection; + i++; + break; + } + case 'controls_if_else': { + const inputDo = this.getInput('ELSE'); + clauseBlock.statementConnection_ = + inputDo && inputDo.connection!.targetConnection; + break; + } + default: + throw TypeError('Unknown block type: ' + clauseBlock.type); + } + clauseBlock = clauseBlock.getNextBlock() as ClauseBlock | null; + } + }, + /** + * Reconstructs the block with all child blocks attached. + */ + rebuildShape_: function (this: IfBlock) { + const valueConnections: Array = [null]; + const statementConnections: Array = [null]; + let elseStatementConnection: Connection | null = null; + + if (this.getInput('ELSE')) { + elseStatementConnection = + this.getInput('ELSE')!.connection!.targetConnection; + } + for (let i = 1; this.getInput('IF' + i); i++) { + const inputIf = this.getInput('IF' + i); + const inputDo = this.getInput('DO' + i); + valueConnections.push(inputIf!.connection!.targetConnection); + statementConnections.push(inputDo!.connection!.targetConnection); + } + this.updateShape_(); + this.reconnectChildBlocks_( + valueConnections, + statementConnections, + elseStatementConnection, + ); + }, + /** + * Modify this block to have the correct number of inputs. + * + * @internal + */ + updateShape_: function (this: IfBlock) { + // Delete everything. + if (this.getInput('ELSE')) { + this.removeInput('ELSE'); + } + for (let i = 1; this.getInput('IF' + i); i++) { + this.removeInput('IF' + i); + this.removeInput('DO' + i); + } + // Rebuild block. + for (let i = 1; i <= this.elseifCount_; i++) { + this.appendValueInput('IF' + i) + .setCheck('Boolean') + .appendField(Msg['CONTROLS_IF_MSG_ELSEIF']); + this.appendStatementInput('DO' + i).appendField( + Msg['CONTROLS_IF_MSG_THEN'], + ); + } + if (this.elseCount_) { + this.appendStatementInput('ELSE').appendField( + Msg['CONTROLS_IF_MSG_ELSE'], + ); + } + }, + /** + * Reconnects child blocks. + * + * @param valueConnections 1-based array of value connections for + * 'if' input. Value at index [0] ignored. + * @param statementConnections 1-based array of statement + * connections for 'do' input. Value at index [0] ignored. + * @param elseStatementConnection Statement connection for else input. + */ + reconnectChildBlocks_: function ( + this: IfBlock, + valueConnections: Array, + statementConnections: Array, + elseStatementConnection: Connection | null, + ) { + for (let i = 1; i <= this.elseifCount_; i++) { + valueConnections[i]?.reconnect(this, 'IF' + i); + statementConnections[i]?.reconnect(this, 'DO' + i); + } + elseStatementConnection?.reconnect(this, 'ELSE'); + }, +}; + +Extensions.registerMutator( + 'controls_if_mutator', + CONTROLS_IF_MUTATOR_MIXIN, + null as unknown as undefined, // TODO(#6920) + ['controls_if_elseif', 'controls_if_else'], +); + +/** + * "controls_if" extension function. Adds mutator, shape updating methods, + * and dynamic tooltip to "controls_if" blocks. + */ +const CONTROLS_IF_TOOLTIP_EXTENSION = function (this: IfBlock) { + this.setTooltip( + function (this: IfBlock) { + if (!this.elseifCount_ && !this.elseCount_) { + return Msg['CONTROLS_IF_TOOLTIP_1']; + } else if (!this.elseifCount_ && this.elseCount_) { + return Msg['CONTROLS_IF_TOOLTIP_2']; + } else if (this.elseifCount_ && !this.elseCount_) { + return Msg['CONTROLS_IF_TOOLTIP_3']; + } else if (this.elseifCount_ && this.elseCount_) { + return Msg['CONTROLS_IF_TOOLTIP_4']; + } + return ''; + }.bind(this), + ); +}; + +Extensions.register('controls_if_tooltip', CONTROLS_IF_TOOLTIP_EXTENSION); + +/** Type of a block that has LOGIC_COMPARE_ONCHANGE_MIXIN */ +type CompareBlock = Block & CompareMixin; +interface CompareMixin extends CompareMixinType { + prevBlocks_?: Array; +} +type CompareMixinType = typeof LOGIC_COMPARE_ONCHANGE_MIXIN; + +/** + * Adds dynamic type validation for the left and right sides of a + * logic_compare block. + */ +const LOGIC_COMPARE_ONCHANGE_MIXIN = { + /** + * Called whenever anything on the workspace changes. + * Prevent mismatched types from being compared. + * + * @param e Change event. + */ + onchange: function (this: CompareBlock, e: AbstractEvent) { + if (!this.prevBlocks_) { + this.prevBlocks_ = [null, null]; + } + + const blockA = this.getInputTargetBlock('A'); + const blockB = this.getInputTargetBlock('B'); + // Disconnect blocks that existed prior to this change if they don't + // match. + if ( + blockA && + blockB && + !this.workspace.connectionChecker.doTypeChecks( + blockA.outputConnection!, + blockB.outputConnection!, + ) + ) { + // Mismatch between two inputs. Revert the block connections, + // bumping away the newly connected block(s). + Events.setGroup(e.group); + const prevA = this.prevBlocks_[0]; + if (prevA !== blockA) { + blockA.unplug(); + if (prevA && !prevA.isDisposed() && !prevA.isShadow()) { + // The shadow block is automatically replaced during unplug(). + this.getInput('A')!.connection!.connect(prevA.outputConnection!); + } + } + const prevB = this.prevBlocks_[1]; + if (prevB !== blockB) { + blockB.unplug(); + if (prevB && !prevB.isDisposed() && !prevB.isShadow()) { + // The shadow block is automatically replaced during unplug(). + this.getInput('B')!.connection!.connect(prevB.outputConnection!); + } + } + this.bumpNeighbours(); + Events.setGroup(false); + } + this.prevBlocks_[0] = this.getInputTargetBlock('A'); + this.prevBlocks_[1] = this.getInputTargetBlock('B'); + }, +}; + +/** + * "logic_compare" extension function. Adds type left and right side type + * checking to "logic_compare" blocks. + */ +const LOGIC_COMPARE_EXTENSION = function (this: CompareBlock) { + // Add onchange handler to ensure types are compatible. + this.mixin(LOGIC_COMPARE_ONCHANGE_MIXIN); +}; + +Extensions.register('logic_compare', LOGIC_COMPARE_EXTENSION); + +/** Type of a block that has LOGIC_TERNARY_ONCHANGE_MIXIN */ +type TernaryBlock = Block & TernaryMixin; +interface TernaryMixin extends TernaryMixinType {} +type TernaryMixinType = typeof LOGIC_TERNARY_ONCHANGE_MIXIN; + +/** + * Adds type coordination between inputs and output. + */ +const LOGIC_TERNARY_ONCHANGE_MIXIN = { + prevParentConnection_: null as Connection | null, + + /** + * Called whenever anything on the workspace changes. + * Prevent mismatched types. + */ + onchange: function (this: TernaryBlock, e: AbstractEvent) { + const blockA = this.getInputTargetBlock('THEN'); + const blockB = this.getInputTargetBlock('ELSE'); + const parentConnection = this.outputConnection!.targetConnection; + // Disconnect blocks that existed prior to this change if they don't + // match. + if ((blockA || blockB) && parentConnection) { + for (let i = 0; i < 2; i++) { + const block = i === 1 ? blockA : blockB; + if ( + block && + !block.workspace.connectionChecker.doTypeChecks( + block.outputConnection!, + parentConnection, + ) + ) { + // Ensure that any disconnections are grouped with the causing + // event. + Events.setGroup(e.group); + if (parentConnection === this.prevParentConnection_) { + this.unplug(); + parentConnection.getSourceBlock().bumpNeighbours(); + } else { + block.unplug(); + block.bumpNeighbours(); + } + Events.setGroup(false); + } + } + } + this.prevParentConnection_ = parentConnection; + }, +}; + +Extensions.registerMixin('logic_ternary', LOGIC_TERNARY_ONCHANGE_MIXIN); + +// Register provided blocks. +defineBlocks(blocks); diff --git a/blocks/loops.js b/blocks/loops.js deleted file mode 100644 index f0d2a98b986..00000000000 --- a/blocks/loops.js +++ /dev/null @@ -1,341 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Loop blocks for Blockly. - * - * This file is scraped to extract a .json file of block definitions. The array - * passed to defineBlocksWithJsonArray(..) must be strict JSON: double quotes - * only, no outside references, no functions, no trailing commas, etc. The one - * exception is end-of-line comments, which the scraper will remove. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Blocks.loops'); // Deprecated -goog.provide('Blockly.Constants.Loops'); - -goog.require('Blockly.Blocks'); - - -/** - * Common HSV hue for all blocks in this category. - * Should be the same as Blockly.Msg.LOOPS_HUE - * @readonly - */ -Blockly.Constants.Loops.HUE = 120; -/** @deprecated Use Blockly.Constants.Loops.HUE */ -Blockly.Blocks.loops.HUE = Blockly.Constants.Loops.HUE; - -Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT - // Block for repeat n times (external number). - { - "type": "controls_repeat_ext", - "message0": "%{BKY_CONTROLS_REPEAT_TITLE}", - "args0": [{ - "type": "input_value", - "name": "TIMES", - "check": "Number" - }], - "message1": "%{BKY_CONTROLS_REPEAT_INPUT_DO} %1", - "args1": [{ - "type": "input_statement", - "name": "DO" - }], - "previousStatement": null, - "nextStatement": null, - "colour": "%{BKY_LOOPS_HUE}", - "tooltip": "%{BKY_CONTROLS_REPEAT_TOOLTIP}", - "helpUrl": "%{BKY_CONTROLS_REPEAT_HELPURL}" - }, - // Block for repeat n times (internal number). - // The 'controls_repeat_ext' block is preferred as it is more flexible. - { - "type": "controls_repeat", - "message0": "%{BKY_CONTROLS_REPEAT_TITLE}", - "args0": [{ - "type": "field_number", - "name": "TIMES", - "value": 10, - "min": 0, - "precision": 1 - }], - "message1": "%{BKY_CONTROLS_REPEAT_INPUT_DO} %1", - "args1": [{ - "type": "input_statement", - "name": "DO" - }], - "previousStatement": null, - "nextStatement": null, - "colour": "%{BKY_LOOPS_HUE}", - "tooltip": "%{BKY_CONTROLS_REPEAT_TOOLTIP}", - "helpUrl": "%{BKY_CONTROLS_REPEAT_HELPURL}" - }, - // Block for 'do while/until' loop. - { - "type": "controls_whileUntil", - "message0": "%1 %2", - "args0": [ - { - "type": "field_dropdown", - "name": "MODE", - "options": [ - ["%{BKY_CONTROLS_WHILEUNTIL_OPERATOR_WHILE}", "WHILE"], - ["%{BKY_CONTROLS_WHILEUNTIL_OPERATOR_UNTIL}", "UNTIL"] - ] - }, - { - "type": "input_value", - "name": "BOOL", - "check": "Boolean" - } - ], - "message1": "%{BKY_CONTROLS_REPEAT_INPUT_DO} %1", - "args1": [{ - "type": "input_statement", - "name": "DO" - }], - "previousStatement": null, - "nextStatement": null, - "colour": "%{BKY_LOOPS_HUE}", - "helpUrl": "%{BKY_CONTROLS_WHILEUNTIL_HELPURL}", - "extensions": ["controls_whileUntil_tooltip"] - }, - // Block for 'for' loop. - { - "type": "controls_for", - "message0": "%{BKY_CONTROLS_FOR_TITLE}", - "args0": [ - { - "type": "field_variable", - "name": "VAR", - "variable": null - }, - { - "type": "input_value", - "name": "FROM", - "check": "Number", - "align": "RIGHT" - }, - { - "type": "input_value", - "name": "TO", - "check": "Number", - "align": "RIGHT" - }, - { - "type": "input_value", - "name": "BY", - "check": "Number", - "align": "RIGHT" - } - ], - "message1": "%{BKY_CONTROLS_REPEAT_INPUT_DO} %1", - "args1": [{ - "type": "input_statement", - "name": "DO" - }], - "inputsInline": true, - "previousStatement": null, - "nextStatement": null, - "colour": "%{BKY_LOOPS_HUE}", - "helpUrl": "%{BKY_CONTROLS_FOR_HELPURL}", - "extensions": [ - "contextMenu_newGetVariableBlock", - "controls_for_tooltip" - ] - }, - // Block for 'for each' loop. - { - "type": "controls_forEach", - "message0": "%{BKY_CONTROLS_FOREACH_TITLE}", - "args0": [ - { - "type": "field_variable", - "name": "VAR", - "variable": null - }, - { - "type": "input_value", - "name": "LIST", - "check": "Array" - } - ], - "message1": "%{BKY_CONTROLS_REPEAT_INPUT_DO} %1", - "args1": [{ - "type": "input_statement", - "name": "DO" - }], - "previousStatement": null, - "nextStatement": null, - "colour": "%{BKY_LOOPS_HUE}", - "helpUrl": "%{BKY_CONTROLS_FOREACH_HELPURL}", - "extensions": [ - "contextMenu_newGetVariableBlock", - "controls_forEach_tooltip" - ] - }, - // Block for flow statements: continue, break. - { - "type": "controls_flow_statements", - "message0": "%1", - "args0": [{ - "type": "field_dropdown", - "name": "FLOW", - "options": [ - ["%{BKY_CONTROLS_FLOW_STATEMENTS_OPERATOR_BREAK}", "BREAK"], - ["%{BKY_CONTROLS_FLOW_STATEMENTS_OPERATOR_CONTINUE}", "CONTINUE"] - ] - }], - "previousStatement": null, - "colour": "%{BKY_LOOPS_HUE}", - "helpUrl": "%{BKY_CONTROLS_FLOW_STATEMENTS_HELPURL}", - "extensions": [ - "controls_flow_tooltip", - "controls_flow_in_loop_check" - ] - } -]); // END JSON EXTRACT (Do not delete this comment.) - -/** - * Tooltips for the 'controls_whileUntil' block, keyed by MODE value. - * @see {Blockly.Extensions#buildTooltipForDropdown} - * @package - * @readonly - */ -Blockly.Constants.Loops.WHILE_UNTIL_TOOLTIPS = { - 'WHILE': '%{BKY_CONTROLS_WHILEUNTIL_TOOLTIP_WHILE}', - 'UNTIL': '%{BKY_CONTROLS_WHILEUNTIL_TOOLTIP_UNTIL}' -}; - -Blockly.Extensions.register('controls_whileUntil_tooltip', - Blockly.Extensions.buildTooltipForDropdown( - 'MODE', Blockly.Constants.Loops.WHILE_UNTIL_TOOLTIPS)); - -/** - * Tooltips for the 'controls_flow_statements' block, keyed by FLOW value. - * @see {Blockly.Extensions#buildTooltipForDropdown} - * @package - * @readonly - */ -Blockly.Constants.Loops.BREAK_CONTINUE_TOOLTIPS = { - 'BREAK': '%{BKY_CONTROLS_FLOW_STATEMENTS_TOOLTIP_BREAK}', - 'CONTINUE': '%{BKY_CONTROLS_FLOW_STATEMENTS_TOOLTIP_CONTINUE}' -}; - -Blockly.Extensions.register('controls_flow_tooltip', - Blockly.Extensions.buildTooltipForDropdown( - 'FLOW', Blockly.Constants.Loops.BREAK_CONTINUE_TOOLTIPS)); - -/** - * Mixin to add a context menu item to create a 'variables_get' block. - * Used by blocks 'controls_for' and 'controls_forEach'. - * @mixin - * @augments Blockly.Block - * @package - * @readonly - */ -Blockly.Constants.Loops.CUSTOM_CONTEXT_MENU_CREATE_VARIABLES_GET_MIXIN = { - /** - * Add context menu option to create getter block for the loop's variable. - * (customContextMenu support limited to web BlockSvg.) - * @param {!Array} options List of menu options to add to. - * @this Blockly.Block - */ - customContextMenu: function(options) { - var varName = this.getFieldValue('VAR'); - if (!this.isCollapsed() && varName != null) { - var option = {enabled: true}; - option.text = - Blockly.Msg.VARIABLES_SET_CREATE_GET.replace('%1', varName); - var xmlField = goog.dom.createDom('field', null, varName); - xmlField.setAttribute('name', 'VAR'); - var xmlBlock = goog.dom.createDom('block', null, xmlField); - xmlBlock.setAttribute('type', 'variables_get'); - option.callback = Blockly.ContextMenu.callbackFactory(this, xmlBlock); - options.push(option); - } - } -}; - -Blockly.Extensions.registerMixin('contextMenu_newGetVariableBlock', - Blockly.Constants.Loops.CUSTOM_CONTEXT_MENU_CREATE_VARIABLES_GET_MIXIN); - -Blockly.Extensions.register('controls_for_tooltip', - Blockly.Extensions.buildTooltipWithFieldValue( - Blockly.Msg.CONTROLS_FOR_TOOLTIP, 'VAR')); - -Blockly.Extensions.register('controls_forEach_tooltip', - Blockly.Extensions.buildTooltipWithFieldValue( - Blockly.Msg.CONTROLS_FOREACH_TOOLTIP, 'VAR')); - -/** - * This mixin adds a check to make sure the 'controls_flow_statements' block - * is contained in a loop. Otherwise a warning is added to the block. - * @mixin - * @augments Blockly.Block - * @package - * @readonly - */ -Blockly.Constants.Loops.CONTROL_FLOW_CHECK_IN_LOOP_MIXIN = { - /** - * List of block types that are loops and thus do not need warnings. - * To add a new loop type add this to your code: - * Blockly.Blocks['controls_flow_statements'].LOOP_TYPES.push('custom_loop'); - */ - LOOP_TYPES: ['controls_repeat', 'controls_repeat_ext', 'controls_forEach', - 'controls_for', 'controls_whileUntil'], - - /** - * Called whenever anything on the workspace changes. - * Add warning if this flow block is not nested inside a loop. - * @param {!Blockly.Events.Abstract} e Change event. - * @this Blockly.Block - */ - onchange: function(/* e */) { - if (!this.workspace.isDragging || this.workspace.isDragging()) { - return; // Don't change state at the start of a drag. - } - var legal = false; - // Is the block nested in a loop? - var block = this; - do { - if (this.LOOP_TYPES.indexOf(block.type) != -1) { - legal = true; - break; - } - block = block.getSurroundParent(); - } while (block); - if (legal) { - this.setWarningText(null); - if (!this.isInFlyout) { - this.setDisabled(false); - } - } else { - this.setWarningText(Blockly.Msg.CONTROLS_FLOW_STATEMENTS_WARNING); - if (!this.isInFlyout && !this.getInheritedDisabled()) { - this.setDisabled(true); - } - } - } -}; - -Blockly.Extensions.registerMixin('controls_flow_in_loop_check', - Blockly.Constants.Loops.CONTROL_FLOW_IN_LOOP_CHECK_MIXIN); diff --git a/blocks/loops.ts b/blocks/loops.ts new file mode 100644 index 00000000000..6d450e53215 --- /dev/null +++ b/blocks/loops.ts @@ -0,0 +1,408 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.libraryBlocks.loops + +import type {Block} from '../core/block.js'; +import { + createBlockDefinitionsFromJsonArray, + defineBlocks, +} from '../core/common.js'; +import * as ContextMenu from '../core/contextmenu.js'; +import type { + ContextMenuOption, + LegacyContextMenuOption, +} from '../core/contextmenu_registry.js'; +import * as Events from '../core/events/events.js'; +import type {Abstract as AbstractEvent} from '../core/events/events_abstract.js'; +import * as eventUtils from '../core/events/utils.js'; +import * as Extensions from '../core/extensions.js'; +import '../core/field_dropdown.js'; +import '../core/field_label.js'; +import '../core/field_number.js'; +import '../core/field_variable.js'; +import {FieldVariable} from '../core/field_variable.js'; +import '../core/icons/warning_icon.js'; +import {Msg} from '../core/msg.js'; +import {WorkspaceSvg} from '../core/workspace_svg.js'; + +/** + * A dictionary of the block definitions provided by this module. + */ +export const blocks = createBlockDefinitionsFromJsonArray([ + // Block for repeat n times (external number). + { + 'type': 'controls_repeat_ext', + 'message0': '%{BKY_CONTROLS_REPEAT_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'TIMES', + 'check': 'Number', + }, + ], + 'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1', + 'args1': [ + { + 'type': 'input_statement', + 'name': 'DO', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'style': 'loop_blocks', + 'tooltip': '%{BKY_CONTROLS_REPEAT_TOOLTIP}', + 'helpUrl': '%{BKY_CONTROLS_REPEAT_HELPURL}', + }, + // Block for repeat n times (internal number). + // The 'controls_repeat_ext' block is preferred as it is more flexible. + { + 'type': 'controls_repeat', + 'message0': '%{BKY_CONTROLS_REPEAT_TITLE}', + 'args0': [ + { + 'type': 'field_number', + 'name': 'TIMES', + 'value': 10, + 'min': 0, + 'precision': 1, + }, + ], + 'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1', + 'args1': [ + { + 'type': 'input_statement', + 'name': 'DO', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'style': 'loop_blocks', + 'tooltip': '%{BKY_CONTROLS_REPEAT_TOOLTIP}', + 'helpUrl': '%{BKY_CONTROLS_REPEAT_HELPURL}', + }, + // Block for 'do while/until' loop. + { + 'type': 'controls_whileUntil', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'MODE', + 'options': [ + ['%{BKY_CONTROLS_WHILEUNTIL_OPERATOR_WHILE}', 'WHILE'], + ['%{BKY_CONTROLS_WHILEUNTIL_OPERATOR_UNTIL}', 'UNTIL'], + ], + }, + { + 'type': 'input_value', + 'name': 'BOOL', + 'check': 'Boolean', + }, + ], + 'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1', + 'args1': [ + { + 'type': 'input_statement', + 'name': 'DO', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'style': 'loop_blocks', + 'helpUrl': '%{BKY_CONTROLS_WHILEUNTIL_HELPURL}', + 'extensions': ['controls_whileUntil_tooltip'], + }, + // Block for 'for' loop. + { + 'type': 'controls_for', + 'message0': '%{BKY_CONTROLS_FOR_TITLE}', + 'args0': [ + { + 'type': 'field_variable', + 'name': 'VAR', + 'variable': null, + }, + { + 'type': 'input_value', + 'name': 'FROM', + 'check': 'Number', + 'align': 'RIGHT', + }, + { + 'type': 'input_value', + 'name': 'TO', + 'check': 'Number', + 'align': 'RIGHT', + }, + { + 'type': 'input_value', + 'name': 'BY', + 'check': 'Number', + 'align': 'RIGHT', + }, + ], + 'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1', + 'args1': [ + { + 'type': 'input_statement', + 'name': 'DO', + }, + ], + 'inputsInline': true, + 'previousStatement': null, + 'nextStatement': null, + 'style': 'loop_blocks', + 'helpUrl': '%{BKY_CONTROLS_FOR_HELPURL}', + 'extensions': ['contextMenu_newGetVariableBlock', 'controls_for_tooltip'], + }, + // Block for 'for each' loop. + { + 'type': 'controls_forEach', + 'message0': '%{BKY_CONTROLS_FOREACH_TITLE}', + 'args0': [ + { + 'type': 'field_variable', + 'name': 'VAR', + 'variable': null, + }, + { + 'type': 'input_value', + 'name': 'LIST', + 'check': 'Array', + }, + ], + 'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1', + 'args1': [ + { + 'type': 'input_statement', + 'name': 'DO', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'style': 'loop_blocks', + 'helpUrl': '%{BKY_CONTROLS_FOREACH_HELPURL}', + 'extensions': [ + 'contextMenu_newGetVariableBlock', + 'controls_forEach_tooltip', + ], + }, + // Block for flow statements: continue, break. + { + 'type': 'controls_flow_statements', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'FLOW', + 'options': [ + ['%{BKY_CONTROLS_FLOW_STATEMENTS_OPERATOR_BREAK}', 'BREAK'], + ['%{BKY_CONTROLS_FLOW_STATEMENTS_OPERATOR_CONTINUE}', 'CONTINUE'], + ], + }, + ], + 'previousStatement': null, + 'style': 'loop_blocks', + 'helpUrl': '%{BKY_CONTROLS_FLOW_STATEMENTS_HELPURL}', + 'suppressPrefixSuffix': true, + 'extensions': ['controls_flow_tooltip', 'controls_flow_in_loop_check'], + }, +]); + +/** + * Tooltips for the 'controls_whileUntil' block, keyed by MODE value. + * + * @see {Extensions#buildTooltipForDropdown} + */ +const WHILE_UNTIL_TOOLTIPS = { + 'WHILE': '%{BKY_CONTROLS_WHILEUNTIL_TOOLTIP_WHILE}', + 'UNTIL': '%{BKY_CONTROLS_WHILEUNTIL_TOOLTIP_UNTIL}', +}; + +Extensions.register( + 'controls_whileUntil_tooltip', + Extensions.buildTooltipForDropdown('MODE', WHILE_UNTIL_TOOLTIPS), +); + +/** + * Tooltips for the 'controls_flow_statements' block, keyed by FLOW value. + * + * @see {Extensions#buildTooltipForDropdown} + */ +const BREAK_CONTINUE_TOOLTIPS = { + 'BREAK': '%{BKY_CONTROLS_FLOW_STATEMENTS_TOOLTIP_BREAK}', + 'CONTINUE': '%{BKY_CONTROLS_FLOW_STATEMENTS_TOOLTIP_CONTINUE}', +}; + +Extensions.register( + 'controls_flow_tooltip', + Extensions.buildTooltipForDropdown('FLOW', BREAK_CONTINUE_TOOLTIPS), +); + +/** Type of a block that has CUSTOM_CONTEXT_MENU_CREATE_VARIABLES_GET_MIXIN */ +type CustomContextMenuBlock = Block & CustomContextMenuMixin; +interface CustomContextMenuMixin extends CustomContextMenuMixinType {} +type CustomContextMenuMixinType = + typeof CUSTOM_CONTEXT_MENU_CREATE_VARIABLES_GET_MIXIN; + +/** + * Mixin to add a context menu item to create a 'variables_get' block. + * Used by blocks 'controls_for' and 'controls_forEach'. + */ +const CUSTOM_CONTEXT_MENU_CREATE_VARIABLES_GET_MIXIN = { + /** + * Add context menu option to create getter block for the loop's variable. + * (customContextMenu support limited to web BlockSvg.) + * + * @param options List of menu options to add to. + */ + customContextMenu: function ( + this: CustomContextMenuBlock, + options: Array, + ) { + if (this.isInFlyout) { + return; + } + const varField = this.getField('VAR') as FieldVariable; + const variable = varField.getVariable()!; + const varName = variable.getName(); + if (!this.isCollapsed() && varName !== null) { + const getVarBlockState = { + type: 'variables_get', + fields: {VAR: varField.saveState(true)}, + }; + + options.push({ + enabled: true, + text: Msg['VARIABLES_SET_CREATE_GET'].replace('%1', varName), + callback: ContextMenu.callbackFactory(this, getVarBlockState), + }); + } + }, +}; + +Extensions.registerMixin( + 'contextMenu_newGetVariableBlock', + CUSTOM_CONTEXT_MENU_CREATE_VARIABLES_GET_MIXIN, +); + +Extensions.register( + 'controls_for_tooltip', + Extensions.buildTooltipWithFieldText('%{BKY_CONTROLS_FOR_TOOLTIP}', 'VAR'), +); + +Extensions.register( + 'controls_forEach_tooltip', + Extensions.buildTooltipWithFieldText( + '%{BKY_CONTROLS_FOREACH_TOOLTIP}', + 'VAR', + ), +); + +/** + * List of block types that are loops and thus do not need warnings. + * To add a new loop type add this to your code: + * + * // If using the Blockly npm package and es6 import syntax: + * import {loops} from 'blockly/blocks'; + * loops.loopTypes.add('custom_loop'); + * + * // Else if using Closure Compiler and goog.modules: + * const {loopTypes} = goog.require('Blockly.libraryBlocks.loops'); + * loopTypes.add('custom_loop'); + * + * // Else if using blockly_compressed + blockss_compressed.js in browser: + * Blockly.libraryBlocks.loopTypes.add('custom_loop'); + */ +export const loopTypes: Set = new Set([ + 'controls_repeat', + 'controls_repeat_ext', + 'controls_forEach', + 'controls_for', + 'controls_whileUntil', +]); + +/** + * Type of a block that has CONTROL_FLOW_IN_LOOP_CHECK_MIXIN + * + * @internal + */ +export type ControlFlowInLoopBlock = Block & ControlFlowInLoopMixin; +interface ControlFlowInLoopMixin extends ControlFlowInLoopMixinType {} +type ControlFlowInLoopMixinType = typeof CONTROL_FLOW_IN_LOOP_CHECK_MIXIN; + +/** + * The language-neutral ID for when the reason why a block is disabled is + * because the block is only valid inside of a loop. + */ +const CONTROL_FLOW_NOT_IN_LOOP_DISABLED_REASON = 'CONTROL_FLOW_NOT_IN_LOOP'; +/** + * This mixin adds a check to make sure the 'controls_flow_statements' block + * is contained in a loop. Otherwise a warning is added to the block. + */ +const CONTROL_FLOW_IN_LOOP_CHECK_MIXIN = { + /** + * Is this block enclosed (at any level) by a loop? + * + * @returns The nearest surrounding loop, or null if none. + */ + getSurroundLoop: function (this: ControlFlowInLoopBlock): Block | null { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let block: Block | null = this; + do { + if (loopTypes.has(block.type)) { + return block; + } + block = block.getSurroundParent(); + } while (block); + return null; + }, + + /** + * Called whenever anything on the workspace changes. + * Add warning if this flow block is not nested inside a loop. + */ + onchange: function (this: ControlFlowInLoopBlock, e: AbstractEvent) { + const ws = this.workspace as WorkspaceSvg; + // Don't change state if: + // * It's at the start of a drag. + // * It's not a move event. + if ( + !ws.isDragging || + ws.isDragging() || + (e.type !== Events.BLOCK_MOVE && e.type !== Events.BLOCK_CREATE) + ) { + return; + } + const enabled = !!this.getSurroundLoop(); + this.setWarningText( + enabled ? null : Msg['CONTROLS_FLOW_STATEMENTS_WARNING'], + ); + + if (!this.isInFlyout) { + try { + // There is no need to record the enable/disable change on the undo/redo + // list since the change will be automatically recreated when replayed. + eventUtils.setRecordUndo(false); + this.setDisabledReason( + !enabled, + CONTROL_FLOW_NOT_IN_LOOP_DISABLED_REASON, + ); + } finally { + eventUtils.setRecordUndo(true); + } + } + }, +}; + +Extensions.registerMixin( + 'controls_flow_in_loop_check', + CONTROL_FLOW_IN_LOOP_CHECK_MIXIN, +); + +// Register provided blocks. +defineBlocks(blocks); diff --git a/blocks/math.js b/blocks/math.js deleted file mode 100644 index 0aff2ab3d46..00000000000 --- a/blocks/math.js +++ /dev/null @@ -1,566 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Math blocks for Blockly. - * - * This file is scraped to extract a .json file of block definitions. The array - * passed to defineBlocksWithJsonArray(..) must be strict JSON: double quotes - * only, no outside references, no functions, no trailing commas, etc. The one - * exception is end-of-line comments, which the scraper will remove. - * @author q.neutron@gmail.com (Quynh Neutron) - */ -'use strict'; - -goog.provide('Blockly.Blocks.math'); // Deprecated -goog.provide('Blockly.Constants.Math'); - -goog.require('Blockly.Blocks'); - - -/** - * Common HSV hue for all blocks in this category. - * Should be the same as Blockly.Msg.MATH_HUE - * @readonly - */ -Blockly.Constants.Math.HUE = 230; -/** @deprecated Use Blockly.Constants.Math.HUE */ -Blockly.Blocks.math.HUE = Blockly.Constants.Math.HUE; - -Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT - // Block for numeric value. - { - "type": "math_number", - "message0": "%1", - "args0": [{ - "type": "field_number", - "name": "NUM", - "value": 0 - }], - "output": "Number", - "colour": "%{BKY_MATH_HUE}", - "helpUrl": "%{BKY_MATH_NUMBER_HELPURL}", - "tooltip": "%{BKY_MATH_NUMBER_TOOLTIP}", - "extensions": ["parent_tooltip_when_inline"] - }, - - // Block for basic arithmetic operator. - { - "type": "math_arithmetic", - "message0": "%1 %2 %3", - "args0": [ - { - "type": "input_value", - "name": "A", - "check": "Number" - }, - { - "type": "field_dropdown", - "name": "OP", - "options": [ - ["%{BKY_MATH_ADDITION_SYMBOL}", "ADD"], - ["%{BKY_MATH_SUBTRACTION_SYMBOL}", "MINUS"], - ["%{BKY_MATH_MULTIPLICATION_SYMBOL}", "MULTIPLY"], - ["%{BKY_MATH_DIVISION_SYMBOL}", "DIVIDE"], - ["%{BKY_MATH_POWER_SYMBOL}", "POWER"] - ] - }, - { - "type": "input_value", - "name": "B", - "check": "Number" - } - ], - "inputsInline": true, - "output": "Number", - "colour": "%{BKY_MATH_HUE}", - "helpUrl": "%{BKY_MATH_ARITHMETIC_HELPURL}", - "extensions": ["math_op_tooltip"] - }, - - // Block for advanced math operators with single operand. - { - "type": "math_single", - "message0": "%1 %2", - "args0": [ - { - "type": "field_dropdown", - "name": "OP", - "options": [ - ["%{BKY_MATH_SINGLE_OP_ROOT}", 'ROOT'], - ["%{BKY_MATH_SINGLE_OP_ABSOLUTE}", 'ABS'], - ['-', 'NEG'], - ['ln', 'LN'], - ['log10', 'LOG10'], - ['e^', 'EXP'], - ['10^', 'POW10'] - ] - }, - { - "type": "input_value", - "name": "NUM", - "check": "Number" - } - ], - "output": "Number", - "colour": "%{BKY_MATH_HUE}", - "helpUrl": "%{BKY_MATH_SINGLE_HELPURL}", - "extensions": ["math_op_tooltip"] - }, - - // Block for trigonometry operators. - { - "type": "math_trig", - "message0": "%1 %2", - "args0": [ - { - "type": "field_dropdown", - "name": "OP", - "options": [ - ["%{BKY_MATH_TRIG_SIN}", "SIN"], - ["%{BKY_MATH_TRIG_COS}", "COS"], - ["%{BKY_MATH_TRIG_TAN}", "TAN"], - ["%{BKY_MATH_TRIG_ASIN}", "ASIN"], - ["%{BKY_MATH_TRIG_ACOS}", "ACOS"], - ["%{BKY_MATH_TRIG_ATAN}", "ATAN"] - ] - }, - { - "type": "input_value", - "name": "NUM", - "check": "Number" - } - ], - "output": "Number", - "colour": "%{BKY_MATH_HUE}", - "helpUrl": "%{BKY_MATH_TRIG_HELPURL}", - "extensions": ["math_op_tooltip"] - }, - - // Block for constants: PI, E, the Golden Ratio, sqrt(2), 1/sqrt(2), INFINITY. - { - "type": "math_constant", - "message0": "%1", - "args0": [ - { - "type": "field_dropdown", - "name": "CONSTANT", - "options": [ - ["\u03c0", "PI"], - ["e", "E"], - ["\u03c6", "GOLDEN_RATIO"], - ["sqrt(2)", "SQRT2"], - ["sqrt(\u00bd)", "SQRT1_2"], - ["\u221e", "INFINITY"] - ] - } - ], - "output": "Number", - "colour": "%{BKY_MATH_HUE}", - "tooltip": "%{BKY_MATH_CONSTANT_TOOLTIP}", - "helpUrl": "%{BKY_MATH_CONSTANT_HELPURL}" - }, - - // Block for checking if a number is even, odd, prime, whole, positive, - // negative or if it is divisible by certain number. - { - "type": "math_number_property", - "message0": "%1 %2", - "args0": [ - { - "type": "input_value", - "name": "NUMBER_TO_CHECK", - "check": "Number" - }, - { - "type": "field_dropdown", - "name": "PROPERTY", - "options": [ - ["%{BKY_MATH_IS_EVEN}", "EVEN"], - ["%{BKY_MATH_IS_ODD}", "ODD"], - ["%{BKY_MATH_IS_PRIME}", "PRIME"], - ["%{BKY_MATH_IS_WHOLE}", "WHOLE"], - ["%{BKY_MATH_IS_POSITIVE}", "POSITIVE"], - ["%{BKY_MATH_IS_NEGATIVE}", "NEGATIVE"], - ["%{BKY_MATH_IS_DIVISIBLE_BY}", "DIVISIBLE_BY"] - ] - } - ], - "inputsInline": true, - "output": "Boolean", - "colour": "%{BKY_MATH_HUE}", - "tooltip": "%{BKY_MATH_IS_TOOLTIP}", - "mutator": "math_is_divisibleby_mutator" - }, - - // Block for adding to a variable in place. - { - "type": "math_change", - "message0": "%{BKY_MATH_CHANGE_TITLE}", - "args0": [ - { - "type": "field_variable", - "name": "VAR", - "variable": "%{BKY_MATH_CHANGE_TITLE_ITEM}" - }, - { - "type": "input_value", - "name": "DELTA", - "check": "Number" - } - ], - "previousStatement": null, - "nextStatement": null, - "colour": "%{BKY_VARIABLES_HUE}", - "helpUrl": "%{BKY_MATH_CHANGE_HELPURL}", - "extensions": ["math_change_tooltip"] - }, - - // Block for rounding functions. - { - "type": "math_round", - "message0": "%1 %2", - "args0": [ - { - "type": "field_dropdown", - "name": "OP", - "options": [ - ["%{BKY_MATH_ROUND_OPERATOR_ROUND}", "ROUND"], - ["%{BKY_MATH_ROUND_OPERATOR_ROUNDUP}", "ROUNDUP"], - ["%{BKY_MATH_ROUND_OPERATOR_ROUNDDOWN}", "ROUNDDOWN"] - ] - }, - { - "type": "input_value", - "name": "NUM", - "check": "Number" - } - ], - "output": "Number", - "colour": "%{BKY_MATH_HUE}", - "helpUrl": "%{BKY_MATH_ROUND_HELPURL}", - "tooltip": "%{BKY_MATH_ROUND_TOOLTIP}" - }, - - // Block for evaluating a list of numbers to return sum, average, min, max, - // etc. Some functions also work on text (min, max, mode, median). - { - "type": "math_on_list", - "message0": "%1 %2", - "args0": [ - { - "type": "field_dropdown", - "name": "OP", - "options": [ - ["%{BKY_MATH_ONLIST_OPERATOR_SUM}", "SUM"], - ["%{BKY_MATH_ONLIST_OPERATOR_MIN}", "MIN"], - ["%{BKY_MATH_ONLIST_OPERATOR_MAX}", "MAX"], - ["%{BKY_MATH_ONLIST_OPERATOR_AVERAGE}", "AVERAGE"], - ["%{BKY_MATH_ONLIST_OPERATOR_MEDIAN}", "MEDIAN"], - ["%{BKY_MATH_ONLIST_OPERATOR_MODE}", "MODE"], - ["%{BKY_MATH_ONLIST_OPERATOR_STD_DEV}", "STD_DEV"], - ["%{BKY_MATH_ONLIST_OPERATOR_RANDOM}", "RANDOM"] - ] - }, - { - "type": "input_value", - "name": "LIST", - "check": "Array" - } - ], - "output": "Number", - "colour": "%{BKY_MATH_HUE}", - "helpUrl": "%{BKY_MATH_ONLIST_HELPURL}", - "mutator": "math_modes_of_list_mutator", - "extensions": ["math_op_tooltip"] - }, - - // Block for remainder of a division. - { - "type": "math_modulo", - "message0": "%{BKY_MATH_MODULO_TITLE}", - "args0": [ - { - "type": "input_value", - "name": "DIVIDEND", - "check": "Number" - }, - { - "type": "input_value", - "name": "DIVISOR", - "check": "Number" - } - ], - "inputsInline": true, - "output": "Number", - "colour": "%{BKY_MATH_HUE}", - "tooltip": "%{BKY_MATH_MODULO_TOOLTIP}", - "helpUrl": "%{BKY_MATH_MODULO_HELPURL}" - }, - - // Block for constraining a number between two limits. - { - "type": "math_constrain", - "message0": "%{BKY_MATH_CONSTRAIN_TITLE}", - "args0": [ - { - "type": "input_value", - "name": "VALUE", - "check": "Number" - }, - { - "type": "input_value", - "name": "LOW", - "check": "Number" - }, - { - "type": "input_value", - "name": "HIGH", - "check": "Number" - } - ], - "inputsInline": true, - "output": "Number", - "colour": "%{BKY_MATH_HUE}", - "tooltip": "%{BKY_MATH_CONSTRAIN_TOOLTIP}", - "helpUrl": "%{BKY_MATH_CONSTRAIN_HELPURL}" - }, - - // Block for random integer between [X] and [Y]. - { - "type": "math_random_int", - "message0": "%{BKY_MATH_RANDOM_INT_TITLE}", - "args0": [ - { - "type": "input_value", - "name": "FROM", - "check": "Number" - }, - { - "type": "input_value", - "name": "TO", - "check": "Number" - } - ], - "inputsInline": true, - "output": "Number", - "colour": "%{BKY_MATH_HUE}", - "tooltip": "%{BKY_MATH_RANDOM_INT_TOOLTIP}", - "helpUrl": "%{BKY_MATH_RANDOM_INT_HELPURL}" - }, - - // Block for random integer between [X] and [Y]. - { - "type": "math_random_float", - "message0": "%{BKY_MATH_RANDOM_FLOAT_TITLE_RANDOM}", - "output": "Number", - "colour": "%{BKY_MATH_HUE}", - "tooltip": "%{BKY_MATH_RANDOM_FLOAT_TOOLTIP}", - "helpUrl": "%{BKY_MATH_RANDOM_FLOAT_HELPURL}" - } -]); // END JSON EXTRACT (Do not delete this comment.) - -/** - * Mapping of math block OP value to tooltip message for blocks - * math_arithmetic, math_simple, math_trig, and math_on_lists. - * @see {Blockly.Extensions#buildTooltipForDropdown} - * @package - * @readonly - */ -Blockly.Constants.Math.TOOLTIPS_BY_OP = { - // math_arithmetic - 'ADD': '%{BKY_MATH_ARITHMETIC_TOOLTIP_ADD}', - 'MINUS': '%{BKY_MATH_ARITHMETIC_TOOLTIP_MINUS}', - 'MULTIPLY': '%{BKY_MATH_ARITHMETIC_TOOLTIP_MULTIPLY}', - 'DIVIDE': '%{BKY_MATH_ARITHMETIC_TOOLTIP_DIVIDE}', - 'POWER': '%{BKY_MATH_ARITHMETIC_TOOLTIP_POWER}', - - // math_simple - 'ROOT': '%{BKY_MATH_SINGLE_TOOLTIP_ROOT}', - 'ABS': '%{BKY_MATH_SINGLE_TOOLTIP_ABS}', - 'NEG': '%{BKY_MATH_SINGLE_TOOLTIP_NEG}', - 'LN': '%{BKY_MATH_SINGLE_TOOLTIP_LN}', - 'LOG10': '%{BKY_MATH_SINGLE_TOOLTIP_LOG10}', - 'EXP': '%{BKY_MATH_SINGLE_TOOLTIP_EXP}', - 'POW10': '%{BKY_MATH_SINGLE_TOOLTIP_POW10}', - - // math_trig - 'SIN': '%{BKY_MATH_TRIG_TOOLTIP_SIN}', - 'COS': '%{BKY_MATH_TRIG_TOOLTIP_COS}', - 'TAN': '%{BKY_MATH_TRIG_TOOLTIP_TAN}', - 'ASIN': '%{BKY_MATH_TRIG_TOOLTIP_ASIN}', - 'ACOS': '%{BKY_MATH_TRIG_TOOLTIP_ACOS}', - 'ATAN': '%{BKY_MATH_TRIG_TOOLTIP_ATAN}', - - // math_on_lists - 'SUM': '%{BKY_MATH_ONLIST_TOOLTIP_SUM}', - 'MIN': '%{BKY_MATH_ONLIST_TOOLTIP_MIN}', - 'MAX': '%{BKY_MATH_ONLIST_TOOLTIP_MAX}', - 'AVERAGE': '%{BKY_MATH_ONLIST_TOOLTIP_AVERAGE}', - 'MEDIAN': '%{BKY_MATH_ONLIST_TOOLTIP_MEDIAN}', - 'MODE': '%{BKY_MATH_ONLIST_TOOLTIP_MODE}', - 'STD_DEV': '%{BKY_MATH_ONLIST_TOOLTIP_STD_DEV}', - 'RANDOM': '%{BKY_MATH_ONLIST_TOOLTIP_RANDOM}' -}; - -Blockly.Extensions.register('math_op_tooltip', - Blockly.Extensions.buildTooltipForDropdown( - 'OP', Blockly.Constants.Math.TOOLTIPS_BY_OP)); - - -/** - * Mixin for mutator functions in the 'math_is_divisibleby_mutator' - * extension. - * @mixin - * @augments Blockly.Block - * @package - */ -Blockly.Constants.Math.IS_DIVISIBLEBY_MUTATOR_MIXIN = { - /** - * Create XML to represent whether the 'divisorInput' should be present. - * @return {Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - var divisorInput = (this.getFieldValue('PROPERTY') == 'DIVISIBLE_BY'); - container.setAttribute('divisor_input', divisorInput); - return container; - }, - /** - * Parse XML to restore the 'divisorInput'. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - var divisorInput = (xmlElement.getAttribute('divisor_input') == 'true'); - this.updateShape_(divisorInput); - }, - /** - * Modify this block to have (or not have) an input for 'is divisible by'. - * @param {boolean} divisorInput True if this block has a divisor input. - * @private - * @this Blockly.Block - */ - updateShape_: function(divisorInput) { - // Add or remove a Value Input. - var inputExists = this.getInput('DIVISOR'); - if (divisorInput) { - if (!inputExists) { - this.appendValueInput('DIVISOR') - .setCheck('Number'); - } - } else if (inputExists) { - this.removeInput('DIVISOR'); - } - } -}; - -/** - * 'math_is_divisibleby_mutator' extension to the 'math_property' block that - * can update the block shape (add/remove divisor input) based on whether - * property is "divisble by". - * @this Blockly.Block - * @package - */ -Blockly.Constants.Math.IS_DIVISIBLE_MUTATOR_EXTENSION = function() { - this.getField('PROPERTY').setValidator(function(option) { - var divisorInput = (option == 'DIVISIBLE_BY'); - this.sourceBlock_.updateShape_(divisorInput); - }); -}; - -Blockly.Extensions.registerMutator('math_is_divisibleby_mutator', - Blockly.Constants.Math.IS_DIVISIBLEBY_MUTATOR_MIXIN, - Blockly.Constants.Math.IS_DIVISIBLE_MUTATOR_EXTENSION); - -/** - * Update the tooltip of 'math_change' block to reference the variable. - * @this Blockly.Block - * @package - */ -Blockly.Constants.Math.CHANGE_TOOLTIP_EXTENSION = function() { - this.setTooltip(function() { - return Blockly.Msg.MATH_CHANGE_TOOLTIP.replace('%1', - this.getFieldValue('VAR')); - }.bind(this)); -}; - -Blockly.Extensions.register('math_change_tooltip', - Blockly.Extensions.buildTooltipWithFieldValue( - Blockly.Msg.MATH_CHANGE_TOOLTIP, 'VAR')); - -/** - * Mixin with mutator methods to support alternate output based if the - * 'math_on_list' block uses the 'MODE' operation. - * @mixin - * @augments Blockly.Block - * @package - * @readonly - */ -Blockly.Constants.Math.LIST_MODES_MUTATOR_MIXIN = { - /** - * Modify this block to have the correct output type. - * @param {string} newOp Either 'MODE' or some op than returns a number. - * @private - * @this Blockly.Block - */ - updateType_: function(newOp) { - if (newOp == 'MODE') { - this.outputConnection.setCheck('Array'); - } else { - this.outputConnection.setCheck('Number'); - } - }, - /** - * Create XML to represent the output type. - * @return {Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - container.setAttribute('op', this.getFieldValue('OP')); - return container; - }, - /** - * Parse XML to restore the output type. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - this.updateType_(xmlElement.getAttribute('op')); - } -}; - -/** - * Extension to 'math_on_list' blocks that allows support of - * modes operation (outputs a list of numbers). - * @this Blockly.Block - * @package - */ -Blockly.Constants.Math.LIST_MODES_MUTATOR_EXTENSION = function() { - this.getField('OP').setValidator(function(newOp) { - this.updateType_(newOp); - }.bind(this)); -}; - -Blockly.Extensions.registerMutator('math_modes_of_list_mutator', - Blockly.Constants.Math.LIST_MODES_MUTATOR_MIXIN, - Blockly.Constants.Math.LIST_MODES_MUTATOR_EXTENSION); diff --git a/blocks/math.ts b/blocks/math.ts new file mode 100644 index 00000000000..e5aef5fbb6e --- /dev/null +++ b/blocks/math.ts @@ -0,0 +1,591 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.libraryBlocks.math + +import type {Block} from '../core/block.js'; +import { + createBlockDefinitionsFromJsonArray, + defineBlocks, +} from '../core/common.js'; +import * as Extensions from '../core/extensions.js'; +import '../core/field_dropdown.js'; +import type {FieldDropdown} from '../core/field_dropdown.js'; +import '../core/field_label.js'; +import '../core/field_number.js'; +import '../core/field_variable.js'; +import * as xmlUtils from '../core/utils/xml.js'; + +/** + * A dictionary of the block definitions provided by this module. + */ +export const blocks = createBlockDefinitionsFromJsonArray([ + // Block for numeric value. + { + 'type': 'math_number', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_number', + 'name': 'NUM', + 'value': 0, + }, + ], + 'output': 'Number', + 'helpUrl': '%{BKY_MATH_NUMBER_HELPURL}', + 'style': 'math_blocks', + 'tooltip': '%{BKY_MATH_NUMBER_TOOLTIP}', + 'extensions': ['parent_tooltip_when_inline'], + }, + + // Block for basic arithmetic operator. + { + 'type': 'math_arithmetic', + 'message0': '%1 %2 %3', + 'args0': [ + { + 'type': 'input_value', + 'name': 'A', + 'check': 'Number', + }, + { + 'type': 'field_dropdown', + 'name': 'OP', + 'options': [ + ['%{BKY_MATH_ADDITION_SYMBOL}', 'ADD'], + ['%{BKY_MATH_SUBTRACTION_SYMBOL}', 'MINUS'], + ['%{BKY_MATH_MULTIPLICATION_SYMBOL}', 'MULTIPLY'], + ['%{BKY_MATH_DIVISION_SYMBOL}', 'DIVIDE'], + ['%{BKY_MATH_POWER_SYMBOL}', 'POWER'], + ], + }, + { + 'type': 'input_value', + 'name': 'B', + 'check': 'Number', + }, + ], + 'inputsInline': true, + 'output': 'Number', + 'style': 'math_blocks', + 'helpUrl': '%{BKY_MATH_ARITHMETIC_HELPURL}', + 'extensions': ['math_op_tooltip'], + }, + + // Block for advanced math operators with single operand. + { + 'type': 'math_single', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'OP', + 'options': [ + ['%{BKY_MATH_SINGLE_OP_ROOT}', 'ROOT'], + ['%{BKY_MATH_SINGLE_OP_ABSOLUTE}', 'ABS'], + ['-', 'NEG'], + ['ln', 'LN'], + ['log10', 'LOG10'], + ['e^', 'EXP'], + ['10^', 'POW10'], + ], + }, + { + 'type': 'input_value', + 'name': 'NUM', + 'check': 'Number', + }, + ], + 'output': 'Number', + 'style': 'math_blocks', + 'helpUrl': '%{BKY_MATH_SINGLE_HELPURL}', + 'extensions': ['math_op_tooltip'], + }, + + // Block for trigonometry operators. + { + 'type': 'math_trig', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'OP', + 'options': [ + ['%{BKY_MATH_TRIG_SIN}', 'SIN'], + ['%{BKY_MATH_TRIG_COS}', 'COS'], + ['%{BKY_MATH_TRIG_TAN}', 'TAN'], + ['%{BKY_MATH_TRIG_ASIN}', 'ASIN'], + ['%{BKY_MATH_TRIG_ACOS}', 'ACOS'], + ['%{BKY_MATH_TRIG_ATAN}', 'ATAN'], + ], + }, + { + 'type': 'input_value', + 'name': 'NUM', + 'check': 'Number', + }, + ], + 'output': 'Number', + 'style': 'math_blocks', + 'helpUrl': '%{BKY_MATH_TRIG_HELPURL}', + 'extensions': ['math_op_tooltip'], + }, + + // Block for constants: PI, E, the Golden Ratio, sqrt(2), 1/sqrt(2), INFINITY. + { + 'type': 'math_constant', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'CONSTANT', + 'options': [ + ['\u03c0', 'PI'], + ['e', 'E'], + ['\u03c6', 'GOLDEN_RATIO'], + ['sqrt(2)', 'SQRT2'], + ['sqrt(\u00bd)', 'SQRT1_2'], + ['\u221e', 'INFINITY'], + ], + }, + ], + 'output': 'Number', + 'style': 'math_blocks', + 'tooltip': '%{BKY_MATH_CONSTANT_TOOLTIP}', + 'helpUrl': '%{BKY_MATH_CONSTANT_HELPURL}', + }, + + // Block for checking if a number is even, odd, prime, whole, positive, + // negative or if it is divisible by certain number. + { + 'type': 'math_number_property', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'input_value', + 'name': 'NUMBER_TO_CHECK', + 'check': 'Number', + }, + { + 'type': 'field_dropdown', + 'name': 'PROPERTY', + 'options': [ + ['%{BKY_MATH_IS_EVEN}', 'EVEN'], + ['%{BKY_MATH_IS_ODD}', 'ODD'], + ['%{BKY_MATH_IS_PRIME}', 'PRIME'], + ['%{BKY_MATH_IS_WHOLE}', 'WHOLE'], + ['%{BKY_MATH_IS_POSITIVE}', 'POSITIVE'], + ['%{BKY_MATH_IS_NEGATIVE}', 'NEGATIVE'], + ['%{BKY_MATH_IS_DIVISIBLE_BY}', 'DIVISIBLE_BY'], + ], + }, + ], + 'inputsInline': true, + 'output': 'Boolean', + 'style': 'math_blocks', + 'tooltip': '%{BKY_MATH_IS_TOOLTIP}', + 'mutator': 'math_is_divisibleby_mutator', + }, + + // Block for adding to a variable in place. + { + 'type': 'math_change', + 'message0': '%{BKY_MATH_CHANGE_TITLE}', + 'args0': [ + { + 'type': 'field_variable', + 'name': 'VAR', + 'variable': '%{BKY_MATH_CHANGE_TITLE_ITEM}', + }, + { + 'type': 'input_value', + 'name': 'DELTA', + 'check': 'Number', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'style': 'variable_blocks', + 'helpUrl': '%{BKY_MATH_CHANGE_HELPURL}', + 'extensions': ['math_change_tooltip'], + }, + + // Block for rounding functions. + { + 'type': 'math_round', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'OP', + 'options': [ + ['%{BKY_MATH_ROUND_OPERATOR_ROUND}', 'ROUND'], + ['%{BKY_MATH_ROUND_OPERATOR_ROUNDUP}', 'ROUNDUP'], + ['%{BKY_MATH_ROUND_OPERATOR_ROUNDDOWN}', 'ROUNDDOWN'], + ], + }, + { + 'type': 'input_value', + 'name': 'NUM', + 'check': 'Number', + }, + ], + 'output': 'Number', + 'style': 'math_blocks', + 'helpUrl': '%{BKY_MATH_ROUND_HELPURL}', + 'tooltip': '%{BKY_MATH_ROUND_TOOLTIP}', + }, + + // Block for evaluating a list of numbers to return sum, average, min, max, + // etc. Some functions also work on text (min, max, mode, median). + { + 'type': 'math_on_list', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'OP', + 'options': [ + ['%{BKY_MATH_ONLIST_OPERATOR_SUM}', 'SUM'], + ['%{BKY_MATH_ONLIST_OPERATOR_MIN}', 'MIN'], + ['%{BKY_MATH_ONLIST_OPERATOR_MAX}', 'MAX'], + ['%{BKY_MATH_ONLIST_OPERATOR_AVERAGE}', 'AVERAGE'], + ['%{BKY_MATH_ONLIST_OPERATOR_MEDIAN}', 'MEDIAN'], + ['%{BKY_MATH_ONLIST_OPERATOR_MODE}', 'MODE'], + ['%{BKY_MATH_ONLIST_OPERATOR_STD_DEV}', 'STD_DEV'], + ['%{BKY_MATH_ONLIST_OPERATOR_RANDOM}', 'RANDOM'], + ], + }, + { + 'type': 'input_value', + 'name': 'LIST', + 'check': 'Array', + }, + ], + 'output': 'Number', + 'style': 'math_blocks', + 'helpUrl': '%{BKY_MATH_ONLIST_HELPURL}', + 'mutator': 'math_modes_of_list_mutator', + 'extensions': ['math_op_tooltip'], + }, + + // Block for remainder of a division. + { + 'type': 'math_modulo', + 'message0': '%{BKY_MATH_MODULO_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'DIVIDEND', + 'check': 'Number', + }, + { + 'type': 'input_value', + 'name': 'DIVISOR', + 'check': 'Number', + }, + ], + 'inputsInline': true, + 'output': 'Number', + 'style': 'math_blocks', + 'tooltip': '%{BKY_MATH_MODULO_TOOLTIP}', + 'helpUrl': '%{BKY_MATH_MODULO_HELPURL}', + }, + + // Block for constraining a number between two limits. + { + 'type': 'math_constrain', + 'message0': '%{BKY_MATH_CONSTRAIN_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'VALUE', + 'check': 'Number', + }, + { + 'type': 'input_value', + 'name': 'LOW', + 'check': 'Number', + }, + { + 'type': 'input_value', + 'name': 'HIGH', + 'check': 'Number', + }, + ], + 'inputsInline': true, + 'output': 'Number', + 'style': 'math_blocks', + 'tooltip': '%{BKY_MATH_CONSTRAIN_TOOLTIP}', + 'helpUrl': '%{BKY_MATH_CONSTRAIN_HELPURL}', + }, + + // Block for random integer between [X] and [Y]. + { + 'type': 'math_random_int', + 'message0': '%{BKY_MATH_RANDOM_INT_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'FROM', + 'check': 'Number', + }, + { + 'type': 'input_value', + 'name': 'TO', + 'check': 'Number', + }, + ], + 'inputsInline': true, + 'output': 'Number', + 'style': 'math_blocks', + 'tooltip': '%{BKY_MATH_RANDOM_INT_TOOLTIP}', + 'helpUrl': '%{BKY_MATH_RANDOM_INT_HELPURL}', + }, + + // Block for random integer between [X] and [Y]. + { + 'type': 'math_random_float', + 'message0': '%{BKY_MATH_RANDOM_FLOAT_TITLE_RANDOM}', + 'output': 'Number', + 'style': 'math_blocks', + 'tooltip': '%{BKY_MATH_RANDOM_FLOAT_TOOLTIP}', + 'helpUrl': '%{BKY_MATH_RANDOM_FLOAT_HELPURL}', + }, + + // Block for calculating atan2 of [X] and [Y]. + { + 'type': 'math_atan2', + 'message0': '%{BKY_MATH_ATAN2_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'X', + 'check': 'Number', + }, + { + 'type': 'input_value', + 'name': 'Y', + 'check': 'Number', + }, + ], + 'inputsInline': true, + 'output': 'Number', + 'style': 'math_blocks', + 'tooltip': '%{BKY_MATH_ATAN2_TOOLTIP}', + 'helpUrl': '%{BKY_MATH_ATAN2_HELPURL}', + }, +]); + +/** + * Mapping of math block OP value to tooltip message for blocks + * math_arithmetic, math_simple, math_trig, and math_on_lists. + * + * @see {Extensions#buildTooltipForDropdown} + * @package + * @readonly + */ +const TOOLTIPS_BY_OP = { + // math_arithmetic + 'ADD': '%{BKY_MATH_ARITHMETIC_TOOLTIP_ADD}', + 'MINUS': '%{BKY_MATH_ARITHMETIC_TOOLTIP_MINUS}', + 'MULTIPLY': '%{BKY_MATH_ARITHMETIC_TOOLTIP_MULTIPLY}', + 'DIVIDE': '%{BKY_MATH_ARITHMETIC_TOOLTIP_DIVIDE}', + 'POWER': '%{BKY_MATH_ARITHMETIC_TOOLTIP_POWER}', + + // math_simple + 'ROOT': '%{BKY_MATH_SINGLE_TOOLTIP_ROOT}', + 'ABS': '%{BKY_MATH_SINGLE_TOOLTIP_ABS}', + 'NEG': '%{BKY_MATH_SINGLE_TOOLTIP_NEG}', + 'LN': '%{BKY_MATH_SINGLE_TOOLTIP_LN}', + 'LOG10': '%{BKY_MATH_SINGLE_TOOLTIP_LOG10}', + 'EXP': '%{BKY_MATH_SINGLE_TOOLTIP_EXP}', + 'POW10': '%{BKY_MATH_SINGLE_TOOLTIP_POW10}', + + // math_trig + 'SIN': '%{BKY_MATH_TRIG_TOOLTIP_SIN}', + 'COS': '%{BKY_MATH_TRIG_TOOLTIP_COS}', + 'TAN': '%{BKY_MATH_TRIG_TOOLTIP_TAN}', + 'ASIN': '%{BKY_MATH_TRIG_TOOLTIP_ASIN}', + 'ACOS': '%{BKY_MATH_TRIG_TOOLTIP_ACOS}', + 'ATAN': '%{BKY_MATH_TRIG_TOOLTIP_ATAN}', + + // math_on_lists + 'SUM': '%{BKY_MATH_ONLIST_TOOLTIP_SUM}', + 'MIN': '%{BKY_MATH_ONLIST_TOOLTIP_MIN}', + 'MAX': '%{BKY_MATH_ONLIST_TOOLTIP_MAX}', + 'AVERAGE': '%{BKY_MATH_ONLIST_TOOLTIP_AVERAGE}', + 'MEDIAN': '%{BKY_MATH_ONLIST_TOOLTIP_MEDIAN}', + 'MODE': '%{BKY_MATH_ONLIST_TOOLTIP_MODE}', + 'STD_DEV': '%{BKY_MATH_ONLIST_TOOLTIP_STD_DEV}', + 'RANDOM': '%{BKY_MATH_ONLIST_TOOLTIP_RANDOM}', +}; + +Extensions.register( + 'math_op_tooltip', + Extensions.buildTooltipForDropdown('OP', TOOLTIPS_BY_OP), +); + +/** Type of a block that has IS_DIVISBLEBY_MUTATOR_MIXIN */ +type DivisiblebyBlock = Block & DivisiblebyMixin; +interface DivisiblebyMixin extends DivisiblebyMixinType {} +type DivisiblebyMixinType = typeof IS_DIVISIBLEBY_MUTATOR_MIXIN; + +/** + * Mixin for mutator functions in the 'math_is_divisibleby_mutator' + * extension. + * + * @mixin + * @augments Block + * @package + */ +const IS_DIVISIBLEBY_MUTATOR_MIXIN = { + /** + * Create XML to represent whether the 'divisorInput' should be present. + * Backwards compatible serialization implementation. + * + * @returns XML storage element. + */ + mutationToDom: function (this: DivisiblebyBlock): Element { + const container = xmlUtils.createElement('mutation'); + const divisorInput = this.getFieldValue('PROPERTY') === 'DIVISIBLE_BY'; + container.setAttribute('divisor_input', String(divisorInput)); + return container; + }, + /** + * Parse XML to restore the 'divisorInput'. + * Backwards compatible serialization implementation. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: DivisiblebyBlock, xmlElement: Element) { + const divisorInput = xmlElement.getAttribute('divisor_input') === 'true'; + this.updateShape_(divisorInput); + }, + + // This block does not need JSO serialization hooks (saveExtraState and + // loadExtraState) because the state of this object is already encoded in the + // dropdown values. + // XML hooks are kept for backwards compatibility. + + /** + * Modify this block to have (or not have) an input for 'is divisible by'. + * + * @param divisorInput True if this block has a divisor input. + */ + updateShape_: function (this: DivisiblebyBlock, divisorInput: boolean) { + // Add or remove a Value Input. + const inputExists = this.getInput('DIVISOR'); + if (divisorInput) { + if (!inputExists) { + this.appendValueInput('DIVISOR').setCheck('Number'); + } + } else if (inputExists) { + this.removeInput('DIVISOR'); + } + }, +}; + +/** + * 'math_is_divisibleby_mutator' extension to the 'math_property' block that + * can update the block shape (add/remove divisor input) based on whether + * property is "divisible by". + */ +const IS_DIVISIBLE_MUTATOR_EXTENSION = function (this: DivisiblebyBlock) { + this.getField('PROPERTY')!.setValidator( + /** @param option The selected dropdown option. */ + function (this: FieldDropdown, option: string) { + const divisorInput = option === 'DIVISIBLE_BY'; + (this.getSourceBlock() as DivisiblebyBlock).updateShape_(divisorInput); + return undefined; // FieldValidators can't be void. Use option as-is. + }, + ); +}; + +Extensions.registerMutator( + 'math_is_divisibleby_mutator', + IS_DIVISIBLEBY_MUTATOR_MIXIN, + IS_DIVISIBLE_MUTATOR_EXTENSION, +); + +// Update the tooltip of 'math_change' block to reference the variable. +Extensions.register( + 'math_change_tooltip', + Extensions.buildTooltipWithFieldText('%{BKY_MATH_CHANGE_TOOLTIP}', 'VAR'), +); + +/** Type of a block that has LIST_MODES_MUTATOR_MIXIN */ +type ListModesBlock = Block & ListModesMixin; +interface ListModesMixin extends ListModesMixinType {} +type ListModesMixinType = typeof LIST_MODES_MUTATOR_MIXIN; + +/** + * Mixin with mutator methods to support alternate output based if the + * 'math_on_list' block uses the 'MODE' operation. + */ +const LIST_MODES_MUTATOR_MIXIN = { + /** + * Modify this block to have the correct output type. + * + * @param newOp Either 'MODE' or some op than returns a number. + */ + updateType_: function (this: ListModesBlock, newOp: string) { + if (newOp === 'MODE') { + this.outputConnection!.setCheck('Array'); + } else { + this.outputConnection!.setCheck('Number'); + } + }, + /** + * Create XML to represent the output type. + * Backwards compatible serialization implementation. + * + * @returns XML storage element. + */ + mutationToDom: function (this: ListModesBlock): Element { + const container = xmlUtils.createElement('mutation'); + container.setAttribute('op', this.getFieldValue('OP')); + return container; + }, + /** + * Parse XML to restore the output type. + * Backwards compatible serialization implementation. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: ListModesBlock, xmlElement: Element) { + const op = xmlElement.getAttribute('op'); + if (op === null) throw new TypeError('xmlElement had no op attribute'); + this.updateType_(op); + }, + + // This block does not need JSO serialization hooks (saveExtraState and + // loadExtraState) because the state of this object is already encoded in the + // dropdown values. + // XML hooks are kept for backwards compatibility. +}; + +/** + * Extension to 'math_on_list' blocks that allows support of + * modes operation (outputs a list of numbers). + */ +const LIST_MODES_MUTATOR_EXTENSION = function (this: ListModesBlock) { + this.getField('OP')!.setValidator( + function (this: ListModesBlock, newOp: string) { + this.updateType_(newOp); + return undefined; + }.bind(this), + ); +}; + +Extensions.registerMutator( + 'math_modes_of_list_mutator', + LIST_MODES_MUTATOR_MIXIN, + LIST_MODES_MUTATOR_EXTENSION, +); + +// Register provided blocks. +defineBlocks(blocks); diff --git a/blocks/procedures.js b/blocks/procedures.js deleted file mode 100644 index 223a655cd19..00000000000 --- a/blocks/procedures.js +++ /dev/null @@ -1,889 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Procedure blocks for Blockly. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Blocks.procedures'); - -goog.require('Blockly.Blocks'); - - -/** - * Common HSV hue for all blocks in this category. - */ -Blockly.Blocks.procedures.HUE = 290; - -Blockly.Blocks['procedures_defnoreturn'] = { - /** - * Block for defining a procedure with no return value. - * @this Blockly.Block - */ - init: function() { - var nameField = new Blockly.FieldTextInput('', - Blockly.Procedures.rename); - nameField.setSpellcheck(false); - this.appendDummyInput() - .appendField(Blockly.Msg.PROCEDURES_DEFNORETURN_TITLE) - .appendField(nameField, 'NAME') - .appendField('', 'PARAMS'); - this.setMutator(new Blockly.Mutator(['procedures_mutatorarg'])); - if ((this.workspace.options.comments || - (this.workspace.options.parentWorkspace && - this.workspace.options.parentWorkspace.options.comments)) && - Blockly.Msg.PROCEDURES_DEFNORETURN_COMMENT) { - this.setCommentText(Blockly.Msg.PROCEDURES_DEFNORETURN_COMMENT); - } - this.setColour(Blockly.Blocks.procedures.HUE); - this.setTooltip(Blockly.Msg.PROCEDURES_DEFNORETURN_TOOLTIP); - this.setHelpUrl(Blockly.Msg.PROCEDURES_DEFNORETURN_HELPURL); - this.arguments_ = []; - this.setStatements_(true); - this.statementConnection_ = null; - }, - /** - * Add or remove the statement block from this function definition. - * @param {boolean} hasStatements True if a statement block is needed. - * @this Blockly.Block - */ - setStatements_: function(hasStatements) { - if (this.hasStatements_ === hasStatements) { - return; - } - if (hasStatements) { - this.appendStatementInput('STACK') - .appendField(Blockly.Msg.PROCEDURES_DEFNORETURN_DO); - if (this.getInput('RETURN')) { - this.moveInputBefore('STACK', 'RETURN'); - } - } else { - this.removeInput('STACK', true); - } - this.hasStatements_ = hasStatements; - }, - /** - * Update the display of parameters for this procedure definition block. - * Display a warning if there are duplicately named parameters. - * @private - * @this Blockly.Block - */ - updateParams_: function() { - // Check for duplicated arguments. - var badArg = false; - var hash = {}; - for (var i = 0; i < this.arguments_.length; i++) { - if (hash['arg_' + this.arguments_[i].toLowerCase()]) { - badArg = true; - break; - } - hash['arg_' + this.arguments_[i].toLowerCase()] = true; - } - if (badArg) { - this.setWarningText(Blockly.Msg.PROCEDURES_DEF_DUPLICATE_WARNING); - } else { - this.setWarningText(null); - } - // Merge the arguments into a human-readable list. - var paramString = ''; - if (this.arguments_.length) { - paramString = Blockly.Msg.PROCEDURES_BEFORE_PARAMS + - ' ' + this.arguments_.join(', '); - } - // The params field is deterministic based on the mutation, - // no need to fire a change event. - Blockly.Events.disable(); - try { - this.setFieldValue(paramString, 'PARAMS'); - } finally { - Blockly.Events.enable(); - } - }, - /** - * Create XML to represent the argument inputs. - * @param {boolean=} opt_paramIds If true include the IDs of the parameter - * quarks. Used by Blockly.Procedures.mutateCallers for reconnection. - * @return {!Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function(opt_paramIds) { - var container = document.createElement('mutation'); - if (opt_paramIds) { - container.setAttribute('name', this.getFieldValue('NAME')); - } - for (var i = 0; i < this.arguments_.length; i++) { - var parameter = document.createElement('arg'); - parameter.setAttribute('name', this.arguments_[i]); - if (opt_paramIds && this.paramIds_) { - parameter.setAttribute('paramId', this.paramIds_[i]); - } - container.appendChild(parameter); - } - - // Save whether the statement input is visible. - if (!this.hasStatements_) { - container.setAttribute('statements', 'false'); - } - return container; - }, - /** - * Parse XML to restore the argument inputs. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - this.arguments_ = []; - for (var i = 0, childNode; childNode = xmlElement.childNodes[i]; i++) { - if (childNode.nodeName.toLowerCase() == 'arg') { - this.arguments_.push(childNode.getAttribute('name')); - } - } - this.updateParams_(); - Blockly.Procedures.mutateCallers(this); - - // Show or hide the statement input. - this.setStatements_(xmlElement.getAttribute('statements') !== 'false'); - }, - /** - * Populate the mutator's dialog with this block's components. - * @param {!Blockly.Workspace} workspace Mutator's workspace. - * @return {!Blockly.Block} Root block in mutator. - * @this Blockly.Block - */ - decompose: function(workspace) { - var containerBlock = workspace.newBlock('procedures_mutatorcontainer'); - containerBlock.initSvg(); - - // Check/uncheck the allow statement box. - if (this.getInput('RETURN')) { - containerBlock.setFieldValue(this.hasStatements_ ? 'TRUE' : 'FALSE', - 'STATEMENTS'); - } else { - containerBlock.getInput('STATEMENT_INPUT').setVisible(false); - } - - // Parameter list. - var connection = containerBlock.getInput('STACK').connection; - for (var i = 0; i < this.arguments_.length; i++) { - var paramBlock = workspace.newBlock('procedures_mutatorarg'); - paramBlock.initSvg(); - paramBlock.setFieldValue(this.arguments_[i], 'NAME'); - // Store the old location. - paramBlock.oldLocation = i; - connection.connect(paramBlock.previousConnection); - connection = paramBlock.nextConnection; - } - // Initialize procedure's callers with blank IDs. - Blockly.Procedures.mutateCallers(this); - return containerBlock; - }, - /** - * Reconfigure this block based on the mutator dialog's components. - * @param {!Blockly.Block} containerBlock Root block in mutator. - * @this Blockly.Block - */ - compose: function(containerBlock) { - // Parameter list. - this.arguments_ = []; - this.paramIds_ = []; - var paramBlock = containerBlock.getInputTargetBlock('STACK'); - while (paramBlock) { - this.arguments_.push(paramBlock.getFieldValue('NAME')); - this.paramIds_.push(paramBlock.id); - paramBlock = paramBlock.nextConnection && - paramBlock.nextConnection.targetBlock(); - } - this.updateParams_(); - Blockly.Procedures.mutateCallers(this); - - // Show/hide the statement input. - var hasStatements = containerBlock.getFieldValue('STATEMENTS'); - if (hasStatements !== null) { - hasStatements = hasStatements == 'TRUE'; - if (this.hasStatements_ != hasStatements) { - if (hasStatements) { - this.setStatements_(true); - // Restore the stack, if one was saved. - Blockly.Mutator.reconnect(this.statementConnection_, this, 'STACK'); - this.statementConnection_ = null; - } else { - // Save the stack, then disconnect it. - var stackConnection = this.getInput('STACK').connection; - this.statementConnection_ = stackConnection.targetConnection; - if (this.statementConnection_) { - var stackBlock = stackConnection.targetBlock(); - stackBlock.unplug(); - stackBlock.bumpNeighbours_(); - } - this.setStatements_(false); - } - } - } - }, - /** - * Return the signature of this procedure definition. - * @return {!Array} Tuple containing three elements: - * - the name of the defined procedure, - * - a list of all its arguments, - * - that it DOES NOT have a return value. - * @this Blockly.Block - */ - getProcedureDef: function() { - return [this.getFieldValue('NAME'), this.arguments_, false]; - }, - /** - * Return all variables referenced by this block. - * @return {!Array.} List of variable names. - * @this Blockly.Block - */ - getVars: function() { - return this.arguments_; - }, - /** - * Notification that a variable is renaming. - * If the name matches one of this block's variables, rename it. - * @param {string} oldName Previous name of variable. - * @param {string} newName Renamed variable. - * @this Blockly.Block - */ - renameVar: function(oldName, newName) { - var change = false; - for (var i = 0; i < this.arguments_.length; i++) { - if (Blockly.Names.equals(oldName, this.arguments_[i])) { - this.arguments_[i] = newName; - change = true; - } - } - if (change) { - this.updateParams_(); - // Update the mutator's variables if the mutator is open. - if (this.mutator.isVisible()) { - var blocks = this.mutator.workspace_.getAllBlocks(); - for (var i = 0, block; block = blocks[i]; i++) { - if (block.type == 'procedures_mutatorarg' && - Blockly.Names.equals(oldName, block.getFieldValue('NAME'))) { - block.setFieldValue(newName, 'NAME'); - } - } - } - } - }, - /** - * Add custom menu options to this block's context menu. - * @param {!Array} options List of menu options to add to. - * @this Blockly.Block - */ - customContextMenu: function(options) { - // Add option to create caller. - var option = {enabled: true}; - var name = this.getFieldValue('NAME'); - option.text = Blockly.Msg.PROCEDURES_CREATE_DO.replace('%1', name); - var xmlMutation = goog.dom.createDom('mutation'); - xmlMutation.setAttribute('name', name); - for (var i = 0; i < this.arguments_.length; i++) { - var xmlArg = goog.dom.createDom('arg'); - xmlArg.setAttribute('name', this.arguments_[i]); - xmlMutation.appendChild(xmlArg); - } - var xmlBlock = goog.dom.createDom('block', null, xmlMutation); - xmlBlock.setAttribute('type', this.callType_); - option.callback = Blockly.ContextMenu.callbackFactory(this, xmlBlock); - options.push(option); - - // Add options to create getters for each parameter. - if (!this.isCollapsed()) { - for (var i = 0; i < this.arguments_.length; i++) { - var option = {enabled: true}; - var name = this.arguments_[i]; - option.text = Blockly.Msg.VARIABLES_SET_CREATE_GET.replace('%1', name); - var xmlField = goog.dom.createDom('field', null, name); - xmlField.setAttribute('name', 'VAR'); - var xmlBlock = goog.dom.createDom('block', null, xmlField); - xmlBlock.setAttribute('type', 'variables_get'); - option.callback = Blockly.ContextMenu.callbackFactory(this, xmlBlock); - options.push(option); - } - } - }, - callType_: 'procedures_callnoreturn' -}; - -Blockly.Blocks['procedures_defreturn'] = { - /** - * Block for defining a procedure with a return value. - * @this Blockly.Block - */ - init: function() { - var nameField = new Blockly.FieldTextInput('', - Blockly.Procedures.rename); - nameField.setSpellcheck(false); - this.appendDummyInput() - .appendField(Blockly.Msg.PROCEDURES_DEFRETURN_TITLE) - .appendField(nameField, 'NAME') - .appendField('', 'PARAMS'); - this.appendValueInput('RETURN') - .setAlign(Blockly.ALIGN_RIGHT) - .appendField(Blockly.Msg.PROCEDURES_DEFRETURN_RETURN); - this.setMutator(new Blockly.Mutator(['procedures_mutatorarg'])); - if ((this.workspace.options.comments || - (this.workspace.options.parentWorkspace && - this.workspace.options.parentWorkspace.options.comments)) && - Blockly.Msg.PROCEDURES_DEFRETURN_COMMENT) { - this.setCommentText(Blockly.Msg.PROCEDURES_DEFRETURN_COMMENT); - } - this.setColour(Blockly.Blocks.procedures.HUE); - this.setTooltip(Blockly.Msg.PROCEDURES_DEFRETURN_TOOLTIP); - this.setHelpUrl(Blockly.Msg.PROCEDURES_DEFRETURN_HELPURL); - this.arguments_ = []; - this.setStatements_(true); - this.statementConnection_ = null; - }, - setStatements_: Blockly.Blocks['procedures_defnoreturn'].setStatements_, - updateParams_: Blockly.Blocks['procedures_defnoreturn'].updateParams_, - mutationToDom: Blockly.Blocks['procedures_defnoreturn'].mutationToDom, - domToMutation: Blockly.Blocks['procedures_defnoreturn'].domToMutation, - decompose: Blockly.Blocks['procedures_defnoreturn'].decompose, - compose: Blockly.Blocks['procedures_defnoreturn'].compose, - /** - * Return the signature of this procedure definition. - * @return {!Array} Tuple containing three elements: - * - the name of the defined procedure, - * - a list of all its arguments, - * - that it DOES have a return value. - * @this Blockly.Block - */ - getProcedureDef: function() { - return [this.getFieldValue('NAME'), this.arguments_, true]; - }, - getVars: Blockly.Blocks['procedures_defnoreturn'].getVars, - renameVar: Blockly.Blocks['procedures_defnoreturn'].renameVar, - customContextMenu: Blockly.Blocks['procedures_defnoreturn'].customContextMenu, - callType_: 'procedures_callreturn' -}; - -Blockly.Blocks['procedures_mutatorcontainer'] = { - /** - * Mutator block for procedure container. - * @this Blockly.Block - */ - init: function() { - this.appendDummyInput() - .appendField(Blockly.Msg.PROCEDURES_MUTATORCONTAINER_TITLE); - this.appendStatementInput('STACK'); - this.appendDummyInput('STATEMENT_INPUT') - .appendField(Blockly.Msg.PROCEDURES_ALLOW_STATEMENTS) - .appendField(new Blockly.FieldCheckbox('TRUE'), 'STATEMENTS'); - this.setColour(Blockly.Blocks.procedures.HUE); - this.setTooltip(Blockly.Msg.PROCEDURES_MUTATORCONTAINER_TOOLTIP); - this.contextMenu = false; - } -}; - -Blockly.Blocks['procedures_mutatorarg'] = { - /** - * Mutator block for procedure argument. - * @this Blockly.Block - */ - init: function() { - var field = new Blockly.FieldTextInput('x', this.validator_); - this.appendDummyInput() - .appendField(Blockly.Msg.PROCEDURES_MUTATORARG_TITLE) - .appendField(field, 'NAME'); - this.setPreviousStatement(true); - this.setNextStatement(true); - this.setColour(Blockly.Blocks.procedures.HUE); - this.setTooltip(Blockly.Msg.PROCEDURES_MUTATORARG_TOOLTIP); - this.contextMenu = false; - - // Create the default variable when we drag the block in from the flyout. - // Have to do this after installing the field on the block. - field.onFinishEditing_ = this.createNewVar_; - field.onFinishEditing_('x'); - }, - /** - * Obtain a valid name for the procedure. - * Merge runs of whitespace. Strip leading and trailing whitespace. - * Beyond this, all names are legal. - * @param {string} newVar User-supplied name. - * @return {?string} Valid name, or null if a name was not specified. - * @private - * @this Blockly.Block - */ - validator_: function(newVar) { - newVar = newVar.replace(/[\s\xa0]+/g, ' ').replace(/^ | $/g, ''); - return newVar || null; - }, - /** - * Called when focusing away from the text field. - * Creates a new variable with this name. - * @param {string} newText The new variable name. - * @private - * @this Blockly.FieldTextInput - */ - createNewVar_: function(newText) { - var source = this.sourceBlock_; - if (source && source.workspace && source.workspace.options && - source.workspace.options.parentWorkspace) { - source.workspace.options.parentWorkspace.createVariable(newText); - } - } -}; - -Blockly.Blocks['procedures_callnoreturn'] = { - /** - * Block for calling a procedure with no return value. - * @this Blockly.Block - */ - init: function() { - this.appendDummyInput('TOPROW') - .appendField(this.id, 'NAME'); - this.setPreviousStatement(true); - this.setNextStatement(true); - this.setColour(Blockly.Blocks.procedures.HUE); - // Tooltip is set in renameProcedure. - this.setHelpUrl(Blockly.Msg.PROCEDURES_CALLNORETURN_HELPURL); - this.arguments_ = []; - this.quarkConnections_ = {}; - this.quarkIds_ = null; - }, - /** - * Returns the name of the procedure this block calls. - * @return {string} Procedure name. - * @this Blockly.Block - */ - getProcedureCall: function() { - // The NAME field is guaranteed to exist, null will never be returned. - return /** @type {string} */ (this.getFieldValue('NAME')); - }, - /** - * Notification that a procedure is renaming. - * If the name matches this block's procedure, rename it. - * @param {string} oldName Previous name of procedure. - * @param {string} newName Renamed procedure. - * @this Blockly.Block - */ - renameProcedure: function(oldName, newName) { - if (Blockly.Names.equals(oldName, this.getProcedureCall())) { - this.setFieldValue(newName, 'NAME'); - this.setTooltip( - (this.outputConnection ? Blockly.Msg.PROCEDURES_CALLRETURN_TOOLTIP : - Blockly.Msg.PROCEDURES_CALLNORETURN_TOOLTIP) - .replace('%1', newName)); - } - }, - /** - * Notification that the procedure's parameters have changed. - * @param {!Array.} paramNames New param names, e.g. ['x', 'y', 'z']. - * @param {!Array.} paramIds IDs of params (consistent for each - * parameter through the life of a mutator, regardless of param renaming), - * e.g. ['piua', 'f8b_', 'oi.o']. - * @private - * @this Blockly.Block - */ - setProcedureParameters_: function(paramNames, paramIds) { - // Data structures: - // this.arguments = ['x', 'y'] - // Existing param names. - // this.quarkConnections_ {piua: null, f8b_: Blockly.Connection} - // Look-up of paramIds to connections plugged into the call block. - // this.quarkIds_ = ['piua', 'f8b_'] - // Existing param IDs. - // Note that quarkConnections_ may include IDs that no longer exist, but - // which might reappear if a param is reattached in the mutator. - var defBlock = Blockly.Procedures.getDefinition(this.getProcedureCall(), - this.workspace); - var mutatorOpen = defBlock && defBlock.mutator && - defBlock.mutator.isVisible(); - if (!mutatorOpen) { - this.quarkConnections_ = {}; - this.quarkIds_ = null; - } - if (!paramIds) { - // Reset the quarks (a mutator is about to open). - return; - } - if (goog.array.equals(this.arguments_, paramNames)) { - // No change. - this.quarkIds_ = paramIds; - return; - } - if (paramIds.length != paramNames.length) { - throw 'Error: paramNames and paramIds must be the same length.'; - } - this.setCollapsed(false); - if (!this.quarkIds_) { - // Initialize tracking for this block. - this.quarkConnections_ = {}; - if (paramNames.join('\n') == this.arguments_.join('\n')) { - // No change to the parameters, allow quarkConnections_ to be - // populated with the existing connections. - this.quarkIds_ = paramIds; - } else { - this.quarkIds_ = []; - } - } - // Switch off rendering while the block is rebuilt. - var savedRendered = this.rendered; - this.rendered = false; - // Update the quarkConnections_ with existing connections. - for (var i = 0; i < this.arguments_.length; i++) { - var input = this.getInput('ARG' + i); - if (input) { - var connection = input.connection.targetConnection; - this.quarkConnections_[this.quarkIds_[i]] = connection; - if (mutatorOpen && connection && - paramIds.indexOf(this.quarkIds_[i]) == -1) { - // This connection should no longer be attached to this block. - connection.disconnect(); - connection.getSourceBlock().bumpNeighbours_(); - } - } - } - // Rebuild the block's arguments. - this.arguments_ = [].concat(paramNames); - this.updateShape_(); - this.quarkIds_ = paramIds; - // Reconnect any child blocks. - if (this.quarkIds_) { - for (var i = 0; i < this.arguments_.length; i++) { - var quarkId = this.quarkIds_[i]; - if (quarkId in this.quarkConnections_) { - var connection = this.quarkConnections_[quarkId]; - if (!Blockly.Mutator.reconnect(connection, this, 'ARG' + i)) { - // Block no longer exists or has been attached elsewhere. - delete this.quarkConnections_[quarkId]; - } - } - } - } - // Restore rendering and show the changes. - this.rendered = savedRendered; - if (this.rendered) { - this.render(); - } - }, - /** - * Modify this block to have the correct number of arguments. - * @private - * @this Blockly.Block - */ - updateShape_: function() { - for (var i = 0; i < this.arguments_.length; i++) { - var field = this.getField('ARGNAME' + i); - if (field) { - // Ensure argument name is up to date. - // The argument name field is deterministic based on the mutation, - // no need to fire a change event. - Blockly.Events.disable(); - try { - field.setValue(this.arguments_[i]); - } finally { - Blockly.Events.enable(); - } - } else { - // Add new input. - field = new Blockly.FieldLabel(this.arguments_[i]); - var input = this.appendValueInput('ARG' + i) - .setAlign(Blockly.ALIGN_RIGHT) - .appendField(field, 'ARGNAME' + i); - input.init(); - } - } - // Remove deleted inputs. - while (this.getInput('ARG' + i)) { - this.removeInput('ARG' + i); - i++; - } - // Add 'with:' if there are parameters, remove otherwise. - var topRow = this.getInput('TOPROW'); - if (topRow) { - if (this.arguments_.length) { - if (!this.getField('WITH')) { - topRow.appendField(Blockly.Msg.PROCEDURES_CALL_BEFORE_PARAMS, 'WITH'); - topRow.init(); - } - } else { - if (this.getField('WITH')) { - topRow.removeField('WITH'); - } - } - } - }, - /** - * Create XML to represent the (non-editable) name and arguments. - * @return {!Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - container.setAttribute('name', this.getProcedureCall()); - for (var i = 0; i < this.arguments_.length; i++) { - var parameter = document.createElement('arg'); - parameter.setAttribute('name', this.arguments_[i]); - container.appendChild(parameter); - } - return container; - }, - /** - * Parse XML to restore the (non-editable) name and parameters. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - var name = xmlElement.getAttribute('name'); - this.renameProcedure(this.getProcedureCall(), name); - var args = []; - var paramIds = []; - for (var i = 0, childNode; childNode = xmlElement.childNodes[i]; i++) { - if (childNode.nodeName.toLowerCase() == 'arg') { - args.push(childNode.getAttribute('name')); - paramIds.push(childNode.getAttribute('paramId')); - } - } - this.setProcedureParameters_(args, paramIds); - }, - /** - * Notification that a variable is renaming. - * If the name matches one of this block's variables, rename it. - * @param {string} oldName Previous name of variable. - * @param {string} newName Renamed variable. - * @this Blockly.Block - */ - renameVar: function(oldName, newName) { - for (var i = 0; i < this.arguments_.length; i++) { - if (Blockly.Names.equals(oldName, this.arguments_[i])) { - this.arguments_[i] = newName; - this.getField('ARGNAME' + i).setValue(newName); - } - } - }, - /** - * Procedure calls cannot exist without the corresponding procedure - * definition. Enforce this link whenever an event is fired. - * @param {!Blockly.Events.Abstract} event Change event. - * @this Blockly.Block - */ - onchange: function(event) { - if (!this.workspace || this.workspace.isFlyout) { - // Block is deleted or is in a flyout. - return; - } - if (event.type == Blockly.Events.BLOCK_CREATE && - event.ids.indexOf(this.id) != -1) { - // Look for the case where a procedure call was created (usually through - // paste) and there is no matching definition. In this case, create - // an empty definition block with the correct signature. - var name = this.getProcedureCall(); - var def = Blockly.Procedures.getDefinition(name, this.workspace); - if (def && (def.type != this.defType_ || - JSON.stringify(def.arguments_) != JSON.stringify(this.arguments_))) { - // The signatures don't match. - def = null; - } - if (!def) { - Blockly.Events.setGroup(event.group); - /** - * Create matching definition block. - * - * - * - * - * - * test - * - * - */ - var xml = goog.dom.createDom('xml'); - var block = goog.dom.createDom('block'); - block.setAttribute('type', this.defType_); - var xy = this.getRelativeToSurfaceXY(); - var x = xy.x + Blockly.SNAP_RADIUS * (this.RTL ? -1 : 1); - var y = xy.y + Blockly.SNAP_RADIUS * 2; - block.setAttribute('x', x); - block.setAttribute('y', y); - var mutation = this.mutationToDom(); - block.appendChild(mutation); - var field = goog.dom.createDom('field'); - field.setAttribute('name', 'NAME'); - field.appendChild(document.createTextNode(this.getProcedureCall())); - block.appendChild(field); - xml.appendChild(block); - Blockly.Xml.domToWorkspace(xml, this.workspace); - Blockly.Events.setGroup(false); - } - } else if (event.type == Blockly.Events.BLOCK_DELETE) { - // Look for the case where a procedure definition has been deleted, - // leaving this block (a procedure call) orphaned. In this case, delete - // the orphan. - var name = this.getProcedureCall(); - var def = Blockly.Procedures.getDefinition(name, this.workspace); - if (!def) { - Blockly.Events.setGroup(event.group); - this.dispose(true, false); - Blockly.Events.setGroup(false); - } - } - }, - /** - * Add menu option to find the definition block for this call. - * @param {!Array} options List of menu options to add to. - * @this Blockly.Block - */ - customContextMenu: function(options) { - var option = {enabled: true}; - option.text = Blockly.Msg.PROCEDURES_HIGHLIGHT_DEF; - var name = this.getProcedureCall(); - var workspace = this.workspace; - option.callback = function() { - var def = Blockly.Procedures.getDefinition(name, workspace); - def && def.select(); - }; - options.push(option); - }, - defType_: 'procedures_defnoreturn' -}; - -Blockly.Blocks['procedures_callreturn'] = { - /** - * Block for calling a procedure with a return value. - * @this Blockly.Block - */ - init: function() { - this.appendDummyInput('TOPROW') - .appendField('', 'NAME'); - this.setOutput(true); - this.setColour(Blockly.Blocks.procedures.HUE); - // Tooltip is set in domToMutation. - this.setHelpUrl(Blockly.Msg.PROCEDURES_CALLRETURN_HELPURL); - this.arguments_ = []; - this.quarkConnections_ = {}; - this.quarkIds_ = null; - }, - getProcedureCall: Blockly.Blocks['procedures_callnoreturn'].getProcedureCall, - renameProcedure: Blockly.Blocks['procedures_callnoreturn'].renameProcedure, - setProcedureParameters_: - Blockly.Blocks['procedures_callnoreturn'].setProcedureParameters_, - updateShape_: Blockly.Blocks['procedures_callnoreturn'].updateShape_, - mutationToDom: Blockly.Blocks['procedures_callnoreturn'].mutationToDom, - domToMutation: Blockly.Blocks['procedures_callnoreturn'].domToMutation, - renameVar: Blockly.Blocks['procedures_callnoreturn'].renameVar, - onchange: Blockly.Blocks['procedures_callnoreturn'].onchange, - customContextMenu: - Blockly.Blocks['procedures_callnoreturn'].customContextMenu, - defType_: 'procedures_defreturn' -}; - -Blockly.Blocks['procedures_ifreturn'] = { - /** - * Block for conditionally returning a value from a procedure. - * @this Blockly.Block - */ - init: function() { - this.appendValueInput('CONDITION') - .setCheck('Boolean') - .appendField(Blockly.Msg.CONTROLS_IF_MSG_IF); - this.appendValueInput('VALUE') - .appendField(Blockly.Msg.PROCEDURES_DEFRETURN_RETURN); - this.setInputsInline(true); - this.setPreviousStatement(true); - this.setNextStatement(true); - this.setColour(Blockly.Blocks.procedures.HUE); - this.setTooltip(Blockly.Msg.PROCEDURES_IFRETURN_TOOLTIP); - this.setHelpUrl(Blockly.Msg.PROCEDURES_IFRETURN_HELPURL); - this.hasReturnValue_ = true; - }, - /** - * Create XML to represent whether this block has a return value. - * @return {!Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - container.setAttribute('value', Number(this.hasReturnValue_)); - return container; - }, - /** - * Parse XML to restore whether this block has a return value. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - var value = xmlElement.getAttribute('value'); - this.hasReturnValue_ = (value == 1); - if (!this.hasReturnValue_) { - this.removeInput('VALUE'); - this.appendDummyInput('VALUE') - .appendField(Blockly.Msg.PROCEDURES_DEFRETURN_RETURN); - } - }, - /** - * Called whenever anything on the workspace changes. - * Add warning if this flow block is not nested inside a loop. - * @param {!Blockly.Events.Abstract} e Change event. - * @this Blockly.Block - */ - onchange: function(/* e */) { - if (!this.workspace.isDragging || this.workspace.isDragging()) { - return; // Don't change state at the start of a drag. - } - var legal = false; - // Is the block nested in a procedure? - var block = this; - do { - if (this.FUNCTION_TYPES.indexOf(block.type) != -1) { - legal = true; - break; - } - block = block.getSurroundParent(); - } while (block); - if (legal) { - // If needed, toggle whether this block has a return value. - if (block.type == 'procedures_defnoreturn' && this.hasReturnValue_) { - this.removeInput('VALUE'); - this.appendDummyInput('VALUE') - .appendField(Blockly.Msg.PROCEDURES_DEFRETURN_RETURN); - this.hasReturnValue_ = false; - } else if (block.type == 'procedures_defreturn' && - !this.hasReturnValue_) { - this.removeInput('VALUE'); - this.appendValueInput('VALUE') - .appendField(Blockly.Msg.PROCEDURES_DEFRETURN_RETURN); - this.hasReturnValue_ = true; - } - this.setWarningText(null); - if (!this.isInFlyout) { - this.setDisabled(false); - } - } else { - this.setWarningText(Blockly.Msg.PROCEDURES_IFRETURN_WARNING); - if (!this.isInFlyout && !this.getInheritedDisabled()) { - this.setDisabled(true); - } - } - }, - /** - * List of block types that are functions and thus do not need warnings. - * To add a new function type add this to your code: - * Blockly.Blocks['procedures_ifreturn'].FUNCTION_TYPES.push('custom_func'); - */ - FUNCTION_TYPES: ['procedures_defnoreturn', 'procedures_defreturn'] -}; diff --git a/blocks/procedures.ts b/blocks/procedures.ts new file mode 100644 index 00000000000..b8bc4fddda1 --- /dev/null +++ b/blocks/procedures.ts @@ -0,0 +1,1364 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.libraryBlocks.procedures + +import type {Block} from '../core/block.js'; +import type {BlockSvg} from '../core/block_svg.js'; +import type {BlockDefinition} from '../core/blocks.js'; +import {defineBlocks} from '../core/common.js'; +import {config} from '../core/config.js'; +import type {Connection} from '../core/connection.js'; +import * as ContextMenu from '../core/contextmenu.js'; +import type { + ContextMenuOption, + LegacyContextMenuOption, +} from '../core/contextmenu_registry.js'; +import * as Events from '../core/events/events.js'; +import type {Abstract as AbstractEvent} from '../core/events/events_abstract.js'; +import type {BlockChange} from '../core/events/events_block_change.js'; +import type {BlockCreate} from '../core/events/events_block_create.js'; +import * as eventUtils from '../core/events/utils.js'; +import {FieldCheckbox} from '../core/field_checkbox.js'; +import {FieldLabel} from '../core/field_label.js'; +import * as fieldRegistry from '../core/field_registry.js'; +import {FieldTextInput} from '../core/field_textinput.js'; +import {getFocusManager} from '../core/focus_manager.js'; +import '../core/icons/comment_icon.js'; +import {MutatorIcon as Mutator} from '../core/icons/mutator_icon.js'; +import '../core/icons/warning_icon.js'; +import {Align} from '../core/inputs/align.js'; +import type { + IVariableModel, + IVariableState, +} from '../core/interfaces/i_variable_model.js'; +import {Msg} from '../core/msg.js'; +import {Names} from '../core/names.js'; +import * as Procedures from '../core/procedures.js'; +import * as xmlUtils from '../core/utils/xml.js'; +import * as Variables from '../core/variables.js'; +import type {Workspace} from '../core/workspace.js'; +import type {WorkspaceSvg} from '../core/workspace_svg.js'; +import * as Xml from '../core/xml.js'; + +/** A dictionary of the block definitions provided by this module. */ +export const blocks: {[key: string]: BlockDefinition} = {}; + +/** Type of a block using the PROCEDURE_DEF_COMMON mixin. */ +type ProcedureBlock = Block & ProcedureMixin; +interface ProcedureMixin extends ProcedureMixinType { + arguments_: string[]; + argumentVarModels_: IVariableModel[]; + callType_: string; + paramIds_: string[]; + hasStatements_: boolean; + statementConnection_: Connection | null; +} +type ProcedureMixinType = typeof PROCEDURE_DEF_COMMON; + +/** Extra state for serialising procedure blocks. */ +type ProcedureExtraState = { + params?: Array<{name: string; id: string}>; + hasStatements: boolean; +}; + +/** + * Common properties for the procedure_defnoreturn and + * procedure_defreturn blocks. + */ +const PROCEDURE_DEF_COMMON = { + /** + * Add or remove the statement block from this function definition. + * + * @param hasStatements True if a statement block is needed. + */ + setStatements_: function (this: ProcedureBlock, hasStatements: boolean) { + if (this.hasStatements_ === hasStatements) { + return; + } + if (hasStatements) { + this.appendStatementInput('STACK').appendField( + Msg['PROCEDURES_DEFNORETURN_DO'], + ); + if (this.getInput('RETURN')) { + this.moveInputBefore('STACK', 'RETURN'); + } + } else { + this.removeInput('STACK', true); + } + this.hasStatements_ = hasStatements; + }, + /** + * Update the display of parameters for this procedure definition block. + * + * @internal + */ + updateParams_: function (this: ProcedureBlock) { + // Merge the arguments into a human-readable list. + let paramString = ''; + if (this.arguments_.length) { + paramString = + Msg['PROCEDURES_BEFORE_PARAMS'] + ' ' + this.arguments_.join(', '); + } + // The params field is deterministic based on the mutation, + // no need to fire a change event. + Events.disable(); + try { + this.setFieldValue(paramString, 'PARAMS'); + } finally { + Events.enable(); + } + }, + /** + * Create XML to represent the argument inputs. + * Backwards compatible serialization implementation. + * + * @param opt_paramIds If true include the IDs of the parameter + * quarks. Used by Procedures.mutateCallers for reconnection. + * @returns XML storage element. + */ + mutationToDom: function ( + this: ProcedureBlock, + opt_paramIds: boolean, + ): Element { + const container = xmlUtils.createElement('mutation'); + if (opt_paramIds) { + container.setAttribute('name', this.getFieldValue('NAME')); + } + for (let i = 0; i < this.argumentVarModels_.length; i++) { + const parameter = xmlUtils.createElement('arg'); + const argModel = this.argumentVarModels_[i]; + parameter.setAttribute('name', argModel.getName()); + parameter.setAttribute('varid', argModel.getId()); + if (opt_paramIds && this.paramIds_) { + parameter.setAttribute('paramId', this.paramIds_[i]); + } + container.appendChild(parameter); + } + + // Save whether the statement input is visible. + if (!this.hasStatements_) { + container.setAttribute('statements', 'false'); + } + return container; + }, + /** + * Parse XML to restore the argument inputs. + * Backwards compatible serialization implementation. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: ProcedureBlock, xmlElement: Element) { + this.arguments_ = []; + this.argumentVarModels_ = []; + for (let i = 0, childNode; (childNode = xmlElement.childNodes[i]); i++) { + if (childNode.nodeName.toLowerCase() === 'arg') { + const childElement = childNode as Element; + const varName = childElement.getAttribute('name')!; + const varId = + childElement.getAttribute('varid') || + childElement.getAttribute('varId'); + this.arguments_.push(varName); + const variable = Variables.getOrCreateVariablePackage( + this.workspace, + varId, + varName, + '', + ); + if (variable !== null) { + this.argumentVarModels_.push(variable); + } else { + console.log( + `Failed to create a variable named "${varName}", ignoring.`, + ); + } + } + } + this.updateParams_(); + Procedures.mutateCallers(this); + + // Show or hide the statement input. + this.setStatements_(xmlElement.getAttribute('statements') !== 'false'); + }, + /** + * Returns the state of this block as a JSON serializable object. + * + * @returns The state of this block, eg the parameters and statements. + */ + saveExtraState: function (this: ProcedureBlock): ProcedureExtraState | null { + if (!this.argumentVarModels_.length && this.hasStatements_) { + return null; + } + const state = Object.create(null); + if (this.argumentVarModels_.length) { + state['params'] = []; + for (let i = 0; i < this.argumentVarModels_.length; i++) { + state['params'].push({ + // We don't need to serialize the name, but just in case we decide + // to separate params from variables. + 'name': this.argumentVarModels_[i].getName(), + 'id': this.argumentVarModels_[i].getId(), + }); + } + } + if (!this.hasStatements_) { + state['hasStatements'] = false; + } + return state; + }, + /** + * Applies the given state to this block. + * + * @param state The state to apply to this block, eg the parameters + * and statements. + */ + loadExtraState: function (this: ProcedureBlock, state: ProcedureExtraState) { + this.arguments_ = []; + this.argumentVarModels_ = []; + if (state['params']) { + for (let i = 0; i < state['params'].length; i++) { + const param = state['params'][i]; + const variable = Variables.getOrCreateVariablePackage( + this.workspace, + param['id'], + param['name'], + '', + ); + this.arguments_.push(variable.getName()); + this.argumentVarModels_.push(variable); + } + } + this.updateParams_(); + Procedures.mutateCallers(this); + this.setStatements_(state['hasStatements'] === false ? false : true); + }, + /** + * Populate the mutator's dialog with this block's components. + * + * @param workspace Mutator's workspace. + * @returns Root block in mutator. + */ + decompose: function ( + this: ProcedureBlock, + workspace: Workspace, + ): ContainerBlock { + /* + * Creates the following XML: + * + * + * + * arg1_name + * etc... + * + * + * + */ + + const containerBlockNode = xmlUtils.createElement('block'); + containerBlockNode.setAttribute('type', 'procedures_mutatorcontainer'); + const statementNode = xmlUtils.createElement('statement'); + statementNode.setAttribute('name', 'STACK'); + containerBlockNode.appendChild(statementNode); + + let node = statementNode; + for (let i = 0; i < this.arguments_.length; i++) { + const argBlockNode = xmlUtils.createElement('block'); + argBlockNode.setAttribute('type', 'procedures_mutatorarg'); + const fieldNode = xmlUtils.createElement('field'); + fieldNode.setAttribute('name', 'NAME'); + const argumentName = xmlUtils.createTextNode(this.arguments_[i]); + fieldNode.appendChild(argumentName); + argBlockNode.appendChild(fieldNode); + const nextNode = xmlUtils.createElement('next'); + argBlockNode.appendChild(nextNode); + + node.appendChild(argBlockNode); + node = nextNode; + } + + const containerBlock = Xml.domToBlock( + containerBlockNode, + workspace, + ) as ContainerBlock; + + if (this.type === 'procedures_defreturn') { + containerBlock.setFieldValue(this.hasStatements_, 'STATEMENTS'); + } else { + containerBlock.removeInput('STATEMENT_INPUT'); + } + + // Initialize procedure's callers with blank IDs. + Procedures.mutateCallers(this); + return containerBlock; + }, + /** + * Reconfigure this block based on the mutator dialog's components. + * + * @param containerBlock Root block in mutator. + */ + compose: function (this: ProcedureBlock, containerBlock: ContainerBlock) { + // Parameter list. + this.arguments_ = []; + this.paramIds_ = []; + this.argumentVarModels_ = []; + let paramBlock = containerBlock.getInputTargetBlock('STACK'); + while (paramBlock && !paramBlock.isInsertionMarker()) { + const varName = paramBlock.getFieldValue('NAME'); + this.arguments_.push(varName); + const variable = this.workspace.getVariable(varName, '')!; + this.argumentVarModels_.push(variable); + + this.paramIds_.push(paramBlock.id); + paramBlock = + paramBlock.nextConnection && paramBlock.nextConnection.targetBlock(); + } + this.updateParams_(); + Procedures.mutateCallers(this); + + // Show/hide the statement input. + let hasStatements = containerBlock.getFieldValue('STATEMENTS'); + if (hasStatements !== null) { + hasStatements = hasStatements === 'TRUE'; + if (this.hasStatements_ !== hasStatements) { + if (hasStatements) { + this.setStatements_(true); + // Restore the stack, if one was saved. + this.statementConnection_?.reconnect(this, 'STACK'); + this.statementConnection_ = null; + } else { + // Save the stack, then disconnect it. + const stackConnection = this.getInput('STACK')!.connection; + this.statementConnection_ = stackConnection!.targetConnection; + if (this.statementConnection_) { + const stackBlock = stackConnection!.targetBlock()!; + stackBlock.unplug(); + stackBlock.bumpNeighbours(); + } + this.setStatements_(false); + } + } + } + }, + /** + * Return all variables referenced by this block. + * + * @returns List of variable names. + */ + getVars: function (this: ProcedureBlock): string[] { + return this.arguments_; + }, + /** + * Return all variables referenced by this block. + * + * @returns List of variable models. + */ + getVarModels: function ( + this: ProcedureBlock, + ): IVariableModel[] { + return this.argumentVarModels_; + }, + /** + * Notification that a variable is renaming. + * If the ID matches one of this block's variables, rename it. + * + * @param oldId ID of variable to rename. + * @param newId ID of new variable. May be the same as oldId, but + * with an updated name. Guaranteed to be the same type as the + * old variable. + */ + renameVarById: function ( + this: ProcedureBlock & BlockSvg, + oldId: string, + newId: string, + ) { + const oldVariable = this.workspace.getVariableById(oldId)!; + if (oldVariable.getType() !== '') { + // Procedure arguments always have the empty type. + return; + } + const oldName = oldVariable.getName(); + const newVar = this.workspace.getVariableById(newId)!; + + let change = false; + for (let i = 0; i < this.argumentVarModels_.length; i++) { + if (this.argumentVarModels_[i].getId() === oldId) { + this.arguments_[i] = newVar.getName(); + this.argumentVarModels_[i] = newVar; + change = true; + } + } + if (change) { + this.displayRenamedVar_(oldName, newVar.getName()); + Procedures.mutateCallers(this); + } + }, + /** + * Notification that a variable is renaming but keeping the same ID. If the + * variable is in use on this block, rerender to show the new name. + * + * @param variable The variable being renamed. + */ + updateVarName: function ( + this: ProcedureBlock & BlockSvg, + variable: IVariableModel, + ) { + const newName = variable.getName(); + let change = false; + let oldName; + for (let i = 0; i < this.argumentVarModels_.length; i++) { + if (this.argumentVarModels_[i].getId() === variable.getId()) { + oldName = this.arguments_[i]; + this.arguments_[i] = newName; + change = true; + } + } + if (change) { + this.displayRenamedVar_(oldName as string, newName); + Procedures.mutateCallers(this); + } + }, + /** + * Update the display to reflect a newly renamed argument. + * + * @internal + * @param oldName The old display name of the argument. + * @param newName The new display name of the argument. + */ + displayRenamedVar_: function ( + this: ProcedureBlock & BlockSvg, + oldName: string, + newName: string, + ) { + this.updateParams_(); + // Update the mutator's variables if the mutator is open. + const mutator = this.getIcon(Mutator.TYPE); + if (mutator && mutator.bubbleIsVisible()) { + const blocks = mutator.getWorkspace()!.getAllBlocks(false); + for (let i = 0, block; (block = blocks[i]); i++) { + if ( + block.type === 'procedures_mutatorarg' && + Names.equals(oldName, block.getFieldValue('NAME')) + ) { + block.setFieldValue(newName, 'NAME'); + } + } + } + }, + /** + * Add custom menu options to this block's context menu. + * + * @param options List of menu options to add to. + */ + customContextMenu: function ( + this: ProcedureBlock, + options: Array, + ) { + if (this.isInFlyout) { + return; + } + // Add option to create caller. + const name = this.getFieldValue('NAME'); + const callProcedureBlockState = { + type: (this as AnyDuringMigration).callType_, + extraState: {name: name, params: this.arguments_}, + }; + options.push({ + enabled: true, + text: Msg['PROCEDURES_CREATE_DO'].replace('%1', name), + callback: ContextMenu.callbackFactory(this, callProcedureBlockState), + }); + + // Add options to create getters for each parameter. + if (!this.isCollapsed()) { + for (let i = 0; i < this.argumentVarModels_.length; i++) { + const argVar = this.argumentVarModels_[i]; + const getVarBlockState = { + type: 'variables_get', + fields: { + VAR: { + name: argVar.getName(), + id: argVar.getId(), + type: argVar.getType(), + }, + }, + }; + options.push({ + enabled: true, + text: Msg['VARIABLES_SET_CREATE_GET'].replace('%1', argVar.getName()), + callback: ContextMenu.callbackFactory(this, getVarBlockState), + }); + } + } + }, +}; + +blocks['procedures_defnoreturn'] = { + ...PROCEDURE_DEF_COMMON, + /** + * Block for defining a procedure with no return value. + */ + init: function (this: ProcedureBlock & BlockSvg) { + const initName = Procedures.findLegalName('', this); + const nameField = fieldRegistry.fromJson({ + type: 'field_input', + text: initName, + }) as FieldTextInput; + nameField!.setValidator(Procedures.rename); + nameField.setSpellcheck(false); + this.appendDummyInput() + .appendField(Msg['PROCEDURES_DEFNORETURN_TITLE']) + .appendField(nameField, 'NAME') + .appendField('', 'PARAMS'); + this.setMutator(new Mutator(['procedures_mutatorarg'], this)); + if ( + (this.workspace.options.comments || + (this.workspace.options.parentWorkspace && + this.workspace.options.parentWorkspace.options.comments)) && + Msg['PROCEDURES_DEFNORETURN_COMMENT'] + ) { + this.setCommentText(Msg['PROCEDURES_DEFNORETURN_COMMENT']); + } + this.setStyle('procedure_blocks'); + this.setTooltip(Msg['PROCEDURES_DEFNORETURN_TOOLTIP']); + this.setHelpUrl(Msg['PROCEDURES_DEFNORETURN_HELPURL']); + this.arguments_ = []; + this.argumentVarModels_ = []; + this.setStatements_(true); + this.statementConnection_ = null; + }, + /** + * Return the signature of this procedure definition. + * + * @returns Tuple containing three elements: + * - the name of the defined procedure, + * - a list of all its arguments, + * - that it DOES NOT have a return value. + */ + getProcedureDef: function (this: ProcedureBlock): [string, string[], false] { + return [this.getFieldValue('NAME'), this.arguments_, false]; + }, + callType_: 'procedures_callnoreturn', +}; + +blocks['procedures_defreturn'] = { + ...PROCEDURE_DEF_COMMON, + /** + * Block for defining a procedure with a return value. + */ + init: function (this: ProcedureBlock & BlockSvg) { + const initName = Procedures.findLegalName('', this); + const nameField = fieldRegistry.fromJson({ + type: 'field_input', + text: initName, + }) as FieldTextInput; + nameField.setValidator(Procedures.rename); + nameField.setSpellcheck(false); + this.appendDummyInput() + .appendField(Msg['PROCEDURES_DEFRETURN_TITLE']) + .appendField(nameField, 'NAME') + .appendField('', 'PARAMS'); + this.appendValueInput('RETURN') + .setAlign(Align.RIGHT) + .appendField(Msg['PROCEDURES_DEFRETURN_RETURN']); + this.setMutator(new Mutator(['procedures_mutatorarg'], this)); + if ( + (this.workspace.options.comments || + (this.workspace.options.parentWorkspace && + this.workspace.options.parentWorkspace.options.comments)) && + Msg['PROCEDURES_DEFRETURN_COMMENT'] + ) { + this.setCommentText(Msg['PROCEDURES_DEFRETURN_COMMENT']); + } + this.setStyle('procedure_blocks'); + this.setTooltip(Msg['PROCEDURES_DEFRETURN_TOOLTIP']); + this.setHelpUrl(Msg['PROCEDURES_DEFRETURN_HELPURL']); + this.arguments_ = []; + this.argumentVarModels_ = []; + this.setStatements_(true); + this.statementConnection_ = null; + }, + /** + * Return the signature of this procedure definition. + * + * @returns Tuple containing three elements: + * - the name of the defined procedure, + * - a list of all its arguments, + * - that it DOES have a return value. + */ + getProcedureDef: function (this: ProcedureBlock): [string, string[], true] { + return [this.getFieldValue('NAME'), this.arguments_, true]; + }, + callType_: 'procedures_callreturn', +}; + +/** Type of a procedures_mutatorcontainer block. */ +type ContainerBlock = Block & ContainerMixin; +interface ContainerMixin extends ContainerMixinType {} +type ContainerMixinType = typeof PROCEDURES_MUTATORCONTAINER; + +const PROCEDURES_MUTATORCONTAINER = { + /** + * Mutator block for procedure container. + */ + init: function (this: ContainerBlock) { + this.appendDummyInput().appendField( + Msg['PROCEDURES_MUTATORCONTAINER_TITLE'], + ); + this.appendStatementInput('STACK'); + this.appendDummyInput('STATEMENT_INPUT') + .appendField(Msg['PROCEDURES_ALLOW_STATEMENTS']) + .appendField( + fieldRegistry.fromJson({ + type: 'field_checkbox', + checked: true, + }) as FieldCheckbox, + 'STATEMENTS', + ); + this.setStyle('procedure_blocks'); + this.setTooltip(Msg['PROCEDURES_MUTATORCONTAINER_TOOLTIP']); + this.contextMenu = false; + }, +}; +blocks['procedures_mutatorcontainer'] = PROCEDURES_MUTATORCONTAINER; + +/** Type of a procedures_mutatorarg block. */ +type ArgumentBlock = Block & ArgumentMixin; +interface ArgumentMixin extends ArgumentMixinType {} +type ArgumentMixinType = typeof PROCEDURES_MUTATORARGUMENT; + +/** + * Field responsible for editing procedure argument names. + */ +class ProcedureArgumentField extends FieldTextInput { + /** + * Whether or not this field is currently being edited interactively. + */ + editingInteractively = false; + + /** + * The procedure argument variable whose name is being interactively edited. + */ + editingVariable?: IVariableModel; + + /** + * Displays the field editor. + * + * @param e The event that triggered display of the field editor. + */ + protected override showEditor_(e?: Event) { + super.showEditor_(e); + this.editingInteractively = true; + this.editingVariable = undefined; + } + + /** + * Handles cleanup when the field editor is dismissed. + */ + override onFinishEditing_(value: string) { + super.onFinishEditing_(value); + this.editingInteractively = false; + } +} + +const PROCEDURES_MUTATORARGUMENT = { + /** + * Mutator block for procedure argument. + */ + init: function (this: ArgumentBlock) { + const field = new ProcedureArgumentField( + Procedures.DEFAULT_ARG, + this.validator_, + ); + + this.appendDummyInput() + .appendField(Msg['PROCEDURES_MUTATORARG_TITLE']) + .appendField(field, 'NAME'); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setStyle('procedure_blocks'); + this.setTooltip(Msg['PROCEDURES_MUTATORARG_TOOLTIP']); + this.contextMenu = false; + }, + + /** + * Obtain a valid name for the procedure argument. Create a variable if + * necessary. + * Merge runs of whitespace. Strip leading and trailing whitespace. + * Beyond this, all names are legal. + * + * @internal + * @param varName User-supplied name. + * @returns Valid name, or null if a name was not specified. + */ + validator_: function ( + this: ProcedureArgumentField, + varName: string, + ): string | null { + const sourceBlock = this.getSourceBlock()!; + const outerWs = sourceBlock.workspace.getRootWorkspace()!; + varName = varName.replace(/[\s\xa0]+/g, ' ').replace(/^ | $/g, ''); + if (!varName) { + return null; + } + + // Prevents duplicate parameter names in functions + const workspace = + (sourceBlock.workspace as WorkspaceSvg).targetWorkspace || + sourceBlock.workspace; + const blocks = workspace.getAllBlocks(false); + const caselessName = varName.toLowerCase(); + for (let i = 0; i < blocks.length; i++) { + if (blocks[i].id === this.getSourceBlock()!.id) { + continue; + } + // Other blocks values may not be set yet when this is loaded. + const otherVar = blocks[i].getFieldValue('NAME'); + if (otherVar && otherVar.toLowerCase() === caselessName) { + return null; + } + } + + // Don't create variables for arg blocks that + // only exist in the mutator's flyout. + if (sourceBlock.isInFlyout) { + return varName; + } + const variableMap = outerWs.getVariableMap(); + const model = variableMap.getVariable(varName, ''); + if (model && model.getName() !== varName) { + // Rename the variable (case change) + variableMap.renameVariable(model, varName); + } + if (!model) { + if (this.editingInteractively) { + if (!this.editingVariable) { + this.editingVariable = variableMap.createVariable(varName, ''); + } else { + variableMap.renameVariable(this.editingVariable, varName); + } + } else { + variableMap.createVariable(varName, ''); + } + } + return varName; + }, +}; +blocks['procedures_mutatorarg'] = PROCEDURES_MUTATORARGUMENT; + +/** Type of a block using the PROCEDURE_CALL_COMMON mixin. */ +type CallBlock = Block & CallMixin; +interface CallMixin extends CallMixinType { + argumentVarModels_: IVariableModel[]; + arguments_: string[]; + defType_: string; + quarkIds_: string[] | null; + quarkConnections_: {[id: string]: Connection}; +} +type CallMixinType = typeof PROCEDURE_CALL_COMMON; + +/** Extra state for serialising call blocks. */ +type CallExtraState = { + name: string; + params?: string[]; +}; + +/** + * The language-neutral ID for when the reason why a block is disabled is + * because the block's corresponding procedure definition is disabled. + */ +const DISABLED_PROCEDURE_DEFINITION_DISABLED_REASON = + 'DISABLED_PROCEDURE_DEFINITION'; + +/** + * Common properties for the procedure_callnoreturn and + * procedure_callreturn blocks. + */ +const PROCEDURE_CALL_COMMON = { + /** + * Returns the name of the procedure this block calls. + * + * @returns Procedure name. + */ + getProcedureCall: function (this: CallBlock): string { + // The NAME field is guaranteed to exist, null will never be returned. + return this.getFieldValue('NAME'); + }, + /** + * Notification that a procedure is renaming. + * If the name matches this block's procedure, rename it. + * + * @param oldName Previous name of procedure. + * @param newName Renamed procedure. + */ + renameProcedure: function ( + this: CallBlock, + oldName: string, + newName: string, + ) { + if (Names.equals(oldName, this.getProcedureCall())) { + this.setFieldValue(newName, 'NAME'); + const baseMsg = this.outputConnection + ? Msg['PROCEDURES_CALLRETURN_TOOLTIP'] + : Msg['PROCEDURES_CALLNORETURN_TOOLTIP']; + this.setTooltip(baseMsg.replace('%1', newName)); + } + }, + /** + * Notification that the procedure's parameters have changed. + * + * @internal + * @param paramNames New param names, e.g. ['x', 'y', 'z']. + * @param paramIds IDs of params (consistent for each parameter + * through the life of a mutator, regardless of param renaming), + * e.g. ['piua', 'f8b_', 'oi.o']. + */ + setProcedureParameters_: function ( + this: CallBlock, + paramNames: string[], + paramIds: string[], + ) { + // Data structures: + // this.arguments = ['x', 'y'] + // Existing param names. + // this.quarkConnections_ {piua: null, f8b_: Connection} + // Look-up of paramIds to connections plugged into the call block. + // this.quarkIds_ = ['piua', 'f8b_'] + // Existing param IDs. + // Note that quarkConnections_ may include IDs that no longer exist, but + // which might reappear if a param is reattached in the mutator. + const defBlock = Procedures.getDefinition( + this.getProcedureCall(), + this.workspace, + ); + const mutatorIcon = defBlock && defBlock.getIcon(Mutator.TYPE); + const mutatorOpen = mutatorIcon && mutatorIcon.bubbleIsVisible(); + if (!mutatorOpen) { + this.quarkConnections_ = {}; + this.quarkIds_ = null; + } else { + // fix #6091 - this call could cause an error when outside if-else + // expanding block while mutating prevents another error (ancient fix) + this.setCollapsed(false); + } + // Test arguments (arrays of strings) for changes. '\n' is not a valid + // argument name character, so it is a valid delimiter here. + if (paramNames.join('\n') === this.arguments_.join('\n')) { + // No change. + this.quarkIds_ = paramIds; + return; + } + if (paramIds.length !== paramNames.length) { + throw RangeError('paramNames and paramIds must be the same length.'); + } + if (!this.quarkIds_) { + // Initialize tracking for this block. + this.quarkConnections_ = {}; + this.quarkIds_ = []; + } + // Update the quarkConnections_ with existing connections. + for (let i = 0; i < this.arguments_.length; i++) { + const input = this.getInput('ARG' + i); + if (input) { + const connection = input.connection!.targetConnection!; + this.quarkConnections_[this.quarkIds_[i]] = connection; + if ( + mutatorOpen && + connection && + !paramIds.includes(this.quarkIds_[i]) + ) { + // This connection should no longer be attached to this block. + connection.disconnect(); + connection.getSourceBlock().bumpNeighbours(); + } + } + } + // Rebuild the block's arguments. + this.arguments_ = ([] as string[]).concat(paramNames); + // And rebuild the argument model list. + this.argumentVarModels_ = []; + for (let i = 0; i < this.arguments_.length; i++) { + const variable = Variables.getOrCreateVariablePackage( + this.workspace, + null, + this.arguments_[i], + '', + ); + this.argumentVarModels_.push(variable); + } + + this.updateShape_(); + this.quarkIds_ = paramIds; + // Reconnect any child blocks. + if (this.quarkIds_) { + for (let i = 0; i < this.arguments_.length; i++) { + const quarkId: string = this.quarkIds_[i]; // TODO(#6920) + if (quarkId in this.quarkConnections_) { + // TODO(#6920): investigate claimed circular initialisers. + const connection: Connection = this.quarkConnections_[quarkId]; + if (!connection?.reconnect(this, 'ARG' + i)) { + // Block no longer exists or has been attached elsewhere. + delete this.quarkConnections_[quarkId]; + } + } + } + } + }, + /** + * Modify this block to have the correct number of arguments. + * + * @internal + */ + updateShape_: function (this: CallBlock) { + for (let i = 0; i < this.arguments_.length; i++) { + const argField = this.getField('ARGNAME' + i); + if (argField) { + // Ensure argument name is up to date. + // The argument name field is deterministic based on the mutation, + // no need to fire a change event. + Events.disable(); + try { + argField.setValue(this.arguments_[i]); + } finally { + Events.enable(); + } + } else { + // Add new input. + const newField = fieldRegistry.fromJson({ + type: 'field_label', + text: this.arguments_[i], + }) as FieldLabel; + this.appendValueInput('ARG' + i) + .setAlign(Align.RIGHT) + .appendField(newField, 'ARGNAME' + i); + } + } + // Remove deleted inputs. + for (let i = this.arguments_.length; this.getInput('ARG' + i); i++) { + this.removeInput('ARG' + i); + } + // Add 'with:' if there are parameters, remove otherwise. + const topRow = this.getInput('TOPROW'); + if (topRow) { + if (this.arguments_.length) { + if (!this.getField('WITH')) { + topRow.appendField(Msg['PROCEDURES_CALL_BEFORE_PARAMS'], 'WITH'); + } + } else { + if (this.getField('WITH')) { + topRow.removeField('WITH'); + } + } + } + }, + /** + * Create XML to represent the (non-editable) name and arguments. + * Backwards compatible serialization implementation. + * + * @returns XML storage element. + */ + mutationToDom: function (this: CallBlock): Element { + const container = xmlUtils.createElement('mutation'); + container.setAttribute('name', this.getProcedureCall()); + for (let i = 0; i < this.arguments_.length; i++) { + const parameter = xmlUtils.createElement('arg'); + parameter.setAttribute('name', this.arguments_[i]); + container.appendChild(parameter); + } + return container; + }, + /** + * Parse XML to restore the (non-editable) name and parameters. + * Backwards compatible serialization implementation. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: CallBlock, xmlElement: Element) { + const name = xmlElement.getAttribute('name')!; + this.renameProcedure(this.getProcedureCall(), name); + const args: string[] = []; + const paramIds = []; + for (let i = 0, childNode; (childNode = xmlElement.childNodes[i]); i++) { + if (childNode.nodeName.toLowerCase() === 'arg') { + args.push((childNode as Element).getAttribute('name')!); + paramIds.push((childNode as Element).getAttribute('paramId')!); + } + } + this.setProcedureParameters_(args, paramIds); + }, + /** + * Returns the state of this block as a JSON serializable object. + * + * @returns The state of this block, ie the params and procedure name. + */ + saveExtraState: function (this: CallBlock): CallExtraState { + const state = Object.create(null); + state['name'] = this.getProcedureCall(); + if (this.arguments_.length) { + state['params'] = this.arguments_; + } + return state; + }, + /** + * Applies the given state to this block. + * + * @param state The state to apply to this block, ie the params and + * procedure name. + */ + loadExtraState: function (this: CallBlock, state: CallExtraState) { + this.renameProcedure(this.getProcedureCall(), state['name']); + const params = state['params']; + if (params) { + const ids: string[] = []; + ids.length = params.length; + ids.fill(null as unknown as string); // TODO(#6920) + this.setProcedureParameters_(params, ids); + } + }, + /** + * Return all variables referenced by this block. + * + * @returns List of variable names. + */ + getVars: function (this: CallBlock): string[] { + return this.arguments_; + }, + /** + * Return all variables referenced by this block. + * + * @returns List of variable models. + */ + getVarModels: function (this: CallBlock): IVariableModel[] { + return this.argumentVarModels_; + }, + /** + * Procedure calls cannot exist without the corresponding procedure + * definition. Enforce this link whenever an event is fired. + * + * @param event Change event. + */ + onchange: function (this: CallBlock, event: AbstractEvent) { + if (!this.workspace || this.workspace.isFlyout) { + // Block is deleted or is in a flyout. + return; + } + if (!event.recordUndo) { + // Events not generated by user. Skip handling. + return; + } + if ( + event.type === Events.BLOCK_CREATE && + (event as BlockCreate).ids!.includes(this.id) + ) { + // Look for the case where a procedure call was created (usually through + // paste) and there is no matching definition. In this case, create + // an empty definition block with the correct signature. + const name = this.getProcedureCall(); + let def = Procedures.getDefinition(name, this.workspace); + if ( + def && + (def.type !== this.defType_ || + JSON.stringify(def.getVars()) !== JSON.stringify(this.arguments_)) + ) { + // The signatures don't match. + def = null; + } + if (!def) { + Events.setGroup(event.group); + /** + * Create matching definition block. + * + * + * + * + * + * test + * + * + */ + const xml = xmlUtils.createElement('xml'); + const block = xmlUtils.createElement('block'); + block.setAttribute('type', this.defType_); + const xy = this.getRelativeToSurfaceXY(); + const x = xy.x + config.snapRadius * (this.RTL ? -1 : 1); + const y = xy.y + config.snapRadius * 2; + block.setAttribute('x', `${x}`); + block.setAttribute('y', `${y}`); + const mutation = this.mutationToDom(); + block.appendChild(mutation); + const field = xmlUtils.createElement('field'); + field.setAttribute('name', 'NAME'); + const callName = this.getProcedureCall(); + const newName = Procedures.findLegalName(callName, this); + if (callName !== newName) { + this.renameProcedure(callName, newName); + } + field.appendChild(xmlUtils.createTextNode(callName)); + block.appendChild(field); + xml.appendChild(block); + Xml.domToWorkspace(xml, this.workspace); + Events.setGroup(false); + } else if (!def.isEnabled()) { + this.setDisabledReason( + true, + DISABLED_PROCEDURE_DEFINITION_DISABLED_REASON, + ); + this.setWarningText( + Msg['PROCEDURES_CALL_DISABLED_DEF_WARNING'].replace('%1', name), + ); + } + } else if (event.type === Events.BLOCK_DELETE) { + // Look for the case where a procedure definition has been deleted, + // leaving this block (a procedure call) orphaned. In this case, delete + // the orphan. + const name = this.getProcedureCall(); + const def = Procedures.getDefinition(name, this.workspace); + if (!def) { + Events.setGroup(event.group); + this.dispose(true); + Events.setGroup(false); + } + } else if ( + event.type === Events.BLOCK_CHANGE && + (event as BlockChange).element === 'disabled' + ) { + const blockChangeEvent = event as BlockChange; + const name = this.getProcedureCall(); + const def = Procedures.getDefinition(name, this.workspace); + if (def && def.id === blockChangeEvent.blockId) { + // in most cases the old group should be '' + const oldGroup = Events.getGroup(); + if (oldGroup) { + // This should only be possible programmatically and may indicate a + // problem with event grouping. If you see this message please + // investigate. If the use ends up being valid we may need to reorder + // events in the undo stack. + console.log( + 'Saw an existing group while responding to a definition change', + ); + } + Events.setGroup(event.group); + const valid = def.isEnabled(); + this.setDisabledReason( + !valid, + DISABLED_PROCEDURE_DEFINITION_DISABLED_REASON, + ); + this.setWarningText( + valid + ? null + : Msg['PROCEDURES_CALL_DISABLED_DEF_WARNING'].replace('%1', name), + ); + Events.setGroup(oldGroup); + } + } + }, + /** + * Add menu option to find the definition block for this call. + * + * @param options List of menu options to add to. + */ + customContextMenu: function ( + this: CallBlock, + options: Array, + ) { + if (!(this.workspace as WorkspaceSvg).isMovable()) { + // If we center on the block and the workspace isn't movable we could + // loose blocks at the edges of the workspace. + return; + } + + const name = this.getProcedureCall(); + const workspace = this.workspace; + options.push({ + enabled: true, + text: Msg['PROCEDURES_HIGHLIGHT_DEF'], + callback: function () { + const def = Procedures.getDefinition(name, workspace); + if (def) { + (workspace as WorkspaceSvg).centerOnBlock(def.id); + getFocusManager().focusNode(def as BlockSvg); + } + }, + }); + }, +}; + +blocks['procedures_callnoreturn'] = { + ...PROCEDURE_CALL_COMMON, + /** + * Block for calling a procedure with no return value. + */ + init: function (this: CallBlock) { + this.appendDummyInput('TOPROW').appendField('', 'NAME'); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setStyle('procedure_blocks'); + // Tooltip is set in renameProcedure. + this.setHelpUrl(Msg['PROCEDURES_CALLNORETURN_HELPURL']); + this.arguments_ = []; + this.argumentVarModels_ = []; + this.quarkConnections_ = {}; + this.quarkIds_ = null; + }, + + defType_: 'procedures_defnoreturn', +}; + +blocks['procedures_callreturn'] = { + ...PROCEDURE_CALL_COMMON, + /** + * Block for calling a procedure with a return value. + */ + init: function (this: CallBlock) { + this.appendDummyInput('TOPROW').appendField('', 'NAME'); + this.setOutput(true); + this.setStyle('procedure_blocks'); + // Tooltip is set in renameProcedure. + this.setHelpUrl(Msg['PROCEDURES_CALLRETURN_HELPURL']); + this.arguments_ = []; + this.argumentVarModels_ = []; + this.quarkConnections_ = {}; + this.quarkIds_ = null; + }, + + defType_: 'procedures_defreturn', +}; + +/** + * Type of a procedures_ifreturn block. + * + * @internal + */ +export type IfReturnBlock = Block & IfReturnMixin; +interface IfReturnMixin extends IfReturnMixinType { + hasReturnValue_: boolean; +} +type IfReturnMixinType = typeof PROCEDURES_IFRETURN; + +/** + * The language-neutral ID for when the reason why a block is disabled is + * because the block is only valid inside of a procedure body. + */ +const UNPARENTED_IFRETURN_DISABLED_REASON = 'UNPARENTED_IFRETURN'; + +const PROCEDURES_IFRETURN = { + /** + * Block for conditionally returning a value from a procedure. + */ + init: function (this: IfReturnBlock) { + this.appendValueInput('CONDITION') + .setCheck('Boolean') + .appendField(Msg['CONTROLS_IF_MSG_IF']); + this.appendValueInput('VALUE').appendField( + Msg['PROCEDURES_DEFRETURN_RETURN'], + ); + this.setInputsInline(true); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setStyle('procedure_blocks'); + this.setTooltip(Msg['PROCEDURES_IFRETURN_TOOLTIP']); + this.setHelpUrl(Msg['PROCEDURES_IFRETURN_HELPURL']); + this.hasReturnValue_ = true; + }, + /** + * Create XML to represent whether this block has a return value. + * + * @returns XML storage element. + */ + mutationToDom: function (this: IfReturnBlock): Element { + const container = xmlUtils.createElement('mutation'); + container.setAttribute('value', String(Number(this.hasReturnValue_))); + return container; + }, + /** + * Parse XML to restore whether this block has a return value. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: IfReturnBlock, xmlElement: Element) { + const value = xmlElement.getAttribute('value'); + this.hasReturnValue_ = value === '1'; + if (!this.hasReturnValue_) { + this.removeInput('VALUE'); + this.appendDummyInput('VALUE').appendField( + Msg['PROCEDURES_DEFRETURN_RETURN'], + ); + } + }, + + // This block does not need JSO serialization hooks (saveExtraState and + // loadExtraState) because the state of this block is already encoded in the + // block's position in the workspace. + // XML hooks are kept for backwards compatibility. + + /** + * Called whenever anything on the workspace changes. + * Add warning if this flow block is not nested inside a loop. + * + * @param e Move event. + */ + onchange: function (this: IfReturnBlock, e: AbstractEvent) { + if ( + ((this.workspace as WorkspaceSvg).isDragging && + (this.workspace as WorkspaceSvg).isDragging()) || + (e.type !== Events.BLOCK_MOVE && e.type !== Events.BLOCK_CREATE) + ) { + return; // Don't change state at the start of a drag. + } + let legal = false; + // Is the block nested in a procedure? + let block = this; // eslint-disable-line @typescript-eslint/no-this-alias + do { + if (this.FUNCTION_TYPES.includes(block.type)) { + legal = true; + break; + } + block = block.getSurroundParent()!; + } while (block); + if (legal) { + // If needed, toggle whether this block has a return value. + if (block.type === 'procedures_defnoreturn' && this.hasReturnValue_) { + this.removeInput('VALUE'); + this.appendDummyInput('VALUE').appendField( + Msg['PROCEDURES_DEFRETURN_RETURN'], + ); + this.hasReturnValue_ = false; + } else if ( + block.type === 'procedures_defreturn' && + !this.hasReturnValue_ + ) { + this.removeInput('VALUE'); + this.appendValueInput('VALUE').appendField( + Msg['PROCEDURES_DEFRETURN_RETURN'], + ); + this.hasReturnValue_ = true; + } + this.setWarningText(null); + } else { + this.setWarningText(Msg['PROCEDURES_IFRETURN_WARNING']); + } + + if (!this.isInFlyout) { + try { + // There is no need to record the enable/disable change on the undo/redo + // list since the change will be automatically recreated when replayed. + eventUtils.setRecordUndo(false); + this.setDisabledReason(!legal, UNPARENTED_IFRETURN_DISABLED_REASON); + } finally { + eventUtils.setRecordUndo(true); + } + } + }, + /** + * List of block types that are functions and thus do not need warnings. + * To add a new function type add this to your code: + * Blocks['procedures_ifreturn'].FUNCTION_TYPES.push('custom_func'); + */ + FUNCTION_TYPES: ['procedures_defnoreturn', 'procedures_defreturn'], +}; +blocks['procedures_ifreturn'] = PROCEDURES_IFRETURN; + +// Register provided blocks. +defineBlocks(blocks); diff --git a/blocks/text.js b/blocks/text.js deleted file mode 100644 index 348f9957924..00000000000 --- a/blocks/text.js +++ /dev/null @@ -1,892 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Text blocks for Blockly. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Blocks.texts'); // Deprecated -goog.provide('Blockly.Constants.Text'); - -goog.require('Blockly.Blocks'); - - -/** - * Common HSV hue for all blocks in this category. - * Should be the same as Blockly.Msg.TEXTS_HUE - * @readonly - */ -Blockly.Constants.Text.HUE = 160; -/** @deprecated Use Blockly.Constants.Text.HUE */ -Blockly.Blocks.texts.HUE = Blockly.Constants.Text.HUE; - -Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT - // Block for text value - { - "type": "text", - "message0": "%1", - "args0": [{ - "type": "field_input", - "name": "TEXT", - "text": "" - }], - "output": "String", - "colour": "%{BKY_TEXTS_HUE}", - "helpUrl": "%{BKY_TEXT_TEXT_HELPURL}", - "tooltip": "%{BKY_TEXT_TEXT_TOOLTIP}", - "extensions": [ - "text_quotes", - "parent_tooltip_when_inline" - ] - }, - { - "type": "text_join", - "message0": "", - "output": "String", - "colour": "%{BKY_TEXTS_HUE}", - "helpUrl": "%{BKY_TEXT_JOIN_HELPURL}", - "tooltip": "%{BKY_TEXT_JOIN_TOOLTIP}", - "mutator": "text_join_mutator" - - }, - { - "type": "text_create_join_container", - "message0": "%{BKY_TEXT_CREATE_JOIN_TITLE_JOIN} %1 %2", - "args0": [{ - "type": "input_dummy" - }, - { - "type": "input_statement", - "name": "STACK" - }], - "colour": "%{BKY_TEXTS_HUE}", - "tooltip": "%{BKY_TEXT_CREATE_JOIN_TOOLTIP}", - "enableContextMenu": false - }, - { - "type": "text_create_join_item", - "message0": "%{BKY_TEXT_CREATE_JOIN_ITEM_TITLE_ITEM}", - "previousStatement": null, - "nextStatement": null, - "colour": "%{BKY_TEXTS_HUE}", - "tooltip": "{%BKY_TEXT_CREATE_JOIN_ITEM_TOOLTIP}", - "enableContextMenu": false - }, - { - "type": "text_append", - "message0": "%{BKY_TEXT_APPEND_TITLE}", - "args0": [{ - "type": "field_variable", - "name": "VAR", - "variable": "%{BKY_TEXT_APPEND_VARIABLE}" - }, - { - "type": "input_value", - "name": "TEXT" - }], - "previousStatement": null, - "nextStatement": null, - "colour": "%{BKY_TEXTS_HUE}", - "extensions": [ - "text_append_tooltip" - ] - }, - { - "type": "text_length", - "message0": "%{BKY_TEXT_LENGTH_TITLE}", - "args0": [ - { - "type": "input_value", - "name": "VALUE", - "check": ['String', 'Array'] - } - ], - "output": 'Number', - "colour": "%{BKY_TEXTS_HUE}", - "tooltip": "%{BKY_TEXT_LENGTH_TOOLTIP}", - "helpUrl": "%{BKY_TEXT_LENGTH_HELPURL}" - }, - { - "type": "text_isEmpty", - "message0": "%{BKY_TEXT_ISEMPTY_TITLE}", - "args0": [ - { - "type": "input_value", - "name": "VALUE", - "check": ['String', 'Array'] - } - ], - "output": 'Boolean', - "colour": "%{BKY_TEXTS_HUE}", - "tooltip": "%{BKY_TEXT_ISEMPTY_TOOLTIP}", - "helpUrl": "%{BKY_TEXT_ISEMPTY_HELPURL}" - }, - { - "type": "text_indexOf", - "message0": "%{BKY_TEXT_INDEXOF_TITLE}", - "args0": [ - { - "type": "input_value", - "name": "VALUE", - "check": "String" - }, - { - "type": "field_dropdown", - "name": "END", - "options": [ - [ - "%{BKY_TEXT_INDEXOF_OPERATOR_FIRST}", - "FIRST" - ], - [ - "%{BKY_TEXT_INDEXOF_OPERATOR_LAST}", - "LAST" - ] - ] - }, - { - "type": "input_value", - "name": "FIND", - "check": "String" - } - ], - "output": "Number", - "colour": "%{BKY_TEXTS_HUE}", - "helpUrl": "%{BKY_TEXT_INDEXOF_HELPURL}", - "inputsInline": true, - "extensions": [ - "text_indexOf_tooltip" - ] - }, - { - "type": "text_charAt", - "message0": "%{BKY_TEXT_CHARAT_TITLE}", // "in text %1 %2" - "args0": [ - { - "type":"input_value", - "name": "VALUE", - "check": "String" - }, - { - "type": "input_dummy", - "name": "AT" - } - ], - "output": "String", - "colour": "%{BKY_TEXTS_HUE}", - "helpUrl": "%{BKY_TEXT_CHARAT_HELPURL}", - "inputsInline": true, - "mutator": "text_charAt_mutator" - } -]); // END JSON EXTRACT (Do not delete this comment.) - -Blockly.Blocks['text_getSubstring'] = { - /** - * Block for getting substring. - * @this Blockly.Block - */ - init: function() { - this['WHERE_OPTIONS_1'] = [ - [Blockly.Msg.TEXT_GET_SUBSTRING_START_FROM_START, 'FROM_START'], - [Blockly.Msg.TEXT_GET_SUBSTRING_START_FROM_END, 'FROM_END'], - [Blockly.Msg.TEXT_GET_SUBSTRING_START_FIRST, 'FIRST'] - ]; - this['WHERE_OPTIONS_2'] = [ - [Blockly.Msg.TEXT_GET_SUBSTRING_END_FROM_START, 'FROM_START'], - [Blockly.Msg.TEXT_GET_SUBSTRING_END_FROM_END, 'FROM_END'], - [Blockly.Msg.TEXT_GET_SUBSTRING_END_LAST, 'LAST'] - ]; - this.setHelpUrl(Blockly.Msg.TEXT_GET_SUBSTRING_HELPURL); - this.setColour(Blockly.Blocks.texts.HUE); - this.appendValueInput('STRING') - .setCheck('String') - .appendField(Blockly.Msg.TEXT_GET_SUBSTRING_INPUT_IN_TEXT); - this.appendDummyInput('AT1'); - this.appendDummyInput('AT2'); - if (Blockly.Msg.TEXT_GET_SUBSTRING_TAIL) { - this.appendDummyInput('TAIL') - .appendField(Blockly.Msg.TEXT_GET_SUBSTRING_TAIL); - } - this.setInputsInline(true); - this.setOutput(true, 'String'); - this.updateAt_(1, true); - this.updateAt_(2, true); - this.setTooltip(Blockly.Msg.TEXT_GET_SUBSTRING_TOOLTIP); - }, - /** - * Create XML to represent whether there are 'AT' inputs. - * @return {!Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - var isAt1 = this.getInput('AT1').type == Blockly.INPUT_VALUE; - container.setAttribute('at1', isAt1); - var isAt2 = this.getInput('AT2').type == Blockly.INPUT_VALUE; - container.setAttribute('at2', isAt2); - return container; - }, - /** - * Parse XML to restore the 'AT' inputs. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - var isAt1 = (xmlElement.getAttribute('at1') == 'true'); - var isAt2 = (xmlElement.getAttribute('at2') == 'true'); - this.updateAt_(1, isAt1); - this.updateAt_(2, isAt2); - }, - /** - * Create or delete an input for a numeric index. - * This block has two such inputs, independant of each other. - * @param {number} n Specify first or second input (1 or 2). - * @param {boolean} isAt True if the input should exist. - * @private - * @this Blockly.Block - */ - updateAt_: function(n, isAt) { - // Create or delete an input for the numeric index. - // Destroy old 'AT' and 'ORDINAL' inputs. - this.removeInput('AT' + n); - this.removeInput('ORDINAL' + n, true); - // Create either a value 'AT' input or a dummy input. - if (isAt) { - this.appendValueInput('AT' + n).setCheck('Number'); - if (Blockly.Msg.ORDINAL_NUMBER_SUFFIX) { - this.appendDummyInput('ORDINAL' + n) - .appendField(Blockly.Msg.ORDINAL_NUMBER_SUFFIX); - } - } else { - this.appendDummyInput('AT' + n); - } - // Move tail, if present, to end of block. - if (n == 2 && Blockly.Msg.TEXT_GET_SUBSTRING_TAIL) { - this.removeInput('TAIL', true); - this.appendDummyInput('TAIL') - .appendField(Blockly.Msg.TEXT_GET_SUBSTRING_TAIL); - } - var menu = new Blockly.FieldDropdown(this['WHERE_OPTIONS_' + n], - function(value) { - var newAt = (value == 'FROM_START') || (value == 'FROM_END'); - // The 'isAt' variable is available due to this function being a - // closure. - if (newAt != isAt) { - var block = this.sourceBlock_; - block.updateAt_(n, newAt); - // This menu has been destroyed and replaced. - // Update the replacement. - block.setFieldValue(value, 'WHERE' + n); - return null; - } - return undefined; - }); - - this.getInput('AT' + n) - .appendField(menu, 'WHERE' + n); - if (n == 1) { - this.moveInputBefore('AT1', 'AT2'); - } - } -}; - -Blockly.Blocks['text_changeCase'] = { - /** - * Block for changing capitalization. - * @this Blockly.Block - */ - init: function() { - var OPERATORS = [ - [Blockly.Msg.TEXT_CHANGECASE_OPERATOR_UPPERCASE, 'UPPERCASE'], - [Blockly.Msg.TEXT_CHANGECASE_OPERATOR_LOWERCASE, 'LOWERCASE'], - [Blockly.Msg.TEXT_CHANGECASE_OPERATOR_TITLECASE, 'TITLECASE'] - ]; - this.setHelpUrl(Blockly.Msg.TEXT_CHANGECASE_HELPURL); - this.setColour(Blockly.Blocks.texts.HUE); - this.appendValueInput('TEXT') - .setCheck('String') - .appendField(new Blockly.FieldDropdown(OPERATORS), 'CASE'); - this.setOutput(true, 'String'); - this.setTooltip(Blockly.Msg.TEXT_CHANGECASE_TOOLTIP); - } -}; - -Blockly.Blocks['text_trim'] = { - /** - * Block for trimming spaces. - * @this Blockly.Block - */ - init: function() { - var OPERATORS = [ - [Blockly.Msg.TEXT_TRIM_OPERATOR_BOTH, 'BOTH'], - [Blockly.Msg.TEXT_TRIM_OPERATOR_LEFT, 'LEFT'], - [Blockly.Msg.TEXT_TRIM_OPERATOR_RIGHT, 'RIGHT'] - ]; - this.setHelpUrl(Blockly.Msg.TEXT_TRIM_HELPURL); - this.setColour(Blockly.Blocks.texts.HUE); - this.appendValueInput('TEXT') - .setCheck('String') - .appendField(new Blockly.FieldDropdown(OPERATORS), 'MODE'); - this.setOutput(true, 'String'); - this.setTooltip(Blockly.Msg.TEXT_TRIM_TOOLTIP); - } -}; - -Blockly.Blocks['text_print'] = { - /** - * Block for print statement. - * @this Blockly.Block - */ - init: function() { - this.jsonInit({ - "message0": Blockly.Msg.TEXT_PRINT_TITLE, - "args0": [ - { - "type": "input_value", - "name": "TEXT" - } - ], - "previousStatement": null, - "nextStatement": null, - "colour": Blockly.Blocks.texts.HUE, - "tooltip": Blockly.Msg.TEXT_PRINT_TOOLTIP, - "helpUrl": Blockly.Msg.TEXT_PRINT_HELPURL - }); - } -}; - -Blockly.Blocks['text_prompt_ext'] = { - /** - * Block for prompt function (external message). - * @this Blockly.Block - */ - init: function() { - var TYPES = [ - [Blockly.Msg.TEXT_PROMPT_TYPE_TEXT, 'TEXT'], - [Blockly.Msg.TEXT_PROMPT_TYPE_NUMBER, 'NUMBER'] - ]; - this.setHelpUrl(Blockly.Msg.TEXT_PROMPT_HELPURL); - this.setColour(Blockly.Blocks.texts.HUE); - // Assign 'this' to a variable for use in the closures below. - var thisBlock = this; - var dropdown = new Blockly.FieldDropdown(TYPES, function(newOp) { - thisBlock.updateType_(newOp); - }); - this.appendValueInput('TEXT') - .appendField(dropdown, 'TYPE'); - this.setOutput(true, 'String'); - this.setTooltip(function() { - return (thisBlock.getFieldValue('TYPE') == 'TEXT') ? - Blockly.Msg.TEXT_PROMPT_TOOLTIP_TEXT : - Blockly.Msg.TEXT_PROMPT_TOOLTIP_NUMBER; - }); - }, - /** - * Modify this block to have the correct output type. - * @param {string} newOp Either 'TEXT' or 'NUMBER'. - * @private - * @this Blockly.Block - */ - updateType_: function(newOp) { - this.outputConnection.setCheck(newOp == 'NUMBER' ? 'Number' : 'String'); - }, - /** - * Create XML to represent the output type. - * @return {!Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - container.setAttribute('type', this.getFieldValue('TYPE')); - return container; - }, - /** - * Parse XML to restore the output type. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - this.updateType_(xmlElement.getAttribute('type')); - } -}; - -Blockly.Blocks['text_prompt'] = { - /** - * Block for prompt function (internal message). - * The 'text_prompt_ext' block is preferred as it is more flexible. - * @this Blockly.Block - */ - init: function() { - this.mixin(Blockly.Constants.Text.QUOTE_IMAGE_MIXIN); - var TYPES = [ - [Blockly.Msg.TEXT_PROMPT_TYPE_TEXT, 'TEXT'], - [Blockly.Msg.TEXT_PROMPT_TYPE_NUMBER, 'NUMBER'] - ]; - - // Assign 'this' to a variable for use in the closures below. - var thisBlock = this; - this.setHelpUrl(Blockly.Msg.TEXT_PROMPT_HELPURL); - this.setColour(Blockly.Blocks.texts.HUE); - var dropdown = new Blockly.FieldDropdown(TYPES, function(newOp) { - thisBlock.updateType_(newOp); - }); - this.appendDummyInput() - .appendField(dropdown, 'TYPE') - .appendField(this.newQuote_(true)) - .appendField(new Blockly.FieldTextInput(''), 'TEXT') - .appendField(this.newQuote_(false)); - this.setOutput(true, 'String'); - this.setTooltip(function() { - return (thisBlock.getFieldValue('TYPE') == 'TEXT') ? - Blockly.Msg.TEXT_PROMPT_TOOLTIP_TEXT : - Blockly.Msg.TEXT_PROMPT_TOOLTIP_NUMBER; - }); - }, - updateType_: Blockly.Blocks['text_prompt_ext'].updateType_, - mutationToDom: Blockly.Blocks['text_prompt_ext'].mutationToDom, - domToMutation: Blockly.Blocks['text_prompt_ext'].domToMutation -}; - -Blockly.Blocks['text_count'] = { - /** - * Block for counting how many times one string appears within another string. - * @this Blockly.Block - */ - init: function() { - this.jsonInit({ - "message0": Blockly.Msg.TEXT_COUNT_MESSAGE0, - "args0": [ - { - "type": "input_value", - "name": "SUB", - "check": "String" - }, - { - "type": "input_value", - "name": "TEXT", - "check": "String" - } - ], - "output": "Number", - "inputsInline": true, - "colour": Blockly.Blocks.texts.HUE, - "tooltip": Blockly.Msg.TEXT_COUNT_TOOLTIP, - "helpUrl": Blockly.Msg.TEXT_COUNT_HELPURL - }); - } -}; - -Blockly.Blocks['text_replace'] = { - /** - * Block for replacing one string with another in the text. - * @this Blockly.Block - */ - init: function() { - this.jsonInit({ - "message0": Blockly.Msg.TEXT_REPLACE_MESSAGE0, - "args0": [ - { - "type": "input_value", - "name": "FROM", - "check": "String" - }, - { - "type": "input_value", - "name": "TO", - "check": "String" - }, - { - "type": "input_value", - "name": "TEXT", - "check": "String" - } - ], - "output": "String", - "inputsInline": true, - "colour": Blockly.Blocks.texts.HUE, - "tooltip": Blockly.Msg.TEXT_REPLACE_TOOLTIP, - "helpUrl": Blockly.Msg.TEXT_REPLACE_HELPURL - }); - } -}; - -Blockly.Blocks['text_reverse'] = { - /** - * Block for reversing a string. - * @this Blockly.Block - */ - init: function() { - this.jsonInit({ - "message0": Blockly.Msg.TEXT_REVERSE_MESSAGE0, - "args0": [ - { - "type": "input_value", - "name": "TEXT", - "check": "String" - } - ], - "output": "String", - "inputsInline": true, - "colour": Blockly.Blocks.texts.HUE, - "tooltip": Blockly.Msg.TEXT_REVERSE_TOOLTIP, - "helpUrl": Blockly.Msg.TEXT_REVERSE_HELPURL - }); - } -}; - -/** - * - * @mixin - * @package - * @readonly - */ -Blockly.Constants.Text.QUOTE_IMAGE_MIXIN = { - /** - * Image data URI of an LTR opening double quote (same as RTL closing couble quote). - * @readonly - */ - QUOTE_IMAGE_LEFT_DATAURI: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAKCAQAAAAqJXdxAAAAn0lEQVQI1z3OMa5BURSF4f/cQhAKjUQhuQmFNwGJEUi0RKN5rU7FHKhpjEH3TEMtkdBSCY1EIv8r7nFX9e29V7EBAOvu7RPjwmWGH/VuF8CyN9/OAdvqIXYLvtRaNjx9mMTDyo+NjAN1HNcl9ZQ5oQMM3dgDUqDo1l8DzvwmtZN7mnD+PkmLa+4mhrxVA9fRowBWmVBhFy5gYEjKMfz9AylsaRRgGzvZAAAAAElFTkSuQmCC', - /** - * Image data URI of an LTR closing double quote (same as RTL opening couble quote). - * @readonly - */ - QUOTE_IMAGE_RIGHT_DATAURI: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAKCAQAAAAqJXdxAAAAqUlEQVQI1z3KvUpCcRiA8ef9E4JNHhI0aFEacm1o0BsI0Slx8wa8gLauoDnoBhq7DcfWhggONDmJJgqCPA7neJ7p934EOOKOnM8Q7PDElo/4x4lFb2DmuUjcUzS3URnGib9qaPNbuXvBO3sGPHJDRG6fGVdMSeWDP2q99FQdFrz26Gu5Tq7dFMzUvbXy8KXeAj57cOklgA+u1B5AoslLtGIHQMaCVnwDnADZIFIrXsoXrgAAAABJRU5ErkJggg==', - /** - * Pixel width of QUOTE_IMAGE_LEFT_DATAURI and QUOTE_IMAGE_RIGHT_DATAURI. - * @readonly - */ - QUOTE_IMAGE_WIDTH: 12, - /** - * Pixel height of QUOTE_IMAGE_LEFT_DATAURI and QUOTE_IMAGE_RIGHT_DATAURI. - * @readonly - */ - QUOTE_IMAGE_HEIGHT: 12, - - /** - * Inserts appropriate quote images before and after the named field. - * @param {string} fieldName The name of the field to wrap with quotes. - */ - quoteField_: function(fieldName) { - for (var i = 0, input; input = this.inputList[i]; i++) { - for (var j = 0, field; field = input.fieldRow[j]; j++) { - if (fieldName == field.name) { - input.insertFieldAt(j, this.newQuote_(true)); - input.insertFieldAt(j + 2, this.newQuote_(false)); - return; - } - } - } - console.warn('field named "' + fieldName + '" not found in ' + this.toDevString()); - }, - - /** - * A helper function that generates a FieldImage of an opening or - * closing double quote. The selected quote will be adapted for RTL blocks. - * @param {boolean} open If the image should be open quote (“ in LTR). - * Otherwise, a closing quote is used (” in LTR). - * @returns {!Blockly.FieldImage} The new field. - */ - newQuote_: function(open) { - var isLeft = this.RTL? !open : open; - var dataUri = isLeft ? - this.QUOTE_IMAGE_LEFT_DATAURI : - this.QUOTE_IMAGE_RIGHT_DATAURI; - return new Blockly.FieldImage( - dataUri, - this.QUOTE_IMAGE_WIDTH, - this.QUOTE_IMAGE_HEIGHT, - isLeft ? '\u201C' : '\u201D'); - } -}; - -/** Wraps TEXT field with images of double quote characters. */ -Blockly.Constants.Text.TEXT_QUOTES_EXTENSION = function() { - this.mixin(Blockly.Constants.Text.QUOTE_IMAGE_MIXIN); - this.quoteField_('TEXT'); -}; - -/** - * Mixin for mutator functions in the 'text_join_mutator' extension. - * @mixin - * @augments Blockly.Block - * @package - */ -Blockly.Constants.Text.TEXT_JOIN_MUTATOR_MIXIN = { - /** - * Create XML to represent number of text inputs. - * @return {!Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - container.setAttribute('items', this.itemCount_); - return container; - }, - /** - * Parse XML to restore the text inputs. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - this.itemCount_ = parseInt(xmlElement.getAttribute('items'), 10); - this.updateShape_(); - }, - /** - * Populate the mutator's dialog with this block's components. - * @param {!Blockly.Workspace} workspace Mutator's workspace. - * @return {!Blockly.Block} Root block in mutator. - * @this Blockly.Block - */ - decompose: function(workspace) { - var containerBlock = workspace.newBlock('text_create_join_container'); - containerBlock.initSvg(); - var connection = containerBlock.getInput('STACK').connection; - for (var i = 0; i < this.itemCount_; i++) { - var itemBlock = workspace.newBlock('text_create_join_item'); - itemBlock.initSvg(); - connection.connect(itemBlock.previousConnection); - connection = itemBlock.nextConnection; - } - return containerBlock; - }, - /** - * Reconfigure this block based on the mutator dialog's components. - * @param {!Blockly.Block} containerBlock Root block in mutator. - * @this Blockly.Block - */ - compose: function(containerBlock) { - var itemBlock = containerBlock.getInputTargetBlock('STACK'); - // Count number of inputs. - var connections = []; - while (itemBlock) { - connections.push(itemBlock.valueConnection_); - itemBlock = itemBlock.nextConnection && - itemBlock.nextConnection.targetBlock(); - } - // Disconnect any children that don't belong. - for (var i = 0; i < this.itemCount_; i++) { - var connection = this.getInput('ADD' + i).connection.targetConnection; - if (connection && connections.indexOf(connection) == -1) { - connection.disconnect(); - } - } - this.itemCount_ = connections.length; - this.updateShape_(); - // Reconnect any child blocks. - for (var i = 0; i < this.itemCount_; i++) { - Blockly.Mutator.reconnect(connections[i], this, 'ADD' + i); - } - }, - /** - * Store pointers to any connected child blocks. - * @param {!Blockly.Block} containerBlock Root block in mutator. - * @this Blockly.Block - */ - saveConnections: function(containerBlock) { - var itemBlock = containerBlock.getInputTargetBlock('STACK'); - var i = 0; - while (itemBlock) { - var input = this.getInput('ADD' + i); - itemBlock.valueConnection_ = input && input.connection.targetConnection; - i++; - itemBlock = itemBlock.nextConnection && - itemBlock.nextConnection.targetBlock(); - } - }, - /** - * Modify this block to have the correct number of inputs. - * @private - * @this Blockly.Block - */ - updateShape_: function() { - if (this.itemCount_ && this.getInput('EMPTY')) { - this.removeInput('EMPTY'); - } else if (!this.itemCount_ && !this.getInput('EMPTY')) { - this.appendDummyInput('EMPTY') - .appendField(this.newQuote_(true)) - .appendField(this.newQuote_(false)); - } - // Add new inputs. - for (var i = 0; i < this.itemCount_; i++) { - if (!this.getInput('ADD' + i)) { - var input = this.appendValueInput('ADD' + i); - if (i == 0) { - input.appendField(Blockly.Msg.TEXT_JOIN_TITLE_CREATEWITH); - } - } - } - // Remove deleted inputs. - while (this.getInput('ADD' + i)) { - this.removeInput('ADD' + i); - i++; - } - } -}; - -// Performs final setup of a text_join block. -Blockly.Constants.Text.TEXT_JOIN_EXTENSION = function() { - // Add the quote mixin for the itemCount_ = 0 case. - this.mixin(Blockly.Constants.Text.QUOTE_IMAGE_MIXIN); - // initialize the mutator values - this.itemCount_ = 2; - this.updateShape_(); - // Configure the mutator ui - this.setMutator(new Blockly.Mutator(['text_create_join_item'])); -}; - -Blockly.Constants.Text.TEXT_APPEND_TOOLTIP_EXTENSION = function() { - // Assign 'this' to a variable for use in the tooltip closure below. - var thisBlock = this; - this.setTooltip(function() { - if (Blockly.Msg.TEXT_APPEND_TOOLTIP) { - return Blockly.Msg.TEXT_APPEND_TOOLTIP.replace('%1', - thisBlock.getFieldValue('VAR')); - } - return ''; - }); -}; - -Blockly.Constants.Text.TEXT_INDEXOF_TOOLTIP_EXTENSION = function() { - // Assign 'this' to a variable for use in the tooltip closure below. - var thisBlock = this; - this.setTooltip(function() { - return Blockly.Msg.TEXT_INDEXOF_TOOLTIP.replace('%1', - thisBlock.workspace.options.oneBasedIndex ? '0' : '-1'); - }); -}; - -/** - * Mixin for mutator functions in the 'text_charAt_mutator' extension. - * @mixin - * @augments Blockly.Block - * @package - */ -Blockly.Constants.Text.TEXT_CHARAT_MUTATOR_MIXIN = { - /** - * Create XML to represent whether there is an 'AT' input. - * @return {!Element} XML storage element. - * @this Blockly.Block - */ - mutationToDom: function() { - var container = document.createElement('mutation'); - var isAt = this.getInput('AT').type == Blockly.INPUT_VALUE; - container.setAttribute('at', isAt); - return container; - }, - /** - * Parse XML to restore the 'AT' input. - * @param {!Element} xmlElement XML storage element. - * @this Blockly.Block - */ - domToMutation: function(xmlElement) { - // Note: Until January 2013 this block did not have mutations, - // so 'at' defaults to true. - var isAt = (xmlElement.getAttribute('at') != 'false'); - this.updateAt_(isAt); - }, - /** - * Create or delete an input for the numeric index. - * @param {boolean} isAt True if the input should exist. - * @private - * @this Blockly.Block - */ - updateAt_: function(isAt) { - // Destroy old 'AT' and 'ORDINAL' inputs. - this.removeInput('AT'); - this.removeInput('ORDINAL', true); - // Create either a value 'AT' input or a dummy input. - if (isAt) { - this.appendValueInput('AT').setCheck('Number'); - if (Blockly.Msg.ORDINAL_NUMBER_SUFFIX) { - this.appendDummyInput('ORDINAL') - .appendField(Blockly.Msg.ORDINAL_NUMBER_SUFFIX); - } - } else { - this.appendDummyInput('AT'); - } - if (Blockly.Msg.TEXT_CHARAT_TAIL) { - this.removeInput('TAIL', true); - this.appendDummyInput('TAIL') - .appendField(Blockly.Msg.TEXT_CHARAT_TAIL); - } - var menu = new Blockly.FieldDropdown(this.WHERE_OPTIONS, function(value) { - var newAt = (value == 'FROM_START') || (value == 'FROM_END'); - // The 'isAt' variable is available due to this function being a closure. - if (newAt != isAt) { - var block = this.sourceBlock_; - block.updateAt_(newAt); - // This menu has been destroyed and replaced. Update the replacement. - block.setFieldValue(value, 'WHERE'); - return null; - } - return undefined; - }); - this.getInput('AT').appendField(menu, 'WHERE'); - } -}; - -// Does the initial mutator update of text_charAt and adds the tooltip -Blockly.Constants.Text.TEXT_CHARAT_EXTENSION = function() { - this.WHERE_OPTIONS = [ - [Blockly.Msg.TEXT_CHARAT_FROM_START, 'FROM_START'], - [Blockly.Msg.TEXT_CHARAT_FROM_END, 'FROM_END'], - [Blockly.Msg.TEXT_CHARAT_FIRST, 'FIRST'], - [Blockly.Msg.TEXT_CHARAT_LAST, 'LAST'], - [Blockly.Msg.TEXT_CHARAT_RANDOM, 'RANDOM'] - ]; - this.updateAt_(true); - // Assign 'this' to a variable for use in the tooltip closure below. - var thisBlock = this; - this.setTooltip(function() { - var where = thisBlock.getFieldValue('WHERE'); - var tooltip = Blockly.Msg.TEXT_CHARAT_TOOLTIP; - if (where == 'FROM_START' || where == 'FROM_END') { - var msg = (where == 'FROM_START') ? - Blockly.Msg.LISTS_INDEX_FROM_START_TOOLTIP : - Blockly.Msg.LISTS_INDEX_FROM_END_TOOLTIP; - if (msg) { - tooltip += ' ' + msg.replace('%1', - thisBlock.workspace.options.oneBasedIndex ? '#1' : '#0'); - } - } - return tooltip; - }); -}; - -Blockly.Extensions.register('text_indexOf_tooltip', - Blockly.Constants.Text.TEXT_INDEXOF_TOOLTIP_EXTENSION); - -Blockly.Extensions.register('text_quotes', - Blockly.Constants.Text.TEXT_QUOTES_EXTENSION); - -Blockly.Extensions.register('text_append_tooltip', - Blockly.Constants.Text.TEXT_APPEND_TOOLTIP_EXTENSION); - -Blockly.Extensions.registerMutator('text_join_mutator', - Blockly.Constants.Text.TEXT_JOIN_MUTATOR_MIXIN, - Blockly.Constants.Text.TEXT_JOIN_EXTENSION); - -Blockly.Extensions.registerMutator('text_charAt_mutator', - Blockly.Constants.Text.TEXT_CHARAT_MUTATOR_MIXIN, - Blockly.Constants.Text.TEXT_CHARAT_EXTENSION); \ No newline at end of file diff --git a/blocks/text.ts b/blocks/text.ts new file mode 100644 index 00000000000..a7ad5374ac4 --- /dev/null +++ b/blocks/text.ts @@ -0,0 +1,1001 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.libraryBlocks.texts + +import type {Block} from '../core/block.js'; +import type {BlockSvg} from '../core/block_svg.js'; +import { + createBlockDefinitionsFromJsonArray, + defineBlocks, +} from '../core/common.js'; +import {Connection} from '../core/connection.js'; +import * as Extensions from '../core/extensions.js'; +import {FieldDropdown} from '../core/field_dropdown.js'; +import {FieldImage} from '../core/field_image.js'; +import * as fieldRegistry from '../core/field_registry.js'; +import {FieldTextInput} from '../core/field_textinput.js'; +import '../core/field_variable.js'; +import {MutatorIcon} from '../core/icons/mutator_icon.js'; +import {Align} from '../core/inputs/align.js'; +import {ValueInput} from '../core/inputs/value_input.js'; +import {Msg} from '../core/msg.js'; +import * as xmlUtils from '../core/utils/xml.js'; +import type {Workspace} from '../core/workspace.js'; + +/** + * A dictionary of the block definitions provided by this module. + */ +export const blocks = createBlockDefinitionsFromJsonArray([ + // Block for text value + { + 'type': 'text', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'TEXT', + 'text': '', + }, + ], + 'output': 'String', + 'style': 'text_blocks', + 'helpUrl': '%{BKY_TEXT_TEXT_HELPURL}', + 'tooltip': '%{BKY_TEXT_TEXT_TOOLTIP}', + 'extensions': ['text_quotes', 'parent_tooltip_when_inline'], + }, + { + 'type': 'text_join', + 'message0': '', + 'output': 'String', + 'style': 'text_blocks', + 'helpUrl': '%{BKY_TEXT_JOIN_HELPURL}', + 'tooltip': '%{BKY_TEXT_JOIN_TOOLTIP}', + 'mutator': 'text_join_mutator', + }, + { + 'type': 'text_create_join_container', + 'message0': '%{BKY_TEXT_CREATE_JOIN_TITLE_JOIN} %1 %2', + 'args0': [ + { + 'type': 'input_dummy', + }, + { + 'type': 'input_statement', + 'name': 'STACK', + }, + ], + 'style': 'text_blocks', + 'tooltip': '%{BKY_TEXT_CREATE_JOIN_TOOLTIP}', + 'enableContextMenu': false, + }, + { + 'type': 'text_create_join_item', + 'message0': '%{BKY_TEXT_CREATE_JOIN_ITEM_TITLE_ITEM}', + 'previousStatement': null, + 'nextStatement': null, + 'style': 'text_blocks', + 'tooltip': '%{BKY_TEXT_CREATE_JOIN_ITEM_TOOLTIP}', + 'enableContextMenu': false, + }, + { + 'type': 'text_append', + 'message0': '%{BKY_TEXT_APPEND_TITLE}', + 'args0': [ + { + 'type': 'field_variable', + 'name': 'VAR', + 'variable': '%{BKY_TEXT_APPEND_VARIABLE}', + }, + { + 'type': 'input_value', + 'name': 'TEXT', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'style': 'text_blocks', + 'extensions': ['text_append_tooltip'], + }, + { + 'type': 'text_length', + 'message0': '%{BKY_TEXT_LENGTH_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'VALUE', + 'check': ['String', 'Array'], + }, + ], + 'output': 'Number', + 'style': 'text_blocks', + 'tooltip': '%{BKY_TEXT_LENGTH_TOOLTIP}', + 'helpUrl': '%{BKY_TEXT_LENGTH_HELPURL}', + }, + { + 'type': 'text_isEmpty', + 'message0': '%{BKY_TEXT_ISEMPTY_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'VALUE', + 'check': ['String', 'Array'], + }, + ], + 'output': 'Boolean', + 'style': 'text_blocks', + 'tooltip': '%{BKY_TEXT_ISEMPTY_TOOLTIP}', + 'helpUrl': '%{BKY_TEXT_ISEMPTY_HELPURL}', + }, + { + 'type': 'text_indexOf', + 'message0': '%{BKY_TEXT_INDEXOF_TITLE}', + 'args0': [ + { + 'type': 'input_value', + 'name': 'VALUE', + 'check': 'String', + }, + { + 'type': 'field_dropdown', + 'name': 'END', + 'options': [ + ['%{BKY_TEXT_INDEXOF_OPERATOR_FIRST}', 'FIRST'], + ['%{BKY_TEXT_INDEXOF_OPERATOR_LAST}', 'LAST'], + ], + }, + { + 'type': 'input_value', + 'name': 'FIND', + 'check': 'String', + }, + ], + 'output': 'Number', + 'style': 'text_blocks', + 'helpUrl': '%{BKY_TEXT_INDEXOF_HELPURL}', + 'inputsInline': true, + 'extensions': ['text_indexOf_tooltip'], + }, + { + 'type': 'text_charAt', + 'message0': '%{BKY_TEXT_CHARAT_TITLE}', // "in text %1 %2" + 'args0': [ + { + 'type': 'input_value', + 'name': 'VALUE', + 'check': 'String', + }, + { + 'type': 'field_dropdown', + 'name': 'WHERE', + 'options': [ + ['%{BKY_TEXT_CHARAT_FROM_START}', 'FROM_START'], + ['%{BKY_TEXT_CHARAT_FROM_END}', 'FROM_END'], + ['%{BKY_TEXT_CHARAT_FIRST}', 'FIRST'], + ['%{BKY_TEXT_CHARAT_LAST}', 'LAST'], + ['%{BKY_TEXT_CHARAT_RANDOM}', 'RANDOM'], + ], + }, + ], + 'output': 'String', + 'style': 'text_blocks', + 'helpUrl': '%{BKY_TEXT_CHARAT_HELPURL}', + 'inputsInline': true, + 'mutator': 'text_charAt_mutator', + }, +]); + +/** Type of a 'text_get_substring' block. */ +type GetSubstringBlock = Block & GetSubstringMixin; +interface GetSubstringMixin extends GetSubstringType { + WHERE_OPTIONS_1: Array<[string, string]>; + WHERE_OPTIONS_2: Array<[string, string]>; +} +type GetSubstringType = typeof GET_SUBSTRING_BLOCK; + +const GET_SUBSTRING_BLOCK = { + /** + * Block for getting substring. + */ + init: function (this: GetSubstringBlock) { + this['WHERE_OPTIONS_1'] = [ + [Msg['TEXT_GET_SUBSTRING_START_FROM_START'], 'FROM_START'], + [Msg['TEXT_GET_SUBSTRING_START_FROM_END'], 'FROM_END'], + [Msg['TEXT_GET_SUBSTRING_START_FIRST'], 'FIRST'], + ]; + this['WHERE_OPTIONS_2'] = [ + [Msg['TEXT_GET_SUBSTRING_END_FROM_START'], 'FROM_START'], + [Msg['TEXT_GET_SUBSTRING_END_FROM_END'], 'FROM_END'], + [Msg['TEXT_GET_SUBSTRING_END_LAST'], 'LAST'], + ]; + this.setHelpUrl(Msg['TEXT_GET_SUBSTRING_HELPURL']); + this.setStyle('text_blocks'); + this.appendValueInput('STRING') + .setCheck('String') + .appendField(Msg['TEXT_GET_SUBSTRING_INPUT_IN_TEXT']); + const createMenu = (n: 1 | 2): FieldDropdown => { + const menu = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: + this[('WHERE_OPTIONS_' + n) as 'WHERE_OPTIONS_1' | 'WHERE_OPTIONS_2'], + }) as FieldDropdown; + menu.setValidator( + /** @param value The input value. */ + function (this: FieldDropdown, value: any): null | undefined { + const oldValue: string | null = this.getValue(); + const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END'; + const newAt = value === 'FROM_START' || value === 'FROM_END'; + if (newAt !== oldAt) { + const block = this.getSourceBlock() as GetSubstringBlock; + block.updateAt_(n, newAt); + } + return undefined; + }, + ); + return menu; + }; + this.appendDummyInput('WHERE1_INPUT').appendField(createMenu(1), 'WHERE1'); + this.appendDummyInput('AT1'); + this.appendDummyInput('WHERE2_INPUT').appendField(createMenu(2), 'WHERE2'); + this.appendDummyInput('AT2'); + if (Msg['TEXT_GET_SUBSTRING_TAIL']) { + this.appendDummyInput('TAIL').appendField(Msg['TEXT_GET_SUBSTRING_TAIL']); + } + this.setInputsInline(true); + this.setOutput(true, 'String'); + this.updateAt_(1, true); + this.updateAt_(2, true); + this.setTooltip(Msg['TEXT_GET_SUBSTRING_TOOLTIP']); + }, + /** + * Create XML to represent whether there are 'AT' inputs. + * Backwards compatible serialization implementation. + * + * @returns XML storage element. + */ + mutationToDom: function (this: GetSubstringBlock): Element { + const container = xmlUtils.createElement('mutation'); + const isAt1 = this.getInput('AT1') instanceof ValueInput; + container.setAttribute('at1', `${isAt1}`); + const isAt2 = this.getInput('AT2') instanceof ValueInput; + container.setAttribute('at2', `${isAt2}`); + return container; + }, + /** + * Parse XML to restore the 'AT' inputs. + * Backwards compatible serialization implementation. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: GetSubstringBlock, xmlElement: Element) { + const isAt1 = xmlElement.getAttribute('at1') === 'true'; + const isAt2 = xmlElement.getAttribute('at2') === 'true'; + this.updateAt_(1, isAt1); + this.updateAt_(2, isAt2); + }, + + // This block does not need JSO serialization hooks (saveExtraState and + // loadExtraState) because the state of this object is already encoded in the + // dropdown values. + // XML hooks are kept for backwards compatibility. + + /** + * Create or delete an input for a numeric index. + * This block has two such inputs, independent of each other. + * + * @internal + * @param n Which input to modify (either 1 or 2). + * @param isAt True if the input includes a value connection, false otherwise. + */ + updateAt_: function (this: GetSubstringBlock, n: 1 | 2, isAt: boolean) { + // Create or delete an input for the numeric index. + // Destroy old 'AT' and 'ORDINAL' inputs. + this.removeInput('AT' + n); + this.removeInput('ORDINAL' + n, true); + // Create either a value 'AT' input or a dummy input. + if (isAt) { + this.appendValueInput('AT' + n).setCheck('Number'); + if (Msg['ORDINAL_NUMBER_SUFFIX']) { + this.appendDummyInput('ORDINAL' + n).appendField( + Msg['ORDINAL_NUMBER_SUFFIX'], + ); + } + } else { + this.appendDummyInput('AT' + n); + } + // Move tail, if present, to end of block. + if (n === 2 && Msg['TEXT_GET_SUBSTRING_TAIL']) { + this.removeInput('TAIL', true); + this.appendDummyInput('TAIL').appendField(Msg['TEXT_GET_SUBSTRING_TAIL']); + } + if (n === 1) { + this.moveInputBefore('AT1', 'WHERE2_INPUT'); + if (this.getInput('ORDINAL1')) { + this.moveInputBefore('ORDINAL1', 'WHERE2_INPUT'); + } + } + }, +}; + +blocks['text_getSubstring'] = GET_SUBSTRING_BLOCK; + +blocks['text_changeCase'] = { + /** + * Block for changing capitalization. + */ + init: function (this: Block) { + const OPERATORS = [ + [Msg['TEXT_CHANGECASE_OPERATOR_UPPERCASE'], 'UPPERCASE'], + [Msg['TEXT_CHANGECASE_OPERATOR_LOWERCASE'], 'LOWERCASE'], + [Msg['TEXT_CHANGECASE_OPERATOR_TITLECASE'], 'TITLECASE'], + ]; + this.setHelpUrl(Msg['TEXT_CHANGECASE_HELPURL']); + this.setStyle('text_blocks'); + this.appendValueInput('TEXT') + .setCheck('String') + .appendField( + fieldRegistry.fromJson({ + type: 'field_dropdown', + options: OPERATORS, + }) as FieldDropdown, + 'CASE', + ); + this.setOutput(true, 'String'); + this.setTooltip(Msg['TEXT_CHANGECASE_TOOLTIP']); + }, +}; + +blocks['text_trim'] = { + /** + * Block for trimming spaces. + */ + init: function (this: Block) { + const OPERATORS = [ + [Msg['TEXT_TRIM_OPERATOR_BOTH'], 'BOTH'], + [Msg['TEXT_TRIM_OPERATOR_LEFT'], 'LEFT'], + [Msg['TEXT_TRIM_OPERATOR_RIGHT'], 'RIGHT'], + ]; + this.setHelpUrl(Msg['TEXT_TRIM_HELPURL']); + this.setStyle('text_blocks'); + this.appendValueInput('TEXT') + .setCheck('String') + .appendField( + fieldRegistry.fromJson({ + type: 'field_dropdown', + options: OPERATORS, + }) as FieldDropdown, + 'MODE', + ); + this.setOutput(true, 'String'); + this.setTooltip(Msg['TEXT_TRIM_TOOLTIP']); + }, +}; + +blocks['text_print'] = { + /** + * Block for print statement. + */ + init: function (this: Block) { + this.jsonInit({ + 'message0': Msg['TEXT_PRINT_TITLE'], + 'args0': [ + { + 'type': 'input_value', + 'name': 'TEXT', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'style': 'text_blocks', + 'tooltip': Msg['TEXT_PRINT_TOOLTIP'], + 'helpUrl': Msg['TEXT_PRINT_HELPURL'], + }); + }, +}; + +type PromptCommonBlock = Block & PromptCommonMixin; +interface PromptCommonMixin extends PromptCommonType {} +type PromptCommonType = typeof PROMPT_COMMON; + +/** + * Common properties for the text_prompt_ext and text_prompt blocks + * definitions. + */ +const PROMPT_COMMON = { + /** + * Modify this block to have the correct output type. + * + * @internal + * @param newOp The new output type. Should be either 'TEXT' or 'NUMBER'. + */ + updateType_: function (this: PromptCommonBlock, newOp: string) { + this.outputConnection!.setCheck(newOp === 'NUMBER' ? 'Number' : 'String'); + }, + /** + * Create XML to represent the output type. + * Backwards compatible serialization implementation. + * + * @returns XML storage element. + */ + mutationToDom: function (this: PromptCommonBlock): Element { + const container = xmlUtils.createElement('mutation'); + container.setAttribute('type', this.getFieldValue('TYPE')); + return container; + }, + /** + * Parse XML to restore the output type. + * Backwards compatible serialization implementation. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: PromptCommonBlock, xmlElement: Element) { + this.updateType_(xmlElement.getAttribute('type')!); + }, + + // These blocks do not need JSO serialization hooks (saveExtraState + // and loadExtraState) because the state of this object is already + // encoded in the dropdown values. + // XML hooks are kept for backwards compatibility. +}; + +blocks['text_prompt_ext'] = { + ...PROMPT_COMMON, + /** + * Block for prompt function (external message). + */ + init: function (this: PromptCommonBlock) { + const TYPES = [ + [Msg['TEXT_PROMPT_TYPE_TEXT'], 'TEXT'], + [Msg['TEXT_PROMPT_TYPE_NUMBER'], 'NUMBER'], + ]; + this.setHelpUrl(Msg['TEXT_PROMPT_HELPURL']); + this.setStyle('text_blocks'); + const dropdown = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: TYPES, + }) as FieldDropdown; + dropdown.setValidator((newOp: string) => { + this.updateType_(newOp); + return undefined; // FieldValidators can't be void. Use option as-is. + }); + this.appendValueInput('TEXT').appendField(dropdown, 'TYPE'); + this.setOutput(true, 'String'); + this.setTooltip(() => { + return this.getFieldValue('TYPE') === 'TEXT' + ? Msg['TEXT_PROMPT_TOOLTIP_TEXT'] + : Msg['TEXT_PROMPT_TOOLTIP_NUMBER']; + }); + }, +}; + +type PromptBlock = Block & PromptCommonMixin & QuoteImageMixin; + +blocks['text_prompt'] = { + ...PROMPT_COMMON, + /** + * Block for prompt function (internal message). + * The 'text_prompt_ext' block is preferred as it is more flexible. + */ + init: function (this: PromptBlock) { + this.mixin(QUOTE_IMAGE_MIXIN); + const TYPES = [ + [Msg['TEXT_PROMPT_TYPE_TEXT'], 'TEXT'], + [Msg['TEXT_PROMPT_TYPE_NUMBER'], 'NUMBER'], + ]; + + this.setHelpUrl(Msg['TEXT_PROMPT_HELPURL']); + this.setStyle('text_blocks'); + const dropdown = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: TYPES, + }) as FieldDropdown; + dropdown.setValidator((newOp: string) => { + this.updateType_(newOp); + return undefined; // FieldValidators can't be void. Use option as-is. + }); + this.appendDummyInput() + .appendField(dropdown, 'TYPE') + .appendField(this.newQuote_(true)) + .appendField( + fieldRegistry.fromJson({ + type: 'field_input', + text: '', + }) as FieldTextInput, + 'TEXT', + ) + .appendField(this.newQuote_(false)); + this.setOutput(true, 'String'); + this.setTooltip(() => { + return this.getFieldValue('TYPE') === 'TEXT' + ? Msg['TEXT_PROMPT_TOOLTIP_TEXT'] + : Msg['TEXT_PROMPT_TOOLTIP_NUMBER']; + }); + }, +}; + +blocks['text_count'] = { + /** + * Block for counting how many times one string appears within another string. + */ + init: function (this: Block) { + this.jsonInit({ + 'message0': Msg['TEXT_COUNT_MESSAGE0'], + 'args0': [ + { + 'type': 'input_value', + 'name': 'SUB', + 'check': 'String', + }, + { + 'type': 'input_value', + 'name': 'TEXT', + 'check': 'String', + }, + ], + 'output': 'Number', + 'inputsInline': true, + 'style': 'text_blocks', + 'tooltip': Msg['TEXT_COUNT_TOOLTIP'], + 'helpUrl': Msg['TEXT_COUNT_HELPURL'], + }); + }, +}; + +blocks['text_replace'] = { + /** + * Block for replacing one string with another in the text. + */ + init: function (this: Block) { + this.jsonInit({ + 'message0': Msg['TEXT_REPLACE_MESSAGE0'], + 'args0': [ + { + 'type': 'input_value', + 'name': 'FROM', + 'check': 'String', + }, + { + 'type': 'input_value', + 'name': 'TO', + 'check': 'String', + }, + { + 'type': 'input_value', + 'name': 'TEXT', + 'check': 'String', + }, + ], + 'output': 'String', + 'inputsInline': true, + 'style': 'text_blocks', + 'tooltip': Msg['TEXT_REPLACE_TOOLTIP'], + 'helpUrl': Msg['TEXT_REPLACE_HELPURL'], + }); + }, +}; + +blocks['text_reverse'] = { + /** + * Block for reversing a string. + */ + init: function (this: Block) { + this.jsonInit({ + 'message0': Msg['TEXT_REVERSE_MESSAGE0'], + 'args0': [ + { + 'type': 'input_value', + 'name': 'TEXT', + 'check': 'String', + }, + ], + 'output': 'String', + 'inputsInline': true, + 'style': 'text_blocks', + 'tooltip': Msg['TEXT_REVERSE_TOOLTIP'], + 'helpUrl': Msg['TEXT_REVERSE_HELPURL'], + }); + }, +}; + +/** Type of a block that has QUOTE_IMAGE_MIXIN */ +type QuoteImageBlock = Block & QuoteImageMixin; +interface QuoteImageMixin extends QuoteImageMixinType {} +type QuoteImageMixinType = typeof QUOTE_IMAGE_MIXIN; + +const QUOTE_IMAGE_MIXIN = { + /** + * Image data URI of an LTR opening double quote (same as RTL closing double + * quote). + */ + QUOTE_IMAGE_LEFT_DATAURI: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAKCAQAAAAqJXdxAAAA' + + 'n0lEQVQI1z3OMa5BURSF4f/cQhAKjUQhuQmFNwGJEUi0RKN5rU7FHKhpjEH3TEMtkdBSCY' + + '1EIv8r7nFX9e29V7EBAOvu7RPjwmWGH/VuF8CyN9/OAdvqIXYLvtRaNjx9mMTDyo+NjAN1' + + 'HNcl9ZQ5oQMM3dgDUqDo1l8DzvwmtZN7mnD+PkmLa+4mhrxVA9fRowBWmVBhFy5gYEjKMf' + + 'z9AylsaRRgGzvZAAAAAElFTkSuQmCC', + /** + * Image data URI of an LTR closing double quote (same as RTL opening double + * quote). + */ + QUOTE_IMAGE_RIGHT_DATAURI: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAKCAQAAAAqJXdxAAAA' + + 'qUlEQVQI1z3KvUpCcRiA8ef9E4JNHhI0aFEacm1o0BsI0Slx8wa8gLauoDnoBhq7DcfWhg' + + 'gONDmJJgqCPA7neJ7p934EOOKOnM8Q7PDElo/4x4lFb2DmuUjcUzS3URnGib9qaPNbuXvB' + + 'O3sGPHJDRG6fGVdMSeWDP2q99FQdFrz26Gu5Tq7dFMzUvbXy8KXeAj57cOklgA+u1B5Aos' + + 'lLtGIHQMaCVnwDnADZIFIrXsoXrgAAAABJRU5ErkJggg==', + /** + * Pixel width of QUOTE_IMAGE_LEFT_DATAURI and QUOTE_IMAGE_RIGHT_DATAURI. + */ + QUOTE_IMAGE_WIDTH: 12, + /** + * Pixel height of QUOTE_IMAGE_LEFT_DATAURI and QUOTE_IMAGE_RIGHT_DATAURI. + */ + QUOTE_IMAGE_HEIGHT: 12, + + /** + * Inserts appropriate quote images before and after the named field. + * + * @param fieldName The name of the field to wrap with quotes. + */ + quoteField_: function (this: QuoteImageBlock, fieldName: string) { + for (let i = 0, input; (input = this.inputList[i]); i++) { + for (let j = 0, field; (field = input.fieldRow[j]); j++) { + if (fieldName === field.name) { + input.insertFieldAt(j, this.newQuote_(true)); + input.insertFieldAt(j + 2, this.newQuote_(false)); + return; + } + } + } + console.warn( + 'field named "' + fieldName + '" not found in ' + this.toDevString(), + ); + }, + + /** + * A helper function that generates a FieldImage of an opening or + * closing double quote. The selected quote will be adapted for RTL blocks. + * + * @param open If the image should be open quote (“ in LTR). + * Otherwise, a closing quote is used (” in LTR). + * @returns The new field. + */ + newQuote_: function (this: QuoteImageBlock, open: boolean): FieldImage { + const isLeft = this.RTL ? !open : open; + const dataUri = isLeft + ? this.QUOTE_IMAGE_LEFT_DATAURI + : this.QUOTE_IMAGE_RIGHT_DATAURI; + return fieldRegistry.fromJson({ + type: 'field_image', + src: dataUri, + width: this.QUOTE_IMAGE_WIDTH, + height: this.QUOTE_IMAGE_HEIGHT, + alt: isLeft ? '\u201C' : '\u201D', + }) as FieldImage; + }, +}; + +/** + * Wraps TEXT field with images of double quote characters. + */ +const QUOTES_EXTENSION = function (this: QuoteImageBlock) { + this.mixin(QUOTE_IMAGE_MIXIN); + this.quoteField_('TEXT'); +}; + +/** + * Type of a block that has TEXT_JOIN_MUTATOR_MIXIN + * + * @internal + */ +export type JoinMutatorBlock = BlockSvg & JoinMutatorMixin & QuoteImageMixin; +interface JoinMutatorMixin extends JoinMutatorMixinType {} +type JoinMutatorMixinType = typeof JOIN_MUTATOR_MIXIN; + +/** Type of a item block in the text_join_mutator bubble. */ +type JoinItemBlock = BlockSvg & JoinItemMixin; +interface JoinItemMixin { + valueConnection_: Connection | null; +} + +/** + * Mixin for mutator functions in the 'text_join_mutator' extension. + */ +const JOIN_MUTATOR_MIXIN = { + itemCount_: 0, + /** + * Create XML to represent number of text inputs. + * Backwards compatible serialization implementation. + * + * @returns XML storage element. + */ + mutationToDom: function (this: JoinMutatorBlock): Element { + const container = xmlUtils.createElement('mutation'); + container.setAttribute('items', `${this.itemCount_}`); + return container; + }, + /** + * Parse XML to restore the text inputs. + * Backwards compatible serialization implementation. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: JoinMutatorBlock, xmlElement: Element) { + this.itemCount_ = parseInt(xmlElement.getAttribute('items')!, 10); + this.updateShape_(); + }, + /** + * Returns the state of this block as a JSON serializable object. + * + * @returns The state of this block, ie the item count. + */ + saveExtraState: function (this: JoinMutatorBlock): {itemCount: number} { + return { + 'itemCount': this.itemCount_, + }; + }, + /** + * Applies the given state to this block. + * + * @param state The state to apply to this block, ie the item count. + */ + loadExtraState: function (this: JoinMutatorBlock, state: {[x: string]: any}) { + this.itemCount_ = state['itemCount']; + this.updateShape_(); + }, + /** + * Populate the mutator's dialog with this block's components. + * + * @param workspace Mutator's workspace. + * @returns Root block in mutator. + */ + decompose: function (this: JoinMutatorBlock, workspace: Workspace): Block { + const containerBlock = workspace.newBlock( + 'text_create_join_container', + ) as BlockSvg; + containerBlock.initSvg(); + let connection = containerBlock.getInput('STACK')!.connection!; + for (let i = 0; i < this.itemCount_; i++) { + const itemBlock = workspace.newBlock( + 'text_create_join_item', + ) as JoinItemBlock; + itemBlock.initSvg(); + connection.connect(itemBlock.previousConnection); + connection = itemBlock.nextConnection; + } + return containerBlock; + }, + /** + * Reconfigure this block based on the mutator dialog's components. + * + * @param containerBlock Root block in mutator. + */ + compose: function (this: JoinMutatorBlock, containerBlock: Block) { + let itemBlock = containerBlock.getInputTargetBlock( + 'STACK', + ) as JoinItemBlock; + // Count number of inputs. + const connections = []; + while (itemBlock) { + if (itemBlock.isInsertionMarker()) { + itemBlock = itemBlock.getNextBlock() as JoinItemBlock; + continue; + } + connections.push(itemBlock.valueConnection_); + itemBlock = itemBlock.getNextBlock() as JoinItemBlock; + } + // Disconnect any children that don't belong. + for (let i = 0; i < this.itemCount_; i++) { + const connection = this.getInput('ADD' + i)!.connection!.targetConnection; + if (connection && !connections.includes(connection)) { + connection.disconnect(); + } + } + this.itemCount_ = connections.length; + this.updateShape_(); + // Reconnect any child blocks. + for (let i = 0; i < this.itemCount_; i++) { + connections[i]?.reconnect(this, 'ADD' + i); + } + }, + /** + * Store pointers to any connected child blocks. + * + * @param containerBlock Root block in mutator. + */ + saveConnections: function (this: JoinMutatorBlock, containerBlock: Block) { + let itemBlock = containerBlock.getInputTargetBlock('STACK'); + let i = 0; + while (itemBlock) { + if (itemBlock.isInsertionMarker()) { + itemBlock = itemBlock.getNextBlock(); + continue; + } + const input = this.getInput('ADD' + i); + (itemBlock as JoinItemBlock).valueConnection_ = + input && input.connection!.targetConnection; + itemBlock = itemBlock.getNextBlock(); + i++; + } + }, + /** + * Modify this block to have the correct number of inputs. + * + */ + updateShape_: function (this: JoinMutatorBlock) { + if (this.itemCount_ && this.getInput('EMPTY')) { + this.removeInput('EMPTY'); + } else if (!this.itemCount_ && !this.getInput('EMPTY')) { + this.appendDummyInput('EMPTY') + .appendField(this.newQuote_(true)) + .appendField(this.newQuote_(false)); + } + // Add new inputs. + for (let i = 0; i < this.itemCount_; i++) { + if (!this.getInput('ADD' + i)) { + const input = this.appendValueInput('ADD' + i).setAlign(Align.RIGHT); + if (i === 0) { + input.appendField(Msg['TEXT_JOIN_TITLE_CREATEWITH']); + } + } + } + // Remove deleted inputs. + for (let i = this.itemCount_; this.getInput('ADD' + i); i++) { + this.removeInput('ADD' + i); + } + }, +}; + +/** + * Performs final setup of a text_join block. + */ +const JOIN_EXTENSION = function (this: JoinMutatorBlock) { + // Add the quote mixin for the itemCount_ = 0 case. + this.mixin(QUOTE_IMAGE_MIXIN); + // Initialize the mutator values. + this.itemCount_ = 2; + this.updateShape_(); + // Configure the mutator UI. + this.setMutator(new MutatorIcon(['text_create_join_item'], this)); +}; + +// Update the tooltip of 'text_append' block to reference the variable. +Extensions.register( + 'text_append_tooltip', + Extensions.buildTooltipWithFieldText('%{BKY_TEXT_APPEND_TOOLTIP}', 'VAR'), +); + +/** + * Update the tooltip of 'text_append' block to reference the variable. + */ +const INDEXOF_TOOLTIP_EXTENSION = function (this: Block) { + this.setTooltip(() => { + return Msg['TEXT_INDEXOF_TOOLTIP'].replace( + '%1', + this.workspace.options.oneBasedIndex ? '0' : '-1', + ); + }); +}; + +/** Type of a block that has TEXT_CHARAT_MUTATOR_MIXIN */ +type CharAtBlock = Block & CharAtMixin; +interface CharAtMixin extends CharAtMixinType {} +type CharAtMixinType = typeof CHARAT_MUTATOR_MIXIN; + +/** + * Mixin for mutator functions in the 'text_charAt_mutator' extension. + */ +const CHARAT_MUTATOR_MIXIN = { + isAt_: false, + /** + * Create XML to represent whether there is an 'AT' input. + * Backwards compatible serialization implementation. + * + * @returns XML storage element. + */ + mutationToDom: function (this: CharAtBlock): Element { + const container = xmlUtils.createElement('mutation'); + container.setAttribute('at', `${this.isAt_}`); + return container; + }, + /** + * Parse XML to restore the 'AT' input. + * Backwards compatible serialization implementation. + * + * @param xmlElement XML storage element. + */ + domToMutation: function (this: CharAtBlock, xmlElement: Element) { + // Note: Until January 2013 this block did not have mutations, + // so 'at' defaults to true. + const isAt = xmlElement.getAttribute('at') !== 'false'; + this.updateAt_(isAt); + }, + + // This block does not need JSO serialization hooks (saveExtraState and + // loadExtraState) because the state of this object is already encoded in the + // dropdown values. + // XML hooks are kept for backwards compatibility. + + /** + * Create or delete an input for the numeric index. + * + * @internal + * @param isAt True if the input should exist. + */ + updateAt_: function (this: CharAtBlock, isAt: boolean) { + // Destroy old 'AT' and 'ORDINAL' inputs. + this.removeInput('AT', true); + this.removeInput('ORDINAL', true); + // Create either a value 'AT' input or a dummy input. + if (isAt) { + this.appendValueInput('AT').setCheck('Number'); + if (Msg['ORDINAL_NUMBER_SUFFIX']) { + this.appendDummyInput('ORDINAL').appendField( + Msg['ORDINAL_NUMBER_SUFFIX'], + ); + } + } + if (Msg['TEXT_CHARAT_TAIL']) { + this.removeInput('TAIL', true); + this.appendDummyInput('TAIL').appendField(Msg['TEXT_CHARAT_TAIL']); + } + + this.isAt_ = isAt; + }, +}; + +/** + * Does the initial mutator update of text_charAt and adds the tooltip + */ +const CHARAT_EXTENSION = function (this: CharAtBlock) { + const dropdown = this.getField('WHERE') as FieldDropdown; + dropdown.setValidator(function (this: FieldDropdown, value: any) { + const newAt = value === 'FROM_START' || value === 'FROM_END'; + const block = this.getSourceBlock() as CharAtBlock; + if (newAt !== block.isAt_) { + block.updateAt_(newAt); + } + return undefined; // FieldValidators can't be void. Use option as-is. + }); + this.updateAt_(true); + this.setTooltip(() => { + const where = this.getFieldValue('WHERE'); + let tooltip = Msg['TEXT_CHARAT_TOOLTIP']; + if (where === 'FROM_START' || where === 'FROM_END') { + const msg = + where === 'FROM_START' + ? Msg['LISTS_INDEX_FROM_START_TOOLTIP'] + : Msg['LISTS_INDEX_FROM_END_TOOLTIP']; + if (msg) { + tooltip += + ' ' + + msg.replace('%1', this.workspace.options.oneBasedIndex ? '#1' : '#0'); + } + } + return tooltip; + }); +}; + +Extensions.register('text_indexOf_tooltip', INDEXOF_TOOLTIP_EXTENSION); + +Extensions.register('text_quotes', QUOTES_EXTENSION); + +Extensions.registerMixin('quote_image_mixin', QUOTE_IMAGE_MIXIN); + +Extensions.registerMutator( + 'text_join_mutator', + JOIN_MUTATOR_MIXIN, + JOIN_EXTENSION, +); + +Extensions.registerMutator( + 'text_charAt_mutator', + CHARAT_MUTATOR_MIXIN, + CHARAT_EXTENSION, +); + +// Register provided blocks. +defineBlocks(blocks); diff --git a/blocks/variables.js b/blocks/variables.js deleted file mode 100644 index 07ae8e6e50e..00000000000 --- a/blocks/variables.js +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Variable blocks for Blockly. - - * This file is scraped to extract a .json file of block definitions. The array - * passed to defineBlocksWithJsonArray(..) must be strict JSON: double quotes - * only, no outside references, no functions, no trailing commas, etc. The one - * exception is end-of-line comments, which the scraper will remove. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Blocks.variables'); // Deprecated. -goog.provide('Blockly.Constants.Variables'); - -goog.require('Blockly.Blocks'); - - -/** - * Common HSV hue for all blocks in this category. - * Should be the same as Blockly.Msg.VARIABLES_HUE. - * @readonly - */ -Blockly.Constants.Variables.HUE = 330; -/** @deprecated Use Blockly.Constants.Variables.HUE */ -Blockly.Blocks.variables.HUE = Blockly.Constants.Variables.HUE; - -Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT - // Block for variable getter. - { - "type": "variables_get", - "message0": "%1", - "args0": [ - { - "type": "field_variable", - "name": "VAR", - "variable": "%{BKY_VARIABLES_DEFAULT_NAME}" - } - ], - "output": null, - "colour": "%{BKY_VARIABLES_HUE}", - "helpUrl": "%{BKY_VARIABLES_GET_HELPURL}", - "tooltip": "%{BKY_VARIABLES_GET_TOOLTIP}", - "extensions": ["contextMenu_variableSetterGetter"] - }, - // Block for variable setter. - { - "type": "variables_set", - "message0": "%{BKY_VARIABLES_SET}", - "args0": [ - { - "type": "field_variable", - "name": "VAR", - "variable": "%{BKY_VARIABLES_DEFAULT_NAME}" - }, - { - "type": "input_value", - "name": "VALUE" - } - ], - "previousStatement": null, - "nextStatement": null, - "colour": "%{BKY_VARIABLES_HUE}", - "tooltip": "%{BKY_VARIABLES_SET_TOOLTIP}", - "helpUrl": "%{BKY_VARIABLES_SET_HELPURL}", - "extensions": ["contextMenu_variableSetterGetter"] - } -]); // END JSON EXTRACT (Do not delete this comment.) - -/** - * Mixin to add context menu items to create getter/setter blocks for this - * setter/getter. - * Used by blocks 'variables_set' and 'variables_get'. - * @mixin - * @augments Blockly.Block - * @package - * @readonly - */ -Blockly.Constants.Variables.CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = { - /** - * Add menu option to create getter/setter block for this setter/getter. - * @param {!Array} options List of menu options to add to. - * @this Blockly.Block - */ - customContextMenu: function(options) { - // Getter blocks have the option to create a setter block, and vice versa. - if (this.type == 'variables_get') { - var opposite_type = 'variables_set'; - var contextMenuMsg = Blockly.Msg.VARIABLES_GET_CREATE_SET; - } else { - var opposite_type = 'variables_get'; - var contextMenuMsg = Blockly.Msg.VARIABLES_SET_CREATE_GET; - } - - var option = {enabled: this.workspace.remainingCapacity() > 0}; - var name = this.getFieldValue('VAR'); - option.text = contextMenuMsg.replace('%1', name); - var xmlField = goog.dom.createDom('field', null, name); - xmlField.setAttribute('name', 'VAR'); - var xmlBlock = goog.dom.createDom('block', null, xmlField); - xmlBlock.setAttribute('type', opposite_type); - option.callback = Blockly.ContextMenu.callbackFactory(this, xmlBlock); - options.push(option); - } -}; - -Blockly.Extensions.registerMixin('contextMenu_variableSetterGetter', - Blockly.Constants.Variables.CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN); diff --git a/blocks/variables.ts b/blocks/variables.ts new file mode 100644 index 00000000000..4f1f640fa81 --- /dev/null +++ b/blocks/variables.ts @@ -0,0 +1,181 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.libraryBlocks.variables + +import type {Block} from '../core/block.js'; +import { + createBlockDefinitionsFromJsonArray, + defineBlocks, +} from '../core/common.js'; +import * as ContextMenu from '../core/contextmenu.js'; +import type { + ContextMenuOption, + LegacyContextMenuOption, +} from '../core/contextmenu_registry.js'; +import * as Extensions from '../core/extensions.js'; +import '../core/field_label.js'; +import {FieldVariable} from '../core/field_variable.js'; +import {Msg} from '../core/msg.js'; +import * as Variables from '../core/variables.js'; + +/** + * A dictionary of the block definitions provided by this module. + */ +export const blocks = createBlockDefinitionsFromJsonArray([ + // Block for variable getter. + { + 'type': 'variables_get', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_variable', + 'name': 'VAR', + 'variable': '%{BKY_VARIABLES_DEFAULT_NAME}', + }, + ], + 'output': null, + 'style': 'variable_blocks', + 'helpUrl': '%{BKY_VARIABLES_GET_HELPURL}', + 'tooltip': '%{BKY_VARIABLES_GET_TOOLTIP}', + 'extensions': ['contextMenu_variableSetterGetter'], + }, + // Block for variable setter. + { + 'type': 'variables_set', + 'message0': '%{BKY_VARIABLES_SET}', + 'args0': [ + { + 'type': 'field_variable', + 'name': 'VAR', + 'variable': '%{BKY_VARIABLES_DEFAULT_NAME}', + }, + { + 'type': 'input_value', + 'name': 'VALUE', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'style': 'variable_blocks', + 'tooltip': '%{BKY_VARIABLES_SET_TOOLTIP}', + 'helpUrl': '%{BKY_VARIABLES_SET_HELPURL}', + 'extensions': ['contextMenu_variableSetterGetter'], + }, +]); + +/** Type of a block that has CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN */ +type VariableBlock = Block & VariableMixin; +interface VariableMixin extends VariableMixinType {} +type VariableMixinType = + typeof CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN; + +/** + * Mixin to add context menu items to create getter/setter blocks for this + * setter/getter. + * Used by blocks 'variables_set' and 'variables_get'. + */ +const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = { + /** + * Add menu option to create getter/setter block for this setter/getter. + * + * @param options List of menu options to add to. + */ + customContextMenu: function ( + this: VariableBlock, + options: Array, + ) { + if (!this.isInFlyout) { + let oppositeType; + let contextMenuMsg; + // Getter blocks have the option to create a setter block, and vice versa. + if (this.type === 'variables_get') { + oppositeType = 'variables_set'; + contextMenuMsg = Msg['VARIABLES_GET_CREATE_SET']; + } else { + oppositeType = 'variables_get'; + contextMenuMsg = Msg['VARIABLES_SET_CREATE_GET']; + } + + const varField = this.getField('VAR')!; + const newVarBlockState = { + type: oppositeType, + fields: {VAR: varField.saveState(true)}, + }; + + options.push({ + enabled: this.workspace.remainingCapacity() > 0, + text: contextMenuMsg.replace('%1', varField.getText()), + callback: ContextMenu.callbackFactory(this, newVarBlockState), + }); + // Getter blocks have the option to rename or delete that variable. + } else { + if ( + this.type === 'variables_get' || + this.type === 'variables_get_reporter' + ) { + const renameOption = { + text: Msg['RENAME_VARIABLE'], + enabled: true, + callback: renameOptionCallbackFactory(this), + }; + const name = this.getField('VAR')!.getText(); + const deleteOption = { + text: Msg['DELETE_VARIABLE'].replace('%1', name), + enabled: true, + callback: deleteOptionCallbackFactory(this), + }; + options.unshift(renameOption); + options.unshift(deleteOption); + } + } + }, +}; + +/** + * Factory for callbacks for rename variable dropdown menu option + * associated with a variable getter block. + * + * @param block The block with the variable to rename. + * @returns A function that renames the variable. + */ +const renameOptionCallbackFactory = function ( + block: VariableBlock, +): () => void { + return function () { + const workspace = block.workspace; + const variableField = block.getField('VAR') as FieldVariable; + const variable = variableField.getVariable()!; + Variables.renameVariable(workspace, variable); + }; +}; + +/** + * Factory for callbacks for delete variable dropdown menu option + * associated with a variable getter block. + * + * @param block The block with the variable to delete. + * @returns A function that deletes the variable. + */ +const deleteOptionCallbackFactory = function ( + block: VariableBlock, +): () => void { + return function () { + const variableField = block.getField('VAR') as FieldVariable; + const variable = variableField.getVariable(); + if (variable) { + Variables.deleteVariable(variable.getWorkspace(), variable, block); + } + }; +}; + +Extensions.registerMixin( + 'contextMenu_variableSetterGetter', + CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN, +); + +// Register provided blocks. +defineBlocks(blocks); diff --git a/blocks/variables_dynamic.ts b/blocks/variables_dynamic.ts new file mode 100644 index 00000000000..8afd24cf2e3 --- /dev/null +++ b/blocks/variables_dynamic.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.libraryBlocks.variablesDynamic + +import type {Block} from '../core/block.js'; +import { + createBlockDefinitionsFromJsonArray, + defineBlocks, +} from '../core/common.js'; +import * as ContextMenu from '../core/contextmenu.js'; +import type { + ContextMenuOption, + LegacyContextMenuOption, +} from '../core/contextmenu_registry.js'; +import {Abstract as AbstractEvent} from '../core/events/events_abstract.js'; +import * as Extensions from '../core/extensions.js'; +import '../core/field_label.js'; +import {FieldVariable} from '../core/field_variable.js'; +import {Msg} from '../core/msg.js'; +import * as Variables from '../core/variables.js'; + +/** + * A dictionary of the block definitions provided by this module. + */ +export const blocks = createBlockDefinitionsFromJsonArray([ + // Block for variable getter. + { + 'type': 'variables_get_dynamic', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_variable', + 'name': 'VAR', + 'variable': '%{BKY_VARIABLES_DEFAULT_NAME}', + }, + ], + 'output': null, + 'style': 'variable_dynamic_blocks', + 'helpUrl': '%{BKY_VARIABLES_GET_HELPURL}', + 'tooltip': '%{BKY_VARIABLES_GET_TOOLTIP}', + 'extensions': ['contextMenu_variableDynamicSetterGetter'], + }, + // Block for variable setter. + { + 'type': 'variables_set_dynamic', + 'message0': '%{BKY_VARIABLES_SET}', + 'args0': [ + { + 'type': 'field_variable', + 'name': 'VAR', + 'variable': '%{BKY_VARIABLES_DEFAULT_NAME}', + }, + { + 'type': 'input_value', + 'name': 'VALUE', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'style': 'variable_dynamic_blocks', + 'tooltip': '%{BKY_VARIABLES_SET_TOOLTIP}', + 'helpUrl': '%{BKY_VARIABLES_SET_HELPURL}', + 'extensions': ['contextMenu_variableDynamicSetterGetter'], + }, +]); + +/** Type of a block that has CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN */ +type VariableBlock = Block & VariableMixin; +interface VariableMixin extends VariableMixinType {} +type VariableMixinType = + typeof CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN; + +/** + * Mixin to add context menu items to create getter/setter blocks for this + * setter/getter. + * Used by blocks 'variables_set_dynamic' and 'variables_get_dynamic'. + */ +const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = { + /** + * Add menu option to create getter/setter block for this setter/getter. + * + * @param options List of menu options to add to. + */ + customContextMenu: function ( + this: VariableBlock, + options: Array, + ) { + // Getter blocks have the option to create a setter block, and vice versa. + if (!this.isInFlyout) { + let oppositeType; + let contextMenuMsg; + if (this.type === 'variables_get_dynamic') { + oppositeType = 'variables_set_dynamic'; + contextMenuMsg = Msg['VARIABLES_GET_CREATE_SET']; + } else { + oppositeType = 'variables_get_dynamic'; + contextMenuMsg = Msg['VARIABLES_SET_CREATE_GET']; + } + + const varField = this.getField('VAR')!; + const newVarBlockState = { + type: oppositeType, + fields: {VAR: varField.saveState(true)}, + }; + + options.push({ + enabled: this.workspace.remainingCapacity() > 0, + text: contextMenuMsg.replace('%1', varField.getText()), + callback: ContextMenu.callbackFactory(this, newVarBlockState), + }); + } else { + if ( + this.type === 'variables_get_dynamic' || + this.type === 'variables_get_reporter_dynamic' + ) { + const renameOption = { + text: Msg['RENAME_VARIABLE'], + enabled: true, + callback: renameOptionCallbackFactory(this), + }; + const name = this.getField('VAR')!.getText(); + const deleteOption = { + text: Msg['DELETE_VARIABLE'].replace('%1', name), + enabled: true, + callback: deleteOptionCallbackFactory(this), + }; + options.unshift(renameOption); + options.unshift(deleteOption); + } + } + }, + /** + * Called whenever anything on the workspace changes. + * Set the connection type for this block. + * + * @param _e Change event. + */ + onchange: function (this: VariableBlock, _e: AbstractEvent) { + const id = this.getFieldValue('VAR'); + const variableModel = Variables.getVariable(this.workspace, id)!; + if (this.type === 'variables_get_dynamic') { + this.outputConnection!.setCheck(variableModel.getType()); + } else { + this.getInput('VALUE')!.connection!.setCheck(variableModel.getType()); + } + }, +}; + +/** + * Factory for callbacks for rename variable dropdown menu option + * associated with a variable getter block. + * + * @param block The block with the variable to rename. + * @returns A function that renames the variable. + */ +const renameOptionCallbackFactory = function (block: VariableBlock) { + return function () { + const workspace = block.workspace; + const variableField = block.getField('VAR') as FieldVariable; + const variable = variableField.getVariable()!; + Variables.renameVariable(workspace, variable); + }; +}; + +/** + * Factory for callbacks for delete variable dropdown menu option + * associated with a variable getter block. + * + * @param block The block with the variable to delete. + * @returns A function that deletes the variable. + */ +const deleteOptionCallbackFactory = function (block: VariableBlock) { + return function () { + const variableField = block.getField('VAR') as FieldVariable; + const variable = variableField.getVariable(); + if (variable) { + Variables.deleteVariable(variable.getWorkspace(), variable, block); + } + }; +}; + +Extensions.registerMixin( + 'contextMenu_variableDynamicSetterGetter', + CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN, +); + +// Register provided blocks. +defineBlocks(blocks); diff --git a/blocks_compressed.js b/blocks_compressed.js deleted file mode 100644 index c8d48e444f6..00000000000 --- a/blocks_compressed.js +++ /dev/null @@ -1,157 +0,0 @@ -// Do not edit this file; automatically generated by build.py. -'use strict'; - - -// Copyright 2012 Google Inc. Apache License 2.0 -Blockly.Blocks.lists={};Blockly.Constants={};Blockly.Constants.Lists={};Blockly.Constants.Lists.HUE=260;Blockly.Blocks.lists.HUE=Blockly.Constants.Lists.HUE; -Blockly.defineBlocksWithJsonArray([{type:"lists_create_empty",message0:"%{BKY_LISTS_CREATE_EMPTY_TITLE}",output:"Array",colour:"%{BKY_LISTS_HUE}",tooltip:"%{BKY_LISTS_CREATE_EMPTY_TOOLTIP}",helpUrl:"%{BKY_LISTS_CREATE_EMPTY_HELPURL}"},{type:"lists_repeat",message0:"%{BKY_LISTS_REPEAT_TITLE}",args0:[{type:"input_value",name:"ITEM"},{type:"input_value",name:"NUM",check:"Number"}],output:"Array",colour:"%{BKY_LISTS_HUE}",tooltip:"%{BKY_LISTS_REPEAT_TOOLTIP}",helpUrl:"%{BKY_LISTS_REPEAT_HELPURL}"},{type:"lists_reverse", -message0:"%{BKY_LISTS_REVERSE_MESSAGE0}",args0:[{type:"input_value",name:"LIST",check:"Array"}],output:"Array",inputsInline:!0,colour:"%{BKY_LISTS_HUE}",tooltip:"%{BKY_LISTS_REVERSE_TOOLTIP}",helpUrl:"%{BKY_LISTS_REVERSE_HELPURL}"},{type:"lists_isEmpty",message0:"%{BKY_LISTS_ISEMPTY_TITLE}",args0:[{type:"input_value",name:"VALUE",check:["String","Array"]}],output:"Boolean",colour:"%{BKY_LISTS_HUE}",tooltip:"%{BKY_LISTS_ISEMPTY_TOOLTIP}",helpUrl:"%{BKY_LISTS_ISEMPTY_HELPURL}"},{type:"lists_length", -message0:"%{BKY_LISTS_LENGTH_TITLE}",args0:[{type:"input_value",name:"VALUE",check:["String","Array"]}],output:"Number",colour:"%{BKY_LISTS_HUE}",tooltip:"%{BKY_LISTS_LENGTH_TOOLTIP}",helpUrl:"%{BKY_LISTS_LENGTH_HELPURL}"}]); -Blockly.Blocks.lists_create_with={init:function(){this.setHelpUrl(Blockly.Msg.LISTS_CREATE_WITH_HELPURL);this.setColour(Blockly.Blocks.lists.HUE);this.itemCount_=3;this.updateShape_();this.setOutput(!0,"Array");this.setMutator(new Blockly.Mutator(["lists_create_with_item"]));this.setTooltip(Blockly.Msg.LISTS_CREATE_WITH_TOOLTIP)},mutationToDom:function(){var a=document.createElement("mutation");a.setAttribute("items",this.itemCount_);return a},domToMutation:function(a){this.itemCount_=parseInt(a.getAttribute("items"), -10);this.updateShape_()},decompose:function(a){var b=a.newBlock("lists_create_with_container");b.initSvg();for(var c=b.getInput("STACK").connection,d=0;d","GT"],["\u2265","GTE"]]},{type:"input_value",name:"B"}],inputsInline:!0,output:"Boolean",colour:"%{BKY_LOGIC_HUE}",helpUrl:"%{BKY_LOGIC_COMPARE_HELPURL}",extensions:["logic_compare", -"logic_op_tooltip"]},{type:"logic_operation",message0:"%1 %2 %3",args0:[{type:"input_value",name:"A",check:"Boolean"},{type:"field_dropdown",name:"OP",options:[["%{BKY_LOGIC_OPERATION_AND}","AND"],["%{BKY_LOGIC_OPERATION_OR}","OR"]]},{type:"input_value",name:"B",check:"Boolean"}],inputsInline:!0,output:"Boolean",colour:"%{BKY_LOGIC_HUE}",helpUrl:"%{BKY_LOGIC_OPERATION_HELPURL}",extensions:["logic_op_tooltip"]},{type:"logic_negate",message0:"%{BKY_LOGIC_NEGATE_TITLE}",args0:[{type:"input_value",name:"BOOL", -check:"Boolean"}],output:"Boolean",colour:"%{BKY_LOGIC_HUE}",tooltip:"%{BKY_LOGIC_NEGATE_TOOLTIP}",helpUrl:"%{BKY_LOGIC_NEGATE_HELPURL}"},{type:"logic_null",message0:"%{BKY_LOGIC_NULL}",output:null,colour:"%{BKY_LOGIC_HUE}",tooltip:"%{BKY_LOGIC_NULL_TOOLTIP}",helpUrl:"%{BKY_LOGIC_NULL_HELPURL}"},{type:"logic_ternary",message0:"%{BKY_LOGIC_TERNARY_CONDITION} %1",args0:[{type:"input_value",name:"IF",check:"Boolean"}],message1:"%{BKY_LOGIC_TERNARY_IF_TRUE} %1",args1:[{type:"input_value",name:"THEN"}], -message2:"%{BKY_LOGIC_TERNARY_IF_FALSE} %1",args2:[{type:"input_value",name:"ELSE"}],output:null,colour:"%{BKY_LOGIC_HUE}",tooltip:"%{BKY_LOGIC_TERNARY_TOOLTIP}",helpUrl:"%{BKY_LOGIC_TERNARY_HELPURL}",extensions:["logic_ternary"]}]); -Blockly.defineBlocksWithJsonArray([{type:"controls_if_if",message0:"%{BKY_CONTROLS_IF_IF_TITLE_IF}",nextStatement:null,enableContextMenu:!1,colour:"%{BKY_LOGIC_HUE}",tooltip:"%{BKY_CONTROLS_IF_IF_TOOLTIP}"},{type:"controls_if_elseif",message0:"%{BKY_CONTROLS_IF_ELSEIF_TITLE_ELSEIF}",previousStatement:null,nextStatement:null,enableContextMenu:!1,colour:"%{BKY_LOGIC_HUE}",tooltip:"%{BKY_CONTROLS_IF_ELSEIF_TOOLTIP}"},{type:"controls_if_else",message0:"%{BKY_CONTROLS_IF_ELSE_TITLE_ELSE}",previousStatement:null, -enableContextMenu:!1,colour:"%{BKY_LOGIC_HUE}",tooltip:"%{BKY_CONTROLS_IF_ELSE_TOOLTIP}"}]);Blockly.Constants.Logic.TOOLTIPS_BY_OP={EQ:"%{BKY_LOGIC_COMPARE_TOOLTIP_EQ}",NEQ:"%{BKY_LOGIC_COMPARE_TOOLTIP_NEQ}",LT:"%{BKY_LOGIC_COMPARE_TOOLTIP_LT}",LTE:"%{BKY_LOGIC_COMPARE_TOOLTIP_LTE}",GT:"%{BKY_LOGIC_COMPARE_TOOLTIP_GT}",GTE:"%{BKY_LOGIC_COMPARE_TOOLTIP_GTE}",AND:"%{BKY_LOGIC_OPERATION_TOOLTIP_AND}",OR:"%{BKY_LOGIC_OPERATION_TOOLTIP_OR}"}; -Blockly.Extensions.register("logic_op_tooltip",Blockly.Extensions.buildTooltipForDropdown("OP",Blockly.Constants.Logic.TOOLTIPS_BY_OP)); -Blockly.Constants.Logic.CONTROLS_IF_MUTATOR_MIXIN={elseifCount_:0,elseCount_:0,mutationToDom:function(){if(!this.elseifCount_&&!this.elseCount_)return null;var a=document.createElement("mutation");this.elseifCount_&&a.setAttribute("elseif",this.elseifCount_);this.elseCount_&&a.setAttribute("else",1);return a},domToMutation:function(a){this.elseifCount_=parseInt(a.getAttribute("elseif"),10)||0;this.elseCount_=parseInt(a.getAttribute("else"),10)||0;this.updateShape_()},decompose:function(a){var b=a.newBlock("controls_if_if"); -b.initSvg();for(var c=b.nextConnection,d=1;d<=this.elseifCount_;d++){var e=a.newBlock("controls_if_elseif");e.initSvg();c.connect(e.previousConnection);c=e.nextConnection}this.elseCount_&&(a=a.newBlock("controls_if_else"),a.initSvg(),c.connect(a.previousConnection));return b},compose:function(a){var b=a.nextConnection.targetBlock();this.elseCount_=this.elseifCount_=0;a=[null];for(var c=[null],d=null;b;){switch(b.type){case "controls_if_elseif":this.elseifCount_++;a.push(b.valueConnection_);c.push(b.statementConnection_); -break;case "controls_if_else":this.elseCount_++;d=b.statementConnection_;break;default:throw"Unknown block type.";}b=b.nextConnection&&b.nextConnection.targetBlock()}this.updateShape_();for(b=1;b<=this.elseifCount_;b++)Blockly.Mutator.reconnect(a[b],this,"IF"+b),Blockly.Mutator.reconnect(c[b],this,"DO"+b);Blockly.Mutator.reconnect(d,this,"ELSE")},saveConnections:function(a){a=a.nextConnection.targetBlock();for(var b=1;a;){switch(a.type){case "controls_if_elseif":var c=this.getInput("IF"+b),d=this.getInput("DO"+ -b);a.valueConnection_=c&&c.connection.targetConnection;a.statementConnection_=d&&d.connection.targetConnection;b++;break;case "controls_if_else":d=this.getInput("ELSE");a.statementConnection_=d&&d.connection.targetConnection;break;default:throw"Unknown block type.";}a=a.nextConnection&&a.nextConnection.targetBlock()}},updateShape_:function(){this.getInput("ELSE")&&this.removeInput("ELSE");for(var a=1;this.getInput("IF"+a);)this.removeInput("IF"+a),this.removeInput("DO"+a),a++;for(a=1;a<=this.elseifCount_;a++)this.appendValueInput("IF"+ -a).setCheck("Boolean").appendField(Blockly.Msg.CONTROLS_IF_MSG_ELSEIF),this.appendStatementInput("DO"+a).appendField(Blockly.Msg.CONTROLS_IF_MSG_THEN);this.elseCount_&&this.appendStatementInput("ELSE").appendField(Blockly.Msg.CONTROLS_IF_MSG_ELSE)}};Blockly.Extensions.registerMutator("controls_if_mutator",Blockly.Constants.Logic.CONTROLS_IF_MUTATOR_MIXIN,null,["controls_if_elseif","controls_if_else"]); -Blockly.Constants.Logic.CONTROLS_IF_TOOLTIP_EXTENSION=function(){this.setTooltip(function(){if(this.elseifCount_||this.elseCount_){if(!this.elseifCount_&&this.elseCount_)return Blockly.Msg.CONTROLS_IF_TOOLTIP_2;if(this.elseifCount_&&!this.elseCount_)return Blockly.Msg.CONTROLS_IF_TOOLTIP_3;if(this.elseifCount_&&this.elseCount_)return Blockly.Msg.CONTROLS_IF_TOOLTIP_4}else return Blockly.Msg.CONTROLS_IF_TOOLTIP_1;return""}.bind(this))};Blockly.Extensions.register("controls_if_tooltip",Blockly.Constants.Logic.CONTROLS_IF_TOOLTIP_EXTENSION); -Blockly.Constants.Logic.fixLogicCompareRtlOpLabels=function(){var a={LT:"\u200f<\u200f",LTE:"\u200f\u2264\u200f",GT:"\u200f>\u200f",GTE:"\u200f\u2265\u200f"},b=this.getField("OP");if(b)for(var b=b.getOptions(),c=0;ce;e++){var f=1==e?b:c;f&&!f.outputConnection.checkType_(d)&&(Blockly.Events.setGroup(a.group),d===this.prevParentConnection_?(this.unplug(),d.getSourceBlock().bumpNeighbours_()):(f.unplug(),f.bumpNeighbours_()),Blockly.Events.setGroup(!1))}this.prevParentConnection_= -d}};Blockly.Extensions.registerMixin("logic_ternary",Blockly.Constants.Logic.LOGIC_TERNARY_ONCHANGE_MIXIN); \ No newline at end of file diff --git a/build.py b/build.py deleted file mode 100755 index e9f4f39e8ea..00000000000 --- a/build.py +++ /dev/null @@ -1,552 +0,0 @@ -#!/usr/bin/python2.7 -# Compresses the core Blockly files into a single JavaScript file. -# -# Copyright 2012 Google Inc. -# https://developers.google.com/blockly/ -# -# 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. - -# Usage: build.py <0 or more of accessible, core, generators, langfiles> -# build.py with no parameters builds all files. -# core builds blockly_compressed, blockly_uncompressed, and blocks_compressed. -# accessible builds blockly_accessible_compressed, -# blockly_accessible_uncompressed, and blocks_compressed. -# generators builds every _compressed.js. -# langfiles builds every msg/js/.js file. - -# This script generates four versions of Blockly's core files. The first pair -# are: -# blockly_compressed.js -# blockly_uncompressed.js -# The compressed file is a concatenation of all of Blockly's core files which -# have been run through Google's Closure Compiler. This is done using the -# online API (which takes a few seconds and requires an Internet connection). -# The uncompressed file is a script that loads in each of Blockly's core files -# one by one. This takes much longer for a browser to load, but is useful -# when debugging code since line numbers are meaningful and variables haven't -# been renamed. The uncompressed file also allows for a faster developement -# cycle since there is no need to rebuild or recompile, just reload. -# -# The second pair are: -# blockly_accessible_compressed.js -# blockly_accessible_uncompressed.js -# These files are analogous to blockly_compressed and blockly_uncompressed, -# but also include the visually-impaired module for Blockly. -# -# This script also generates: -# blocks_compressed.js: The compressed Blockly language blocks. -# javascript_compressed.js: The compressed Javascript generator. -# python_compressed.js: The compressed Python generator. -# dart_compressed.js: The compressed Dart generator. -# lua_compressed.js: The compressed Lua generator. -# msg/js/.js for every language defined in msg/js/.json. - -import sys -if sys.version_info[0] != 2: - raise Exception("Blockly build only compatible with Python 2.x.\n" - "You are using: " + sys.version) - -for arg in sys.argv[1:len(sys.argv)]: - if (arg != 'core' and - arg != 'accessible' and - arg != 'generators' and - arg != 'langfiles'): - raise Exception("Invalid argument: \"" + arg + "\". Usage: build.py <0 or more of accessible," + - " core, generators, langfiles>") - -import errno, glob, httplib, json, os, re, subprocess, threading, urllib - - -def import_path(fullpath): - """Import a file with full path specification. - Allows one to import from any directory, something __import__ does not do. - - Args: - fullpath: Path and filename of import. - - Returns: - An imported module. - """ - path, filename = os.path.split(fullpath) - filename, ext = os.path.splitext(filename) - sys.path.append(path) - module = __import__(filename) - reload(module) # Might be out of date. - del sys.path[-1] - return module - - -HEADER = ("// Do not edit this file; automatically generated by build.py.\n" - "'use strict';\n") - - -class Gen_uncompressed(threading.Thread): - """Generate a JavaScript file that loads Blockly's raw files. - Runs in a separate thread. - """ - def __init__(self, search_paths, target_filename): - threading.Thread.__init__(self) - self.search_paths = search_paths - self.target_filename = target_filename - - def run(self): - f = open(self.target_filename, 'w') - f.write(HEADER) - f.write(""" -var isNodeJS = !!(typeof module !== 'undefined' && module.exports && - typeof window === 'undefined'); - -if (isNodeJS) { - var window = {}; - require('closure-library'); -} - -window.BLOCKLY_DIR = (function() { - if (!isNodeJS) { - // Find name of current directory. - var scripts = document.getElementsByTagName('script'); - var re = new RegExp('(.+)[\/]blockly_(.*)uncompressed\.js$'); - for (var i = 0, script; script = scripts[i]; i++) { - var match = re.exec(script.src); - if (match) { - return match[1]; - } - } - alert('Could not detect Blockly\\'s directory name.'); - } - return ''; -})(); - -window.BLOCKLY_BOOT = function() { - var dir = ''; - if (isNodeJS) { - require('closure-library'); - dir = 'blockly'; - } else { - // Execute after Closure has loaded. - if (!window.goog) { - alert('Error: Closure not found. Read this:\\n' + - 'developers.google.com/blockly/guides/modify/web/closure'); - } - dir = window.BLOCKLY_DIR.match(/[^\\/]+$/)[0]; - } -""") - add_dependency = [] - base_path = calcdeps.FindClosureBasePath(self.search_paths) - for dep in calcdeps.BuildDependenciesFromFiles(self.search_paths): - add_dependency.append(calcdeps.GetDepsLine(dep, base_path)) - add_dependency.sort() # Deterministic build. - add_dependency = '\n'.join(add_dependency) - # Find the Blockly directory name and replace it with a JS variable. - # This allows blockly_uncompressed.js to be compiled on one computer and be - # used on another, even if the directory name differs. - m = re.search('[\\/]([^\\/]+)[\\/]core[\\/]blockly.js', add_dependency) - add_dependency = re.sub('([\\/])' + re.escape(m.group(1)) + - '([\\/]core[\\/])', '\\1" + dir + "\\2', add_dependency) - f.write(add_dependency + '\n') - - provides = [] - for dep in calcdeps.BuildDependenciesFromFiles(self.search_paths): - if not dep.filename.startswith(os.pardir + os.sep): # '../' - provides.extend(dep.provides) - provides.sort() # Deterministic build. - f.write('\n') - f.write('// Load Blockly.\n') - for provide in provides: - f.write("goog.require('%s');\n" % provide) - - f.write(""" -delete this.BLOCKLY_DIR; -delete this.BLOCKLY_BOOT; -}; - -if (isNodeJS) { - window.BLOCKLY_BOOT(); - module.exports = Blockly; -} else { - // Delete any existing Closure (e.g. Soy's nogoog_shim). - document.write(''); - // Load fresh Closure Library. - document.write(''); - document.write(''); -} -""") - f.close() - print("SUCCESS: " + self.target_filename) - - -class Gen_compressed(threading.Thread): - """Generate a JavaScript file that contains all of Blockly's core and all - required parts of Closure, compiled together. - Uses the Closure Compiler's online API. - Runs in a separate thread. - """ - def __init__(self, search_paths, bundles): - threading.Thread.__init__(self) - self.search_paths = search_paths - self.bundles = bundles - - def run(self): - if ('core' in self.bundles): - self.gen_core() - - if ('accessible' in self.bundles): - self.gen_accessible() - - if ('core' in self.bundles or 'accessible' in self.bundles): - self.gen_blocks() - - if ('generators' in self.bundles): - self.gen_generator("javascript") - self.gen_generator("python") - self.gen_generator("php") - self.gen_generator("dart") - self.gen_generator("lua") - - def gen_core(self): - target_filename = "blockly_compressed.js" - # Define the parameters for the POST request. - params = [ - ("compilation_level", "SIMPLE_OPTIMIZATIONS"), - ("use_closure_library", "true"), - ("output_format", "json"), - ("output_info", "compiled_code"), - ("output_info", "warnings"), - ("output_info", "errors"), - ("output_info", "statistics"), - ] - - # Read in all the source files. - filenames = calcdeps.CalculateDependencies(self.search_paths, - [os.path.join("core", "blockly.js")]) - filenames.sort() # Deterministic build. - for filename in filenames: - # Filter out the Closure files (the compiler will add them). - if filename.startswith(os.pardir + os.sep): # '../' - continue - f = open(filename) - params.append(("js_code", "".join(f.readlines()))) - f.close() - - self.do_compile(params, target_filename, filenames, "") - - def gen_accessible(self): - target_filename = "blockly_accessible_compressed.js" - # Define the parameters for the POST request. - params = [ - ("compilation_level", "SIMPLE_OPTIMIZATIONS"), - ("use_closure_library", "true"), - ("language_out", "ES5"), - ("output_format", "json"), - ("output_info", "compiled_code"), - ("output_info", "warnings"), - ("output_info", "errors"), - ("output_info", "statistics"), - ] - - # Read in all the source files. - filenames = calcdeps.CalculateDependencies(self.search_paths, - [os.path.join("accessible", "app.component.js")]) - filenames.sort() # Deterministic build. - for filename in filenames: - # Filter out the Closure files (the compiler will add them). - if filename.startswith(os.pardir + os.sep): # '../' - continue - f = open(filename) - params.append(("js_code", "".join(f.readlines()))) - f.close() - - self.do_compile(params, target_filename, filenames, "") - - def gen_blocks(self): - target_filename = "blocks_compressed.js" - # Define the parameters for the POST request. - params = [ - ("compilation_level", "SIMPLE_OPTIMIZATIONS"), - ("output_format", "json"), - ("output_info", "compiled_code"), - ("output_info", "warnings"), - ("output_info", "errors"), - ("output_info", "statistics"), - ] - - # Read in all the source files. - # Add Blockly.Blocks to be compatible with the compiler. - params.append(("js_code", "goog.provide('Blockly.Blocks');")) - filenames = glob.glob(os.path.join("blocks", "*.js")) - filenames.sort() # Deterministic build. - for filename in filenames: - f = open(filename) - params.append(("js_code", "".join(f.readlines()))) - f.close() - - # Remove Blockly.Blocks to be compatible with Blockly. - remove = "var Blockly={Blocks:{}};" - self.do_compile(params, target_filename, filenames, remove) - - def gen_generator(self, language): - target_filename = language + "_compressed.js" - # Define the parameters for the POST request. - params = [ - ("compilation_level", "SIMPLE_OPTIMIZATIONS"), - ("output_format", "json"), - ("output_info", "compiled_code"), - ("output_info", "warnings"), - ("output_info", "errors"), - ("output_info", "statistics"), - ] - - # Read in all the source files. - # Add Blockly.Generator to be compatible with the compiler. - params.append(("js_code", "goog.provide('Blockly.Generator');")) - filenames = glob.glob( - os.path.join("generators", language, "*.js")) - filenames.sort() # Deterministic build. - filenames.insert(0, os.path.join("generators", language + ".js")) - for filename in filenames: - f = open(filename) - params.append(("js_code", "".join(f.readlines()))) - f.close() - filenames.insert(0, "[goog.provide]") - - # Remove Blockly.Generator to be compatible with Blockly. - remove = "var Blockly={Generator:{}};" - self.do_compile(params, target_filename, filenames, remove) - - def do_compile(self, params, target_filename, filenames, remove): - # Send the request to Google. - headers = {"Content-type": "application/x-www-form-urlencoded"} - conn = httplib.HTTPConnection("closure-compiler.appspot.com") - conn.request("POST", "/compile", urllib.urlencode(params), headers) - response = conn.getresponse() - json_str = response.read() - conn.close() - - # Parse the JSON response. - json_data = json.loads(json_str) - - def file_lookup(name): - if not name.startswith("Input_"): - return "???" - n = int(name[6:]) - 1 - return filenames[n] - - if json_data.has_key("serverErrors"): - errors = json_data["serverErrors"] - for error in errors: - print("SERVER ERROR: %s" % target_filename) - print(error["error"]) - elif json_data.has_key("errors"): - errors = json_data["errors"] - for error in errors: - print("FATAL ERROR") - print(error["error"]) - if error["file"]: - print("%s at line %d:" % ( - file_lookup(error["file"]), error["lineno"])) - print(error["line"]) - print((" " * error["charno"]) + "^") - sys.exit(1) - else: - if json_data.has_key("warnings"): - warnings = json_data["warnings"] - for warning in warnings: - print("WARNING") - print(warning["warning"]) - if warning["file"]: - print("%s at line %d:" % ( - file_lookup(warning["file"]), warning["lineno"])) - print(warning["line"]) - print((" " * warning["charno"]) + "^") - print() - - if not json_data.has_key("compiledCode"): - print("FATAL ERROR: Compiler did not return compiledCode.") - sys.exit(1) - - code = HEADER + "\n" + json_data["compiledCode"] - code = code.replace(remove, "") - - # Trim down Google's Apache licences. - # The Closure Compiler used to preserve these until August 2015. - # Delete this in a few months if the licences don't return. - LICENSE = re.compile("""/\\* - - [\w ]+ - - (Copyright \\d+ Google Inc.) - https://developers.google.com/blockly/ - - 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. -\\*/""") - code = re.sub(LICENSE, r"\n// \1 Apache License 2.0", code) - - stats = json_data["statistics"] - original_b = stats["originalSize"] - compressed_b = stats["compressedSize"] - if original_b > 0 and compressed_b > 0: - f = open(target_filename, "w") - f.write(code) - f.close() - - original_kb = int(original_b / 1024 + 0.5) - compressed_kb = int(compressed_b / 1024 + 0.5) - ratio = int(float(compressed_b) / float(original_b) * 100 + 0.5) - print("SUCCESS: " + target_filename) - print("Size changed from %d KB to %d KB (%d%%)." % ( - original_kb, compressed_kb, ratio)) - else: - print("UNKNOWN ERROR") - - -class Gen_langfiles(threading.Thread): - """Generate JavaScript file for each natural language supported. - - Runs in a separate thread. - """ - - def __init__(self, force_gen): - threading.Thread.__init__(self) - self.force_gen = force_gen - - def _rebuild(self, srcs, dests): - # Determine whether any of the files in srcs is newer than any in dests. - try: - return (max(os.path.getmtime(src) for src in srcs) > - min(os.path.getmtime(dest) for dest in dests)) - except OSError as e: - # Was a file not found? - if e.errno == errno.ENOENT: - # If it was a source file, we can't proceed. - if e.filename in srcs: - print("Source file missing: " + e.filename) - sys.exit(1) - else: - # If a destination file was missing, rebuild. - return True - else: - print("Error checking file creation times: " + e) - - def run(self): - # The files msg/json/{en,qqq,synonyms}.json depend on msg/messages.js. - if (self.force_gen or - self._rebuild([os.path.join("msg", "messages.js")], - [os.path.join("msg", "json", f) for f in - ["en.json", "qqq.json", "synonyms.json"]])): - try: - subprocess.check_call([ - "python", - os.path.join("i18n", "js_to_json.py"), - "--input_file", "msg/messages.js", - "--output_dir", "msg/json/", - "--quiet"]) - except (subprocess.CalledProcessError, OSError) as e: - # Documentation for subprocess.check_call says that CalledProcessError - # will be raised on failure, but I found that OSError is also possible. - print("Error running i18n/js_to_json.py: ", e) - sys.exit(1) - - # Checking whether it is necessary to rebuild the js files would be a lot of - # work since we would have to compare each .json file with each - # .js file. Rebuilding is easy and cheap, so just go ahead and do it. - try: - # Use create_messages.py to create .js files from .json files. - cmd = [ - "python", - os.path.join("i18n", "create_messages.py"), - "--source_lang_file", os.path.join("msg", "json", "en.json"), - "--source_synonym_file", os.path.join("msg", "json", "synonyms.json"), - "--source_constants_file", os.path.join("msg", "json", "constants.json"), - "--key_file", os.path.join("msg", "json", "keys.json"), - "--output_dir", os.path.join("msg", "js"), - "--quiet"] - json_files = glob.glob(os.path.join("msg", "json", "*.json")) - json_files = [file for file in json_files if not - (file.endswith(("keys.json", "synonyms.json", "qqq.json", "constants.json")))] - cmd.extend(json_files) - subprocess.check_call(cmd) - except (subprocess.CalledProcessError, OSError) as e: - print("Error running i18n/create_messages.py: ", e) - sys.exit(1) - - # Output list of .js files created. - for f in json_files: - # This assumes the path to the current directory does not contain "json". - f = f.replace("json", "js") - if os.path.isfile(f): - print("SUCCESS: " + f) - else: - print("FAILED to create " + f) - - -if __name__ == "__main__": - try: - calcdeps = import_path(os.path.join( - os.path.pardir, "closure-library", "closure", "bin", "calcdeps.py")) - except ImportError: - if os.path.isdir(os.path.join(os.path.pardir, "closure-library-read-only")): - # Dir got renamed when Closure moved from Google Code to GitHub in 2014. - print("Error: Closure directory needs to be renamed from" - "'closure-library-read-only' to 'closure-library'.\n" - "Please rename this directory.") - elif os.path.isdir(os.path.join(os.path.pardir, "google-closure-library")): - # When Closure is installed by npm, it is named "google-closure-library". - #calcdeps = import_path(os.path.join( - # os.path.pardir, "google-closure-library", "closure", "bin", "calcdeps.py")) - print("Error: Closure directory needs to be renamed from" - "'google-closure-library' to 'closure-library'.\n" - "Please rename this directory.") - else: - print("""Error: Closure not found. Read this: -developers.google.com/blockly/guides/modify/web/closure""") - sys.exit(1) - - core_search_paths = calcdeps.ExpandDirectories( - ["core", os.path.join(os.path.pardir, "closure-library")]) - core_search_paths.sort() # Deterministic build. - full_search_paths = calcdeps.ExpandDirectories( - ["accessible", "core", os.path.join(os.path.pardir, "closure-library")]) - full_search_paths.sort() # Deterministic build. - - if (len(sys.argv) == 1): - args = ['core', 'accessible', 'generators', 'defaultlangfiles'] - else: - args = sys.argv - - # Uncompressed and compressed are run in parallel threads. - # Uncompressed is limited by processor speed. - if ('core' in args): - Gen_uncompressed(core_search_paths, 'blockly_uncompressed.js').start() - - if ('accessible' in args): - Gen_uncompressed(full_search_paths, 'blockly_accessible_uncompressed.js').start() - - # Compressed is limited by network and server speed. - Gen_compressed(full_search_paths, args).start() - - # This is run locally in a separate thread - # defaultlangfiles checks for changes in the msg files, while manually asking - # to build langfiles will force the messages to be rebuilt. - if ('langfiles' in args or 'defaultlangfiles' in args): - Gen_langfiles('langfiles' in args).start() diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 00000000000..f944970267c --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Rules configuration for commitlint. + * https://commitlint.js.org/reference/rules.html#subject-full-stop + * + * Extends the conventional-commit spec at + * https://github.com/conventional-changelog/commitlint/tree/master/@commitlint/config-conventional + */ + +export default { + extends: ['@commitlint/config-conventional'], + rules: { + // Warn if not in this list. Allow for judicious creativity. + 'type-enum': [ + 1, + 'always', + [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'refactor', + 'release', + 'revert', + 'test', + ], + ], + 'subject-case': [0], + }, + helpUrl: + 'https://developers.google.com/blockly/guides/contribute/get-started/commits', +}; diff --git a/core/any_aliases.ts b/core/any_aliases.ts new file mode 100644 index 00000000000..b04621726db --- /dev/null +++ b/core/any_aliases.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// eslint-disable-next-line +type AnyDuringMigration = any; diff --git a/core/block.js b/core/block.js deleted file mode 100644 index da77fe13514..00000000000 --- a/core/block.js +++ /dev/null @@ -1,1503 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2011 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 The class representing one block. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Block'); - -goog.require('Blockly.Blocks'); -goog.require('Blockly.Comment'); -goog.require('Blockly.Connection'); -goog.require('Blockly.Extensions'); -goog.require('Blockly.Input'); -goog.require('Blockly.Mutator'); -goog.require('Blockly.Warning'); -goog.require('Blockly.Workspace'); -goog.require('Blockly.Xml'); -goog.require('goog.array'); -goog.require('goog.asserts'); -goog.require('goog.math.Coordinate'); -goog.require('goog.string'); - - -/** - * Class for one block. - * Not normally called directly, workspace.newBlock() is preferred. - * @param {!Blockly.Workspace} workspace The block's workspace. - * @param {?string} prototypeName Name of the language object containing - * type-specific functions for this block. - * @param {string=} opt_id Optional ID. Use this ID if provided, otherwise - * create a new id. - * @constructor - */ -Blockly.Block = function(workspace, prototypeName, opt_id) { - /** @type {string} */ - this.id = (opt_id && !workspace.getBlockById(opt_id)) ? - opt_id : Blockly.utils.genUid(); - workspace.blockDB_[this.id] = this; - /** @type {Blockly.Connection} */ - this.outputConnection = null; - /** @type {Blockly.Connection} */ - this.nextConnection = null; - /** @type {Blockly.Connection} */ - this.previousConnection = null; - /** @type {!Array.} */ - this.inputList = []; - /** @type {boolean|undefined} */ - this.inputsInline = undefined; - /** @type {boolean} */ - this.disabled = false; - /** @type {string|!Function} */ - this.tooltip = ''; - /** @type {boolean} */ - this.contextMenu = true; - - /** - * @type {Blockly.Block} - * @private - */ - this.parentBlock_ = null; - - /** - * @type {!Array.} - * @private - */ - this.childBlocks_ = []; - - /** - * @type {boolean} - * @private - */ - this.deletable_ = true; - - /** - * @type {boolean} - * @private - */ - this.movable_ = true; - - /** - * @type {boolean} - * @private - */ - this.editable_ = true; - - /** - * @type {boolean} - * @private - */ - this.isShadow_ = false; - - /** - * @type {boolean} - * @private - */ - this.collapsed_ = false; - - /** @type {string|Blockly.Comment} */ - this.comment = null; - - /** - * The block's position in workspace units. (0, 0) is at the workspace's - * origin; scale does not change this value. - * @type {!goog.math.Coordinate} - * @private - */ - this.xy_ = new goog.math.Coordinate(0, 0); - - /** @type {!Blockly.Workspace} */ - this.workspace = workspace; - /** @type {boolean} */ - this.isInFlyout = workspace.isFlyout; - /** @type {boolean} */ - this.isInMutator = workspace.isMutator; - - /** @type {boolean} */ - this.RTL = workspace.RTL; - - // Copy the type-specific functions and data from the prototype. - if (prototypeName) { - /** @type {string} */ - this.type = prototypeName; - var prototype = Blockly.Blocks[prototypeName]; - goog.asserts.assertObject(prototype, - 'Error: Unknown block type "%s".', prototypeName); - goog.mixin(this, prototype); - } - - workspace.addTopBlock(this); - - // Call an initialization function, if it exists. - if (goog.isFunction(this.init)) { - this.init(); - } - // Record initial inline state. - /** @type {boolean|undefined} */ - this.inputsInlineDefault = this.inputsInline; - if (Blockly.Events.isEnabled()) { - Blockly.Events.fire(new Blockly.Events.BlockCreate(this)); - } - // Bind an onchange function, if it exists. - if (goog.isFunction(this.onchange)) { - this.setOnChange(this.onchange); - } -}; - -/** - * Obtain a newly created block. - * @param {!Blockly.Workspace} workspace The block's workspace. - * @param {?string} prototypeName Name of the language object containing - * type-specific functions for this block. - * @return {!Blockly.Block} The created block. - * @deprecated December 2015 - */ -Blockly.Block.obtain = function(workspace, prototypeName) { - console.warn('Deprecated call to Blockly.Block.obtain, ' + - 'use workspace.newBlock instead.'); - return workspace.newBlock(prototypeName); -}; - -/** - * Optional text data that round-trips beween blocks and XML. - * Has no effect. May be used by 3rd parties for meta information. - * @type {?string} - */ -Blockly.Block.prototype.data = null; - -/** - * Colour of the block in '#RRGGBB' format. - * @type {string} - * @private - */ -Blockly.Block.prototype.colour_ = '#000000'; - -/** - * Dispose of this block. - * @param {boolean} healStack If true, then try to heal any gap by connecting - * the next statement with the previous statement. Otherwise, dispose of - * all children of this block. - */ -Blockly.Block.prototype.dispose = function(healStack) { - if (!this.workspace) { - // Already deleted. - return; - } - // Terminate onchange event calls. - if (this.onchangeWrapper_) { - this.workspace.removeChangeListener(this.onchangeWrapper_); - } - this.unplug(healStack); - if (Blockly.Events.isEnabled()) { - Blockly.Events.fire(new Blockly.Events.BlockDelete(this)); - } - Blockly.Events.disable(); - - try { - // This block is now at the top of the workspace. - // Remove this block from the workspace's list of top-most blocks. - if (this.workspace) { - this.workspace.removeTopBlock(this); - // Remove from block database. - delete this.workspace.blockDB_[this.id]; - this.workspace = null; - } - - // Just deleting this block from the DOM would result in a memory leak as - // well as corruption of the connection database. Therefore we must - // methodically step through the blocks and carefully disassemble them. - - // First, dispose of all my children. - for (var i = this.childBlocks_.length - 1; i >= 0; i--) { - this.childBlocks_[i].dispose(false); - } - // Then dispose of myself. - // Dispose of all inputs and their fields. - for (var i = 0, input; input = this.inputList[i]; i++) { - input.dispose(); - } - this.inputList.length = 0; - // Dispose of any remaining connections (next/previous/output). - var connections = this.getConnections_(true); - for (var i = 0; i < connections.length; i++) { - var connection = connections[i]; - if (connection.isConnected()) { - connection.disconnect(); - } - connections[i].dispose(); - } - } finally { - Blockly.Events.enable(); - } -}; - -/** - * Unplug this block from its superior block. If this block is a statement, - * optionally reconnect the block underneath with the block on top. - * @param {boolean} opt_healStack Disconnect child statement and reconnect - * stack. Defaults to false. - */ -Blockly.Block.prototype.unplug = function(opt_healStack) { - if (this.outputConnection) { - if (this.outputConnection.isConnected()) { - // Disconnect from any superior block. - this.outputConnection.disconnect(); - } - } else if (this.previousConnection) { - var previousTarget = null; - if (this.previousConnection.isConnected()) { - // Remember the connection that any next statements need to connect to. - previousTarget = this.previousConnection.targetConnection; - // Detach this block from the parent's tree. - this.previousConnection.disconnect(); - } - var nextBlock = this.getNextBlock(); - if (opt_healStack && nextBlock) { - // Disconnect the next statement. - var nextTarget = this.nextConnection.targetConnection; - nextTarget.disconnect(); - if (previousTarget && previousTarget.checkType_(nextTarget)) { - // Attach the next statement to the previous statement. - previousTarget.connect(nextTarget); - } - } - } -}; - -/** - * Returns all connections originating from this block. - * @return {!Array.} Array of connections. - * @private - */ -Blockly.Block.prototype.getConnections_ = function() { - var myConnections = []; - if (this.outputConnection) { - myConnections.push(this.outputConnection); - } - if (this.previousConnection) { - myConnections.push(this.previousConnection); - } - if (this.nextConnection) { - myConnections.push(this.nextConnection); - } - for (var i = 0, input; input = this.inputList[i]; i++) { - if (input.connection) { - myConnections.push(input.connection); - } - } - return myConnections; -}; - -/** - * Walks down a stack of blocks and finds the last next connection on the stack. - * @return {Blockly.Connection} The last next connection on the stack, or null. - * @package - */ -Blockly.Block.prototype.lastConnectionInStack_ = function() { - var nextConnection = this.nextConnection; - while (nextConnection) { - var nextBlock = nextConnection.targetBlock(); - if (!nextBlock) { - // Found a next connection with nothing on the other side. - return nextConnection; - } - nextConnection = nextBlock.nextConnection; - } - // Ran out of next connections. - return null; -}; - -/** - * Bump unconnected blocks out of alignment. Two blocks which aren't actually - * connected should not coincidentally line up on screen. - * @private - */ -Blockly.Block.prototype.bumpNeighbours_ = function() { - console.warn('Not expected to reach this bumpNeighbours_ function. The ' + - 'BlockSvg function for bumpNeighbours_ was expected to be called instead.'); -}; - -/** - * Return the parent block or null if this block is at the top level. - * @return {Blockly.Block} The block that holds the current block. - */ -Blockly.Block.prototype.getParent = function() { - // Look at the DOM to see if we are nested in another block. - return this.parentBlock_; -}; - -/** - * Return the input that connects to the specified block. - * @param {!Blockly.Block} block A block connected to an input on this block. - * @return {Blockly.Input} The input that connects to the specified block. - */ -Blockly.Block.prototype.getInputWithBlock = function(block) { - for (var i = 0, input; input = this.inputList[i]; i++) { - if (input.connection && input.connection.targetBlock() == block) { - return input; - } - } - return null; -}; - -/** - * Return the parent block that surrounds the current block, or null if this - * block has no surrounding block. A parent block might just be the previous - * statement, whereas the surrounding block is an if statement, while loop, etc. - * @return {Blockly.Block} The block that surrounds the current block. - */ -Blockly.Block.prototype.getSurroundParent = function() { - var block = this; - do { - var prevBlock = block; - block = block.getParent(); - if (!block) { - // Ran off the top. - return null; - } - } while (block.getNextBlock() == prevBlock); - // This block is an enclosing parent, not just a statement in a stack. - return block; -}; - -/** - * Return the next statement block directly connected to this block. - * @return {Blockly.Block} The next statement block or null. - */ -Blockly.Block.prototype.getNextBlock = function() { - return this.nextConnection && this.nextConnection.targetBlock(); -}; - -/** - * Return the top-most block in this block's tree. - * This will return itself if this block is at the top level. - * @return {!Blockly.Block} The root block. - */ -Blockly.Block.prototype.getRootBlock = function() { - var rootBlock; - var block = this; - do { - rootBlock = block; - block = rootBlock.parentBlock_; - } while (block); - return rootBlock; -}; - -/** - * Find all the blocks that are directly nested inside this one. - * Includes value and block inputs, as well as any following statement. - * Excludes any connection on an output tab or any preceding statement. - * @return {!Array.} Array of blocks. - */ -Blockly.Block.prototype.getChildren = function() { - return this.childBlocks_; -}; - -/** - * Set parent of this block to be a new block or null. - * @param {Blockly.Block} newParent New parent block. - */ -Blockly.Block.prototype.setParent = function(newParent) { - if (newParent == this.parentBlock_) { - return; - } - if (this.parentBlock_) { - // Remove this block from the old parent's child list. - goog.array.remove(this.parentBlock_.childBlocks_, this); - - // Disconnect from superior blocks. - if (this.previousConnection && this.previousConnection.isConnected()) { - throw 'Still connected to previous block.'; - } - if (this.outputConnection && this.outputConnection.isConnected()) { - throw 'Still connected to parent block.'; - } - this.parentBlock_ = null; - // This block hasn't actually moved on-screen, so there's no need to update - // its connection locations. - } else { - // Remove this block from the workspace's list of top-most blocks. - this.workspace.removeTopBlock(this); - } - - this.parentBlock_ = newParent; - if (newParent) { - // Add this block to the new parent's child list. - newParent.childBlocks_.push(this); - } else { - this.workspace.addTopBlock(this); - } -}; - -/** - * Find all the blocks that are directly or indirectly nested inside this one. - * Includes this block in the list. - * Includes value and block inputs, as well as any following statements. - * Excludes any connection on an output tab or any preceding statements. - * @return {!Array.} Flattened array of blocks. - */ -Blockly.Block.prototype.getDescendants = function() { - var blocks = [this]; - for (var child, x = 0; child = this.childBlocks_[x]; x++) { - blocks.push.apply(blocks, child.getDescendants()); - } - return blocks; -}; - -/** - * Get whether this block is deletable or not. - * @return {boolean} True if deletable. - */ -Blockly.Block.prototype.isDeletable = function() { - return this.deletable_ && !this.isShadow_ && - !(this.workspace && this.workspace.options.readOnly); -}; - -/** - * Set whether this block is deletable or not. - * @param {boolean} deletable True if deletable. - */ -Blockly.Block.prototype.setDeletable = function(deletable) { - this.deletable_ = deletable; -}; - -/** - * Get whether this block is movable or not. - * @return {boolean} True if movable. - */ -Blockly.Block.prototype.isMovable = function() { - return this.movable_ && !this.isShadow_ && - !(this.workspace && this.workspace.options.readOnly); -}; - -/** - * Set whether this block is movable or not. - * @param {boolean} movable True if movable. - */ -Blockly.Block.prototype.setMovable = function(movable) { - this.movable_ = movable; -}; - -/** - * Get whether this block is a shadow block or not. - * @return {boolean} True if a shadow. - */ -Blockly.Block.prototype.isShadow = function() { - return this.isShadow_; -}; - -/** - * Set whether this block is a shadow block or not. - * @param {boolean} shadow True if a shadow. - */ -Blockly.Block.prototype.setShadow = function(shadow) { - this.isShadow_ = shadow; -}; - -/** - * Get whether this block is editable or not. - * @return {boolean} True if editable. - */ -Blockly.Block.prototype.isEditable = function() { - return this.editable_ && !(this.workspace && this.workspace.options.readOnly); -}; - -/** - * Set whether this block is editable or not. - * @param {boolean} editable True if editable. - */ -Blockly.Block.prototype.setEditable = function(editable) { - this.editable_ = editable; - for (var i = 0, input; input = this.inputList[i]; i++) { - for (var j = 0, field; field = input.fieldRow[j]; j++) { - field.updateEditable(); - } - } -}; - -/** - * Set whether the connections are hidden (not tracked in a database) or not. - * Recursively walk down all child blocks (except collapsed blocks). - * @param {boolean} hidden True if connections are hidden. - */ -Blockly.Block.prototype.setConnectionsHidden = function(hidden) { - if (!hidden && this.isCollapsed()) { - if (this.outputConnection) { - this.outputConnection.setHidden(hidden); - } - if (this.previousConnection) { - this.previousConnection.setHidden(hidden); - } - if (this.nextConnection) { - this.nextConnection.setHidden(hidden); - var child = this.nextConnection.targetBlock(); - if (child) { - child.setConnectionsHidden(hidden); - } - } - } else { - var myConnections = this.getConnections_(true); - for (var i = 0, connection; connection = myConnections[i]; i++) { - connection.setHidden(hidden); - if (connection.isSuperior()) { - var child = connection.targetBlock(); - if (child) { - child.setConnectionsHidden(hidden); - } - } - } - } -}; - -/** - * Set the URL of this block's help page. - * @param {string|Function} url URL string for block help, or function that - * returns a URL. Null for no help. - */ -Blockly.Block.prototype.setHelpUrl = function(url) { - this.helpUrl = url; -}; - -/** - * Change the tooltip text for a block. - * @param {string|!Function} newTip Text for tooltip or a parent element to - * link to for its tooltip. May be a function that returns a string. - */ -Blockly.Block.prototype.setTooltip = function(newTip) { - this.tooltip = newTip; -}; - -/** - * Get the colour of a block. - * @return {string} #RRGGBB string. - */ -Blockly.Block.prototype.getColour = function() { - return this.colour_; -}; - -/** - * Change the colour of a block. - * @param {number|string} colour HSV hue value, or #RRGGBB string. - */ -Blockly.Block.prototype.setColour = function(colour) { - var hue = Number(colour); - if (!isNaN(hue)) { - this.colour_ = Blockly.hueToRgb(hue); - } else if (goog.isString(colour) && colour.match(/^#[0-9a-fA-F]{6}$/)) { - this.colour_ = colour; - } else { - throw 'Invalid colour: ' + colour; - } -}; - -/** - * Sets a callback function to use whenever the block's parent workspace - * changes, replacing any prior onchange handler. This is usually only called - * from the constructor, the block type initializer function, or an extension - * initializer function. - * @param {function(Blockly.Events.Abstract)} onchangeFn The callback to call - * when the block's workspace changes. - * @throws {Error} if onchangeFn is not falsey or a function. - */ -Blockly.Block.prototype.setOnChange = function(onchangeFn) { - if (onchangeFn && !goog.isFunction(onchangeFn)) { - throw new Error("onchange must be a function."); - } - if (this.onchangeWrapper_) { - this.workspace.removeChangeListener(this.onchangeWrapper_); - } - this.onchange = onchangeFn; - if (this.onchange) { - this.onchangeWrapper_ = onchangeFn.bind(this); - this.workspace.addChangeListener(this.onchangeWrapper_); - } -}; - -/** - * Returns the named field from a block. - * @param {string} name The name of the field. - * @return {Blockly.Field} Named field, or null if field does not exist. - */ -Blockly.Block.prototype.getField = function(name) { - for (var i = 0, input; input = this.inputList[i]; i++) { - for (var j = 0, field; field = input.fieldRow[j]; j++) { - if (field.name === name) { - return field; - } - } - } - return null; -}; - -/** - * Return all variables referenced by this block. - * @return {!Array.} List of variable names. - */ -Blockly.Block.prototype.getVars = function() { - var vars = []; - for (var i = 0, input; input = this.inputList[i]; i++) { - for (var j = 0, field; field = input.fieldRow[j]; j++) { - if (field instanceof Blockly.FieldVariable) { - vars.push(field.getValue()); - } - } - } - return vars; -}; - -/** - * Notification that a variable is renaming. - * If the name matches one of this block's variables, rename it. - * @param {string} oldName Previous name of variable. - * @param {string} newName Renamed variable. - */ -Blockly.Block.prototype.renameVar = function(oldName, newName) { - for (var i = 0, input; input = this.inputList[i]; i++) { - for (var j = 0, field; field = input.fieldRow[j]; j++) { - if (field instanceof Blockly.FieldVariable && - Blockly.Names.equals(oldName, field.getValue())) { - field.setValue(newName); - } - } - } -}; - -/** - * Returns the language-neutral value from the field of a block. - * @param {string} name The name of the field. - * @return {?string} Value from the field or null if field does not exist. - */ -Blockly.Block.prototype.getFieldValue = function(name) { - var field = this.getField(name); - if (field) { - return field.getValue(); - } - return null; -}; - -/** - * Change the field value for a block (e.g. 'CHOOSE' or 'REMOVE'). - * @param {string} newValue Value to be the new field. - * @param {string} name The name of the field. - */ -Blockly.Block.prototype.setFieldValue = function(newValue, name) { - var field = this.getField(name); - goog.asserts.assertObject(field, 'Field "%s" not found.', name); - field.setValue(newValue); -}; - -/** - * Set whether this block can chain onto the bottom of another block. - * @param {boolean} newBoolean True if there can be a previous statement. - * @param {string|Array.|null|undefined} opt_check Statement type or - * list of statement types. Null/undefined if any type could be connected. - */ -Blockly.Block.prototype.setPreviousStatement = function(newBoolean, opt_check) { - if (newBoolean) { - if (opt_check === undefined) { - opt_check = null; - } - if (!this.previousConnection) { - goog.asserts.assert(!this.outputConnection, - 'Remove output connection prior to adding previous connection.'); - this.previousConnection = - this.makeConnection_(Blockly.PREVIOUS_STATEMENT); - } - this.previousConnection.setCheck(opt_check); - } else { - if (this.previousConnection) { - goog.asserts.assert(!this.previousConnection.isConnected(), - 'Must disconnect previous statement before removing connection.'); - this.previousConnection.dispose(); - this.previousConnection = null; - } - } -}; - -/** - * Set whether another block can chain onto the bottom of this block. - * @param {boolean} newBoolean True if there can be a next statement. - * @param {string|Array.|null|undefined} opt_check Statement type or - * list of statement types. Null/undefined if any type could be connected. - */ -Blockly.Block.prototype.setNextStatement = function(newBoolean, opt_check) { - if (newBoolean) { - if (opt_check === undefined) { - opt_check = null; - } - if (!this.nextConnection) { - this.nextConnection = this.makeConnection_(Blockly.NEXT_STATEMENT); - } - this.nextConnection.setCheck(opt_check); - } else { - if (this.nextConnection) { - goog.asserts.assert(!this.nextConnection.isConnected(), - 'Must disconnect next statement before removing connection.'); - this.nextConnection.dispose(); - this.nextConnection = null; - } - } -}; - -/** - * Set whether this block returns a value. - * @param {boolean} newBoolean True if there is an output. - * @param {string|Array.|null|undefined} opt_check Returned type or list - * of returned types. Null or undefined if any type could be returned - * (e.g. variable get). - */ -Blockly.Block.prototype.setOutput = function(newBoolean, opt_check) { - if (newBoolean) { - if (opt_check === undefined) { - opt_check = null; - } - if (!this.outputConnection) { - goog.asserts.assert(!this.previousConnection, - 'Remove previous connection prior to adding output connection.'); - this.outputConnection = this.makeConnection_(Blockly.OUTPUT_VALUE); - } - this.outputConnection.setCheck(opt_check); - } else { - if (this.outputConnection) { - goog.asserts.assert(!this.outputConnection.isConnected(), - 'Must disconnect output value before removing connection.'); - this.outputConnection.dispose(); - this.outputConnection = null; - } - } -}; - -/** - * Set whether value inputs are arranged horizontally or vertically. - * @param {boolean} newBoolean True if inputs are horizontal. - */ -Blockly.Block.prototype.setInputsInline = function(newBoolean) { - if (this.inputsInline != newBoolean) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this, 'inline', null, this.inputsInline, newBoolean)); - this.inputsInline = newBoolean; - } -}; - -/** - * Get whether value inputs are arranged horizontally or vertically. - * @return {boolean} True if inputs are horizontal. - */ -Blockly.Block.prototype.getInputsInline = function() { - if (this.inputsInline != undefined) { - // Set explicitly. - return this.inputsInline; - } - // Not defined explicitly. Figure out what would look best. - for (var i = 1; i < this.inputList.length; i++) { - if (this.inputList[i - 1].type == Blockly.DUMMY_INPUT && - this.inputList[i].type == Blockly.DUMMY_INPUT) { - // Two dummy inputs in a row. Don't inline them. - return false; - } - } - for (var i = 1; i < this.inputList.length; i++) { - if (this.inputList[i - 1].type == Blockly.INPUT_VALUE && - this.inputList[i].type == Blockly.DUMMY_INPUT) { - // Dummy input after a value input. Inline them. - return true; - } - } - return false; -}; - -/** - * Set whether the block is disabled or not. - * @param {boolean} disabled True if disabled. - */ -Blockly.Block.prototype.setDisabled = function(disabled) { - if (this.disabled != disabled) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this, 'disabled', null, this.disabled, disabled)); - this.disabled = disabled; - } -}; - -/** - * Get whether the block is disabled or not due to parents. - * The block's own disabled property is not considered. - * @return {boolean} True if disabled. - */ -Blockly.Block.prototype.getInheritedDisabled = function() { - var ancestor = this.getSurroundParent(); - while (ancestor) { - if (ancestor.disabled) { - return true; - } - ancestor = ancestor.getSurroundParent(); - } - // Ran off the top. - return false; -}; - -/** - * Get whether the block is collapsed or not. - * @return {boolean} True if collapsed. - */ -Blockly.Block.prototype.isCollapsed = function() { - return this.collapsed_; -}; - -/** - * Set whether the block is collapsed or not. - * @param {boolean} collapsed True if collapsed. - */ -Blockly.Block.prototype.setCollapsed = function(collapsed) { - if (this.collapsed_ != collapsed) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this, 'collapsed', null, this.collapsed_, collapsed)); - this.collapsed_ = collapsed; - } -}; - -/** - * Create a human-readable text representation of this block and any children. - * @param {number=} opt_maxLength Truncate the string to this length. - * @param {string=} opt_emptyToken The placeholder string used to denote an - * empty field. If not specified, '?' is used. - * @return {string} Text of block. - */ -Blockly.Block.prototype.toString = function(opt_maxLength, opt_emptyToken) { - var text = []; - var emptyFieldPlaceholder = opt_emptyToken || '?'; - if (this.collapsed_) { - text.push(this.getInput('_TEMP_COLLAPSED_INPUT').fieldRow[0].text_); - } else { - for (var i = 0, input; input = this.inputList[i]; i++) { - for (var j = 0, field; field = input.fieldRow[j]; j++) { - if (field instanceof Blockly.FieldDropdown && !field.getValue()) { - text.push(emptyFieldPlaceholder); - } else { - text.push(field.getText()); - } - } - if (input.connection) { - var child = input.connection.targetBlock(); - if (child) { - text.push(child.toString(undefined, opt_emptyToken)); - } else { - text.push(emptyFieldPlaceholder); - } - } - } - } - text = goog.string.trim(text.join(' ')) || '???'; - if (opt_maxLength) { - // TODO: Improve truncation so that text from this block is given priority. - // E.g. "1+2+3+4+5+6+7+8+9=0" should be "...6+7+8+9=0", not "1+2+3+4+5...". - // E.g. "1+2+3+4+5=6+7+8+9+0" should be "...4+5=6+7...". - text = goog.string.truncate(text, opt_maxLength); - } - return text; -}; - -/** - * Shortcut for appending a value input row. - * @param {string} name Language-neutral identifier which may used to find this - * input again. Should be unique to this block. - * @return {!Blockly.Input} The input object created. - */ -Blockly.Block.prototype.appendValueInput = function(name) { - return this.appendInput_(Blockly.INPUT_VALUE, name); -}; - -/** - * Shortcut for appending a statement input row. - * @param {string} name Language-neutral identifier which may used to find this - * input again. Should be unique to this block. - * @return {!Blockly.Input} The input object created. - */ -Blockly.Block.prototype.appendStatementInput = function(name) { - return this.appendInput_(Blockly.NEXT_STATEMENT, name); -}; - -/** - * Shortcut for appending a dummy input row. - * @param {string=} opt_name Language-neutral identifier which may used to find - * this input again. Should be unique to this block. - * @return {!Blockly.Input} The input object created. - */ -Blockly.Block.prototype.appendDummyInput = function(opt_name) { - return this.appendInput_(Blockly.DUMMY_INPUT, opt_name || ''); -}; - -/** - * Initialize this block using a cross-platform, internationalization-friendly - * JSON description. - * @param {!Object} json Structured data describing the block. - */ -Blockly.Block.prototype.jsonInit = function(json) { - - // Validate inputs. - goog.asserts.assert(json['output'] == undefined || - json['previousStatement'] == undefined, - 'Must not have both an output and a previousStatement.'); - - // Set basic properties of block. - if (json['colour'] !== undefined) { - var rawValue = json['colour']; - var colour = goog.isString(rawValue) ? - Blockly.utils.replaceMessageReferences(rawValue) : rawValue; - this.setColour(colour); - } - - // Interpolate the message blocks. - var i = 0; - while (json['message' + i] !== undefined) { - this.interpolate_(json['message' + i], json['args' + i] || [], - json['lastDummyAlign' + i]); - i++; - } - - if (json['inputsInline'] !== undefined) { - this.setInputsInline(json['inputsInline']); - } - // Set output and previous/next connections. - if (json['output'] !== undefined) { - this.setOutput(true, json['output']); - } - if (json['previousStatement'] !== undefined) { - this.setPreviousStatement(true, json['previousStatement']); - } - if (json['nextStatement'] !== undefined) { - this.setNextStatement(true, json['nextStatement']); - } - if (json['tooltip'] !== undefined) { - var rawValue = json['tooltip']; - var localizedText = Blockly.utils.replaceMessageReferences(rawValue); - this.setTooltip(localizedText); - } - if (json['enableContextMenu'] !== undefined) { - var rawValue = json['enableContextMenu']; - this.contextMenu = !!rawValue; - } - if (json['helpUrl'] !== undefined) { - var rawValue = json['helpUrl']; - var localizedValue = Blockly.utils.replaceMessageReferences(rawValue); - this.setHelpUrl(localizedValue); - } - if (goog.isString(json['extensions'])) { - console.warn('JSON attribute \'extensions\' should be an array of ' + - 'strings. Found raw string in JSON for \'' + json['type'] + '\' block.'); - json['extensions'] = [json['extensions']]; // Correct and continue. - } - - // Add the mutator to the block - if (json['mutator'] !== undefined) { - Blockly.Extensions.apply(json['mutator'], this, true); - } - - if (Array.isArray(json['extensions'])) { - var extensionNames = json['extensions']; - for (var i = 0; i < extensionNames.length; ++i) { - var extensionName = extensionNames[i]; - Blockly.Extensions.apply(extensionName, this, false); - } - } -}; - -/** - * Add key/values from mixinObj to this block object. By default, this method - * will check that the keys in mixinObj will not overwrite existing values in - * the block, including prototype values. This provides some insurance against - * mixin / extension incompatibilities with future block features. This check - * can be disabled by passing true as the second argument. - * @param {!Object} mixinObj The key/values pairs to add to this block object. - * @param {boolean=} opt_disableCheck Option flag to disable overwrite checks. - */ -Blockly.Block.prototype.mixin = function(mixinObj, opt_disableCheck) { - if (goog.isDef(opt_disableCheck) && !goog.isBoolean(opt_disableCheck)) { - throw new Error("opt_disableCheck must be a boolean if provided"); - } - if (!opt_disableCheck) { - var overwrites = []; - for (var key in mixinObj) { - if (this[key] !== undefined) { - overwrites.push(key); - } - } - if (overwrites.length) { - throw new Error('Mixin will overwrite block members: ' + - JSON.stringify(overwrites)); - } - } - goog.mixin(this, mixinObj); -}; - -/** - * Interpolate a message description onto the block. - * @param {string} message Text contains interpolation tokens (%1, %2, ...) - * that match with fields or inputs defined in the args array. - * @param {!Array} args Array of arguments to be interpolated. - * @param {string=} lastDummyAlign If a dummy input is added at the end, - * how should it be aligned? - * @private - */ -Blockly.Block.prototype.interpolate_ = function(message, args, lastDummyAlign) { - var tokens = Blockly.utils.tokenizeInterpolation(message); - // Interpolate the arguments. Build a list of elements. - var indexDup = []; - var indexCount = 0; - var elements = []; - for (var i = 0; i < tokens.length; i++) { - var token = tokens[i]; - if (typeof token == 'number') { - if (token <= 0 || token > args.length) { - throw new Error('Block \"' + this.type + '\": ' + - 'Message index %' + token + ' out of range.'); - } - if (indexDup[token]) { - throw new Error('Block \"' + this.type + '\": ' + - 'Message index %' + token + ' duplicated.'); - } - indexDup[token] = true; - indexCount++; - elements.push(args[token - 1]); - } else { - token = token.trim(); - if (token) { - elements.push(token); - } - } - } - if(indexCount != args.length) { - throw new Error('Block \"' + this.type + '\": ' + - 'Message does not reference all ' + args.length + ' arg(s).'); - } - // Add last dummy input if needed. - if (elements.length && (typeof elements[elements.length - 1] == 'string' || - goog.string.startsWith(elements[elements.length - 1]['type'], - 'field_'))) { - var dummyInput = {type: 'input_dummy'}; - if (lastDummyAlign) { - dummyInput['align'] = lastDummyAlign; - } - elements.push(dummyInput); - } - // Lookup of alignment constants. - var alignmentLookup = { - 'LEFT': Blockly.ALIGN_LEFT, - 'RIGHT': Blockly.ALIGN_RIGHT, - 'CENTRE': Blockly.ALIGN_CENTRE - }; - // Populate block with inputs and fields. - var fieldStack = []; - for (var i = 0; i < elements.length; i++) { - var element = elements[i]; - if (typeof element == 'string') { - fieldStack.push([element, undefined]); - } else { - var field = null; - var input = null; - do { - var altRepeat = false; - if (typeof element == 'string') { - field = new Blockly.FieldLabel(element); - } else { - switch (element['type']) { - case 'input_value': - input = this.appendValueInput(element['name']); - break; - case 'input_statement': - input = this.appendStatementInput(element['name']); - break; - case 'input_dummy': - input = this.appendDummyInput(element['name']); - break; - case 'field_label': - field = Blockly.Block.newFieldLabelFromJson_(element); - break; - case 'field_input': - field = Blockly.Block.newFieldTextInputFromJson_(element); - break; - case 'field_angle': - field = new Blockly.FieldAngle(element['angle']); - break; - case 'field_checkbox': - field = new Blockly.FieldCheckbox( - element['checked'] ? 'TRUE' : 'FALSE'); - break; - case 'field_colour': - field = new Blockly.FieldColour(element['colour']); - break; - case 'field_variable': - field = Blockly.Block.newFieldVariableFromJson_(element); - break; - case 'field_dropdown': - field = new Blockly.FieldDropdown(element['options']); - break; - case 'field_image': - field = Blockly.Block.newFieldImageFromJson_(element); - break; - case 'field_number': - field = new Blockly.FieldNumber(element['value'], - element['min'], element['max'], element['precision']); - break; - case 'field_date': - if (Blockly.FieldDate) { - field = new Blockly.FieldDate(element['date']); - break; - } - // Fall through if FieldDate is not compiled in. - default: - // Unknown field. - if (element['alt']) { - element = element['alt']; - altRepeat = true; - } - } - } - } while (altRepeat); - if (field) { - fieldStack.push([field, element['name']]); - } else if (input) { - if (element['check']) { - input.setCheck(element['check']); - } - if (element['align']) { - input.setAlign(alignmentLookup[element['align']]); - } - for (var j = 0; j < fieldStack.length; j++) { - input.appendField(fieldStack[j][0], fieldStack[j][1]); - } - fieldStack.length = 0; - } - } - } -}; - -/** - * Helper function to construct a FieldImage from a JSON arg object, - * dereferencing any string table references. - * @param {!Object} options A JSON object with options (src, width, height, and alt). - * @returns {!Blockly.FieldImage} The new image. - * @private - */ -Blockly.Block.newFieldImageFromJson_ = function(options) { - var src = Blockly.utils.replaceMessageReferences(options['src']); - var width = Number(Blockly.utils.replaceMessageReferences(options['width'])); - var height = - Number(Blockly.utils.replaceMessageReferences(options['height'])); - var alt = Blockly.utils.replaceMessageReferences(options['alt']); - return new Blockly.FieldImage(src, width, height, alt); -}; - -/** - * Helper function to construct a FieldLabel from a JSON arg object, - * dereferencing any string table references. - * @param {!Object} options A JSON object with options (text, and class). - * @returns {!Blockly.FieldLabel} The new label. - * @private - */ -Blockly.Block.newFieldLabelFromJson_ = function(options) { - var text = Blockly.utils.replaceMessageReferences(options['text']); - return new Blockly.FieldLabel(text, options['class']); -}; - -/** - * Helper function to construct a FieldTextInput from a JSON arg object, - * dereferencing any string table references. - * @param {!Object} options A JSON object with options (text, class, and - * spellcheck). - * @returns {!Blockly.FieldTextInput} The new text input. - * @private - */ -Blockly.Block.newFieldTextInputFromJson_ = function(options) { - var text = Blockly.utils.replaceMessageReferences(options['text']); - var field = new Blockly.FieldTextInput(text, options['class']); - if (typeof options['spellcheck'] == 'boolean') { - field.setSpellcheck(options['spellcheck']); - } - return field; -}; - -/** - * Helper function to construct a FieldVariable from a JSON arg object, - * dereferencing any string table references. - * @param {!Object} options A JSON object with options (variable). - * @returns {!Blockly.FieldVariable} The variable field. - * @private - */ -Blockly.Block.newFieldVariableFromJson_ = function(options) { - var varname = Blockly.utils.replaceMessageReferences(options['variable']); - return new Blockly.FieldVariable(varname); -}; - - -/** - * Add a value input, statement input or local variable to this block. - * @param {number} type Either Blockly.INPUT_VALUE or Blockly.NEXT_STATEMENT or - * Blockly.DUMMY_INPUT. - * @param {string} name Language-neutral identifier which may used to find this - * input again. Should be unique to this block. - * @return {!Blockly.Input} The input object created. - * @private - */ -Blockly.Block.prototype.appendInput_ = function(type, name) { - var connection = null; - if (type == Blockly.INPUT_VALUE || type == Blockly.NEXT_STATEMENT) { - connection = this.makeConnection_(type); - } - var input = new Blockly.Input(type, name, this, connection); - // Append input to list. - this.inputList.push(input); - return input; -}; - -/** - * Move a named input to a different location on this block. - * @param {string} name The name of the input to move. - * @param {?string} refName Name of input that should be after the moved input, - * or null to be the input at the end. - */ -Blockly.Block.prototype.moveInputBefore = function(name, refName) { - if (name == refName) { - return; - } - // Find both inputs. - var inputIndex = -1; - var refIndex = refName ? -1 : this.inputList.length; - for (var i = 0, input; input = this.inputList[i]; i++) { - if (input.name == name) { - inputIndex = i; - if (refIndex != -1) { - break; - } - } else if (refName && input.name == refName) { - refIndex = i; - if (inputIndex != -1) { - break; - } - } - } - goog.asserts.assert(inputIndex != -1, 'Named input "%s" not found.', name); - goog.asserts.assert(refIndex != -1, 'Reference input "%s" not found.', - refName); - this.moveNumberedInputBefore(inputIndex, refIndex); -}; - -/** - * Move a numbered input to a different location on this block. - * @param {number} inputIndex Index of the input to move. - * @param {number} refIndex Index of input that should be after the moved input. - */ -Blockly.Block.prototype.moveNumberedInputBefore = function( - inputIndex, refIndex) { - // Validate arguments. - goog.asserts.assert(inputIndex != refIndex, 'Can\'t move input to itself.'); - goog.asserts.assert(inputIndex < this.inputList.length, - 'Input index ' + inputIndex + ' out of bounds.'); - goog.asserts.assert(refIndex <= this.inputList.length, - 'Reference input ' + refIndex + ' out of bounds.'); - // Remove input. - var input = this.inputList[inputIndex]; - this.inputList.splice(inputIndex, 1); - if (inputIndex < refIndex) { - refIndex--; - } - // Reinsert input. - this.inputList.splice(refIndex, 0, input); -}; - -/** - * Remove an input from this block. - * @param {string} name The name of the input. - * @param {boolean=} opt_quiet True to prevent error if input is not present. - * @throws {goog.asserts.AssertionError} if the input is not present and - * opt_quiet is not true. - */ -Blockly.Block.prototype.removeInput = function(name, opt_quiet) { - for (var i = 0, input; input = this.inputList[i]; i++) { - if (input.name == name) { - if (input.connection && input.connection.isConnected()) { - input.connection.setShadowDom(null); - var block = input.connection.targetBlock(); - if (block.isShadow()) { - // Destroy any attached shadow block. - block.dispose(); - } else { - // Disconnect any attached normal block. - block.unplug(); - } - } - input.dispose(); - this.inputList.splice(i, 1); - return; - } - } - if (!opt_quiet) { - goog.asserts.fail('Input "%s" not found.', name); - } -}; - -/** - * Fetches the named input object. - * @param {string} name The name of the input. - * @return {Blockly.Input} The input object, or null if input does not exist. - */ -Blockly.Block.prototype.getInput = function(name) { - for (var i = 0, input; input = this.inputList[i]; i++) { - if (input.name == name) { - return input; - } - } - // This input does not exist. - return null; -}; - -/** - * Fetches the block attached to the named input. - * @param {string} name The name of the input. - * @return {Blockly.Block} The attached value block, or null if the input is - * either disconnected or if the input does not exist. - */ -Blockly.Block.prototype.getInputTargetBlock = function(name) { - var input = this.getInput(name); - return input && input.connection && input.connection.targetBlock(); -}; - -/** - * Returns the comment on this block (or '' if none). - * @return {string} Block's comment. - */ -Blockly.Block.prototype.getCommentText = function() { - return this.comment || ''; -}; - -/** - * Set this block's comment text. - * @param {?string} text The text, or null to delete. - */ -Blockly.Block.prototype.setCommentText = function(text) { - if (this.comment != text) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this, 'comment', null, this.comment, text || '')); - this.comment = text; - } -}; - -/** - * Set this block's warning text. - * @param {?string} text The text, or null to delete. - */ -Blockly.Block.prototype.setWarningText = function(/* text */) { - // NOP. -}; - -/** - * Give this block a mutator dialog. - * @param {Blockly.Mutator} mutator A mutator dialog instance or null to remove. - */ -Blockly.Block.prototype.setMutator = function(/* mutator */) { - // NOP. -}; - -/** - * Return the coordinates of the top-left corner of this block relative to the - * drawing surface's origin (0,0), in workspace units. - * @return {!goog.math.Coordinate} Object with .x and .y properties. - */ -Blockly.Block.prototype.getRelativeToSurfaceXY = function() { - return this.xy_; -}; - -/** - * Move a block by a relative offset. - * @param {number} dx Horizontal offset, in workspace units. - * @param {number} dy Vertical offset, in workspace units. - */ -Blockly.Block.prototype.moveBy = function(dx, dy) { - goog.asserts.assert(!this.parentBlock_, 'Block has parent.'); - var event = new Blockly.Events.BlockMove(this); - this.xy_.translate(dx, dy); - event.recordNew(); - Blockly.Events.fire(event); -}; - -/** - * Create a connection of the specified type. - * @param {number} type The type of the connection to create. - * @return {!Blockly.Connection} A new connection of the specified type. - * @private - */ -Blockly.Block.prototype.makeConnection_ = function(type) { - return new Blockly.Connection(this, type); -}; - -/** - * Recursively checks whether all statement and value inputs are filled with - * blocks. Also checks all following statement blocks in this stack. - * @param {boolean=} opt_shadowBlocksAreFilled An optional argument controlling - * whether shadow blocks are counted as filled. Defaults to true. - * @return {boolean} True if all inputs are filled, false otherwise. - */ -Blockly.Block.prototype.allInputsFilled = function(opt_shadowBlocksAreFilled) { - // Account for the shadow block filledness toggle. - if (opt_shadowBlocksAreFilled === undefined) { - opt_shadowBlocksAreFilled = true; - } - if (!opt_shadowBlocksAreFilled && this.isShadow()) { - return false; - } - - // Recursively check each input block of the current block. - for (var i = 0, input; input = this.inputList[i]; i++) { - if (!input.connection) { - continue; - } - var target = input.connection.targetBlock(); - if (!target || !target.allInputsFilled(opt_shadowBlocksAreFilled)) { - return false; - } - } - - // Recursively check the next block after the current block. - var next = this.getNextBlock(); - if (next) { - return next.allInputsFilled(opt_shadowBlocksAreFilled); - } - - return true; -}; - -/** - * This method returns a string describing this Block in developer terms (type - * name and ID; English only). - * - * Intended to on be used in console logs and errors. If you need a string that - * uses the user's native language (including block text, field values, and - * child blocks), use [toString()]{@link Blockly.Block#toString}. - * @return {string} The description. - */ -Blockly.Block.prototype.toDevString = function() { - var msg = this.type ? '"' + this.type + '" block' : 'Block'; - if (this.id) { - msg += ' (id="' + this.id + '")'; - } - return msg; -}; diff --git a/core/block.ts b/core/block.ts new file mode 100644 index 00000000000..af44facda5d --- /dev/null +++ b/core/block.ts @@ -0,0 +1,2511 @@ +/** + * @license + * Copyright 2011 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The class representing one block. + * + * @class + */ +// Former goog.module ID: Blockly.Block + +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_block_change.js'; +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_block_create.js'; +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_block_delete.js'; + +import {Blocks} from './blocks.js'; +import * as common from './common.js'; +import {Connection} from './connection.js'; +import {ConnectionType} from './connection_type.js'; +import * as constants from './constants.js'; +import type {Abstract} from './events/events_abstract.js'; +import type {BlockChange} from './events/events_block_change.js'; +import type {BlockMove} from './events/events_block_move.js'; +import {EventType} from './events/type.js'; +import * as eventUtils from './events/utils.js'; +import * as Extensions from './extensions.js'; +import type {Field} from './field.js'; +import * as fieldRegistry from './field_registry.js'; +import {DuplicateIconType} from './icons/exceptions.js'; +import {IconType} from './icons/icon_types.js'; +import type {MutatorIcon} from './icons/mutator_icon.js'; +import {Align} from './inputs/align.js'; +import {DummyInput} from './inputs/dummy_input.js'; +import {EndRowInput} from './inputs/end_row_input.js'; +import {Input} from './inputs/input.js'; +import {StatementInput} from './inputs/statement_input.js'; +import {ValueInput} from './inputs/value_input.js'; +import {isCommentIcon} from './interfaces/i_comment_icon.js'; +import {type IIcon} from './interfaces/i_icon.js'; +import type { + IVariableModel, + IVariableState, +} from './interfaces/i_variable_model.js'; +import * as registry from './registry.js'; +import * as Tooltip from './tooltip.js'; +import * as arrayUtils from './utils/array.js'; +import {Coordinate} from './utils/coordinate.js'; +import * as idGenerator from './utils/idgenerator.js'; +import * as parsing from './utils/parsing.js'; +import {Size} from './utils/size.js'; +import type {Workspace} from './workspace.js'; + +/** + * Class for one block. + * Not normally called directly, workspace.newBlock() is preferred. + */ +export class Block { + /** + * An optional callback method to use whenever the block's parent workspace + * changes. This is usually only called from the constructor, the block type + * initializer function, or an extension initializer function. + */ + onchange?: ((p1: Abstract) => void) | null; + + /** The language-neutral ID given to the collapsed input. */ + static readonly COLLAPSED_INPUT_NAME: string = constants.COLLAPSED_INPUT_NAME; + + /** The language-neutral ID given to the collapsed field. */ + static readonly COLLAPSED_FIELD_NAME: string = constants.COLLAPSED_FIELD_NAME; + + /** + * Optional text data that round-trips between blocks and XML. + * Has no effect. May be used by 3rd parties for meta information. + */ + data: string | null = null; + + /** + * Has this block been disposed of? + * + * @internal + */ + disposed = false; + + /** + * Colour of the block as HSV hue value (0-360) + * This may be null if the block colour was not set via a hue number. + */ + private hue: number | null = null; + + /** Colour of the block in '#RRGGBB' format. */ + protected colour_ = '#000000'; + + /** Name of the block style. */ + protected styleName_ = ''; + + /** An optional method called during initialization. */ + init?: () => void; + + /** An optional method called during disposal. */ + destroy?: () => void; + + /** + * An optional serialization method for defining how to serialize the + * mutation state to XML. This must be coupled with defining + * `domToMutation`. + */ + mutationToDom?: (...p1: AnyDuringMigration[]) => Element; + + /** + * An optional deserialization method for defining how to deserialize the + * mutation state from XML. This must be coupled with defining + * `mutationToDom`. + */ + domToMutation?: (p1: Element) => void; + + /** + * An optional serialization method for defining how to serialize the + * block's extra state (eg mutation state) to something JSON compatible. + * This must be coupled with defining `loadExtraState`. + * + * @param doFullSerialization Whether or not to serialize the full state of + * the extra state (rather than possibly saving a reference to some + * state). This is used during copy-paste. See the + * {@link https://developers.devsite.google.com/blockly/guides/create-custom-blocks/extensions#full_serialization_and_backing_data | block serialization docs} + * for more information. + */ + saveExtraState?: (doFullSerialization?: boolean) => AnyDuringMigration; + + /** + * An optional serialization method for defining how to deserialize the + * block's extra state (eg mutation state) from something JSON compatible. + * This must be coupled with defining `saveExtraState`. + */ + loadExtraState?: (p1: AnyDuringMigration) => void; + + /** + * An optional property for suppressing adding STATEMENT_PREFIX and + * STATEMENT_SUFFIX to generated code. + */ + suppressPrefixSuffix: boolean | null = false; + + /** + * An optional method for declaring developer variables, to be used + * by generators. Developer variables are never shown to the user, + * but are declared as global variables in the generated code. + * + * @returns a list of developer variable names. + */ + getDeveloperVariables?: () => string[]; + + /** + * An optional method that reconfigures the block based on the + * contents of the mutator dialog. + * + * @param rootBlock The root block in the mutator flyout. + */ + compose?: (rootBlock: Block) => void; + + /** + * An optional function that populates the mutator flyout with + * blocks representing this block's configuration. + * + * @param workspace The mutator flyout's workspace. + * @returns The root block created in the flyout's workspace. + */ + decompose?: (workspace: Workspace) => Block; + + id: string; + outputConnection: Connection | null = null; + nextConnection: Connection | null = null; + previousConnection: Connection | null = null; + inputList: Input[] = []; + inputsInline?: boolean; + icons: IIcon[] = []; + private disabledReasons = new Set(); + tooltip: Tooltip.TipInfo = ''; + contextMenu = true; + + protected parentBlock_: this | null = null; + + protected childBlocks_: this[] = []; + + private deletable = true; + + private movable = true; + + private editable = true; + + private shadow = false; + + protected collapsed_ = false; + protected outputShape_: number | null = null; + + /** + * Is the current block currently in the process of being disposed? + */ + protected disposing = false; + + /** + * Has this block been fully initialized? E.g. all fields initailized. + * + * @internal + */ + initialized = false; + + private readonly xy: Coordinate; + isInFlyout: boolean; + isInMutator: boolean; + RTL: boolean; + + /** True if this block is an insertion marker. */ + protected isInsertionMarker_ = false; + + /** Name of the type of hat. */ + hat?: string; + + /** Is this block a BlockSVG? */ + readonly rendered: boolean = false; + + /** + * String for block help, or function that returns a URL. Null for no help. + */ + helpUrl: string | (() => string) | null = null; + + /** A bound callback function to use when the parent workspace changes. */ + private onchangeWrapper: ((p1: Abstract) => void) | null = null; + + /** + * A count of statement inputs on the block. + * + * @internal + */ + statementInputCount = 0; + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + type!: string; + // Record initial inline state. + inputsInlineDefault?: boolean; + workspace: Workspace; + + /** + * @param workspace The block's workspace. + * @param prototypeName Name of the language object containing type-specific + * functions for this block. + * @param opt_id Optional ID. Use this ID if provided, otherwise create a new + * ID. + * @throws When the prototypeName is not valid or not allowed. + */ + constructor(workspace: Workspace, prototypeName: string, opt_id?: string) { + this.workspace = workspace; + + this.id = + opt_id && !workspace.getBlockById(opt_id) ? opt_id : idGenerator.genUid(); + workspace.setBlockById(this.id, this); + + /** + * The block's position in workspace units. (0, 0) is at the workspace's + * origin; scale does not change this value. + */ + this.xy = new Coordinate(0, 0); + this.isInFlyout = workspace.isFlyout; + this.isInMutator = workspace.isMutator; + + this.RTL = workspace.RTL; + + // Copy the type-specific functions and data from the prototype. + if (prototypeName) { + this.type = prototypeName; + const prototype = Blocks[prototypeName]; + if (!prototype || typeof prototype !== 'object') { + throw TypeError('Invalid block definition for type: ' + prototypeName); + } + Object.assign(this, prototype); + } + + workspace.addTopBlock(this); + workspace.addTypedBlock(this); + + if (new.target === Block) { + this.doInit_(); + } + } + + /** Calls the init() function and handles associated event firing, etc. */ + protected doInit_() { + // All events fired should be part of the same group. + // Any events fired during init should not be undoable, + // so that block creation is atomic. + const existingGroup = eventUtils.getGroup(); + if (!existingGroup) { + eventUtils.setGroup(true); + } + const initialUndoFlag = eventUtils.getRecordUndo(); + + try { + // Call an initialization function, if it exists. + if (typeof this.init === 'function') { + eventUtils.setRecordUndo(false); + this.init(); + eventUtils.setRecordUndo(initialUndoFlag); + } + + // Fire a create event. + if (eventUtils.isEnabled()) { + eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(this)); + } + } finally { + eventUtils.setGroup(existingGroup); + // In case init threw, recordUndo flag should still be reset. + eventUtils.setRecordUndo(initialUndoFlag); + } + this.inputsInlineDefault = this.inputsInline; + + // Bind an onchange function, if it exists. + if (typeof this.onchange === 'function') { + this.setOnChange(this.onchange); + } + } + + /** + * Dispose of this block. + * + * @param healStack If true, then try to heal any gap by connecting the next + * statement with the previous statement. Otherwise, dispose of all + * children of this block. + */ + dispose(healStack = false) { + this.disposing = true; + + // Dispose of this change listener before unplugging. + // Technically not necessary due to the event firing delay. + // But future-proofing. + if (this.onchangeWrapper) { + this.workspace.removeChangeListener(this.onchangeWrapper); + } + + this.unplug(healStack); + if (eventUtils.isEnabled()) { + // Constructing the delete event is costly. Only perform if necessary. + eventUtils.fire(new (eventUtils.get(EventType.BLOCK_DELETE))(this)); + } + this.workspace.removeTopBlock(this); + this.disposeInternal(); + } + + /** + * Disposes of this block without doing things required by the top block. + * E.g. does not fire events, unplug the block, etc. + */ + protected disposeInternal() { + this.disposing = true; + if (this.onchangeWrapper) { + this.workspace.removeChangeListener(this.onchangeWrapper); + } + + this.workspace.removeTypedBlock(this); + this.workspace.removeBlockById(this.id); + + if (typeof this.destroy === 'function') this.destroy(); + + this.childBlocks_.forEach((c) => c.disposeInternal()); + this.inputList.forEach((i) => i.dispose()); + this.inputList.length = 0; + this.getConnections_(true).forEach((c) => c.dispose()); + this.disposed = true; + } + + /** + * Returns true if the block is either in the process of being disposed, or + * is disposed. + * + * @internal + */ + isDeadOrDying(): boolean { + return this.disposing || this.disposed; + } + + /** + * Call initModel on all fields on the block. + * May be called more than once. + * Either initModel or initSvg must be called after creating a block and + * before the first interaction with it. Interactions include UI actions + * (e.g. clicking and dragging) and firing events (e.g. create, delete, and + * change). + */ + initModel() { + if (this.initialized) return; + for (const input of this.inputList) { + input.initModel(); + } + this.initialized = true; + } + + /** + * Unplug this block from its superior block. If this block is a statement, + * optionally reconnect the block underneath with the block on top. + * + * @param opt_healStack Disconnect child statement and reconnect stack. + * Defaults to false. + */ + unplug(opt_healStack?: boolean) { + if (this.outputConnection) { + this.unplugFromRow(opt_healStack); + } + if (this.previousConnection) { + this.unplugFromStack(opt_healStack); + } + } + + /** + * Unplug this block's output from an input on another block. Optionally + * reconnect the block's parent to the only child block, if possible. + * + * @param opt_healStack Disconnect right-side block and connect to left-side + * block. Defaults to false. + */ + private unplugFromRow(opt_healStack?: boolean) { + let parentConnection = null; + if (this.outputConnection?.isConnected()) { + parentConnection = this.outputConnection.targetConnection; + // Disconnect from any superior block. + this.outputConnection.disconnect(); + } + + // Return early in obvious cases. + if (!parentConnection || !opt_healStack) { + return; + } + + const thisConnection = this.getOnlyValueConnection(); + if ( + !thisConnection || + !thisConnection.isConnected() || + thisConnection.targetBlock()!.isShadow() + ) { + // Too many or too few possible connections on this block, or there's + // nothing on the other side of this connection. + return; + } + + const childConnection = thisConnection.targetConnection; + // Disconnect the child block. + childConnection?.disconnect(); + // Connect child to the parent if possible, otherwise bump away. + if ( + this.workspace.connectionChecker.canConnect( + childConnection, + parentConnection, + false, + ) + ) { + parentConnection.connect(childConnection!); + } else { + childConnection?.onFailedConnect(parentConnection); + } + } + + /** + * Returns the connection on the value input that is connected to another + * block. When an insertion marker is connected to a connection with a block + * already attached, the connected block is attached to the insertion marker. + * Since only one block can be displaced and attached to the insertion marker + * this should only ever return one connection. + * + * @returns The connection on the value input, or null. + */ + private getOnlyValueConnection(): Connection | null { + let connection = null; + for (let i = 0; i < this.inputList.length; i++) { + const thisConnection = this.inputList[i].connection; + if ( + thisConnection && + thisConnection.type === ConnectionType.INPUT_VALUE && + thisConnection.targetConnection + ) { + if (connection) { + return null; // More than one value input found. + } + connection = thisConnection; + } + } + return connection; + } + + /** + * Unplug this statement block from its superior block. Optionally reconnect + * the block underneath with the block on top. + * + * @param opt_healStack Disconnect child statement and reconnect stack. + * Defaults to false. + */ + private unplugFromStack(opt_healStack?: boolean) { + let previousTarget = null; + if (this.previousConnection?.isConnected()) { + // Remember the connection that any next statements need to connect to. + previousTarget = this.previousConnection.targetConnection; + // Detach this block from the parent's tree. + this.previousConnection.disconnect(); + } + + if (!opt_healStack) return; + + // Immovable or shadow next blocks need to move along with the block; keep + // going until we encounter a normal block or run off the end of the stack. + let nextBlock = this.getNextBlock(); + while (nextBlock && (nextBlock.isShadow() || !nextBlock.isMovable())) { + nextBlock = nextBlock.getNextBlock(); + } + if (!nextBlock) return; + + // Disconnect the next statement. + const nextTarget = + nextBlock.previousConnection?.targetBlock()?.nextConnection + ?.targetConnection ?? null; + nextTarget?.disconnect(); + if ( + previousTarget && + this.workspace.connectionChecker.canConnect( + previousTarget, + nextTarget, + false, + ) + ) { + // Attach the next statement to the previous statement. + previousTarget.connect(nextTarget!); + } + } + + /** + * Returns all connections originating from this block. + * + * @param _all If true, return all connections even hidden ones. + * @returns Array of connections. + * @internal + */ + getConnections_(_all: boolean): Connection[] { + const myConnections = []; + if (this.outputConnection) { + myConnections.push(this.outputConnection); + } + if (this.previousConnection) { + myConnections.push(this.previousConnection); + } + if (this.nextConnection) { + myConnections.push(this.nextConnection); + } + for (let i = 0, input; (input = this.inputList[i]); i++) { + if (input.connection) { + myConnections.push(input.connection); + } + } + return myConnections; + } + + /** + * Walks down a stack of blocks and finds the last next connection on the + * stack. + * + * @param ignoreShadows If true,the last connection on a non-shadow block will + * be returned. If false, this will follow shadows to find the last + * connection. + * @returns The last next connection on the stack, or null. + * @internal + */ + lastConnectionInStack(ignoreShadows: boolean): Connection | null { + let nextConnection = this.nextConnection; + while (nextConnection) { + const nextBlock = nextConnection.targetBlock(); + if (!nextBlock || (ignoreShadows && nextBlock.isShadow())) { + return nextConnection; + } + nextConnection = nextBlock.nextConnection; + } + return null; + } + + /** + * Bump unconnected blocks out of alignment. Two blocks which aren't actually + * connected should not coincidentally line up on screen. + */ + bumpNeighbours() {} + + /** + * Return the parent block or null if this block is at the top level. The + * parent block is either the block connected to the previous connection (for + * a statement block) or the block connected to the output connection (for a + * value block). + * + * @returns The block (if any) that holds the current block. + */ + getParent(): this | null { + return this.parentBlock_; + } + + /** + * Return the input that connects to the specified block. + * + * @param block A block connected to an input on this block. + * @returns The input (if any) that connects to the specified block. + */ + getInputWithBlock(block: Block): Input | null { + for (let i = 0, input; (input = this.inputList[i]); i++) { + if (input.connection && input.connection.targetBlock() === block) { + return input; + } + } + return null; + } + + /** + * Return the parent block that surrounds the current block, or null if this + * block has no surrounding block. A parent block might just be the previous + * statement, whereas the surrounding block is an if statement, while loop, + * etc. + * + * @returns The block (if any) that surrounds the current block. + */ + getSurroundParent(): this | null { + /* eslint-disable-next-line @typescript-eslint/no-this-alias */ + let block: this | null = this; + let prevBlock; + do { + prevBlock = block; + block = block.getParent(); + if (!block) { + // Ran off the top. + return null; + } + } while (block.getNextBlock() === prevBlock); + // This block is an enclosing parent, not just a statement in a stack. + return block; + } + + /** + * Return the next statement block directly connected to this block. + * + * @returns The next statement block or null. + */ + getNextBlock(): Block | null { + return this.nextConnection && this.nextConnection.targetBlock(); + } + + /** + * Returns the block connected to the previous connection. + * + * @returns The previous statement block or null. + */ + getPreviousBlock(): Block | null { + return this.previousConnection && this.previousConnection.targetBlock(); + } + + /** + * Return the top-most block in this block's tree. + * This will return itself if this block is at the top level. + * + * @returns The root block. + */ + getRootBlock(): this { + let rootBlock: this; + /* eslint-disable-next-line @typescript-eslint/no-this-alias */ + let block: this | null = this; + do { + rootBlock = block; + block = rootBlock.parentBlock_; + } while (block); + return rootBlock; + } + + /** + * Walk up from the given block up through the stack of blocks to find + * the top block of the sub stack. If we are nested in a statement input only + * find the top-most nested block. Do not go all the way to the root block. + * + * @returns The top block in a stack. + * @internal + */ + getTopStackBlock(): this { + /* eslint-disable-next-line @typescript-eslint/no-this-alias */ + let block = this; + let previous; + do { + previous = block.getPreviousBlock(); + // AnyDuringMigration because: Type 'Block' is not assignable to type + // 'this'. + } while ( + previous && + previous.getNextBlock() === block && + (block = previous as AnyDuringMigration) + ); + return block; + } + + /** + * Find all the blocks that are directly nested inside this one. + * Includes value and statement inputs, as well as any following statement. + * Excludes any connection on an output tab or any preceding statement. + * Blocks are optionally sorted by position; top to bottom. + * + * @param ordered Sort the list if true. + * @returns Array of blocks. + */ + getChildren(ordered: boolean): Block[] { + if (!ordered) { + return this.childBlocks_; + } + const blocks = []; + for (let i = 0, input; (input = this.inputList[i]); i++) { + if (input.connection) { + const child = input.connection.targetBlock(); + if (child) { + blocks.push(child); + } + } + } + const next = this.getNextBlock(); + if (next) { + blocks.push(next); + } + return blocks; + } + + /** + * Set parent of this block to be a new block or null. + * + * @param newParent New parent block. + * @internal + */ + setParent(newParent: this | null) { + if (newParent === this.parentBlock_) { + return; + } + + // Check that block is connected to new parent if new parent is not null and + // that block is not connected to superior one if new parent is null. + const targetBlock = + (this.previousConnection && this.previousConnection.targetBlock()) || + (this.outputConnection && this.outputConnection.targetBlock()); + const isConnected = !!targetBlock; + + if (isConnected && newParent && targetBlock !== newParent) { + throw Error('Block connected to superior one that is not new parent.'); + } else if (!isConnected && newParent) { + throw Error('Block not connected to new parent.'); + } else if (isConnected && !newParent) { + throw Error( + 'Cannot set parent to null while block is still connected to' + + ' superior block.', + ); + } + + // This block hasn't actually moved on-screen, so there's no need to + // update its connection locations. + if (this.parentBlock_) { + // Remove this block from the old parent's child list. + arrayUtils.removeElem(this.parentBlock_.childBlocks_, this); + } else { + // New parent must be non-null so remove this block from the + // workspace's list of top-most blocks. + this.workspace.removeTopBlock(this); + } + + this.parentBlock_ = newParent; + if (newParent) { + // Add this block to the new parent's child list. + newParent.childBlocks_.push(this); + } else { + this.workspace.addTopBlock(this); + } + } + + /** + * Find all the blocks that are directly or indirectly nested inside this one. + * Includes this block in the list. + * Includes value and statement inputs, as well as any following statements. + * Excludes any connection on an output tab or any preceding statements. + * Blocks are optionally sorted by position; top to bottom. + * + * @param ordered Sort the list if true. + * @returns Flattened array of blocks. + */ + getDescendants(ordered: boolean): this[] { + const blocks = [this]; + const childBlocks = this.getChildren(ordered); + for (let child, i = 0; (child = childBlocks[i]); i++) { + // AnyDuringMigration because: Argument of type 'Block[]' is not + // assignable to parameter of type 'this[]'. + blocks.push(...(child.getDescendants(ordered) as AnyDuringMigration)); + } + return blocks; + } + + /** + * Get whether this block is deletable or not. + * + * @returns True if deletable. + */ + isDeletable(): boolean { + return ( + this.deletable && + !this.isInFlyout && + !this.shadow && + !this.isDeadOrDying() && + !this.workspace.isReadOnly() + ); + } + + /** + * Return whether this block's own deletable property is true or false. + * + * @returns True if the block's deletable property is true, false otherwise. + */ + isOwnDeletable(): boolean { + return this.deletable; + } + + /** + * Set whether this block is deletable or not. + * + * @param deletable True if deletable. + */ + setDeletable(deletable: boolean) { + this.deletable = deletable; + } + + /** + * Get whether this block is movable or not. + * + * @returns True if movable. + * @internal + */ + isMovable(): boolean { + return ( + this.movable && + !this.isInFlyout && + !this.shadow && + !this.isDeadOrDying() && + !this.workspace.isReadOnly() + ); + } + + /** + * Return whether this block's own movable property is true or false. + * + * @returns True if the block's movable property is true, false otherwise. + * @internal + */ + isOwnMovable(): boolean { + return this.movable; + } + + /** + * Set whether this block is movable or not. + * + * @param movable True if movable. + */ + setMovable(movable: boolean) { + this.movable = movable; + } + + /** + * Get whether is block is duplicatable or not. If duplicating this block and + * descendants will put this block over the workspace's capacity this block is + * not duplicatable. If duplicating this block and descendants will put any + * type over their maxInstances this block is not duplicatable. + * + * @returns True if duplicatable. + */ + isDuplicatable(): boolean { + if (!this.workspace.hasBlockLimits()) { + return true; + } + return this.workspace.isCapacityAvailable( + common.getBlockTypeCounts(this, true), + ); + } + + /** + * Get whether this block is a shadow block or not. + * + * @returns True if a shadow. + */ + isShadow(): boolean { + return this.shadow; + } + + /** + * Set whether this block is a shadow block or not. + * This method is internal and should not be called by users of Blockly. To + * create shadow blocks programmatically call connection.setShadowState + * + * @param shadow True if a shadow. + * @internal + */ + setShadow(shadow: boolean) { + this.shadow = shadow; + } + + /** + * Get whether this block is an insertion marker block or not. + * + * @returns True if an insertion marker. + */ + isInsertionMarker(): boolean { + return this.isInsertionMarker_; + } + + /** + * Set whether this block is an insertion marker block or not. + * Once set this cannot be unset. + * + * @param insertionMarker True if an insertion marker. + * @internal + */ + setInsertionMarker(insertionMarker: boolean) { + this.isInsertionMarker_ = insertionMarker; + } + + /** + * Get whether this block is editable or not. + * + * @returns True if editable. + * @internal + */ + isEditable(): boolean { + return ( + this.editable && !this.isDeadOrDying() && !this.workspace.isReadOnly() + ); + } + + /** + * Return whether this block's own editable property is true or false. + * + * @returns True if the block's editable property is true, false otherwise. + */ + isOwnEditable(): boolean { + return this.editable; + } + + /** + * Set whether this block is editable or not. + * + * @param editable True if editable. + */ + setEditable(editable: boolean) { + this.editable = editable; + for (const field of this.getFields()) { + field.updateEditable(); + } + } + + /** + * Returns if this block has been disposed of / deleted. + * + * @returns True if this block has been disposed of / deleted. + */ + isDisposed(): boolean { + return this.disposed; + } + + /** + * @returns True if this block is a value block with a single editable field. + * @internal + */ + isSimpleReporter(): boolean { + if (!this.outputConnection) return false; + + for (const input of this.inputList) { + if (input.connection || input.fieldRow.length > 1) return false; + } + return true; + } + + /** + * Find the connection on this block that corresponds to the given connection + * on the other block. + * Used to match connections between a block and its insertion marker. + * + * @param otherBlock The other block to match against. + * @param conn The other connection to match. + * @returns The matching connection on this block, or null. + * @internal + */ + getMatchingConnection( + otherBlock: Block, + conn: Connection, + ): Connection | null { + const connections = this.getConnections_(true); + const otherConnections = otherBlock.getConnections_(true); + if (connections.length !== otherConnections.length) { + throw Error('Connection lists did not match in length.'); + } + for (let i = 0; i < otherConnections.length; i++) { + if (otherConnections[i] === conn) { + return connections[i]; + } + } + return null; + } + + /** + * Set the URL of this block's help page. + * + * @param url URL string for block help, or function that returns a URL. Null + * for no help. + */ + setHelpUrl(url: string | (() => string)) { + this.helpUrl = url; + } + + /** + * Sets the tooltip for this block. + * + * @param newTip The text for the tooltip, a function that returns the text + * for the tooltip, or a parent object whose tooltip will be used. To not + * display a tooltip pass the empty string. + */ + setTooltip(newTip: Tooltip.TipInfo) { + this.tooltip = newTip; + } + + /** + * Returns the tooltip text for this block. + * + * @returns The tooltip text for this block. + */ + getTooltip(): string { + return Tooltip.getTooltipOfObject(this); + } + + /** + * Get the colour of a block. + * + * @returns #RRGGBB string. + */ + getColour(): string { + return this.colour_; + } + + /** + * Get the name of the block style. + * + * @returns Name of the block style. + */ + getStyleName(): string { + return this.styleName_; + } + + /** + * Get the HSV hue value of a block. Null if hue not set. + * + * @returns Hue value (0-360). + */ + getHue(): number | null { + return this.hue; + } + + /** + * Change the colour of a block. + * + * @param colour HSV hue value (0 to 360), #RRGGBB string, or a message + * reference string pointing to one of those two values. + */ + setColour(colour: number | string) { + const parsed = parsing.parseBlockColour(colour); + this.hue = parsed.hue; + this.colour_ = parsed.hex; + } + + /** + * Set the style and colour values of a block. + * + * @param blockStyleName Name of the block style. + */ + setStyle(blockStyleName: string) { + this.styleName_ = blockStyleName; + } + + /** + * Sets a callback function to use whenever the block's parent workspace + * changes, replacing any prior onchange handler. This is usually only called + * from the constructor, the block type initializer function, or an extension + * initializer function. + * + * @param onchangeFn The callback to call when the block's workspace changes. + * @throws {Error} if onchangeFn is not falsey and not a function. + */ + setOnChange(onchangeFn: (p1: Abstract) => void) { + if (onchangeFn && typeof onchangeFn !== 'function') { + throw Error('onchange must be a function.'); + } + if (this.onchangeWrapper) { + this.workspace.removeChangeListener(this.onchangeWrapper); + } + this.onchange = onchangeFn; + this.onchangeWrapper = onchangeFn.bind(this); + this.workspace.addChangeListener(this.onchangeWrapper); + } + + /** + * Returns the named field from a block. + * + * @param name The name of the field. + * @returns Named field, or null if field does not exist. + */ + getField(name: string): Field | null { + if (typeof name !== 'string') { + throw TypeError( + 'Block.prototype.getField expects a string ' + + 'with the field name but received ' + + (name === undefined ? 'nothing' : name + ' of type ' + typeof name) + + ' instead', + ); + } + for (const field of this.getFields()) { + if (field.name === name) { + return field; + } + } + return null; + } + + /** + * Returns a generator that provides every field on the block. + * + * @returns A generator that can be used to iterate the fields on the block. + */ + *getFields(): Generator { + for (const input of this.inputList) { + for (const field of input.fieldRow) { + yield field; + } + } + } + + /** + * Return all variables referenced by this block. + * + * @returns List of variable ids. + */ + getVars(): string[] { + const vars: string[] = []; + for (const field of this.getFields()) { + if (field.referencesVariables()) { + vars.push(field.getValue()); + } + } + return vars; + } + + /** + * Return all variables referenced by this block. + * + * @returns List of variable models. + * @internal + */ + getVarModels(): IVariableModel[] { + const vars = []; + for (const field of this.getFields()) { + if (field.referencesVariables()) { + const model = this.workspace.getVariableById( + field.getValue() as string, + ); + // Check if the variable actually exists (and isn't just a potential + // variable). + if (model) { + vars.push(model); + } + } + } + return vars; + } + + /** + * Notification that a variable is renaming but keeping the same ID. If the + * variable is in use on this block, rerender to show the new name. + * + * @param variable The variable being renamed. + * @internal + */ + updateVarName(variable: IVariableModel) { + for (const field of this.getFields()) { + if ( + field.referencesVariables() && + variable.getId() === field.getValue() + ) { + field.refreshVariableName(); + } + } + } + + /** + * Notification that a variable is renaming. + * If the ID matches one of this block's variables, rename it. + * + * @param oldId ID of variable to rename. + * @param newId ID of new variable. May be the same as oldId, but with an + * updated name. + */ + renameVarById(oldId: string, newId: string) { + for (const field of this.getFields()) { + if (field.referencesVariables() && oldId === field.getValue()) { + field.setValue(newId); + } + } + } + + /** + * Returns the language-neutral value of the given field. + * + * @param name The name of the field. + * @returns Value of the field or null if field does not exist. + */ + getFieldValue(name: string): AnyDuringMigration { + const field = this.getField(name); + if (field) { + return field.getValue(); + } + return null; + } + + /** + * Sets the value of the given field for this block. + * + * @param newValue The value to set. + * @param name The name of the field to set the value of. + */ + setFieldValue(newValue: AnyDuringMigration, name: string) { + const field = this.getField(name); + if (!field) { + throw Error('Field "' + name + '" not found.'); + } + field.setValue(newValue); + } + + /** + * Set whether this block can chain onto the bottom of another block. + * + * @param newBoolean True if there can be a previous statement. + * @param opt_check Statement type or list of statement types. Null/undefined + * if any type could be connected. + */ + setPreviousStatement( + newBoolean: boolean, + opt_check?: string | string[] | null, + ) { + if (newBoolean) { + if (opt_check === undefined) { + opt_check = null; + } + if (!this.previousConnection) { + this.previousConnection = this.makeConnection_( + ConnectionType.PREVIOUS_STATEMENT, + ); + } + this.previousConnection.setCheck(opt_check); + } else { + if (this.previousConnection) { + if (this.previousConnection.isConnected()) { + throw Error( + 'Must disconnect previous statement before removing ' + + 'connection.', + ); + } + this.previousConnection.dispose(); + this.previousConnection = null; + } + } + } + + /** + * Set whether another block can chain onto the bottom of this block. + * + * @param newBoolean True if there can be a next statement. + * @param opt_check Statement type or list of statement types. Null/undefined + * if any type could be connected. + */ + setNextStatement(newBoolean: boolean, opt_check?: string | string[] | null) { + if (newBoolean) { + if (opt_check === undefined) { + opt_check = null; + } + if (!this.nextConnection) { + this.nextConnection = this.makeConnection_( + ConnectionType.NEXT_STATEMENT, + ); + } + this.nextConnection.setCheck(opt_check); + } else { + if (this.nextConnection) { + if (this.nextConnection.isConnected()) { + throw Error( + 'Must disconnect next statement before removing ' + 'connection.', + ); + } + this.nextConnection.dispose(); + this.nextConnection = null; + } + } + } + + /** + * Set whether this block returns a value. + * + * @param newBoolean True if there is an output. + * @param opt_check Returned type or list of returned types. Null or + * undefined if any type could be returned (e.g. variable get). + */ + setOutput(newBoolean: boolean, opt_check?: string | string[] | null) { + if (newBoolean) { + if (opt_check === undefined) { + opt_check = null; + } + if (!this.outputConnection) { + this.outputConnection = this.makeConnection_( + ConnectionType.OUTPUT_VALUE, + ); + } + this.outputConnection.setCheck(opt_check); + } else { + if (this.outputConnection) { + if (this.outputConnection.isConnected()) { + throw Error( + 'Must disconnect output value before removing connection.', + ); + } + this.outputConnection.dispose(); + this.outputConnection = null; + } + } + } + + /** + * Set whether value inputs are arranged horizontally or vertically. + * + * @param newBoolean True if inputs are horizontal. + */ + setInputsInline(newBoolean: boolean) { + if (this.inputsInline !== newBoolean) { + eventUtils.fire( + new (eventUtils.get(EventType.BLOCK_CHANGE))( + this, + 'inline', + null, + this.inputsInline, + newBoolean, + ), + ); + this.inputsInline = newBoolean; + } + } + + /** + * Get whether value inputs are arranged horizontally or vertically. + * + * @returns True if inputs are horizontal. + */ + getInputsInline(): boolean { + if (this.inputsInline !== undefined) { + // Set explicitly. + return this.inputsInline; + } + // Not defined explicitly. Figure out what would look best. + for (let i = 1; i < this.inputList.length; i++) { + if ( + this.inputList[i - 1] instanceof DummyInput && + this.inputList[i] instanceof DummyInput + ) { + // Two dummy inputs in a row. Don't inline them. + return false; + } + } + for (let i = 1; i < this.inputList.length; i++) { + if ( + this.inputList[i - 1] instanceof ValueInput && + this.inputList[i] instanceof DummyInput + ) { + // Dummy input after a value input. Inline them. + return true; + } + } + for (let i = 0; i < this.inputList.length; i++) { + if (this.inputList[i] instanceof EndRowInput) { + // A row-end input is present. Inline value inputs. + return true; + } + } + return false; + } + + /** + * Set the block's output shape. + * + * @param outputShape Value representing an output shape. + */ + setOutputShape(outputShape: number | null) { + this.outputShape_ = outputShape; + } + + /** + * Get the block's output shape. + * + * @returns Value representing output shape if one exists. + */ + getOutputShape(): number | null { + return this.outputShape_; + } + + /** + * Get whether this block is enabled or not. A block is considered enabled + * if there aren't any reasons why it would be disabled. A block may still + * be disabled for other reasons even if the user attempts to manually + * enable it, such as when the block is in an invalid location. + * + * @returns True if enabled. + */ + isEnabled(): boolean { + return this.disabledReasons.size === 0; + } + + /** + * Add or remove a reason why the block might be disabled. If a block has + * any reasons to be disabled, then the block itself will be considered + * disabled. A block could be disabled for multiple independent reasons + * simultaneously, such as when the user manually disables it, or the block + * is invalid. + * + * @param disabled If true, then the block should be considered disabled for + * at least the provided reason, otherwise the block is no longer disabled + * for that reason. + * @param reason A language-neutral identifier for a reason why the block + * could be disabled. Call this method again with the same identifier to + * update whether the block is currently disabled for this reason. + */ + setDisabledReason(disabled: boolean, reason: string): void { + // Workspaces that were serialized before the reason for being disabled + // could be specified may have blocks that are disabled without a known + // reason. On being loaded, these blocks will default to having the manually + // disabled reason. However, if the user isn't allowed to manually disable + // or enable blocks, then this manually disabled reason cannot be removed. + // For backward compatibility with these legacy workspaces, when removing + // any disabled reason and the workspace does not allow manually disabling + // but the block is manually disabled, then remove the manually disabled + // reason in addition to the more specific reason. For example, when an + // orphaned block is no longer orphaned, the block should be enabled again. + if ( + !disabled && + !this.workspace.options.disable && + this.hasDisabledReason(constants.MANUALLY_DISABLED) && + reason != constants.MANUALLY_DISABLED + ) { + this.setDisabledReason(false, constants.MANUALLY_DISABLED); + } + + if (this.disabledReasons.has(reason) !== disabled) { + if (disabled) { + this.disabledReasons.add(reason); + } else { + this.disabledReasons.delete(reason); + } + const blockChangeEvent = new (eventUtils.get(EventType.BLOCK_CHANGE))( + this, + 'disabled', + /* name= */ null, + /* oldValue= */ !disabled, + /* newValue= */ disabled, + ) as BlockChange; + blockChangeEvent.setDisabledReason(reason); + eventUtils.fire(blockChangeEvent); + } + } + + /** + * Get whether the block is disabled or not due to parents. + * The block's own disabled property is not considered. + * + * @returns True if disabled. + */ + getInheritedDisabled(): boolean { + let ancestor = this.getSurroundParent(); + while (ancestor) { + if (!ancestor.isEnabled()) { + return true; + } + ancestor = ancestor.getSurroundParent(); + } + // Ran off the top. + return false; + } + + /** + * Get whether the block is currently disabled for the provided reason. + * + * @param reason A language-neutral identifier for a reason why the block + * could be disabled. + * @returns Whether the block is disabled for the provided reason. + */ + hasDisabledReason(reason: string): boolean { + return this.disabledReasons.has(reason); + } + + /** + * Get a set of reasons why the block is currently disabled, if any. If the + * block is enabled, this set will be empty. + * + * @returns The set of reasons why the block is disabled, if any. + */ + getDisabledReasons(): ReadonlySet { + return this.disabledReasons; + } + + /** + * Get whether the block is collapsed or not. + * + * @returns True if collapsed. + */ + isCollapsed(): boolean { + return this.collapsed_; + } + + /** + * Set whether the block is collapsed or not. + * + * @param collapsed True if collapsed. + */ + setCollapsed(collapsed: boolean) { + if (this.collapsed_ !== collapsed) { + eventUtils.fire( + new (eventUtils.get(EventType.BLOCK_CHANGE))( + this, + 'collapsed', + null, + this.collapsed_, + collapsed, + ), + ); + this.collapsed_ = collapsed; + } + } + + /** + * Create a human-readable text representation of this block and any children. + * + * @param opt_maxLength Truncate the string to this length. + * @param opt_emptyToken The placeholder string used to denote an empty input. + * If not specified, '?' is used. + * @returns Text of block. + */ + toString(opt_maxLength?: number, opt_emptyToken?: string): string { + const tokens = this.toTokens(opt_emptyToken); + + // Run through our tokens array and simplify expression to remove + // parentheses around single field blocks. + // E.g. ['repeat', '(', '10', ')', 'times', 'do', '?'] + for (let i = 2; i < tokens.length; i++) { + if (tokens[i - 2] === '(' && tokens[i] === ')') { + tokens[i - 2] = tokens[i - 1]; + tokens.splice(i - 1, 2); + } + } + + // Join the text array, removing the spaces around added parentheses. + let prev = ''; + let text: string = tokens.reduce((acc, curr) => { + const val = acc + (prev === '(' || curr === ')' ? '' : ' ') + curr; + prev = curr[curr.length - 1]; + return val; + }, ''); + + text = text.trim() || '???'; + if (opt_maxLength) { + // TODO: Improve truncation so that text from this block is given + // priority. E.g. "1+2+3+4+5+6+7+8+9=0" should be "...6+7+8+9=0", not + // "1+2+3+4+5...". E.g. "1+2+3+4+5=6+7+8+9+0" should be "...4+5=6+7...". + if (text.length > opt_maxLength) { + text = text.substring(0, opt_maxLength - 3) + '...'; + } + } + return text; + } + + /** + * Converts this block into string tokens. + * + * @param emptyToken The token to use in place of an empty input. + * Defaults to '?'. + * @returns The array of string tokens representing this block. + */ + private toTokens(emptyToken = '?'): string[] { + const tokens = []; + /** + * Whether or not to add parentheses around an input. + * + * @param connection The connection. + * @returns True if we should add parentheses around the input. + */ + function shouldAddParentheses(connection: Connection): boolean { + let checks = connection.getCheck(); + if (!checks && connection.targetConnection) { + checks = connection.targetConnection.getCheck(); + } + return ( + !!checks && (checks.includes('Boolean') || checks.includes('Number')) + ); + } + + for (const input of this.inputList) { + if (input.name == constants.COLLAPSED_INPUT_NAME) { + continue; + } + for (const field of input.fieldRow) { + tokens.push(field.getText()); + } + if (input.connection) { + const child = input.connection.targetBlock(); + if (child) { + const shouldAddParens = shouldAddParentheses(input.connection); + if (shouldAddParens) tokens.push('('); + tokens.push(...child.toTokens(emptyToken)); + if (shouldAddParens) tokens.push(')'); + } else { + tokens.push(emptyToken); + } + } + } + return tokens; + } + + /** + * Appends a value input row. + * + * @param name Language-neutral identifier which may used to find this input + * again. Should be unique to this block. + * @returns The input object created. + */ + appendValueInput(name: string): Input { + return this.appendInput(new ValueInput(name, this)); + } + + /** + * Appends a statement input row. + * + * @param name Language-neutral identifier which may used to find this input + * again. Should be unique to this block. + * @returns The input object created. + */ + appendStatementInput(name: string): Input { + this.statementInputCount++; + return this.appendInput(new StatementInput(name, this)); + } + + /** + * Appends a dummy input row. + * + * @param name Optional language-neutral identifier which may used to find + * this input again. Should be unique to this block. + * @returns The input object created. + */ + appendDummyInput(name = ''): Input { + return this.appendInput(new DummyInput(name, this)); + } + + /** + * Appends an input that ends the row. + * + * @param name Optional language-neutral identifier which may used to find + * this input again. Should be unique to this block. + * @returns The input object created. + */ + appendEndRowInput(name = ''): Input { + return this.appendInput(new EndRowInput(name, this)); + } + + /** + * Appends the given input row. + * + * Allows for custom inputs to be appended to the block. + */ + appendInput(input: Input): Input { + this.inputList.push(input); + return input; + } + + /** + * Appends an input with the given input type and name to the block after + * constructing it from the registry. + * + * @param type The name the input is registered under in the registry. + * @param name The name the input will have within the block. + * @returns The constucted input, or null if there was no constructor + * associated with the type. + */ + private appendInputFromRegistry(type: string, name: string): Input | null { + const inputConstructor = registry.getClass( + registry.Type.INPUT, + type, + false, + ); + if (!inputConstructor) return null; + return this.appendInput(new inputConstructor(name, this)); + } + + /** + * Initialize this block using a cross-platform, internationalization-friendly + * JSON description. + * + * @param json Structured data describing the block. + */ + jsonInit(json: AnyDuringMigration) { + const warningPrefix = json['type'] ? 'Block "' + json['type'] + '": ' : ''; + + // Validate inputs. + if (json['output'] && json['previousStatement']) { + throw Error( + warningPrefix + 'Must not have both an output and a previousStatement.', + ); + } + + // Validate that each arg has a corresponding message + let n = 0; + while (json['args' + n]) { + if (json['message' + n] === undefined) { + throw Error( + warningPrefix + + `args${n} must have a corresponding message (message${n}).`, + ); + } + n++; + } + + // Set basic properties of block. + // Makes styles backward compatible with old way of defining hat style. + if (json['style'] && json['style'].hat) { + this.hat = json['style'].hat; + // Must set to null so it doesn't error when checking for style and + // colour. + json['style'] = null; + } + + if (json['style'] && json['colour']) { + throw Error(warningPrefix + 'Must not have both a colour and a style.'); + } else if (json['style']) { + this.jsonInitStyle(json, warningPrefix); + } else { + this.jsonInitColour(json, warningPrefix); + } + + // Interpolate the message blocks. + let i = 0; + while (json['message' + i] !== undefined) { + this.interpolate( + json['message' + i], + json['args' + i] || [], + // Backwards compatibility: lastDummyAlign aliases implicitAlign. + json['implicitAlign' + i] || json['lastDummyAlign' + i], + warningPrefix, + ); + i++; + } + + if (json['inputsInline'] !== undefined) { + eventUtils.disable(); + this.setInputsInline(json['inputsInline']); + eventUtils.enable(); + } + + // Set output and previous/next connections. + if (json['output'] !== undefined) { + this.setOutput(true, json['output']); + } + if (json['outputShape'] !== undefined) { + this.setOutputShape(json['outputShape']); + } + if (json['previousStatement'] !== undefined) { + this.setPreviousStatement(true, json['previousStatement']); + } + if (json['nextStatement'] !== undefined) { + this.setNextStatement(true, json['nextStatement']); + } + if (json['tooltip'] !== undefined) { + const rawValue = json['tooltip']; + const localizedText = parsing.replaceMessageReferences(rawValue); + this.setTooltip(localizedText); + } + if (json['enableContextMenu'] !== undefined) { + this.contextMenu = !!json['enableContextMenu']; + } + if (json['suppressPrefixSuffix'] !== undefined) { + this.suppressPrefixSuffix = !!json['suppressPrefixSuffix']; + } + if (json['helpUrl'] !== undefined) { + const rawValue = json['helpUrl']; + const localizedValue = parsing.replaceMessageReferences(rawValue); + this.setHelpUrl(localizedValue); + } + if (typeof json['extensions'] === 'string') { + console.warn( + warningPrefix + + "JSON attribute 'extensions' should be an array of" + + " strings. Found raw string in JSON for '" + + json['type'] + + "' block.", + ); + json['extensions'] = [json['extensions']]; // Correct and continue. + } + + // Add the mutator to the block. + if (json['mutator'] !== undefined) { + Extensions.apply(json['mutator'], this, true); + } + + const extensionNames = json['extensions']; + if (Array.isArray(extensionNames)) { + for (let j = 0; j < extensionNames.length; j++) { + Extensions.apply(extensionNames[j], this, false); + } + } + } + + /** + * Initialize the colour of this block from the JSON description. + * + * @param json Structured data describing the block. + * @param warningPrefix Warning prefix string identifying block. + */ + private jsonInitColour(json: AnyDuringMigration, warningPrefix: string) { + if ('colour' in json) { + if (json['colour'] === undefined) { + console.warn(warningPrefix + 'Undefined colour value.'); + } else { + const rawValue = json['colour']; + try { + this.setColour(rawValue); + } catch { + console.warn(warningPrefix + 'Illegal colour value: ', rawValue); + } + } + } + } + + /** + * Initialize the style of this block from the JSON description. + * + * @param json Structured data describing the block. + * @param warningPrefix Warning prefix string identifying block. + */ + private jsonInitStyle(json: AnyDuringMigration, warningPrefix: string) { + const blockStyleName = json['style']; + try { + this.setStyle(blockStyleName); + } catch { + console.warn(warningPrefix + 'Style does not exist: ', blockStyleName); + } + } + + /** + * Add key/values from mixinObj to this block object. By default, this method + * will check that the keys in mixinObj will not overwrite existing values in + * the block, including prototype values. This provides some insurance against + * mixin / extension incompatibilities with future block features. This check + * can be disabled by passing true as the second argument. + * + * @param mixinObj The key/values pairs to add to this block object. + * @param opt_disableCheck Option flag to disable overwrite checks. + */ + mixin(mixinObj: AnyDuringMigration, opt_disableCheck?: boolean) { + if ( + opt_disableCheck !== undefined && + typeof opt_disableCheck !== 'boolean' + ) { + throw Error('opt_disableCheck must be a boolean if provided'); + } + if (!opt_disableCheck) { + const overwrites = []; + for (const key in mixinObj) { + if ((this as AnyDuringMigration)[key] !== undefined) { + overwrites.push(key); + } + } + if (overwrites.length) { + throw Error( + 'Mixin will overwrite block members: ' + JSON.stringify(overwrites), + ); + } + } + Object.assign(this, mixinObj); + } + + /** + * Interpolate a message description onto the block. + * + * @param message Text contains interpolation tokens (%1, %2, ...) that match + * with fields or inputs defined in the args array. + * @param args Array of arguments to be interpolated. + * @param implicitAlign If an implicit input is added at the end or in place + * of newline tokens, how should it be aligned? + * @param warningPrefix Warning prefix string identifying block. + */ + private interpolate( + message: string, + args: AnyDuringMigration[], + implicitAlign: string | undefined, + warningPrefix: string, + ) { + const tokens = parsing.tokenizeInterpolation(message); + this.validateTokens(tokens, args.length); + const elements = this.interpolateArguments(tokens, args, implicitAlign); + + // An array of [field, fieldName] tuples. + const fieldStack = []; + for (let i = 0, element; (element = elements[i]); i++) { + if (this.isInputKeyword(element['type'])) { + const input = this.inputFromJson(element, warningPrefix); + // Should never be null, but just in case. + if (input) { + for (let j = 0, tuple; (tuple = fieldStack[j]); j++) { + input.appendField(tuple[0], tuple[1]); + } + fieldStack.length = 0; + } + } else { + // All other types, including ones starting with 'input_' get routed + // here. + const field = this.fieldFromJson(element); + if (field) { + fieldStack.push([field, element['name']]); + } + } + } + } + + /** + * Validates that the tokens are within the correct bounds, with no + * duplicates, and that all of the arguments are referred to. Throws errors if + * any of these things are not true. + * + * @param tokens An array of tokens to validate + * @param argsCount The number of args that need to be referred to. + */ + private validateTokens(tokens: Array, argsCount: number) { + const visitedArgsHash = []; + let visitedArgsCount = 0; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (typeof token !== 'number') { + continue; + } + if (token < 1 || token > argsCount) { + throw Error( + 'Block "' + + this.type + + '": ' + + 'Message index %' + + token + + ' out of range.', + ); + } + if (visitedArgsHash[token]) { + throw Error( + 'Block "' + + this.type + + '": ' + + 'Message index %' + + token + + ' duplicated.', + ); + } + visitedArgsHash[token] = true; + visitedArgsCount++; + } + if (visitedArgsCount !== argsCount) { + throw Error( + 'Block "' + + this.type + + '": ' + + 'Message does not reference all ' + + argsCount + + ' arg(s).', + ); + } + } + + /** + * Inserts args in place of numerical tokens. String args are converted to + * JSON that defines a label field. Newline characters are converted to + * end-row inputs, and if necessary an extra dummy input is added to the end + * of the elements. + * + * @param tokens The tokens to interpolate + * @param args The arguments to insert. + * @param implicitAlign The alignment to use for any implicitly added end-row + * or dummy inputs, if necessary. + * @returns The JSON definitions of field and inputs to add to the block. + */ + private interpolateArguments( + tokens: Array, + args: Array, + implicitAlign: string | undefined, + ): AnyDuringMigration[] { + const elements = []; + for (let i = 0; i < tokens.length; i++) { + let element = tokens[i]; + if (typeof element === 'number') { + element = args[element - 1]; + } + // Args can be strings, which is why this isn't elseif. + if (typeof element === 'string') { + if (element === '\n') { + // Convert newline tokens to end-row inputs. + const newlineInput = {'type': 'input_end_row'}; + if (implicitAlign) { + (newlineInput as AnyDuringMigration)['align'] = implicitAlign; + } + element = newlineInput as AnyDuringMigration; + } else { + // AnyDuringMigration because: Type '{ text: string; type: string; } + // | null' is not assignable to type 'string | number'. + element = this.stringToFieldJson(element) as AnyDuringMigration; + if (!element) { + continue; + } + } + } + elements.push(element); + } + + const length = elements.length; + if ( + length && + !this.isInputKeyword((elements as AnyDuringMigration)[length - 1]['type']) + ) { + const dummyInput = {'type': 'input_dummy'}; + if (implicitAlign) { + (dummyInput as AnyDuringMigration)['align'] = implicitAlign; + } + elements.push(dummyInput); + } + + return elements; + } + + /** + * Creates a field from the JSON definition of a field. If a field with the + * given type cannot be found, this attempts to create a different field using + * the 'alt' property of the JSON definition (if it exists). + * + * @param element The element to try to turn into a field. + * @returns The field defined by the JSON, or null if one couldn't be created. + */ + private fieldFromJson(element: { + alt?: string; + type: string; + text?: string; + }): Field | null { + const field = fieldRegistry.fromJson(element); + if (!field && element['alt']) { + if (typeof element['alt'] === 'string') { + const json = this.stringToFieldJson(element['alt']); + return json ? this.fieldFromJson(json) : null; + } + return this.fieldFromJson(element['alt']); + } + return field; + } + + /** + * Creates an input from the JSON definition of an input. Sets the input's + * check and alignment if they are provided. + * + * @param element The JSON to turn into an input. + * @param warningPrefix The prefix to add to warnings to help the developer + * debug. + * @returns The input that has been created, or null if one could not be + * created for some reason (should never happen). + */ + private inputFromJson( + element: AnyDuringMigration, + warningPrefix: string, + ): Input | null { + const alignmentLookup = { + 'LEFT': Align.LEFT, + 'RIGHT': Align.RIGHT, + 'CENTRE': Align.CENTRE, + 'CENTER': Align.CENTRE, + }; + + let input = null; + switch (element['type']) { + case 'input_value': + input = this.appendValueInput(element['name']); + break; + case 'input_statement': + input = this.appendStatementInput(element['name']); + break; + case 'input_dummy': + input = this.appendDummyInput(element['name']); + break; + case 'input_end_row': + input = this.appendEndRowInput(element['name']); + break; + default: { + input = this.appendInputFromRegistry(element['type'], element['name']); + break; + } + } + // Should never be hit because of interpolate_'s checks, but just in case. + if (!input) { + return null; + } + + if (element['check']) { + input.setCheck(element['check']); + } + if (element['align']) { + const alignment = (alignmentLookup as AnyDuringMigration)[ + element['align'].toUpperCase() + ]; + if (alignment === undefined) { + console.warn(warningPrefix + 'Illegal align value: ', element['align']); + } else { + input.setAlign(alignment); + } + } + return input; + } + + /** + * Returns true if the given string matches one of the input keywords. + * + * @param str The string to check. + * @returns True if the given string matches one of the input keywords, false + * otherwise. + */ + private isInputKeyword(str: string): boolean { + return ( + str === 'input_value' || + str === 'input_statement' || + str === 'input_dummy' || + str === 'input_end_row' || + registry.hasItem(registry.Type.INPUT, str) + ); + } + + /** + * Turns a string into the JSON definition of a label field. If the string + * becomes an empty string when trimmed, this returns null. + * + * @param str String to turn into the JSON definition of a label field. + * @returns The JSON definition or null. + */ + private stringToFieldJson(str: string): {text: string; type: string} | null { + str = str.trim(); + if (str) { + return { + 'type': 'field_label', + 'text': str, + }; + } + return null; + } + + /** + * Move a named input to a different location on this block. + * + * @param name The name of the input to move. + * @param refName Name of input that should be after the moved input, or null + * to be the input at the end. + */ + moveInputBefore(name: string, refName: string | null) { + if (name === refName) { + return; + } + // Find both inputs. + let inputIndex = -1; + let refIndex = refName ? -1 : this.inputList.length; + for (let i = 0, input; (input = this.inputList[i]); i++) { + if (input.name === name) { + inputIndex = i; + if (refIndex !== -1) { + break; + } + } else if (refName && input.name === refName) { + refIndex = i; + if (inputIndex !== -1) { + break; + } + } + } + if (inputIndex === -1) { + throw Error('Named input "' + name + '" not found.'); + } + if (refIndex === -1) { + throw Error('Reference input "' + refName + '" not found.'); + } + this.moveNumberedInputBefore(inputIndex, refIndex); + } + + /** + * Move a numbered input to a different location on this block. + * + * @param inputIndex Index of the input to move. + * @param refIndex Index of input that should be after the moved input. + */ + moveNumberedInputBefore(inputIndex: number, refIndex: number) { + // Validate arguments. + if (inputIndex === refIndex) { + throw Error("Can't move input to itself."); + } + if (inputIndex >= this.inputList.length) { + throw RangeError('Input index ' + inputIndex + ' out of bounds.'); + } + if (refIndex > this.inputList.length) { + throw RangeError('Reference input ' + refIndex + ' out of bounds.'); + } + // Remove input. + const input = this.inputList[inputIndex]; + this.inputList.splice(inputIndex, 1); + if (inputIndex < refIndex) { + refIndex--; + } + // Reinsert input. + this.inputList.splice(refIndex, 0, input); + } + + /** + * Remove an input from this block. + * + * @param name The name of the input. + * @param opt_quiet True to prevent an error if input is not present. + * @returns True if operation succeeds, false if input is not present and + * opt_quiet is true. + * @throws {Error} if the input is not present and opt_quiet is not true. + */ + removeInput(name: string, opt_quiet?: boolean): boolean { + for (let i = 0, input; (input = this.inputList[i]); i++) { + if (input.name === name) { + if (input instanceof StatementInput) this.statementInputCount--; + input.dispose(); + this.inputList.splice(i, 1); + return true; + } + } + if (opt_quiet) { + return false; + } + throw Error('Input not found: ' + name); + } + + /** + * Fetches the named input object. + * + * @param name The name of the input. + * @returns The input object, or null if input does not exist. + */ + getInput(name: string): Input | null { + for (let i = 0, input; (input = this.inputList[i]); i++) { + if (input.name === name) { + return input; + } + } + // This input does not exist. + return null; + } + + /** + * Fetches the block attached to the named input. + * + * @param name The name of the input. + * @returns The attached value block, or null if the input is either + * disconnected or if the input does not exist. + */ + getInputTargetBlock(name: string): Block | null { + const input = this.getInput(name); + return input && input.connection && input.connection.targetBlock(); + } + + /** + * Returns the comment on this block (or null if there is no comment). + * + * @returns Block's comment. + */ + getCommentText(): string | null { + const comment = this.getIcon(IconType.COMMENT); + return comment?.getText() ?? null; + } + + /** + * Set this block's comment text. + * + * @param text The text, or null to delete. + */ + setCommentText(text: string | null) { + const comment = this.getIcon(IconType.COMMENT); + const oldText = comment?.getText() ?? null; + if (oldText === text) return; + if (text !== null) { + let comment = this.getIcon(IconType.COMMENT); + if (!comment) { + const commentConstructor = registry.getClass( + registry.Type.ICON, + IconType.COMMENT.toString(), + false, + ); + if (!commentConstructor) { + throw new Error( + 'No comment icon class is registered, so a comment cannot be set', + ); + } + const icon = new commentConstructor(this); + if (!isCommentIcon(icon)) { + throw new Error( + 'The class registered as a comment icon does not conform to the ' + + 'ICommentIcon interface', + ); + } + comment = this.addIcon(icon); + } + eventUtils.disable(); + comment.setText(text); + eventUtils.enable(); + } else { + this.removeIcon(IconType.COMMENT); + } + + eventUtils.fire( + new (eventUtils.get(EventType.BLOCK_CHANGE))( + this, + 'comment', + null, + oldText, + text, + ), + ); + } + + /** + * Set this block's warning text. + * + * @param _text The text, or null to delete. + * @param _opt_id An optional ID for the warning text to be able to maintain + * multiple warnings. + */ + setWarningText(_text: string | null, _opt_id?: string) { + // NOOP. + } + + /** + * Give this block a mutator dialog. + * + * @param _mutator A mutator dialog instance or null to remove. + */ + setMutator(_mutator: MutatorIcon) { + // NOOP. + } + + /** Adds the given icon to the block. */ + addIcon(icon: T): T { + if (this.hasIcon(icon.getType())) throw new DuplicateIconType(icon); + this.icons.push(icon); + this.icons.sort((a, b) => a.getWeight() - b.getWeight()); + return icon; + } + + /** + * Removes the icon whose getType matches the given type iconType from the + * block. + * + * @param type The type of the icon to remove from the block. + * @returns True if an icon with the given type was found, false otherwise. + */ + removeIcon(type: IconType): boolean { + if (!this.hasIcon(type)) return false; + this.getIcon(type)?.dispose(); + this.icons = this.icons.filter((icon) => !icon.getType().equals(type)); + return true; + } + + /** + * @returns True if an icon with the given type exists on the block, + * false otherwise. + */ + hasIcon(type: IconType): boolean { + return this.icons.some((icon) => icon.getType().equals(type)); + } + + /** + * @param type The type of the icon to retrieve. Prefer passing an `IconType` + * for proper type checking when using typescript. + * @returns The icon with the given type if it exists on the block, undefined + * otherwise. + */ + getIcon(type: IconType | string): T | undefined { + if (type instanceof IconType) { + return this.icons.find((icon) => icon.getType().equals(type)) as T; + } else { + return this.icons.find((icon) => icon.getType().toString() === type) as T; + } + } + + /** @returns An array of the icons attached to this block. */ + getIcons(): IIcon[] { + return [...this.icons]; + } + + /** + * Return the coordinates of the top-left corner of this block relative to the + * drawing surface's origin (0,0), in workspace units. + * + * @returns Object with .x and .y properties. + */ + getRelativeToSurfaceXY(): Coordinate { + return this.xy; + } + + /** + * Move a block by a relative offset. + * + * @param dx Horizontal offset, in workspace units. + * @param dy Vertical offset, in workspace units. + * @param reason Why is this move happening? 'drag', 'bump', 'snap', ... + */ + moveBy(dx: number, dy: number, reason?: string[]) { + if (this.parentBlock_) { + throw Error('Block has parent'); + } + const event = new (eventUtils.get(EventType.BLOCK_MOVE))(this) as BlockMove; + if (reason) event.setReason(reason); + this.xy.translate(dx, dy); + event.recordNew(); + eventUtils.fire(event); + } + + /** + * Create a connection of the specified type. + * + * @param type The type of the connection to create. + * @returns A new connection of the specified type. + * @internal + */ + makeConnection_(type: ConnectionType): Connection { + return new Connection(this, type); + } + + /** + * Recursively checks whether all statement and value inputs are filled with + * blocks. Also checks all following statement blocks in this stack. + * + * @param opt_shadowBlocksAreFilled An optional argument controlling whether + * shadow blocks are counted as filled. Defaults to true. + * @returns True if all inputs are filled, false otherwise. + */ + allInputsFilled(opt_shadowBlocksAreFilled?: boolean): boolean { + // Account for the shadow block filledness toggle. + if (opt_shadowBlocksAreFilled === undefined) { + opt_shadowBlocksAreFilled = true; + } + if (!opt_shadowBlocksAreFilled && this.isShadow()) { + return false; + } + + // Recursively check each input block of the current block. + for (let i = 0, input; (input = this.inputList[i]); i++) { + if (!input.connection) { + continue; + } + const target = input.connection.targetBlock(); + if (!target || !target.allInputsFilled(opt_shadowBlocksAreFilled)) { + return false; + } + } + + // Recursively check the next block after the current block. + const next = this.getNextBlock(); + if (next) { + return next.allInputsFilled(opt_shadowBlocksAreFilled); + } + + return true; + } + + /** + * This method returns a string describing this Block in developer terms (type + * name and ID; English only). + * + * Intended to on be used in console logs and errors. If you need a string + * that uses the user's native language (including block text, field values, + * and child blocks), use {@link (Block:class).toString | toString()}. + * + * @returns The description. + */ + toDevString(): string { + let msg = this.type ? '"' + this.type + '" block' : 'Block'; + if (this.id) { + msg += ' (id="' + this.id + '")'; + } + return msg; + } +} + +export namespace Block { + export interface CommentModel { + text: string | null; + pinned: boolean; + size: Size; + } +} + +export type CommentModel = Block.CommentModel; diff --git a/core/block_animations.ts b/core/block_animations.ts new file mode 100644 index 00000000000..2dbf90777ac --- /dev/null +++ b/core/block_animations.ts @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.blockAnimations + +import type {BlockSvg} from './block_svg.js'; +import * as dom from './utils/dom.js'; +import {Svg} from './utils/svg.js'; + +/** A bounding box for a cloned block. */ +interface CloneRect { + x: number; + y: number; + width: number; + height: number; +} + +/** PID of disconnect UI animation. There can only be one at a time. */ +let disconnectPid: ReturnType | null = null; + +/** The wobbling block. There can only be one at a time. */ +let wobblingBlock: BlockSvg | null = null; + +/** + * Play some UI effects (sound, animation) when disposing of a block. + * + * @param block The block being disposed of. + * @internal + */ +export function disposeUiEffect(block: BlockSvg) { + // Disposing is going to take so long the animation won't play anyway. + if (block.getDescendants(false).length > 100) return; + + const workspace = block.workspace; + const svgGroup = block.getSvgRoot(); + workspace.getAudioManager().play('delete'); + + const xy = block.getRelativeToSurfaceXY(); + // Deeply clone the current block. + const clone: SVGGElement = svgGroup.cloneNode(true) as SVGGElement; + clone.setAttribute('transform', 'translate(' + xy.x + ',' + xy.y + ')'); + workspace.getLayerManager()?.appendToAnimationLayer({ + getSvgRoot: () => { + return clone; + }, + }); + const cloneRect = { + 'x': xy.x, + 'y': xy.y, + 'width': block.width, + 'height': block.height, + }; + disposeUiStep(clone, cloneRect, workspace.RTL, new Date()); +} +/** + * Animate a cloned block and eventually dispose of it. + * This is a class method, not an instance method since the original block has + * been destroyed and is no longer accessible. + * + * @param clone SVG element to animate and dispose of. + * @param rect Starting rect of the clone. + * @param rtl True if RTL, false if LTR. + * @param start Date of animation's start. + */ +function disposeUiStep( + clone: Element, + rect: CloneRect, + rtl: boolean, + start: Date, +) { + const ms = new Date().getTime() - start.getTime(); + const percent = ms / 150; + if (percent > 1) { + dom.removeNode(clone); + } else { + const x = rect.x + (((rtl ? -1 : 1) * rect.width) / 2) * percent; + const y = rect.y + (rect.height / 2) * percent; + const scale = 1 - percent; + clone.setAttribute( + 'transform', + 'translate(' + x + ',' + y + ')' + ' scale(' + scale + ')', + ); + setTimeout(disposeUiStep, 10, clone, rect, rtl, start); + } +} + +/** + * Play some UI effects (sound, ripple) after a connection has been established. + * + * @param block The block being connected. + * @internal + */ +export function connectionUiEffect(block: BlockSvg) { + const workspace = block.workspace; + const scale = workspace.scale; + workspace.getAudioManager().play('click'); + if (scale < 1) { + return; // Too small to care about visual effects. + } + // Determine the absolute coordinates of the inferior block. + const xy = workspace.getSvgXY(block.getSvgRoot()); + // Offset the coordinates based on the two connection types, fix scale. + if (block.outputConnection) { + xy.x += (block.RTL ? 3 : -3) * scale; + xy.y += 13 * scale; + } else if (block.previousConnection) { + xy.x += (block.RTL ? -23 : 23) * scale; + xy.y += 3 * scale; + } + const ripple = dom.createSvgElement( + Svg.CIRCLE, + { + 'cx': xy.x, + 'cy': xy.y, + 'r': 0, + 'fill': 'none', + 'stroke': '#888', + 'stroke-width': 10, + }, + workspace.getParentSvg(), + ); + + const scaleAnimation = dom.createSvgElement( + Svg.ANIMATE, + { + 'id': 'animationCircle', + 'begin': 'indefinite', + 'attributeName': 'r', + 'dur': '150ms', + 'from': 0, + 'to': 25 * scale, + }, + ripple, + ); + const opacityAnimation = dom.createSvgElement( + Svg.ANIMATE, + { + 'id': 'animationOpacity', + 'begin': 'indefinite', + 'attributeName': 'opacity', + 'dur': '150ms', + 'from': 1, + 'to': 0, + }, + ripple, + ); + + scaleAnimation.beginElement(); + opacityAnimation.beginElement(); + + setTimeout(() => void dom.removeNode(ripple), 150); +} + +/** + * Play some UI effects (sound, animation) when disconnecting a block. + * + * @param block The block being disconnected. + * @internal + */ +export function disconnectUiEffect(block: BlockSvg) { + disconnectUiStop(); + block.workspace.getAudioManager().play('disconnect'); + if (block.workspace.scale < 1) { + return; // Too small to care about visual effects. + } + // Horizontal distance for bottom of block to wiggle. + const DISPLACEMENT = 10; + // Scale magnitude of skew to height of block. + const height = block.getHeightWidth().height; + let magnitude = (Math.atan(DISPLACEMENT / height) / Math.PI) * 180; + if (!block.RTL) { + magnitude *= -1; + } + // Start the animation. + wobblingBlock = block; + disconnectUiStep(block, magnitude, new Date(), 0); +} + +/** + * Animate a brief wiggle of a disconnected block. + * + * @param block Block to animate. + * @param magnitude Maximum degrees skew (reversed for RTL). + * @param start Date of animation's start for deciding when to stop. + * @param step Which step of the animation we're on. + */ +function disconnectUiStep( + block: BlockSvg, + magnitude: number, + start: Date, + step: number, +) { + const DURATION = 200; // Milliseconds. + const WIGGLES = [0.66, 1, 0.66, 0, -0.66, -1, -0.66, 0]; // Single cycle + + let skew = ''; + if (start.getTime() + DURATION > new Date().getTime()) { + const val = Math.round(WIGGLES[step % WIGGLES.length] * magnitude); + skew = `skewX(${val})`; + disconnectPid = setTimeout( + disconnectUiStep, + 15, + block, + magnitude, + start, + step + 1, + ); + } + + block + .getSvgRoot() + .setAttribute('transform', `${block.getTranslation()} ${skew}`); +} + +/** + * Stop the disconnect UI animation immediately. + * + * @internal + */ +export function disconnectUiStop() { + if (!wobblingBlock) return; + if (disconnectPid) { + clearTimeout(disconnectPid); + disconnectPid = null; + } + wobblingBlock + .getSvgRoot() + .setAttribute('transform', wobblingBlock.getTranslation()); + wobblingBlock = null; +} diff --git a/core/block_drag_surface.js b/core/block_drag_surface.js deleted file mode 100644 index be72fa8e862..00000000000 --- a/core/block_drag_surface.js +++ /dev/null @@ -1,220 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 class that manages a surface for dragging blocks. When a - * block drag is started, we move the block (and children) to a separate dom - * element that we move around using translate3d. At the end of the drag, the - * blocks are put back in into the svg they came from. This helps performance by - * avoiding repainting the entire svg on every mouse move while dragging blocks. - * @author picklesrus - */ - -'use strict'; - -goog.provide('Blockly.BlockDragSurfaceSvg'); -goog.require('Blockly.utils'); -goog.require('goog.asserts'); -goog.require('goog.math.Coordinate'); - - -/** - * Class for a drag surface for the currently dragged block. This is a separate - * SVG that contains only the currently moving block, or nothing. - * @param {!Element} container Containing element. - * @constructor - */ -Blockly.BlockDragSurfaceSvg = function(container) { - /** - * @type {!Element} - * @private - */ - this.container_ = container; - this.createDom(); -}; - -/** - * The SVG drag surface. Set once by Blockly.BlockDragSurfaceSvg.createDom. - * @type {Element} - * @private - */ -Blockly.BlockDragSurfaceSvg.prototype.SVG_ = null; - -/** - * This is where blocks live while they are being dragged if the drag surface - * is enabled. - * @type {Element} - * @private - */ -Blockly.BlockDragSurfaceSvg.prototype.dragGroup_ = null; - -/** - * Containing HTML element; parent of the workspace and the drag surface. - * @type {Element} - * @private - */ -Blockly.BlockDragSurfaceSvg.prototype.container_ = null; - -/** - * Cached value for the scale of the drag surface. - * Used to set/get the correct translation during and after a drag. - * @type {number} - * @private - */ -Blockly.BlockDragSurfaceSvg.prototype.scale_ = 1; - -/** - * Cached value for the translation of the drag surface. - * This translation is in pixel units, because the scale is applied to the - * drag group rather than the top-level SVG. - * @type {goog.math.Coordinate} - * @private - */ -Blockly.BlockDragSurfaceSvg.prototype.surfaceXY_ = null; - -/** - * Create the drag surface and inject it into the container. - */ -Blockly.BlockDragSurfaceSvg.prototype.createDom = function() { - if (this.SVG_) { - return; // Already created. - } - this.SVG_ = Blockly.utils.createSvgElement('svg', { - 'xmlns': Blockly.SVG_NS, - 'xmlns:html': Blockly.HTML_NS, - 'xmlns:xlink': 'http://www.w3.org/1999/xlink', - 'version': '1.1', - 'class': 'blocklyBlockDragSurface' - }, this.container_); - this.dragGroup_ = Blockly.utils.createSvgElement('g', {}, this.SVG_); -}; - -/** - * Set the SVG blocks on the drag surface's group and show the surface. - * Only one block group should be on the drag surface at a time. - * @param {!Element} blocks Block or group of blocks to place on the drag - * surface. - */ -Blockly.BlockDragSurfaceSvg.prototype.setBlocksAndShow = function(blocks) { - goog.asserts.assert(this.dragGroup_.childNodes.length == 0, - 'Already dragging a block.'); - // appendChild removes the blocks from the previous parent - this.dragGroup_.appendChild(blocks); - this.SVG_.style.display = 'block'; - this.surfaceXY_ = new goog.math.Coordinate(0, 0); -}; - -/** - * Translate and scale the entire drag surface group to the given position, to - * keep in sync with the workspace. - * @param {number} x X translation in workspace coordinates. - * @param {number} y Y translation in workspace coordinates. - * @param {number} scale Scale of the group. - */ -Blockly.BlockDragSurfaceSvg.prototype.translateAndScaleGroup = function(x, y, scale) { - this.scale_ = scale; - // This is a work-around to prevent a the blocks from rendering - // fuzzy while they are being dragged on the drag surface. - x = x.toFixed(0); - y = y.toFixed(0); - this.dragGroup_.setAttribute('transform', 'translate('+ x + ','+ y + ')' + - ' scale(' + scale + ')'); -}; - -/** - * Translate the drag surface's SVG based on its internal state. - * @private - */ -Blockly.BlockDragSurfaceSvg.prototype.translateSurfaceInternal_ = function() { - var x = this.surfaceXY_.x; - var y = this.surfaceXY_.y; - // This is a work-around to prevent a the blocks from rendering - // fuzzy while they are being dragged on the drag surface. - x = x.toFixed(0); - y = y.toFixed(0); - this.SVG_.style.display = 'block'; - - Blockly.utils.setCssTransform(this.SVG_, - 'translate3d(' + x + 'px, ' + y + 'px, 0px)'); -}; - -/** - * Translate the entire drag surface during a drag. - * We translate the drag surface instead of the blocks inside the surface - * so that the browser avoids repainting the SVG. - * Because of this, the drag coordinates must be adjusted by scale. - * @param {number} x X translation for the entire surface. - * @param {number} y Y translation for the entire surface. - */ -Blockly.BlockDragSurfaceSvg.prototype.translateSurface = function(x, y) { - this.surfaceXY_ = new goog.math.Coordinate(x * this.scale_, y * this.scale_); - this.translateSurfaceInternal_(); -}; - -/** - * Reports the surface translation in scaled workspace coordinates. - * Use this when finishing a drag to return blocks to the correct position. - * @return {!goog.math.Coordinate} Current translation of the surface. - */ -Blockly.BlockDragSurfaceSvg.prototype.getSurfaceTranslation = function() { - var xy = Blockly.utils.getRelativeXY(this.SVG_); - return new goog.math.Coordinate(xy.x / this.scale_, xy.y / this.scale_); -}; - -/** - * Provide a reference to the drag group (primarily for - * BlockSvg.getRelativeToSurfaceXY). - * @return {Element} Drag surface group element. - */ -Blockly.BlockDragSurfaceSvg.prototype.getGroup = function() { - return this.dragGroup_; -}; - -/** - * Get the current blocks on the drag surface, if any (primarily - * for BlockSvg.getRelativeToSurfaceXY). - * @return {!Element|undefined} Drag surface block DOM element, or undefined - * if no blocks exist. - */ -Blockly.BlockDragSurfaceSvg.prototype.getCurrentBlock = function() { - return this.dragGroup_.firstChild; -}; - -/** - * Clear the group and hide the surface; move the blocks off onto the provided - * element. - * If the block is being deleted it doesn't need to go back to the original - * surface, since it would be removed immediately during dispose. - * @param {Element} opt_newSurface Surface the dragging blocks should be moved - * to, or null if the blocks should be removed from this surface without - * being moved to a different surface. - */ -Blockly.BlockDragSurfaceSvg.prototype.clearAndHide = function(opt_newSurface) { - if (opt_newSurface) { - // appendChild removes the node from this.dragGroup_ - opt_newSurface.appendChild(this.getCurrentBlock()); - } else { - this.dragGroup_.removeChild(this.getCurrentBlock()); - } - this.SVG_.style.display = 'none'; - goog.asserts.assert(this.dragGroup_.childNodes.length == 0, - 'Drag group was not cleared.'); - this.surfaceXY_ = null; -}; diff --git a/core/block_dragger.js b/core/block_dragger.js deleted file mode 100644 index 0ba1749f074..00000000000 --- a/core/block_dragger.js +++ /dev/null @@ -1,324 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Methods for dragging a block visually. - * @author fenichel@google.com (Rachel Fenichel) - */ -'use strict'; - -goog.provide('Blockly.BlockDragger'); - -goog.require('Blockly.DraggedConnectionManager'); - -goog.require('goog.math.Coordinate'); -goog.require('goog.asserts'); - - -/** - * Class for a block dragger. It moves blocks around the workspace when they - * are being dragged by a mouse or touch. - * @param {!Blockly.Block} block The block to drag. - * @param {!Blockly.WorkspaceSvg} workspace The workspace to drag on. - * @constructor - */ -Blockly.BlockDragger = function(block, workspace) { - /** - * The top block in the stack that is being dragged. - * @type {!Blockly.BlockSvg} - * @private - */ - this.draggingBlock_ = block; - - /** - * The workspace on which the block is being dragged. - * @type {!Blockly.WorkspaceSvg} - * @private - */ - this.workspace_ = workspace; - - /** - * Object that keeps track of connections on dragged blocks. - * @type {!Blockly.DraggedConnectionManager} - * @private - */ - this.draggedConnectionManager_ = new Blockly.DraggedConnectionManager( - this.draggingBlock_); - - /** - * Which delete area the mouse pointer is over, if any. - * One of {@link Blockly.DELETE_AREA_TRASH}, - * {@link Blockly.DELETE_AREA_TOOLBOX}, or {@link Blockly.DELETE_AREA_NONE}. - * @type {?number} - * @private - */ - this.deleteArea_ = null; - - /** - * Whether the block would be deleted if dropped immediately. - * @type {boolean} - * @private - */ - this.wouldDeleteBlock_ = false; - - /** - * The location of the top left corner of the dragging block at the beginning - * of the drag in workspace coordinates. - * @type {!goog.math.Coordinate} - * @private - */ - this.startXY_ = this.draggingBlock_.getRelativeToSurfaceXY(); - - /** - * A list of all of the icons (comment, warning, and mutator) that are - * on this block and its descendants. Moving an icon moves the bubble that - * extends from it if that bubble is open. - * @type {Array.} - * @private - */ - this.dragIconData_ = Blockly.BlockDragger.initIconData_(block); -}; - -/** - * Sever all links from this object. - * @package - */ -Blockly.BlockDragger.prototype.dispose = function() { - this.draggingBlock_ = null; - this.workspace_ = null; - this.startWorkspace_ = null; - this.dragIconData_.length = 0; - - if (this.draggedConnectionManager_) { - this.draggedConnectionManager_.dispose(); - this.draggedConnectionManager_ = null; - } -}; - -/** - * Make a list of all of the icons (comment, warning, and mutator) that are - * on this block and its descendants. Moving an icon moves the bubble that - * extends from it if that bubble is open. - * @param {!Blockly.BlockSvg} block The root block that is being dragged. - * @return {!Array.} The list of all icons and their locations. - * @private - */ -Blockly.BlockDragger.initIconData_ = function(block) { - // Build a list of icons that need to be moved and where they started. - var dragIconData = []; - var descendants = block.getDescendants(); - for (var i = 0, descendant; descendant = descendants[i]; i++) { - var icons = descendant.getIcons(); - for (var j = 0; j < icons.length; j++) { - var data = { - // goog.math.Coordinate with x and y properties (workspace coordinates). - location: icons[j].getIconLocation(), - // Blockly.Icon - icon: icons[j] - }; - dragIconData.push(data); - } - } - return dragIconData; -}; - -/** - * Start dragging a block. This includes moving it to the drag surface. - * @param {!goog.math.Coordinate} currentDragDeltaXY How far the pointer has - * moved from the position at mouse down, in pixel units. - * @package - */ -Blockly.BlockDragger.prototype.startBlockDrag = function(currentDragDeltaXY) { - if (!Blockly.Events.getGroup()) { - Blockly.Events.setGroup(true); - } - - this.workspace_.setResizesEnabled(false); - Blockly.BlockSvg.disconnectUiStop_(); - - if (this.draggingBlock_.getParent()) { - this.draggingBlock_.unplug(); - var delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY); - var newLoc = goog.math.Coordinate.sum(this.startXY_, delta); - - this.draggingBlock_.translate(newLoc.x, newLoc.y); - this.draggingBlock_.disconnectUiEffect(); - } - this.draggingBlock_.setDragging(true); - // For future consideration: we may be able to put moveToDragSurface inside - // the block dragger, which would also let the block not track the block drag - // surface. - this.draggingBlock_.moveToDragSurface_(); - - if (this.workspace_.toolbox_) { - this.workspace_.toolbox_.addDeleteStyle(); - } -}; - -/** - * Execute a step of block dragging, based on the given event. Update the - * display accordingly. - * @param {!Event} e The most recent move event. - * @param {!goog.math.Coordinate} currentDragDeltaXY How far the pointer has - * moved from the position at the start of the drag, in pixel units. - * @package - */ -Blockly.BlockDragger.prototype.dragBlock = function(e, currentDragDeltaXY) { - var delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY); - var newLoc = goog.math.Coordinate.sum(this.startXY_, delta); - - this.draggingBlock_.moveDuringDrag(newLoc); - this.dragIcons_(delta); - - this.deleteArea_ = this.workspace_.isDeleteArea(e); - this.draggedConnectionManager_.update(delta, this.deleteArea_); - - this.updateCursorDuringBlockDrag_(); -}; - -/** - * Finish a block drag and put the block back on the workspace. - * @param {!Event} e The mouseup/touchend event. - * @param {!goog.math.Coordinate} currentDragDeltaXY How far the pointer has - * moved from the position at the start of the drag, in pixel units. - * @package - */ -Blockly.BlockDragger.prototype.endBlockDrag = function(e, currentDragDeltaXY) { - // Make sure internal state is fresh. - this.dragBlock(e, currentDragDeltaXY); - this.dragIconData_ = []; - - Blockly.BlockSvg.disconnectUiStop_(); - - var delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY); - var newLoc = goog.math.Coordinate.sum(this.startXY_, delta); - this.draggingBlock_.moveOffDragSurface_(newLoc); - - var deleted = this.maybeDeleteBlock_(); - if (!deleted) { - // These are expensive and don't need to be done if we're deleting. - this.draggingBlock_.moveConnections_(delta.x, delta.y); - this.draggingBlock_.setDragging(false); - this.draggedConnectionManager_.applyConnections(); - this.draggingBlock_.render(); - this.fireMoveEvent_(); - this.draggingBlock_.scheduleSnapAndBump(); - } - this.workspace_.setResizesEnabled(true); - - if (this.workspace_.toolbox_) { - this.workspace_.toolbox_.removeDeleteStyle(); - } - Blockly.Events.setGroup(false); -}; - -/** - * Fire a move event at the end of a block drag. - * @private - */ -Blockly.BlockDragger.prototype.fireMoveEvent_ = function() { - var event = new Blockly.Events.BlockMove(this.draggingBlock_); - event.oldCoordinate = this.startXY_; - event.recordNew(); - Blockly.Events.fire(event); -}; - -/** - * Shut the trash can and, if necessary, delete the dragging block. - * Should be called at the end of a block drag. - * @return {boolean} whether the block was deleted. - * @private - */ -Blockly.BlockDragger.prototype.maybeDeleteBlock_ = function() { - var trashcan = this.workspace_.trashcan; - - if (this.wouldDeleteBlock_) { - if (trashcan) { - goog.Timer.callOnce(trashcan.close, 100, trashcan); - } - // Fire a move event, so we know where to go back to for an undo. - this.fireMoveEvent_(); - this.draggingBlock_.dispose(false, true); - } else if (trashcan) { - // Make sure the trash can is closed. - trashcan.close(); - } - return this.wouldDeleteBlock_; -}; - -/** - * Update the cursor (and possibly the trash can lid) to reflect whether the - * dragging block would be deleted if released immediately. - * @private - */ -Blockly.BlockDragger.prototype.updateCursorDuringBlockDrag_ = function() { - this.wouldDeleteBlock_ = this.draggedConnectionManager_.wouldDeleteBlock(); - var trashcan = this.workspace_.trashcan; - if (this.wouldDeleteBlock_) { - this.draggingBlock_.setDeleteStyle(true); - if (this.deleteArea_ == Blockly.DELETE_AREA_TRASH && trashcan) { - trashcan.setOpen_(true); - } - } else { - this.draggingBlock_.setDeleteStyle(false); - if (trashcan) { - trashcan.setOpen_(false); - } - } -}; - -/** - * Convert a coordinate object from pixels to workspace units, including a - * correction for mutator workspaces. - * This function does not consider differing origins. It simply scales the - * input's x and y values. - * @param {!goog.math.Coordinate} pixelCoord A coordinate with x and y values - * in css pixel units. - * @return {!goog.math.Coordinate} The input coordinate divided by the workspace - * scale. - * @private - */ -Blockly.BlockDragger.prototype.pixelsToWorkspaceUnits_ = function(pixelCoord) { - var result = new goog.math.Coordinate(pixelCoord.x / this.workspace_.scale, - pixelCoord.y / this.workspace_.scale); - if (this.workspace_.isMutator) { - // If we're in a mutator, its scale is always 1, purely because of some - // oddities in our rendering optimizations. The actual scale is the same as - // the scale on the parent workspace. - // Fix that for dragging. - var mainScale = this.workspace_.options.parentWorkspace.scale; - result = result.scale(1 / mainScale); - } - return result; -}; - -/** - * Move all of the icons connected to this drag. - * @param {!goog.math.Coordinate} dxy How far to move the icons from their - * original positions, in workspace units. - * @private - */ -Blockly.BlockDragger.prototype.dragIcons_ = function(dxy) { - // Moving icons moves their associated bubbles. - for (var i = 0; i < this.dragIconData_.length; i++) { - var data = this.dragIconData_[i]; - data.icon.setIconLocation(goog.math.Coordinate.sum(data.location, dxy)); - } -}; diff --git a/core/block_flyout_inflater.ts b/core/block_flyout_inflater.ts new file mode 100644 index 00000000000..80f86855182 --- /dev/null +++ b/core/block_flyout_inflater.ts @@ -0,0 +1,283 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; +import * as common from './common.js'; +import {MANUALLY_DISABLED} from './constants.js'; +import type {Abstract as AbstractEvent} from './events/events_abstract.js'; +import {EventType} from './events/type.js'; +import {FlyoutItem} from './flyout_item.js'; +import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import * as registry from './registry.js'; +import * as blocks from './serialization/blocks.js'; +import type {BlockInfo} from './utils/toolbox.js'; +import * as utilsXml from './utils/xml.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; +import * as Xml from './xml.js'; + +/** + * The language-neutral ID for when the reason why a block is disabled is + * because the workspace is at block capacity. + */ +const WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON = + 'WORKSPACE_AT_BLOCK_CAPACITY'; + +const BLOCK_TYPE = 'block'; + +/** + * Class responsible for creating blocks for flyouts. + */ +export class BlockFlyoutInflater implements IFlyoutInflater { + protected permanentlyDisabledBlocks = new Set(); + protected listeners = new Map(); + protected flyout?: IFlyout; + private capacityWrapper: (event: AbstractEvent) => void; + + /** + * Creates a new BlockFlyoutInflater instance. + */ + constructor() { + this.capacityWrapper = this.filterFlyoutBasedOnCapacity.bind(this); + } + + /** + * Inflates a flyout block from the given state and adds it to the flyout. + * + * @param state A JSON representation of a flyout block. + * @param flyout The flyout to create the block on. + * @returns A newly created block. + */ + load(state: object, flyout: IFlyout): FlyoutItem { + this.setFlyout(flyout); + const block = this.createBlock(state as BlockInfo, flyout.getWorkspace()); + + if (!block.isEnabled()) { + // Record blocks that were initially disabled. + // Do not enable these blocks as a result of capacity filtering. + this.permanentlyDisabledBlocks.add(block); + } else { + this.updateStateBasedOnCapacity(block); + } + + // Mark blocks as being inside a flyout. This is used to detect and + // prevent the closure of the flyout if the user right-clicks on such + // a block. + block.getDescendants(false).forEach((b) => (b.isInFlyout = true)); + this.addBlockListeners(block); + + return new FlyoutItem(block, BLOCK_TYPE); + } + + /** + * Creates a block on the given workspace. + * + * @param blockDefinition A JSON representation of the block to create. + * @param workspace The workspace to create the block on. + * @returns The newly created block. + */ + createBlock(blockDefinition: BlockInfo, workspace: WorkspaceSvg): BlockSvg { + let block; + if (blockDefinition['blockxml']) { + const xml = ( + typeof blockDefinition['blockxml'] === 'string' + ? utilsXml.textToDom(blockDefinition['blockxml']) + : blockDefinition['blockxml'] + ) as Element; + block = Xml.domToBlockInternal(xml, workspace); + } else { + if (blockDefinition['enabled'] === undefined) { + blockDefinition['enabled'] = + blockDefinition['disabled'] !== 'true' && + blockDefinition['disabled'] !== true; + } + if ( + blockDefinition['disabledReasons'] === undefined && + blockDefinition['enabled'] === false + ) { + blockDefinition['disabledReasons'] = [MANUALLY_DISABLED]; + } + // These fields used to be allowed and may still be present, but are + // ignored here since everything in the flyout should always be laid out + // linearly. + if ('x' in blockDefinition) { + delete blockDefinition['x']; + } + if ('y' in blockDefinition) { + delete blockDefinition['y']; + } + block = blocks.appendInternal(blockDefinition as blocks.State, workspace); + } + + return block as BlockSvg; + } + + /** + * Returns the amount of space that should follow this block. + * + * @param state A JSON representation of a flyout block. + * @param defaultGap The default spacing for flyout items. + * @returns The amount of space that should follow this block. + */ + gapForItem(state: object, defaultGap: number): number { + const blockState = state as BlockInfo; + let gap; + if (blockState['gap']) { + gap = parseInt(String(blockState['gap'])); + } else if (blockState['blockxml']) { + const xml = ( + typeof blockState['blockxml'] === 'string' + ? utilsXml.textToDom(blockState['blockxml']) + : blockState['blockxml'] + ) as Element; + gap = parseInt(xml.getAttribute('gap')!); + } + + return !gap || isNaN(gap) ? defaultGap : gap; + } + + /** + * Disposes of the given block. + * + * @param item The flyout block to dispose of. + */ + disposeItem(item: FlyoutItem): void { + const element = item.getElement(); + if (!(element instanceof BlockSvg)) return; + this.removeListeners(element.id); + element.dispose(false, false); + } + + /** + * Removes event listeners for the block with the given ID. + * + * @param blockId The ID of the block to remove event listeners from. + */ + protected removeListeners(blockId: string) { + const blockListeners = this.listeners.get(blockId) ?? []; + blockListeners.forEach((l) => browserEvents.unbind(l)); + this.listeners.delete(blockId); + } + + /** + * Updates this inflater's flyout. + * + * @param flyout The flyout that owns this inflater. + */ + protected setFlyout(flyout: IFlyout) { + if (this.flyout === flyout) return; + + if (this.flyout) { + this.flyout.targetWorkspace?.removeChangeListener(this.capacityWrapper); + } + this.flyout = flyout; + this.flyout.targetWorkspace?.addChangeListener(this.capacityWrapper); + } + + /** + * Updates the enabled state of the given block based on the capacity of the + * workspace. + * + * @param block The block to update the enabled/disabled state of. + */ + private updateStateBasedOnCapacity(block: BlockSvg) { + const enable = this.flyout?.targetWorkspace?.isCapacityAvailable( + common.getBlockTypeCounts(block), + ); + let currentBlock: BlockSvg | null = block; + while (currentBlock) { + currentBlock.setDisabledReason( + !enable, + WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON, + ); + currentBlock = currentBlock.getNextBlock(); + } + } + + /** + * Add listeners to a block that has been added to the flyout. + * + * @param block The block to add listeners for. + */ + protected addBlockListeners(block: BlockSvg) { + const blockListeners = []; + + blockListeners.push( + browserEvents.conditionalBind( + block.getSvgRoot(), + 'pointerdown', + block, + (e: PointerEvent) => { + const gesture = this.flyout?.targetWorkspace?.getGesture(e); + if (gesture && this.flyout) { + gesture.setStartBlock(block); + gesture.handleFlyoutStart(e, this.flyout); + } + }, + ), + ); + + blockListeners.push( + browserEvents.bind(block.getSvgRoot(), 'pointermove', null, () => { + if (!this.flyout?.targetWorkspace?.isDragging()) { + block.addSelect(); + } + }), + ); + blockListeners.push( + browserEvents.bind(block.getSvgRoot(), 'pointerleave', null, () => { + if (!this.flyout?.targetWorkspace?.isDragging()) { + block.removeSelect(); + } + }), + ); + + this.listeners.set(block.id, blockListeners); + } + + /** + * Updates the state of blocks in our owning flyout to be disabled/enabled + * based on the capacity of the workspace for more blocks of that type. + * + * @param event The event that triggered this update. + */ + private filterFlyoutBasedOnCapacity(event: AbstractEvent) { + if ( + !this.flyout || + (event && + !( + event.type === EventType.BLOCK_CREATE || + event.type === EventType.BLOCK_DELETE + )) + ) + return; + + this.flyout + .getWorkspace() + .getTopBlocks(false) + .forEach((block) => { + if (!this.permanentlyDisabledBlocks.has(block)) { + this.updateStateBasedOnCapacity(block); + } + }); + } + + /** + * Returns the type of items this inflater is responsible for creating. + * + * @returns An identifier for the type of items this inflater creates. + */ + getType() { + return BLOCK_TYPE; + } +} + +registry.register( + registry.Type.FLYOUT_INFLATER, + BLOCK_TYPE, + BlockFlyoutInflater, +); diff --git a/core/block_render_svg.js b/core/block_render_svg.js deleted file mode 100644 index 4c3bfb33b1a..00000000000 --- a/core/block_render_svg.js +++ /dev/null @@ -1,1000 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Methods for graphically rendering a block as SVG. - * @author fenichel@google.com (Rachel Fenichel) - */ - -'use strict'; - -goog.provide('Blockly.BlockSvg.render'); - -goog.require('Blockly.BlockSvg'); - -goog.require('goog.userAgent'); - - -// UI constants for rendering blocks. -/** - * Horizontal space between elements. - * @const - */ -Blockly.BlockSvg.SEP_SPACE_X = 10; -/** - * Vertical space between elements. - * @const - */ -Blockly.BlockSvg.SEP_SPACE_Y = 10; -/** - * Vertical padding around inline elements. - * @const - */ -Blockly.BlockSvg.INLINE_PADDING_Y = 5; -/** - * Minimum height of a block. - * @const - */ -Blockly.BlockSvg.MIN_BLOCK_Y = 25; -/** - * Height of horizontal puzzle tab. - * @const - */ -Blockly.BlockSvg.TAB_HEIGHT = 20; -/** - * Width of horizontal puzzle tab. - * @const - */ -Blockly.BlockSvg.TAB_WIDTH = 8; -/** - * Width of vertical tab (inc left margin). - * @const - */ -Blockly.BlockSvg.NOTCH_WIDTH = 30; -/** - * Rounded corner radius. - * @const - */ -Blockly.BlockSvg.CORNER_RADIUS = 8; -/** - * Do blocks with no previous or output connections have a 'hat' on top? - * @const - */ -Blockly.BlockSvg.START_HAT = false; -/** - * Height of the top hat. - * @const - */ -Blockly.BlockSvg.START_HAT_HEIGHT = 15; -/** - * Path of the top hat's curve. - * @const - */ -Blockly.BlockSvg.START_HAT_PATH = 'c 30,-' + - Blockly.BlockSvg.START_HAT_HEIGHT + ' 70,-' + - Blockly.BlockSvg.START_HAT_HEIGHT + ' 100,0'; -/** - * Path of the top hat's curve's highlight in LTR. - * @const - */ -Blockly.BlockSvg.START_HAT_HIGHLIGHT_LTR = - 'c 17.8,-9.2 45.3,-14.9 75,-8.7 M 100.5,0.5'; -/** - * Path of the top hat's curve's highlight in RTL. - * @const - */ -Blockly.BlockSvg.START_HAT_HIGHLIGHT_RTL = - 'm 25,-8.7 c 29.7,-6.2 57.2,-0.5 75,8.7'; -/** - * Distance from shape edge to intersect with a curved corner at 45 degrees. - * Applies to highlighting on around the inside of a curve. - * @const - */ -Blockly.BlockSvg.DISTANCE_45_INSIDE = (1 - Math.SQRT1_2) * - (Blockly.BlockSvg.CORNER_RADIUS - 0.5) + 0.5; -/** - * Distance from shape edge to intersect with a curved corner at 45 degrees. - * Applies to highlighting on around the outside of a curve. - * @const - */ -Blockly.BlockSvg.DISTANCE_45_OUTSIDE = (1 - Math.SQRT1_2) * - (Blockly.BlockSvg.CORNER_RADIUS + 0.5) - 0.5; -/** - * SVG path for drawing next/previous notch from left to right. - * @const - */ -Blockly.BlockSvg.NOTCH_PATH_LEFT = 'l 6,4 3,0 6,-4'; -/** - * SVG path for drawing next/previous notch from left to right with - * highlighting. - * @const - */ -Blockly.BlockSvg.NOTCH_PATH_LEFT_HIGHLIGHT = 'l 6,4 3,0 6,-4'; -/** - * SVG path for drawing next/previous notch from right to left. - * @const - */ -Blockly.BlockSvg.NOTCH_PATH_RIGHT = 'l -6,4 -3,0 -6,-4'; -/** - * SVG path for drawing jagged teeth at the end of collapsed blocks. - * @const - */ -Blockly.BlockSvg.JAGGED_TEETH = 'l 8,0 0,4 8,4 -16,8 8,4'; -/** - * Height of SVG path for jagged teeth at the end of collapsed blocks. - * @const - */ -Blockly.BlockSvg.JAGGED_TEETH_HEIGHT = 20; -/** - * Width of SVG path for jagged teeth at the end of collapsed blocks. - * @const - */ -Blockly.BlockSvg.JAGGED_TEETH_WIDTH = 15; -/** - * SVG path for drawing a horizontal puzzle tab from top to bottom. - * @const - */ -Blockly.BlockSvg.TAB_PATH_DOWN = 'v 5 c 0,10 -' + Blockly.BlockSvg.TAB_WIDTH + - ',-8 -' + Blockly.BlockSvg.TAB_WIDTH + ',7.5 s ' + - Blockly.BlockSvg.TAB_WIDTH + ',-2.5 ' + Blockly.BlockSvg.TAB_WIDTH + ',7.5'; -/** - * SVG path for drawing a horizontal puzzle tab from top to bottom with - * highlighting from the upper-right. - * @const - */ -Blockly.BlockSvg.TAB_PATH_DOWN_HIGHLIGHT_RTL = 'v 6.5 m -' + - (Blockly.BlockSvg.TAB_WIDTH * 0.97) + ',3 q -' + - (Blockly.BlockSvg.TAB_WIDTH * 0.05) + ',10 ' + - (Blockly.BlockSvg.TAB_WIDTH * 0.3) + ',9.5 m ' + - (Blockly.BlockSvg.TAB_WIDTH * 0.67) + ',-1.9 v 1.4'; - -/** - * SVG start point for drawing the top-left corner. - * @const - */ -Blockly.BlockSvg.TOP_LEFT_CORNER_START = - 'm 0,' + Blockly.BlockSvg.CORNER_RADIUS; -/** - * SVG start point for drawing the top-left corner's highlight in RTL. - * @const - */ -Blockly.BlockSvg.TOP_LEFT_CORNER_START_HIGHLIGHT_RTL = - 'm ' + Blockly.BlockSvg.DISTANCE_45_INSIDE + ',' + - Blockly.BlockSvg.DISTANCE_45_INSIDE; -/** - * SVG start point for drawing the top-left corner's highlight in LTR. - * @const - */ -Blockly.BlockSvg.TOP_LEFT_CORNER_START_HIGHLIGHT_LTR = - 'm 0.5,' + (Blockly.BlockSvg.CORNER_RADIUS - 0.5); -/** - * SVG path for drawing the rounded top-left corner. - * @const - */ -Blockly.BlockSvg.TOP_LEFT_CORNER = - 'A ' + Blockly.BlockSvg.CORNER_RADIUS + ',' + - Blockly.BlockSvg.CORNER_RADIUS + ' 0 0,1 ' + - Blockly.BlockSvg.CORNER_RADIUS + ',0'; -/** - * SVG path for drawing the highlight on the rounded top-left corner. - * @const - */ -Blockly.BlockSvg.TOP_LEFT_CORNER_HIGHLIGHT = - 'A ' + (Blockly.BlockSvg.CORNER_RADIUS - 0.5) + ',' + - (Blockly.BlockSvg.CORNER_RADIUS - 0.5) + ' 0 0,1 ' + - Blockly.BlockSvg.CORNER_RADIUS + ',0.5'; -/** - * SVG path for drawing the top-left corner of a statement input. - * Includes the top notch, a horizontal space, and the rounded inside corner. - * @const - */ -Blockly.BlockSvg.INNER_TOP_LEFT_CORNER = - Blockly.BlockSvg.NOTCH_PATH_RIGHT + ' h -' + - (Blockly.BlockSvg.NOTCH_WIDTH - 15 - Blockly.BlockSvg.CORNER_RADIUS) + - ' a ' + Blockly.BlockSvg.CORNER_RADIUS + ',' + - Blockly.BlockSvg.CORNER_RADIUS + ' 0 0,0 -' + - Blockly.BlockSvg.CORNER_RADIUS + ',' + - Blockly.BlockSvg.CORNER_RADIUS; -/** - * SVG path for drawing the bottom-left corner of a statement input. - * Includes the rounded inside corner. - * @const - */ -Blockly.BlockSvg.INNER_BOTTOM_LEFT_CORNER = - 'a ' + Blockly.BlockSvg.CORNER_RADIUS + ',' + - Blockly.BlockSvg.CORNER_RADIUS + ' 0 0,0 ' + - Blockly.BlockSvg.CORNER_RADIUS + ',' + - Blockly.BlockSvg.CORNER_RADIUS; -/** - * SVG path for drawing highlight on the top-left corner of a statement - * input in RTL. - * @const - */ -Blockly.BlockSvg.INNER_TOP_LEFT_CORNER_HIGHLIGHT_RTL = - 'a ' + Blockly.BlockSvg.CORNER_RADIUS + ',' + - Blockly.BlockSvg.CORNER_RADIUS + ' 0 0,0 ' + - (-Blockly.BlockSvg.DISTANCE_45_OUTSIDE - 0.5) + ',' + - (Blockly.BlockSvg.CORNER_RADIUS - - Blockly.BlockSvg.DISTANCE_45_OUTSIDE); -/** - * SVG path for drawing highlight on the bottom-left corner of a statement - * input in RTL. - * @const - */ -Blockly.BlockSvg.INNER_BOTTOM_LEFT_CORNER_HIGHLIGHT_RTL = - 'a ' + (Blockly.BlockSvg.CORNER_RADIUS + 0.5) + ',' + - (Blockly.BlockSvg.CORNER_RADIUS + 0.5) + ' 0 0,0 ' + - (Blockly.BlockSvg.CORNER_RADIUS + 0.5) + ',' + - (Blockly.BlockSvg.CORNER_RADIUS + 0.5); -/** - * SVG path for drawing highlight on the bottom-left corner of a statement - * input in LTR. - * @const - */ -Blockly.BlockSvg.INNER_BOTTOM_LEFT_CORNER_HIGHLIGHT_LTR = - 'a ' + (Blockly.BlockSvg.CORNER_RADIUS + 0.5) + ',' + - (Blockly.BlockSvg.CORNER_RADIUS + 0.5) + ' 0 0,0 ' + - (Blockly.BlockSvg.CORNER_RADIUS - - Blockly.BlockSvg.DISTANCE_45_OUTSIDE) + ',' + - (Blockly.BlockSvg.DISTANCE_45_OUTSIDE + 0.5); - -/** - * Returns a bounding box describing the dimensions of this block - * and any blocks stacked below it. - * @return {!{height: number, width: number}} Object with height and width - * properties in workspace units. - */ -Blockly.BlockSvg.prototype.getHeightWidth = function() { - var height = this.height; - var width = this.width; - // Recursively add size of subsequent blocks. - var nextBlock = this.getNextBlock(); - if (nextBlock) { - var nextHeightWidth = nextBlock.getHeightWidth(); - height += nextHeightWidth.height - 4; // Height of tab. - width = Math.max(width, nextHeightWidth.width); - } else if (!this.nextConnection && !this.outputConnection) { - // Add a bit of margin under blocks with no bottom tab. - height += 2; - } - return {height: height, width: width}; -}; - -/** - * Render the block. - * Lays out and reflows a block based on its contents and settings. - * @param {boolean=} opt_bubble If false, just render this block. - * If true, also render block's parent, grandparent, etc. Defaults to true. - */ -Blockly.BlockSvg.prototype.render = function(opt_bubble) { - Blockly.Field.startCache(); - this.rendered = true; - - var cursorX = Blockly.BlockSvg.SEP_SPACE_X; - if (this.RTL) { - cursorX = -cursorX; - } - // Move the icons into position. - var icons = this.getIcons(); - for (var i = 0; i < icons.length; i++) { - cursorX = icons[i].renderIcon(cursorX); - } - cursorX += this.RTL ? - Blockly.BlockSvg.SEP_SPACE_X : -Blockly.BlockSvg.SEP_SPACE_X; - // If there are no icons, cursorX will be 0, otherwise it will be the - // width that the first label needs to move over by. - - var inputRows = this.renderCompute_(cursorX); - this.renderDraw_(cursorX, inputRows); - this.renderMoveConnections_(); - - if (opt_bubble !== false) { - // Render all blocks above this one (propagate a reflow). - var parentBlock = this.getParent(); - if (parentBlock) { - parentBlock.render(true); - } else { - // Top-most block. Fire an event to allow scrollbars to resize. - this.workspace.resizeContents(); - } - } - Blockly.Field.stopCache(); -}; - -/** - * Render a list of fields starting at the specified location. - * @param {!Array.} fieldList List of fields. - * @param {number} cursorX X-coordinate to start the fields. - * @param {number} cursorY Y-coordinate to start the fields. - * @return {number} X-coordinate of the end of the field row (plus a gap). - * @private - */ -Blockly.BlockSvg.prototype.renderFields_ = - function(fieldList, cursorX, cursorY) { - /* eslint-disable indent */ - cursorY += Blockly.BlockSvg.INLINE_PADDING_Y; - if (this.RTL) { - cursorX = -cursorX; - } - for (var t = 0, field; field = fieldList[t]; t++) { - var root = field.getSvgRoot(); - if (!root) { - continue; - } - - // Force a width re-calculation on IE and Edge to get around the issue - // described in Blockly.Field.getCachedWidth - if (goog.userAgent.IE || goog.userAgent.EDGE) { - field.updateWidth(); - } - - if (this.RTL) { - cursorX -= field.renderSep + field.renderWidth; - root.setAttribute('transform', - 'translate(' + cursorX + ',' + cursorY + ')'); - if (field.renderWidth) { - cursorX -= Blockly.BlockSvg.SEP_SPACE_X; - } - } else { - root.setAttribute('transform', - 'translate(' + (cursorX + field.renderSep) + ',' + cursorY + ')'); - if (field.renderWidth) { - cursorX += field.renderSep + field.renderWidth + - Blockly.BlockSvg.SEP_SPACE_X; - } - } - } - return this.RTL ? -cursorX : cursorX; -}; /* eslint-enable indent */ - -/** - * Computes the height and widths for each row and field. - * @param {number} iconWidth Offset of first row due to icons. - * @return {!Array.>} 2D array of objects, each containing - * position information. - * @private - */ -Blockly.BlockSvg.prototype.renderCompute_ = function(iconWidth) { - var inputList = this.inputList; - var inputRows = []; - inputRows.rightEdge = iconWidth + Blockly.BlockSvg.SEP_SPACE_X * 2; - if (this.previousConnection || this.nextConnection) { - inputRows.rightEdge = Math.max(inputRows.rightEdge, - Blockly.BlockSvg.NOTCH_WIDTH + Blockly.BlockSvg.SEP_SPACE_X); - } - var fieldValueWidth = 0; // Width of longest external value field. - var fieldStatementWidth = 0; // Width of longest statement field. - var hasValue = false; - var hasStatement = false; - var hasDummy = false; - var lastType = undefined; - var isInline = this.getInputsInline() && !this.isCollapsed(); - for (var i = 0, input; input = inputList[i]; i++) { - if (!input.isVisible()) { - continue; - } - var row; - if (!isInline || !lastType || - lastType == Blockly.NEXT_STATEMENT || - input.type == Blockly.NEXT_STATEMENT) { - // Create new row. - lastType = input.type; - row = []; - if (isInline && input.type != Blockly.NEXT_STATEMENT) { - row.type = Blockly.BlockSvg.INLINE; - } else { - row.type = input.type; - } - row.height = 0; - inputRows.push(row); - } else { - row = inputRows[inputRows.length - 1]; - } - row.push(input); - - // Compute minimum input size. - input.renderHeight = Blockly.BlockSvg.MIN_BLOCK_Y; - // The width is currently only needed for inline value inputs. - if (isInline && input.type == Blockly.INPUT_VALUE) { - input.renderWidth = Blockly.BlockSvg.TAB_WIDTH + - Blockly.BlockSvg.SEP_SPACE_X * 1.25; - } else { - input.renderWidth = 0; - } - // Expand input size if there is a connection. - if (input.connection && input.connection.isConnected()) { - var linkedBlock = input.connection.targetBlock(); - var bBox = linkedBlock.getHeightWidth(); - input.renderHeight = Math.max(input.renderHeight, bBox.height); - input.renderWidth = Math.max(input.renderWidth, bBox.width); - } - // Blocks have a one pixel shadow that should sometimes overhang. - if (!isInline && i == inputList.length - 1) { - // Last value input should overhang. - input.renderHeight--; - } else if (!isInline && input.type == Blockly.INPUT_VALUE && - inputList[i + 1] && inputList[i + 1].type == Blockly.NEXT_STATEMENT) { - // Value input above statement input should overhang. - input.renderHeight--; - } - - row.height = Math.max(row.height, input.renderHeight); - input.fieldWidth = 0; - if (inputRows.length == 1) { - // The first row gets shifted to accommodate any icons. - input.fieldWidth += this.RTL ? -iconWidth : iconWidth; - } - var previousFieldEditable = false; - for (var j = 0, field; field = input.fieldRow[j]; j++) { - if (j != 0) { - input.fieldWidth += Blockly.BlockSvg.SEP_SPACE_X; - } - // Get the dimensions of the field. - var fieldSize = field.getSize(); - field.renderWidth = fieldSize.width; - field.renderSep = (previousFieldEditable && field.EDITABLE) ? - Blockly.BlockSvg.SEP_SPACE_X : 0; - input.fieldWidth += field.renderWidth + field.renderSep; - row.height = Math.max(row.height, fieldSize.height); - previousFieldEditable = field.EDITABLE; - } - - if (row.type != Blockly.BlockSvg.INLINE) { - if (row.type == Blockly.NEXT_STATEMENT) { - hasStatement = true; - fieldStatementWidth = Math.max(fieldStatementWidth, input.fieldWidth); - } else { - if (row.type == Blockly.INPUT_VALUE) { - hasValue = true; - } else if (row.type == Blockly.DUMMY_INPUT) { - hasDummy = true; - } - fieldValueWidth = Math.max(fieldValueWidth, input.fieldWidth); - } - } - } - - // Make inline rows a bit thicker in order to enclose the values. - for (var y = 0, row; row = inputRows[y]; y++) { - row.thicker = false; - if (row.type == Blockly.BlockSvg.INLINE) { - for (var z = 0, input; input = row[z]; z++) { - if (input.type == Blockly.INPUT_VALUE) { - row.height += 2 * Blockly.BlockSvg.INLINE_PADDING_Y; - row.thicker = true; - break; - } - } - } - } - - // Compute the statement edge. - // This is the width of a block where statements are nested. - inputRows.statementEdge = 2 * Blockly.BlockSvg.SEP_SPACE_X + - fieldStatementWidth; - // Compute the preferred right edge. Inline blocks may extend beyond. - // This is the width of the block where external inputs connect. - if (hasStatement) { - inputRows.rightEdge = Math.max(inputRows.rightEdge, - inputRows.statementEdge + Blockly.BlockSvg.NOTCH_WIDTH); - } - if (hasValue) { - inputRows.rightEdge = Math.max(inputRows.rightEdge, fieldValueWidth + - Blockly.BlockSvg.SEP_SPACE_X * 2 + Blockly.BlockSvg.TAB_WIDTH); - } else if (hasDummy) { - inputRows.rightEdge = Math.max(inputRows.rightEdge, fieldValueWidth + - Blockly.BlockSvg.SEP_SPACE_X * 2); - } - - inputRows.hasValue = hasValue; - inputRows.hasStatement = hasStatement; - inputRows.hasDummy = hasDummy; - return inputRows; -}; - - -/** - * Draw the path of the block. - * Move the fields to the correct locations. - * @param {number} iconWidth Offset of first row due to icons. - * @param {!Array.>} inputRows 2D array of objects, each - * containing position information. - * @private - */ -Blockly.BlockSvg.prototype.renderDraw_ = function(iconWidth, inputRows) { - this.startHat_ = false; - // Reset the height to zero and let the rendering process add in - // portions of the block height as it goes. (e.g. hats, inputs, etc.) - this.height = 0; - // Should the top and bottom left corners be rounded or square? - if (this.outputConnection) { - this.squareTopLeftCorner_ = true; - this.squareBottomLeftCorner_ = true; - } else { - this.squareTopLeftCorner_ = false; - this.squareBottomLeftCorner_ = false; - // If this block is in the middle of a stack, square the corners. - if (this.previousConnection) { - var prevBlock = this.previousConnection.targetBlock(); - if (prevBlock && prevBlock.getNextBlock() == this) { - this.squareTopLeftCorner_ = true; - } - } else if (Blockly.BlockSvg.START_HAT) { - // No output or previous connection. - this.squareTopLeftCorner_ = true; - this.startHat_ = true; - this.height += Blockly.BlockSvg.START_HAT_HEIGHT; - inputRows.rightEdge = Math.max(inputRows.rightEdge, 100); - } - var nextBlock = this.getNextBlock(); - if (nextBlock) { - this.squareBottomLeftCorner_ = true; - } - } - - // Assemble the block's path. - var steps = []; - var inlineSteps = []; - // The highlighting applies to edges facing the upper-left corner. - // Since highlighting is a two-pixel wide border, it would normally overhang - // the edge of the block by a pixel. So undersize all measurements by a pixel. - var highlightSteps = []; - var highlightInlineSteps = []; - - this.renderDrawTop_(steps, highlightSteps, inputRows.rightEdge); - var cursorY = this.renderDrawRight_(steps, highlightSteps, inlineSteps, - highlightInlineSteps, inputRows, iconWidth); - this.renderDrawBottom_(steps, highlightSteps, cursorY); - this.renderDrawLeft_(steps, highlightSteps); - - var pathString = steps.join(' ') + '\n' + inlineSteps.join(' '); - this.svgPath_.setAttribute('d', pathString); - this.svgPathDark_.setAttribute('d', pathString); - pathString = highlightSteps.join(' ') + '\n' + highlightInlineSteps.join(' '); - this.svgPathLight_.setAttribute('d', pathString); - if (this.RTL) { - // Mirror the block's path. - this.svgPath_.setAttribute('transform', 'scale(-1 1)'); - this.svgPathLight_.setAttribute('transform', 'scale(-1 1)'); - this.svgPathDark_.setAttribute('transform', 'translate(1,1) scale(-1 1)'); - } -}; - -/** - * Update all of the connections on this block with the new locations calculated - * in renderCompute. Also move all of the connected blocks based on the new - * connection locations. - * @private - */ -Blockly.BlockSvg.prototype.renderMoveConnections_ = function() { - var blockTL = this.getRelativeToSurfaceXY(); - // Don't tighten previous or output connections because they are inferior - // connections. - if (this.previousConnection) { - this.previousConnection.moveToOffset(blockTL); - } - if (this.outputConnection) { - this.outputConnection.moveToOffset(blockTL); - } - - for (var i = 0; i < this.inputList.length; i++) { - var conn = this.inputList[i].connection; - if (conn) { - conn.moveToOffset(blockTL); - if (conn.isConnected()) { - conn.tighten_(); - } - } - } - - if (this.nextConnection) { - this.nextConnection.moveToOffset(blockTL); - if (this.nextConnection.isConnected()) { - this.nextConnection.tighten_(); - } - } - -}; - -/** - * Render the top edge of the block. - * @param {!Array.} steps Path of block outline. - * @param {!Array.} highlightSteps Path of block highlights. - * @param {number} rightEdge Minimum width of block. - * @private - */ -Blockly.BlockSvg.prototype.renderDrawTop_ = - function(steps, highlightSteps, rightEdge) { - /* eslint-disable indent */ - // Position the cursor at the top-left starting point. - if (this.squareTopLeftCorner_) { - steps.push('m 0,0'); - highlightSteps.push('m 0.5,0.5'); - if (this.startHat_) { - steps.push(Blockly.BlockSvg.START_HAT_PATH); - highlightSteps.push(this.RTL ? - Blockly.BlockSvg.START_HAT_HIGHLIGHT_RTL : - Blockly.BlockSvg.START_HAT_HIGHLIGHT_LTR); - } - } else { - steps.push(Blockly.BlockSvg.TOP_LEFT_CORNER_START); - highlightSteps.push(this.RTL ? - Blockly.BlockSvg.TOP_LEFT_CORNER_START_HIGHLIGHT_RTL : - Blockly.BlockSvg.TOP_LEFT_CORNER_START_HIGHLIGHT_LTR); - // Top-left rounded corner. - steps.push(Blockly.BlockSvg.TOP_LEFT_CORNER); - highlightSteps.push(Blockly.BlockSvg.TOP_LEFT_CORNER_HIGHLIGHT); - } - - // Top edge. - if (this.previousConnection) { - steps.push('H', Blockly.BlockSvg.NOTCH_WIDTH - 15); - highlightSteps.push('H', Blockly.BlockSvg.NOTCH_WIDTH - 15); - steps.push(Blockly.BlockSvg.NOTCH_PATH_LEFT); - highlightSteps.push(Blockly.BlockSvg.NOTCH_PATH_LEFT_HIGHLIGHT); - - var connectionX = (this.RTL ? - -Blockly.BlockSvg.NOTCH_WIDTH : Blockly.BlockSvg.NOTCH_WIDTH); - this.previousConnection.setOffsetInBlock(connectionX, 0); - } - steps.push('H', rightEdge); - highlightSteps.push('H', rightEdge - 0.5); - this.width = rightEdge; -}; /* eslint-enable indent */ - -/** - * Render the right edge of the block. - * @param {!Array.} steps Path of block outline. - * @param {!Array.} highlightSteps Path of block highlights. - * @param {!Array.} inlineSteps Inline block outlines. - * @param {!Array.} highlightInlineSteps Inline block highlights. - * @param {!Array.>} inputRows 2D array of objects, each - * containing position information. - * @param {number} iconWidth Offset of first row due to icons. - * @return {number} Height of block. - * @private - */ -Blockly.BlockSvg.prototype.renderDrawRight_ = function(steps, highlightSteps, - inlineSteps, highlightInlineSteps, inputRows, iconWidth) { - var cursorX; - var cursorY = 0; - var connectionX, connectionY; - for (var y = 0, row; row = inputRows[y]; y++) { - cursorX = Blockly.BlockSvg.SEP_SPACE_X; - if (y == 0) { - cursorX += this.RTL ? -iconWidth : iconWidth; - } - highlightSteps.push('M', (inputRows.rightEdge - 0.5) + ',' + - (cursorY + 0.5)); - if (this.isCollapsed()) { - // Jagged right edge. - var input = row[0]; - var fieldX = cursorX; - var fieldY = cursorY; - this.renderFields_(input.fieldRow, fieldX, fieldY); - steps.push(Blockly.BlockSvg.JAGGED_TEETH); - highlightSteps.push('h 8'); - var remainder = row.height - Blockly.BlockSvg.JAGGED_TEETH_HEIGHT; - steps.push('v', remainder); - if (this.RTL) { - highlightSteps.push('v 3.9 l 7.2,3.4 m -14.5,8.9 l 7.3,3.5'); - highlightSteps.push('v', remainder - 0.7); - } - this.width += Blockly.BlockSvg.JAGGED_TEETH_WIDTH; - } else if (row.type == Blockly.BlockSvg.INLINE) { - // Inline inputs. - for (var x = 0, input; input = row[x]; x++) { - var fieldX = cursorX; - var fieldY = cursorY; - if (row.thicker) { - // Lower the field slightly. - fieldY += Blockly.BlockSvg.INLINE_PADDING_Y; - } - // TODO: Align inline field rows (left/right/centre). - cursorX = this.renderFields_(input.fieldRow, fieldX, fieldY); - if (input.type != Blockly.DUMMY_INPUT) { - cursorX += input.renderWidth + Blockly.BlockSvg.SEP_SPACE_X; - } - if (input.type == Blockly.INPUT_VALUE) { - inlineSteps.push('M', (cursorX - Blockly.BlockSvg.SEP_SPACE_X) + - ',' + (cursorY + Blockly.BlockSvg.INLINE_PADDING_Y)); - inlineSteps.push('h', Blockly.BlockSvg.TAB_WIDTH - 2 - - input.renderWidth); - inlineSteps.push(Blockly.BlockSvg.TAB_PATH_DOWN); - inlineSteps.push('v', input.renderHeight + 1 - - Blockly.BlockSvg.TAB_HEIGHT); - inlineSteps.push('h', input.renderWidth + 2 - - Blockly.BlockSvg.TAB_WIDTH); - inlineSteps.push('z'); - if (this.RTL) { - // Highlight right edge, around back of tab, and bottom. - highlightInlineSteps.push('M', - (cursorX - Blockly.BlockSvg.SEP_SPACE_X - 2.5 + - Blockly.BlockSvg.TAB_WIDTH - input.renderWidth) + ',' + - (cursorY + Blockly.BlockSvg.INLINE_PADDING_Y + 0.5)); - highlightInlineSteps.push( - Blockly.BlockSvg.TAB_PATH_DOWN_HIGHLIGHT_RTL); - highlightInlineSteps.push('v', - input.renderHeight - Blockly.BlockSvg.TAB_HEIGHT + 2.5); - highlightInlineSteps.push('h', - input.renderWidth - Blockly.BlockSvg.TAB_WIDTH + 2); - } else { - // Highlight right edge, bottom. - highlightInlineSteps.push('M', - (cursorX - Blockly.BlockSvg.SEP_SPACE_X + 0.5) + ',' + - (cursorY + Blockly.BlockSvg.INLINE_PADDING_Y + 0.5)); - highlightInlineSteps.push('v', input.renderHeight + 1); - highlightInlineSteps.push('h', Blockly.BlockSvg.TAB_WIDTH - 2 - - input.renderWidth); - // Short highlight glint at bottom of tab. - highlightInlineSteps.push('M', - (cursorX - input.renderWidth - Blockly.BlockSvg.SEP_SPACE_X + - 0.9) + ',' + (cursorY + Blockly.BlockSvg.INLINE_PADDING_Y + - Blockly.BlockSvg.TAB_HEIGHT - 0.7)); - highlightInlineSteps.push('l', - (Blockly.BlockSvg.TAB_WIDTH * 0.46) + ',-2.1'); - } - // Create inline input connection. - if (this.RTL) { - connectionX = -cursorX - - Blockly.BlockSvg.TAB_WIDTH + Blockly.BlockSvg.SEP_SPACE_X + - input.renderWidth + 1; - } else { - connectionX = cursorX + - Blockly.BlockSvg.TAB_WIDTH - Blockly.BlockSvg.SEP_SPACE_X - - input.renderWidth - 1; - } - connectionY = cursorY + Blockly.BlockSvg.INLINE_PADDING_Y + 1; - input.connection.setOffsetInBlock(connectionX, connectionY); - } - } - - cursorX = Math.max(cursorX, inputRows.rightEdge); - this.width = Math.max(this.width, cursorX); - steps.push('H', cursorX); - highlightSteps.push('H', cursorX - 0.5); - steps.push('v', row.height); - if (this.RTL) { - highlightSteps.push('v', row.height - 1); - } - } else if (row.type == Blockly.INPUT_VALUE) { - // External input. - var input = row[0]; - var fieldX = cursorX; - var fieldY = cursorY; - if (input.align != Blockly.ALIGN_LEFT) { - var fieldRightX = inputRows.rightEdge - input.fieldWidth - - Blockly.BlockSvg.TAB_WIDTH - 2 * Blockly.BlockSvg.SEP_SPACE_X; - if (input.align == Blockly.ALIGN_RIGHT) { - fieldX += fieldRightX; - } else if (input.align == Blockly.ALIGN_CENTRE) { - fieldX += fieldRightX / 2; - } - } - this.renderFields_(input.fieldRow, fieldX, fieldY); - steps.push(Blockly.BlockSvg.TAB_PATH_DOWN); - var v = row.height - Blockly.BlockSvg.TAB_HEIGHT; - steps.push('v', v); - if (this.RTL) { - // Highlight around back of tab. - highlightSteps.push(Blockly.BlockSvg.TAB_PATH_DOWN_HIGHLIGHT_RTL); - highlightSteps.push('v', v + 0.5); - } else { - // Short highlight glint at bottom of tab. - highlightSteps.push('M', (inputRows.rightEdge - 5) + ',' + - (cursorY + Blockly.BlockSvg.TAB_HEIGHT - 0.7)); - highlightSteps.push('l', (Blockly.BlockSvg.TAB_WIDTH * 0.46) + - ',-2.1'); - } - // Create external input connection. - connectionX = this.RTL ? -inputRows.rightEdge - 1 : - inputRows.rightEdge + 1; - input.connection.setOffsetInBlock(connectionX, cursorY); - if (input.connection.isConnected()) { - this.width = Math.max(this.width, inputRows.rightEdge + - input.connection.targetBlock().getHeightWidth().width - - Blockly.BlockSvg.TAB_WIDTH + 1); - } - } else if (row.type == Blockly.DUMMY_INPUT) { - // External naked field. - var input = row[0]; - var fieldX = cursorX; - var fieldY = cursorY; - if (input.align != Blockly.ALIGN_LEFT) { - var fieldRightX = inputRows.rightEdge - input.fieldWidth - - 2 * Blockly.BlockSvg.SEP_SPACE_X; - if (inputRows.hasValue) { - fieldRightX -= Blockly.BlockSvg.TAB_WIDTH; - } - if (input.align == Blockly.ALIGN_RIGHT) { - fieldX += fieldRightX; - } else if (input.align == Blockly.ALIGN_CENTRE) { - fieldX += fieldRightX / 2; - } - } - this.renderFields_(input.fieldRow, fieldX, fieldY); - steps.push('v', row.height); - if (this.RTL) { - highlightSteps.push('v', row.height - 1); - } - } else if (row.type == Blockly.NEXT_STATEMENT) { - // Nested statement. - var input = row[0]; - if (y == 0) { - // If the first input is a statement stack, add a small row on top. - steps.push('v', Blockly.BlockSvg.SEP_SPACE_Y); - if (this.RTL) { - highlightSteps.push('v', Blockly.BlockSvg.SEP_SPACE_Y - 1); - } - cursorY += Blockly.BlockSvg.SEP_SPACE_Y; - } - var fieldX = cursorX; - var fieldY = cursorY; - if (input.align != Blockly.ALIGN_LEFT) { - var fieldRightX = inputRows.statementEdge - input.fieldWidth - - 2 * Blockly.BlockSvg.SEP_SPACE_X; - if (input.align == Blockly.ALIGN_RIGHT) { - fieldX += fieldRightX; - } else if (input.align == Blockly.ALIGN_CENTRE) { - fieldX += fieldRightX / 2; - } - } - this.renderFields_(input.fieldRow, fieldX, fieldY); - cursorX = inputRows.statementEdge + Blockly.BlockSvg.NOTCH_WIDTH; - steps.push('H', cursorX); - steps.push(Blockly.BlockSvg.INNER_TOP_LEFT_CORNER); - steps.push('v', row.height - 2 * Blockly.BlockSvg.CORNER_RADIUS); - steps.push(Blockly.BlockSvg.INNER_BOTTOM_LEFT_CORNER); - steps.push('H', inputRows.rightEdge); - if (this.RTL) { - highlightSteps.push('M', - (cursorX - Blockly.BlockSvg.NOTCH_WIDTH + - Blockly.BlockSvg.DISTANCE_45_OUTSIDE) + - ',' + (cursorY + Blockly.BlockSvg.DISTANCE_45_OUTSIDE)); - highlightSteps.push( - Blockly.BlockSvg.INNER_TOP_LEFT_CORNER_HIGHLIGHT_RTL); - highlightSteps.push('v', - row.height - 2 * Blockly.BlockSvg.CORNER_RADIUS); - highlightSteps.push( - Blockly.BlockSvg.INNER_BOTTOM_LEFT_CORNER_HIGHLIGHT_RTL); - highlightSteps.push('H', inputRows.rightEdge - 0.5); - } else { - highlightSteps.push('M', - (cursorX - Blockly.BlockSvg.NOTCH_WIDTH + - Blockly.BlockSvg.DISTANCE_45_OUTSIDE) + ',' + - (cursorY + row.height - Blockly.BlockSvg.DISTANCE_45_OUTSIDE)); - highlightSteps.push( - Blockly.BlockSvg.INNER_BOTTOM_LEFT_CORNER_HIGHLIGHT_LTR); - highlightSteps.push('H', inputRows.rightEdge - 0.5); - } - // Create statement connection. - connectionX = this.RTL ? -cursorX : cursorX + 1; - input.connection.setOffsetInBlock(connectionX, cursorY + 1); - - if (input.connection.isConnected()) { - this.width = Math.max(this.width, inputRows.statementEdge + - input.connection.targetBlock().getHeightWidth().width); - } - if (y == inputRows.length - 1 || - inputRows[y + 1].type == Blockly.NEXT_STATEMENT) { - // If the final input is a statement stack, add a small row underneath. - // Consecutive statement stacks are also separated by a small divider. - steps.push('v', Blockly.BlockSvg.SEP_SPACE_Y); - if (this.RTL) { - highlightSteps.push('v', Blockly.BlockSvg.SEP_SPACE_Y - 1); - } - cursorY += Blockly.BlockSvg.SEP_SPACE_Y; - } - } - cursorY += row.height; - } - if (!inputRows.length) { - cursorY = Blockly.BlockSvg.MIN_BLOCK_Y; - steps.push('V', cursorY); - if (this.RTL) { - highlightSteps.push('V', cursorY - 1); - } - } - return cursorY; -}; - -/** - * Render the bottom edge of the block. - * @param {!Array.} steps Path of block outline. - * @param {!Array.} highlightSteps Path of block highlights. - * @param {number} cursorY Height of block. - * @private - */ -Blockly.BlockSvg.prototype.renderDrawBottom_ = - function(steps, highlightSteps, cursorY) { - /* eslint-disable indent */ - this.height += cursorY + 1; // Add one for the shadow. - if (this.nextConnection) { - steps.push('H', (Blockly.BlockSvg.NOTCH_WIDTH + (this.RTL ? 0.5 : - 0.5)) + - ' ' + Blockly.BlockSvg.NOTCH_PATH_RIGHT); - // Create next block connection. - var connectionX; - if (this.RTL) { - connectionX = -Blockly.BlockSvg.NOTCH_WIDTH; - } else { - connectionX = Blockly.BlockSvg.NOTCH_WIDTH; - } - this.nextConnection.setOffsetInBlock(connectionX, cursorY + 1); - this.height += 4; // Height of tab. - } - - // Should the bottom-left corner be rounded or square? - if (this.squareBottomLeftCorner_) { - steps.push('H 0'); - if (!this.RTL) { - highlightSteps.push('M', '0.5,' + (cursorY - 0.5)); - } - } else { - steps.push('H', Blockly.BlockSvg.CORNER_RADIUS); - steps.push('a', Blockly.BlockSvg.CORNER_RADIUS + ',' + - Blockly.BlockSvg.CORNER_RADIUS + ' 0 0,1 -' + - Blockly.BlockSvg.CORNER_RADIUS + ',-' + - Blockly.BlockSvg.CORNER_RADIUS); - if (!this.RTL) { - highlightSteps.push('M', Blockly.BlockSvg.DISTANCE_45_INSIDE + ',' + - (cursorY - Blockly.BlockSvg.DISTANCE_45_INSIDE)); - highlightSteps.push('A', (Blockly.BlockSvg.CORNER_RADIUS - 0.5) + ',' + - (Blockly.BlockSvg.CORNER_RADIUS - 0.5) + ' 0 0,1 ' + - '0.5,' + (cursorY - Blockly.BlockSvg.CORNER_RADIUS)); - } - } -}; /* eslint-enable indent */ - -/** - * Render the left edge of the block. - * @param {!Array.} steps Path of block outline. - * @param {!Array.} highlightSteps Path of block highlights. - * @private - */ -Blockly.BlockSvg.prototype.renderDrawLeft_ = function(steps, highlightSteps) { - if (this.outputConnection) { - // Create output connection. - this.outputConnection.setOffsetInBlock(0, 0); - steps.push('V', Blockly.BlockSvg.TAB_HEIGHT); - steps.push('c 0,-10 -' + Blockly.BlockSvg.TAB_WIDTH + ',8 -' + - Blockly.BlockSvg.TAB_WIDTH + ',-7.5 s ' + Blockly.BlockSvg.TAB_WIDTH + - ',2.5 ' + Blockly.BlockSvg.TAB_WIDTH + ',-7.5'); - if (this.RTL) { - highlightSteps.push('M', (Blockly.BlockSvg.TAB_WIDTH * -0.25) + ',8.4'); - highlightSteps.push('l', (Blockly.BlockSvg.TAB_WIDTH * -0.45) + ',-2.1'); - } else { - highlightSteps.push('V', Blockly.BlockSvg.TAB_HEIGHT - 1.5); - highlightSteps.push('m', (Blockly.BlockSvg.TAB_WIDTH * -0.92) + - ',-0.5 q ' + (Blockly.BlockSvg.TAB_WIDTH * -0.19) + - ',-5.5 0,-11'); - highlightSteps.push('m', (Blockly.BlockSvg.TAB_WIDTH * 0.92) + - ',1 V 0.5 H 1'); - } - this.width += Blockly.BlockSvg.TAB_WIDTH; - } else if (!this.RTL) { - if (this.squareTopLeftCorner_) { - // Statement block in a stack. - highlightSteps.push('V', 0.5); - } else { - highlightSteps.push('V', Blockly.BlockSvg.CORNER_RADIUS); - } - } - steps.push('z'); -}; diff --git a/core/block_svg.js b/core/block_svg.js deleted file mode 100644 index 6c57850ea39..00000000000 --- a/core/block_svg.js +++ /dev/null @@ -1,1548 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Methods for graphically rendering a block as SVG. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.BlockSvg'); - -goog.require('Blockly.Block'); -goog.require('Blockly.ContextMenu'); -goog.require('Blockly.Grid'); -goog.require('Blockly.RenderedConnection'); -goog.require('Blockly.Touch'); -goog.require('Blockly.utils'); -goog.require('goog.Timer'); -goog.require('goog.asserts'); -goog.require('goog.dom'); -goog.require('goog.math.Coordinate'); -goog.require('goog.userAgent'); - - -/** - * Class for a block's SVG representation. - * Not normally called directly, workspace.newBlock() is preferred. - * @param {!Blockly.Workspace} workspace The block's workspace. - * @param {?string} prototypeName Name of the language object containing - * type-specific functions for this block. - * @param {string=} opt_id Optional ID. Use this ID if provided, otherwise - * create a new id. - * @extends {Blockly.Block} - * @constructor - */ -Blockly.BlockSvg = function(workspace, prototypeName, opt_id) { - // Create core elements for the block. - /** - * @type {SVGElement} - * @private - */ - this.svgGroup_ = Blockly.utils.createSvgElement('g', {}, null); - this.svgGroup_.translate_ = ''; - - /** - * @type {SVGElement} - * @private - */ - this.svgPathDark_ = Blockly.utils.createSvgElement('path', - {'class': 'blocklyPathDark', 'transform': 'translate(1,1)'}, - this.svgGroup_); - - /** - * @type {SVGElement} - * @private - */ - this.svgPath_ = Blockly.utils.createSvgElement('path', {'class': 'blocklyPath'}, - this.svgGroup_); - - /** - * @type {SVGElement} - * @private - */ - this.svgPathLight_ = Blockly.utils.createSvgElement('path', - {'class': 'blocklyPathLight'}, this.svgGroup_); - this.svgPath_.tooltip = this; - - /** @type {boolean} */ - this.rendered = false; - - /** - * Whether to move the block to the drag surface when it is dragged. - * True if it should move, false if it should be translated directly. - * @type {boolean} - * @private - */ - this.useDragSurface_ = Blockly.utils.is3dSupported() && !!workspace.blockDragSurface_; - - Blockly.Tooltip.bindMouseEvents(this.svgPath_); - Blockly.BlockSvg.superClass_.constructor.call(this, - workspace, prototypeName, opt_id); -}; -goog.inherits(Blockly.BlockSvg, Blockly.Block); - -/** - * Height of this block, not including any statement blocks above or below. - * Height is in workspace units. - */ -Blockly.BlockSvg.prototype.height = 0; -/** - * Width of this block, including any connected value blocks. - * Width is in workspace units. - */ -Blockly.BlockSvg.prototype.width = 0; - -/** - * Original location of block being dragged. - * @type {goog.math.Coordinate} - * @private - */ -Blockly.BlockSvg.prototype.dragStartXY_ = null; - -/** - * Constant for identifying rows that are to be rendered inline. - * Don't collide with Blockly.INPUT_VALUE and friends. - * @const - */ -Blockly.BlockSvg.INLINE = -1; - -/** - * Create and initialize the SVG representation of the block. - * May be called more than once. - */ -Blockly.BlockSvg.prototype.initSvg = function() { - goog.asserts.assert(this.workspace.rendered, 'Workspace is headless.'); - for (var i = 0, input; input = this.inputList[i]; i++) { - input.init(); - } - var icons = this.getIcons(); - for (var i = 0; i < icons.length; i++) { - icons[i].createIcon(); - } - this.updateColour(); - this.updateMovable(); - if (!this.workspace.options.readOnly && !this.eventsInit_) { - Blockly.bindEventWithChecks_(this.getSvgRoot(), 'mousedown', this, - this.onMouseDown_); - } - this.eventsInit_ = true; - - if (!this.getSvgRoot().parentNode) { - this.workspace.getCanvas().appendChild(this.getSvgRoot()); - } -}; - -/** - * Select this block. Highlight it visually. - */ -Blockly.BlockSvg.prototype.select = function() { - if (this.isShadow() && this.getParent()) { - // Shadow blocks should not be selected. - this.getParent().select(); - return; - } - if (Blockly.selected == this) { - return; - } - var oldId = null; - if (Blockly.selected) { - oldId = Blockly.selected.id; - // Unselect any previously selected block. - Blockly.Events.disable(); - try { - Blockly.selected.unselect(); - } finally { - Blockly.Events.enable(); - } - } - var event = new Blockly.Events.Ui(null, 'selected', oldId, this.id); - event.workspaceId = this.workspace.id; - Blockly.Events.fire(event); - Blockly.selected = this; - this.addSelect(); -}; - -/** - * Unselect this block. Remove its highlighting. - */ -Blockly.BlockSvg.prototype.unselect = function() { - if (Blockly.selected != this) { - return; - } - var event = new Blockly.Events.Ui(null, 'selected', this.id, null); - event.workspaceId = this.workspace.id; - Blockly.Events.fire(event); - Blockly.selected = null; - this.removeSelect(); -}; - -/** - * Block's mutator icon (if any). - * @type {Blockly.Mutator} - */ -Blockly.BlockSvg.prototype.mutator = null; - -/** - * Block's comment icon (if any). - * @type {Blockly.Comment} - */ -Blockly.BlockSvg.prototype.comment = null; - -/** - * Block's warning icon (if any). - * @type {Blockly.Warning} - */ -Blockly.BlockSvg.prototype.warning = null; - -/** - * Returns a list of mutator, comment, and warning icons. - * @return {!Array} List of icons. - */ -Blockly.BlockSvg.prototype.getIcons = function() { - var icons = []; - if (this.mutator) { - icons.push(this.mutator); - } - if (this.comment) { - icons.push(this.comment); - } - if (this.warning) { - icons.push(this.warning); - } - return icons; -}; - -/** - * Set parent of this block to be a new block or null. - * @param {Blockly.BlockSvg} newParent New parent block. - */ -Blockly.BlockSvg.prototype.setParent = function(newParent) { - if (newParent == this.parentBlock_) { - return; - } - var svgRoot = this.getSvgRoot(); - if (this.parentBlock_ && svgRoot) { - // Move this block up the DOM. Keep track of x/y translations. - var xy = this.getRelativeToSurfaceXY(); - this.workspace.getCanvas().appendChild(svgRoot); - svgRoot.setAttribute('transform', 'translate(' + xy.x + ',' + xy.y + ')'); - } - - Blockly.Field.startCache(); - Blockly.BlockSvg.superClass_.setParent.call(this, newParent); - Blockly.Field.stopCache(); - - if (newParent) { - var oldXY = this.getRelativeToSurfaceXY(); - newParent.getSvgRoot().appendChild(svgRoot); - var newXY = this.getRelativeToSurfaceXY(); - // Move the connections to match the child's new position. - this.moveConnections_(newXY.x - oldXY.x, newXY.y - oldXY.y); - } -}; - -/** - * Return the coordinates of the top-left corner of this block relative to the - * drawing surface's origin (0,0), in workspace units. - * If the block is on the workspace, (0, 0) is the origin of the workspace - * coordinate system. - * This does not change with workspace scale. - * @return {!goog.math.Coordinate} Object with .x and .y properties in - * workspace coordinates. - */ -Blockly.BlockSvg.prototype.getRelativeToSurfaceXY = function() { - var x = 0; - var y = 0; - - var dragSurfaceGroup = this.useDragSurface_ ? - this.workspace.blockDragSurface_.getGroup() : null; - - var element = this.getSvgRoot(); - if (element) { - do { - // Loop through this block and every parent. - var xy = Blockly.utils.getRelativeXY(element); - x += xy.x; - y += xy.y; - // If this element is the current element on the drag surface, include - // the translation of the drag surface itself. - if (this.useDragSurface_ && - this.workspace.blockDragSurface_.getCurrentBlock() == element) { - var surfaceTranslation = this.workspace.blockDragSurface_.getSurfaceTranslation(); - x += surfaceTranslation.x; - y += surfaceTranslation.y; - } - element = element.parentNode; - } while (element && element != this.workspace.getCanvas() && - element != dragSurfaceGroup); - } - return new goog.math.Coordinate(x, y); -}; - -/** - * Move a block by a relative offset. - * @param {number} dx Horizontal offset in workspace units. - * @param {number} dy Vertical offset in workspace units. - */ -Blockly.BlockSvg.prototype.moveBy = function(dx, dy) { - goog.asserts.assert(!this.parentBlock_, 'Block has parent.'); - var event = new Blockly.Events.BlockMove(this); - var xy = this.getRelativeToSurfaceXY(); - this.translate(xy.x + dx, xy.y + dy); - this.moveConnections_(dx, dy); - event.recordNew(); - this.workspace.resizeContents(); - Blockly.Events.fire(event); -}; - -/** - * Transforms a block by setting the translation on the transform attribute - * of the block's SVG. - * @param {number} x The x coordinate of the translation in workspace units. - * @param {number} y The y coordinate of the translation in workspace units. - */ -Blockly.BlockSvg.prototype.translate = function(x, y) { - this.getSvgRoot().setAttribute('transform', - 'translate(' + x + ',' + y + ')'); -}; - -/** - * Move this block to its workspace's drag surface, accounting for positioning. - * Generally should be called at the same time as setDragging_(true). - * Does nothing if useDragSurface_ is false. - * @private - */ -Blockly.BlockSvg.prototype.moveToDragSurface_ = function() { - if (!this.useDragSurface_) { - return; - } - // The translation for drag surface blocks, - // is equal to the current relative-to-surface position, - // to keep the position in sync as it move on/off the surface. - // This is in workspace coordinates. - var xy = this.getRelativeToSurfaceXY(); - this.clearTransformAttributes_(); - this.workspace.blockDragSurface_.translateSurface(xy.x, xy.y); - // Execute the move on the top-level SVG component - this.workspace.blockDragSurface_.setBlocksAndShow(this.getSvgRoot()); -}; - -/** - * Move this block back to the workspace block canvas. - * Generally should be called at the same time as setDragging_(false). - * Does nothing if useDragSurface_ is false. - * @param {!goog.math.Coordinate} newXY The position the block should take on - * on the workspace canvas, in workspace coordinates. - * @private - */ -Blockly.BlockSvg.prototype.moveOffDragSurface_ = function(newXY) { - if (!this.useDragSurface_) { - return; - } - // Translate to current position, turning off 3d. - this.translate(newXY.x, newXY.y); - this.workspace.blockDragSurface_.clearAndHide(this.workspace.getCanvas()); -}; - -/** - * Move this block during a drag, taking into account whether we are using a - * drag surface to translate blocks. - * This block must be a top-level block. - * @param {!goog.math.Coordinate} newLoc The location to translate to, in - * workspace coordinates. - * @package - */ -Blockly.BlockSvg.prototype.moveDuringDrag = function(newLoc) { - if (this.useDragSurface_) { - this.workspace.blockDragSurface_.translateSurface(newLoc.x, newLoc.y); - } else { - this.svgGroup_.translate_ = 'translate(' + newLoc.x + ',' + newLoc.y + ')'; - this.svgGroup_.setAttribute('transform', - this.svgGroup_.translate_ + this.svgGroup_.skew_); - } -}; - -/** - * Clear the block of transform="..." attributes. - * Used when the block is switching from 3d to 2d transform or vice versa. - * @private - */ -Blockly.BlockSvg.prototype.clearTransformAttributes_ = function() { - Blockly.utils.removeAttribute(this.getSvgRoot(), 'transform'); -}; - -/** - * Snap this block to the nearest grid point. - */ -Blockly.BlockSvg.prototype.snapToGrid = function() { - if (!this.workspace) { - return; // Deleted block. - } - if (this.workspace.isDragging()) { - return; // Don't bump blocks during a drag. - } - if (this.getParent()) { - return; // Only snap top-level blocks. - } - if (this.isInFlyout) { - return; // Don't move blocks around in a flyout. - } - var grid = this.workspace.getGrid(); - if (!grid || !grid.shouldSnap()) { - return; // Config says no snapping. - } - var spacing = grid.getSpacing(); - var half = spacing / 2; - var xy = this.getRelativeToSurfaceXY(); - var dx = Math.round((xy.x - half) / spacing) * spacing + half - xy.x; - var dy = Math.round((xy.y - half) / spacing) * spacing + half - xy.y; - dx = Math.round(dx); - dy = Math.round(dy); - if (dx != 0 || dy != 0) { - this.moveBy(dx, dy); - } -}; - -/** - * Returns the coordinates of a bounding box describing the dimensions of this - * block and any blocks stacked below it. - * Coordinate system: workspace coordinates. - * @return {!{topLeft: goog.math.Coordinate, bottomRight: goog.math.Coordinate}} - * Object with top left and bottom right coordinates of the bounding box. - */ -Blockly.BlockSvg.prototype.getBoundingRectangle = function() { - var blockXY = this.getRelativeToSurfaceXY(this); - var tab = this.outputConnection ? Blockly.BlockSvg.TAB_WIDTH : 0; - var blockBounds = this.getHeightWidth(); - var topLeft; - var bottomRight; - if (this.RTL) { - // Width has the tab built into it already so subtract it here. - topLeft = new goog.math.Coordinate(blockXY.x - (blockBounds.width - tab), - blockXY.y); - // Add the width of the tab/puzzle piece knob to the x coordinate - // since X is the corner of the rectangle, not the whole puzzle piece. - bottomRight = new goog.math.Coordinate(blockXY.x + tab, - blockXY.y + blockBounds.height); - } else { - // Subtract the width of the tab/puzzle piece knob to the x coordinate - // since X is the corner of the rectangle, not the whole puzzle piece. - topLeft = new goog.math.Coordinate(blockXY.x - tab, blockXY.y); - // Width has the tab built into it already so subtract it here. - bottomRight = new goog.math.Coordinate(blockXY.x + blockBounds.width - tab, - blockXY.y + blockBounds.height); - } - return {topLeft: topLeft, bottomRight: bottomRight}; -}; - -/** - * Set whether the block is collapsed or not. - * @param {boolean} collapsed True if collapsed. - */ -Blockly.BlockSvg.prototype.setCollapsed = function(collapsed) { - if (this.collapsed_ == collapsed) { - return; - } - var renderList = []; - // Show/hide the inputs. - for (var i = 0, input; input = this.inputList[i]; i++) { - renderList.push.apply(renderList, input.setVisible(!collapsed)); - } - - var COLLAPSED_INPUT_NAME = '_TEMP_COLLAPSED_INPUT'; - if (collapsed) { - var icons = this.getIcons(); - for (var i = 0; i < icons.length; i++) { - icons[i].setVisible(false); - } - var text = this.toString(Blockly.COLLAPSE_CHARS); - this.appendDummyInput(COLLAPSED_INPUT_NAME).appendField(text).init(); - } else { - this.removeInput(COLLAPSED_INPUT_NAME); - // Clear any warnings inherited from enclosed blocks. - this.setWarningText(null); - } - Blockly.BlockSvg.superClass_.setCollapsed.call(this, collapsed); - - if (!renderList.length) { - // No child blocks, just render this block. - renderList[0] = this; - } - if (this.rendered) { - for (var i = 0, block; block = renderList[i]; i++) { - block.render(); - } - // Don't bump neighbours. - // Although bumping neighbours would make sense, users often collapse - // all their functions and store them next to each other. Expanding and - // bumping causes all their definitions to go out of alignment. - } -}; - -/** - * Open the next (or previous) FieldTextInput. - * @param {Blockly.Field|Blockly.Block} start Current location. - * @param {boolean} forward If true go forward, otherwise backward. - */ -Blockly.BlockSvg.prototype.tab = function(start, forward) { - // This function need not be efficient since it runs once on a keypress. - // Create an ordered list of all text fields and connected inputs. - var list = []; - for (var i = 0, input; input = this.inputList[i]; i++) { - for (var j = 0, field; field = input.fieldRow[j]; j++) { - if (field instanceof Blockly.FieldTextInput) { - // TODO: Also support dropdown fields. - list.push(field); - } - } - if (input.connection) { - var block = input.connection.targetBlock(); - if (block) { - list.push(block); - } - } - } - var i = list.indexOf(start); - if (i == -1) { - // No start location, start at the beginning or end. - i = forward ? -1 : list.length; - } - var target = list[forward ? i + 1 : i - 1]; - if (!target) { - // Ran off of list. - var parent = this.getParent(); - if (parent) { - parent.tab(this, forward); - } - } else if (target instanceof Blockly.Field) { - target.showEditor_(); - } else { - target.tab(null, forward); - } -}; - -/** - * Handle a mouse-down on an SVG block. - * @param {!Event} e Mouse down event or touch start event. - * @private - */ -Blockly.BlockSvg.prototype.onMouseDown_ = function(e) { - var gesture = this.workspace.getGesture(e); - if (gesture) { - gesture.handleBlockStart(e, this); - } -}; - -/** - * Load the block's help page in a new window. - * @private - */ -Blockly.BlockSvg.prototype.showHelp_ = function() { - var url = goog.isFunction(this.helpUrl) ? this.helpUrl() : this.helpUrl; - if (url) { - window.open(url); - } -}; - -/** - * Show the context menu for this block. - * @param {!Event} e Mouse event. - * @private - */ -Blockly.BlockSvg.prototype.showContextMenu_ = function(e) { - if (this.workspace.options.readOnly || !this.contextMenu) { - return; - } - // Save the current block in a variable for use in closures. - var block = this; - var menuOptions = []; - - if (this.isDeletable() && this.isMovable() && !block.isInFlyout) { - // Option to duplicate this block. - var duplicateOption = { - text: Blockly.Msg.DUPLICATE_BLOCK, - enabled: true, - callback: function() { - Blockly.duplicate_(block); - } - }; - if (this.getDescendants().length > this.workspace.remainingCapacity()) { - duplicateOption.enabled = false; - } - menuOptions.push(duplicateOption); - - if (this.isEditable() && !this.collapsed_ && - this.workspace.options.comments) { - // Option to add/remove a comment. - var commentOption = {enabled: !goog.userAgent.IE}; - if (this.comment) { - commentOption.text = Blockly.Msg.REMOVE_COMMENT; - commentOption.callback = function() { - block.setCommentText(null); - }; - } else { - commentOption.text = Blockly.Msg.ADD_COMMENT; - commentOption.callback = function() { - block.setCommentText(''); - }; - } - menuOptions.push(commentOption); - } - - // Option to make block inline. - if (!this.collapsed_) { - for (var i = 1; i < this.inputList.length; i++) { - if (this.inputList[i - 1].type != Blockly.NEXT_STATEMENT && - this.inputList[i].type != Blockly.NEXT_STATEMENT) { - // Only display this option if there are two value or dummy inputs - // next to each other. - var inlineOption = {enabled: true}; - var isInline = this.getInputsInline(); - inlineOption.text = isInline ? - Blockly.Msg.EXTERNAL_INPUTS : Blockly.Msg.INLINE_INPUTS; - inlineOption.callback = function() { - block.setInputsInline(!isInline); - }; - menuOptions.push(inlineOption); - break; - } - } - } - - if (this.workspace.options.collapse) { - // Option to collapse/expand block. - if (this.collapsed_) { - var expandOption = {enabled: true}; - expandOption.text = Blockly.Msg.EXPAND_BLOCK; - expandOption.callback = function() { - block.setCollapsed(false); - }; - menuOptions.push(expandOption); - } else { - var collapseOption = {enabled: true}; - collapseOption.text = Blockly.Msg.COLLAPSE_BLOCK; - collapseOption.callback = function() { - block.setCollapsed(true); - }; - menuOptions.push(collapseOption); - } - } - - if (this.workspace.options.disable) { - // Option to disable/enable block. - var disableOption = { - text: this.disabled ? - Blockly.Msg.ENABLE_BLOCK : Blockly.Msg.DISABLE_BLOCK, - enabled: !this.getInheritedDisabled(), - callback: function() { - block.setDisabled(!block.disabled); - } - }; - menuOptions.push(disableOption); - } - - // Option to delete this block. - // Count the number of blocks that are nested in this block. - var descendantCount = this.getDescendants().length; - var nextBlock = this.getNextBlock(); - if (nextBlock) { - // Blocks in the current stack would survive this block's deletion. - descendantCount -= nextBlock.getDescendants().length; - } - var deleteOption = { - text: descendantCount == 1 ? Blockly.Msg.DELETE_BLOCK : - Blockly.Msg.DELETE_X_BLOCKS.replace('%1', String(descendantCount)), - enabled: true, - callback: function() { - Blockly.Events.setGroup(true); - block.dispose(true, true); - Blockly.Events.setGroup(false); - } - }; - menuOptions.push(deleteOption); - } - - // Option to get help. - var url = goog.isFunction(this.helpUrl) ? this.helpUrl() : this.helpUrl; - var helpOption = {enabled: !!url}; - helpOption.text = Blockly.Msg.HELP; - helpOption.callback = function() { - block.showHelp_(); - }; - menuOptions.push(helpOption); - - // Allow the block to add or modify menuOptions. - if (this.customContextMenu && !block.isInFlyout) { - this.customContextMenu(menuOptions); - } - - Blockly.ContextMenu.show(e, menuOptions, this.RTL); - Blockly.ContextMenu.currentBlock = this; -}; - -/** - * Move the connections for this block and all blocks attached under it. - * Also update any attached bubbles. - * @param {number} dx Horizontal offset from current location, in workspace - * units. - * @param {number} dy Vertical offset from current location, in workspace - * units. - * @private - */ -Blockly.BlockSvg.prototype.moveConnections_ = function(dx, dy) { - if (!this.rendered) { - // Rendering is required to lay out the blocks. - // This is probably an invisible block attached to a collapsed block. - return; - } - var myConnections = this.getConnections_(false); - for (var i = 0; i < myConnections.length; i++) { - myConnections[i].moveBy(dx, dy); - } - var icons = this.getIcons(); - for (var i = 0; i < icons.length; i++) { - icons[i].computeIconLocation(); - } - - // Recurse through all blocks attached under this one. - for (var i = 0; i < this.childBlocks_.length; i++) { - this.childBlocks_[i].moveConnections_(dx, dy); - } -}; - -/** - * Recursively adds or removes the dragging class to this node and its children. - * @param {boolean} adding True if adding, false if removing. - * @package - */ -Blockly.BlockSvg.prototype.setDragging = function(adding) { - if (adding) { - var group = this.getSvgRoot(); - group.translate_ = ''; - group.skew_ = ''; - Blockly.draggingConnections_ = - Blockly.draggingConnections_.concat(this.getConnections_(true)); - Blockly.utils.addClass(/** @type {!Element} */ (this.svgGroup_), - 'blocklyDragging'); - } else { - Blockly.draggingConnections_ = []; - Blockly.utils.removeClass(/** @type {!Element} */ (this.svgGroup_), - 'blocklyDragging'); - } - // Recurse through all blocks attached under this one. - for (var i = 0; i < this.childBlocks_.length; i++) { - this.childBlocks_[i].setDragging(adding); - } -}; - -/** - * Add or remove the UI indicating if this block is movable or not. - */ -Blockly.BlockSvg.prototype.updateMovable = function() { - if (this.isMovable()) { - Blockly.utils.addClass(/** @type {!Element} */ (this.svgGroup_), - 'blocklyDraggable'); - } else { - Blockly.utils.removeClass(/** @type {!Element} */ (this.svgGroup_), - 'blocklyDraggable'); - } -}; - -/** - * Set whether this block is movable or not. - * @param {boolean} movable True if movable. - */ -Blockly.BlockSvg.prototype.setMovable = function(movable) { - Blockly.BlockSvg.superClass_.setMovable.call(this, movable); - this.updateMovable(); -}; - -/** - * Set whether this block is editable or not. - * @param {boolean} editable True if editable. - */ -Blockly.BlockSvg.prototype.setEditable = function(editable) { - Blockly.BlockSvg.superClass_.setEditable.call(this, editable); - var icons = this.getIcons(); - for (var i = 0; i < icons.length; i++) { - icons[i].updateEditable(); - } -}; - -/** - * Set whether this block is a shadow block or not. - * @param {boolean} shadow True if a shadow. - */ -Blockly.BlockSvg.prototype.setShadow = function(shadow) { - Blockly.BlockSvg.superClass_.setShadow.call(this, shadow); - this.updateColour(); -}; - -/** - * Return the root node of the SVG or null if none exists. - * @return {Element} The root SVG node (probably a group). - */ -Blockly.BlockSvg.prototype.getSvgRoot = function() { - return this.svgGroup_; -}; - -/** - * Dispose of this block. - * @param {boolean} healStack If true, then try to heal any gap by connecting - * the next statement with the previous statement. Otherwise, dispose of - * all children of this block. - * @param {boolean} animate If true, show a disposal animation and sound. - */ -Blockly.BlockSvg.prototype.dispose = function(healStack, animate) { - if (!this.workspace) { - // The block has already been deleted. - return; - } - Blockly.Tooltip.hide(); - Blockly.Field.startCache(); - // Save the block's workspace temporarily so we can resize the - // contents once the block is disposed. - var blockWorkspace = this.workspace; - // If this block is being dragged, unlink the mouse events. - if (Blockly.selected == this) { - this.unselect(); - this.workspace.cancelCurrentGesture(); - } - // If this block has a context menu open, close it. - if (Blockly.ContextMenu.currentBlock == this) { - Blockly.ContextMenu.hide(); - } - - if (animate && this.rendered) { - this.unplug(healStack); - this.disposeUiEffect(); - } - // Stop rerendering. - this.rendered = false; - - Blockly.Events.disable(); - try { - var icons = this.getIcons(); - for (var i = 0; i < icons.length; i++) { - icons[i].dispose(); - } - } finally { - Blockly.Events.enable(); - } - Blockly.BlockSvg.superClass_.dispose.call(this, healStack); - - goog.dom.removeNode(this.svgGroup_); - blockWorkspace.resizeContents(); - // Sever JavaScript to DOM connections. - this.svgGroup_ = null; - this.svgPath_ = null; - this.svgPathLight_ = null; - this.svgPathDark_ = null; - Blockly.Field.stopCache(); -}; - -/** - * Play some UI effects (sound, animation) when disposing of a block. - */ -Blockly.BlockSvg.prototype.disposeUiEffect = function() { - this.workspace.getAudioManager().play('delete'); - - var xy = this.workspace.getSvgXY(/** @type {!Element} */ (this.svgGroup_)); - // Deeply clone the current block. - var clone = this.svgGroup_.cloneNode(true); - clone.translateX_ = xy.x; - clone.translateY_ = xy.y; - clone.setAttribute('transform', - 'translate(' + clone.translateX_ + ',' + clone.translateY_ + ')'); - this.workspace.getParentSvg().appendChild(clone); - clone.bBox_ = clone.getBBox(); - // Start the animation. - Blockly.BlockSvg.disposeUiStep_(clone, this.RTL, new Date, - this.workspace.scale); -}; - -/** - * Animate a cloned block and eventually dispose of it. - * This is a class method, not an instance method since the original block has - * been destroyed and is no longer accessible. - * @param {!Element} clone SVG element to animate and dispose of. - * @param {boolean} rtl True if RTL, false if LTR. - * @param {!Date} start Date of animation's start. - * @param {number} workspaceScale Scale of workspace. - * @private - */ -Blockly.BlockSvg.disposeUiStep_ = function(clone, rtl, start, workspaceScale) { - var ms = new Date - start; - var percent = ms / 150; - if (percent > 1) { - goog.dom.removeNode(clone); - } else { - var x = clone.translateX_ + - (rtl ? -1 : 1) * clone.bBox_.width * workspaceScale / 2 * percent; - var y = clone.translateY_ + clone.bBox_.height * workspaceScale * percent; - var scale = (1 - percent) * workspaceScale; - clone.setAttribute('transform', 'translate(' + x + ',' + y + ')' + - ' scale(' + scale + ')'); - var closure = function() { - Blockly.BlockSvg.disposeUiStep_(clone, rtl, start, workspaceScale); - }; - setTimeout(closure, 10); - } -}; - -/** - * Play some UI effects (sound, ripple) after a connection has been established. - */ -Blockly.BlockSvg.prototype.connectionUiEffect = function() { - this.workspace.getAudioManager().play('click'); - if (this.workspace.scale < 1) { - return; // Too small to care about visual effects. - } - // Determine the absolute coordinates of the inferior block. - var xy = this.workspace.getSvgXY(/** @type {!Element} */ (this.svgGroup_)); - // Offset the coordinates based on the two connection types, fix scale. - if (this.outputConnection) { - xy.x += (this.RTL ? 3 : -3) * this.workspace.scale; - xy.y += 13 * this.workspace.scale; - } else if (this.previousConnection) { - xy.x += (this.RTL ? -23 : 23) * this.workspace.scale; - xy.y += 3 * this.workspace.scale; - } - var ripple = Blockly.utils.createSvgElement('circle', - {'cx': xy.x, 'cy': xy.y, 'r': 0, 'fill': 'none', - 'stroke': '#888', 'stroke-width': 10}, - this.workspace.getParentSvg()); - // Start the animation. - Blockly.BlockSvg.connectionUiStep_(ripple, new Date, this.workspace.scale); -}; - -/** - * Expand a ripple around a connection. - * @param {!Element} ripple Element to animate. - * @param {!Date} start Date of animation's start. - * @param {number} workspaceScale Scale of workspace. - * @private - */ -Blockly.BlockSvg.connectionUiStep_ = function(ripple, start, workspaceScale) { - var ms = new Date - start; - var percent = ms / 150; - if (percent > 1) { - goog.dom.removeNode(ripple); - } else { - ripple.setAttribute('r', percent * 25 * workspaceScale); - ripple.style.opacity = 1 - percent; - var closure = function() { - Blockly.BlockSvg.connectionUiStep_(ripple, start, workspaceScale); - }; - Blockly.BlockSvg.disconnectUiStop_.pid_ = setTimeout(closure, 10); - } -}; - -/** - * Play some UI effects (sound, animation) when disconnecting a block. - */ -Blockly.BlockSvg.prototype.disconnectUiEffect = function() { - this.workspace.getAudioManager().play('disconnect'); - if (this.workspace.scale < 1) { - return; // Too small to care about visual effects. - } - // Horizontal distance for bottom of block to wiggle. - var DISPLACEMENT = 10; - // Scale magnitude of skew to height of block. - var height = this.getHeightWidth().height; - var magnitude = Math.atan(DISPLACEMENT / height) / Math.PI * 180; - if (!this.RTL) { - magnitude *= -1; - } - // Start the animation. - Blockly.BlockSvg.disconnectUiStep_(this.svgGroup_, magnitude, new Date); -}; - -/** - * Animate a brief wiggle of a disconnected block. - * @param {!Element} group SVG element to animate. - * @param {number} magnitude Maximum degrees skew (reversed for RTL). - * @param {!Date} start Date of animation's start. - * @private - */ -Blockly.BlockSvg.disconnectUiStep_ = function(group, magnitude, start) { - var DURATION = 200; // Milliseconds. - var WIGGLES = 3; // Half oscillations. - - var ms = new Date - start; - var percent = ms / DURATION; - - if (percent > 1) { - group.skew_ = ''; - } else { - var skew = Math.round(Math.sin(percent * Math.PI * WIGGLES) * - (1 - percent) * magnitude); - group.skew_ = 'skewX(' + skew + ')'; - var closure = function() { - Blockly.BlockSvg.disconnectUiStep_(group, magnitude, start); - }; - Blockly.BlockSvg.disconnectUiStop_.group = group; - Blockly.BlockSvg.disconnectUiStop_.pid = setTimeout(closure, 10); - } - group.setAttribute('transform', group.translate_ + group.skew_); -}; - -/** - * Stop the disconnect UI animation immediately. - * @private - */ -Blockly.BlockSvg.disconnectUiStop_ = function() { - if (Blockly.BlockSvg.disconnectUiStop_.group) { - clearTimeout(Blockly.BlockSvg.disconnectUiStop_.pid); - var group = Blockly.BlockSvg.disconnectUiStop_.group; - group.skew_ = ''; - group.setAttribute('transform', group.translate_); - Blockly.BlockSvg.disconnectUiStop_.group = null; - } -}; - -/** - * PID of disconnect UI animation. There can only be one at a time. - * @type {number} - */ -Blockly.BlockSvg.disconnectUiStop_.pid = 0; - -/** - * SVG group of wobbling block. There can only be one at a time. - * @type {Element} - */ -Blockly.BlockSvg.disconnectUiStop_.group = null; - -/** - * Change the colour of a block. - */ -Blockly.BlockSvg.prototype.updateColour = function() { - if (this.disabled) { - // Disabled blocks don't have colour. - return; - } - var hexColour = this.getColour(); - var rgb = goog.color.hexToRgb(hexColour); - if (this.isShadow()) { - rgb = goog.color.lighten(rgb, 0.6); - hexColour = goog.color.rgbArrayToHex(rgb); - this.svgPathLight_.style.display = 'none'; - this.svgPathDark_.setAttribute('fill', hexColour); - } else { - this.svgPathLight_.style.display = ''; - var hexLight = goog.color.rgbArrayToHex(goog.color.lighten(rgb, 0.3)); - var hexDark = goog.color.rgbArrayToHex(goog.color.darken(rgb, 0.2)); - this.svgPathLight_.setAttribute('stroke', hexLight); - this.svgPathDark_.setAttribute('fill', hexDark); - } - this.svgPath_.setAttribute('fill', hexColour); - - var icons = this.getIcons(); - for (var i = 0; i < icons.length; i++) { - icons[i].updateColour(); - } - - // Bump every dropdown to change its colour. - for (var x = 0, input; input = this.inputList[x]; x++) { - for (var y = 0, field; field = input.fieldRow[y]; y++) { - field.setText(null); - } - } -}; - -/** - * Enable or disable a block. - */ -Blockly.BlockSvg.prototype.updateDisabled = function() { - if (this.disabled || this.getInheritedDisabled()) { - if (Blockly.utils.addClass(/** @type {!Element} */ (this.svgGroup_), - 'blocklyDisabled')) { - this.svgPath_.setAttribute('fill', - 'url(#' + this.workspace.options.disabledPatternId + ')'); - } - } else { - if (Blockly.utils.removeClass(/** @type {!Element} */ (this.svgGroup_), - 'blocklyDisabled')) { - this.updateColour(); - } - } - var children = this.getChildren(); - for (var i = 0, child; child = children[i]; i++) { - child.updateDisabled(); - } -}; - -/** - * Returns the comment on this block (or '' if none). - * @return {string} Block's comment. - */ -Blockly.BlockSvg.prototype.getCommentText = function() { - if (this.comment) { - var comment = this.comment.getText(); - // Trim off trailing whitespace. - return comment.replace(/\s+$/, '').replace(/ +\n/g, '\n'); - } - return ''; -}; - -/** - * Set this block's comment text. - * @param {?string} text The text, or null to delete. - */ -Blockly.BlockSvg.prototype.setCommentText = function(text) { - var changedState = false; - if (goog.isString(text)) { - if (!this.comment) { - this.comment = new Blockly.Comment(this); - changedState = true; - } - this.comment.setText(/** @type {string} */ (text)); - } else { - if (this.comment) { - this.comment.dispose(); - changedState = true; - } - } - if (changedState && this.rendered) { - this.render(); - // Adding or removing a comment icon will cause the block to change shape. - this.bumpNeighbours_(); - } -}; - -/** - * Set this block's warning text. - * @param {?string} text The text, or null to delete. - * @param {string=} opt_id An optional ID for the warning text to be able to - * maintain multiple warnings. - */ -Blockly.BlockSvg.prototype.setWarningText = function(text, opt_id) { - if (!this.setWarningText.pid_) { - // Create a database of warning PIDs. - // Only runs once per block (and only those with warnings). - this.setWarningText.pid_ = Object.create(null); - } - var id = opt_id || ''; - if (!id) { - // Kill all previous pending processes, this edit supersedes them all. - for (var n in this.setWarningText.pid_) { - clearTimeout(this.setWarningText.pid_[n]); - delete this.setWarningText.pid_[n]; - } - } else if (this.setWarningText.pid_[id]) { - // Only queue up the latest change. Kill any earlier pending process. - clearTimeout(this.setWarningText.pid_[id]); - delete this.setWarningText.pid_[id]; - } - if (this.workspace.isDragging()) { - // Don't change the warning text during a drag. - // Wait until the drag finishes. - var thisBlock = this; - this.setWarningText.pid_[id] = setTimeout(function() { - if (thisBlock.workspace) { // Check block wasn't deleted. - delete thisBlock.setWarningText.pid_[id]; - thisBlock.setWarningText(text, id); - } - }, 100); - return; - } - if (this.isInFlyout) { - text = null; - } - - // Bubble up to add a warning on top-most collapsed block. - var parent = this.getSurroundParent(); - var collapsedParent = null; - while (parent) { - if (parent.isCollapsed()) { - collapsedParent = parent; - } - parent = parent.getSurroundParent(); - } - if (collapsedParent) { - collapsedParent.setWarningText(text, 'collapsed ' + this.id + ' ' + id); - } - - var changedState = false; - if (goog.isString(text)) { - if (!this.warning) { - this.warning = new Blockly.Warning(this); - changedState = true; - } - this.warning.setText(/** @type {string} */ (text), id); - } else { - // Dispose all warnings if no id is given. - if (this.warning && !id) { - this.warning.dispose(); - changedState = true; - } else if (this.warning) { - var oldText = this.warning.getText(); - this.warning.setText('', id); - var newText = this.warning.getText(); - if (!newText) { - this.warning.dispose(); - } - changedState = oldText != newText; - } - } - if (changedState && this.rendered) { - this.render(); - // Adding or removing a warning icon will cause the block to change shape. - this.bumpNeighbours_(); - } -}; - -/** - * Give this block a mutator dialog. - * @param {Blockly.Mutator} mutator A mutator dialog instance or null to remove. - */ -Blockly.BlockSvg.prototype.setMutator = function(mutator) { - if (this.mutator && this.mutator !== mutator) { - this.mutator.dispose(); - } - if (mutator) { - mutator.block_ = this; - this.mutator = mutator; - mutator.createIcon(); - } -}; - -/** - * Set whether the block is disabled or not. - * @param {boolean} disabled True if disabled. - */ -Blockly.BlockSvg.prototype.setDisabled = function(disabled) { - if (this.disabled != disabled) { - Blockly.BlockSvg.superClass_.setDisabled.call(this, disabled); - if (this.rendered) { - this.updateDisabled(); - } - } -}; - -/** - * Set whether the block is highlighted or not. Block highlighting is - * often used to visually mark blocks currently being executed. - * @param {boolean} highlighted True if highlighted. - */ -Blockly.BlockSvg.prototype.setHighlighted = function(highlighted) { - if (!this.rendered) { - return; - } - if (highlighted) { - this.svgPath_.setAttribute('filter', - 'url(#' + this.workspace.options.embossFilterId + ')'); - this.svgPathLight_.style.display = 'none'; - } else { - Blockly.utils.removeAttribute(this.svgPath_, 'filter'); - delete this.svgPathLight_.style.display; - } -}; - -/** - * Select this block. Highlight it visually. - */ -Blockly.BlockSvg.prototype.addSelect = function() { - Blockly.utils.addClass(/** @type {!Element} */ (this.svgGroup_), - 'blocklySelected'); -}; - -/** - * Unselect this block. Remove its highlighting. - */ -Blockly.BlockSvg.prototype.removeSelect = function() { - Blockly.utils.removeClass(/** @type {!Element} */ (this.svgGroup_), - 'blocklySelected'); -}; - -/** - * Update the cursor over this block by adding or removing a class. - * @param {boolean} enable True if the delete cursor should be shown, false - * otherwise. - * @package - */ -Blockly.BlockSvg.prototype.setDeleteStyle = function(enable) { - if (enable) { - Blockly.utils.addClass(/** @type {!Element} */ (this.svgGroup_), - 'blocklyDraggingDelete'); - } else { - Blockly.utils.removeClass(/** @type {!Element} */ (this.svgGroup_), - 'blocklyDraggingDelete'); - } -}; - -// Overrides of functions on Blockly.Block that take into account whether the -// block has been rendered. - -/** - * Change the colour of a block. - * @param {number|string} colour HSV hue value, or #RRGGBB string. - */ -Blockly.BlockSvg.prototype.setColour = function(colour) { - Blockly.BlockSvg.superClass_.setColour.call(this, colour); - - if (this.rendered) { - this.updateColour(); - } -}; - -/** - * Move this block to the front of the visible workspace. - * tags do not respect z-index so svg renders them in the - * order that they are in the dom. By placing this block first within the - * block group's , it will render on top of any other blocks. - * @package - */ -Blockly.BlockSvg.prototype.bringToFront = function() { - var block = this; - do { - var root = block.getSvgRoot(); - root.parentNode.appendChild(root); - block = block.getParent(); - } while (block); -}; - -/** - * Set whether this block can chain onto the bottom of another block. - * @param {boolean} newBoolean True if there can be a previous statement. - * @param {string|Array.|null|undefined} opt_check Statement type or - * list of statement types. Null/undefined if any type could be connected. - */ -Blockly.BlockSvg.prototype.setPreviousStatement = - function(newBoolean, opt_check) { - /* eslint-disable indent */ - Blockly.BlockSvg.superClass_.setPreviousStatement.call(this, newBoolean, - opt_check); - - if (this.rendered) { - this.render(); - this.bumpNeighbours_(); - } -}; /* eslint-enable indent */ - -/** - * Set whether another block can chain onto the bottom of this block. - * @param {boolean} newBoolean True if there can be a next statement. - * @param {string|Array.|null|undefined} opt_check Statement type or - * list of statement types. Null/undefined if any type could be connected. - */ -Blockly.BlockSvg.prototype.setNextStatement = function(newBoolean, opt_check) { - Blockly.BlockSvg.superClass_.setNextStatement.call(this, newBoolean, - opt_check); - - if (this.rendered) { - this.render(); - this.bumpNeighbours_(); - } -}; - -/** - * Set whether this block returns a value. - * @param {boolean} newBoolean True if there is an output. - * @param {string|Array.|null|undefined} opt_check Returned type or list - * of returned types. Null or undefined if any type could be returned - * (e.g. variable get). - */ -Blockly.BlockSvg.prototype.setOutput = function(newBoolean, opt_check) { - Blockly.BlockSvg.superClass_.setOutput.call(this, newBoolean, opt_check); - - if (this.rendered) { - this.render(); - this.bumpNeighbours_(); - } -}; - -/** - * Set whether value inputs are arranged horizontally or vertically. - * @param {boolean} newBoolean True if inputs are horizontal. - */ -Blockly.BlockSvg.prototype.setInputsInline = function(newBoolean) { - Blockly.BlockSvg.superClass_.setInputsInline.call(this, newBoolean); - - if (this.rendered) { - this.render(); - this.bumpNeighbours_(); - } -}; - -/** - * Remove an input from this block. - * @param {string} name The name of the input. - * @param {boolean=} opt_quiet True to prevent error if input is not present. - * @throws {goog.asserts.AssertionError} if the input is not present and - * opt_quiet is not true. - */ -Blockly.BlockSvg.prototype.removeInput = function(name, opt_quiet) { - Blockly.BlockSvg.superClass_.removeInput.call(this, name, opt_quiet); - - if (this.rendered) { - this.render(); - // Removing an input will cause the block to change shape. - this.bumpNeighbours_(); - } -}; - -/** - * Move a numbered input to a different location on this block. - * @param {number} inputIndex Index of the input to move. - * @param {number} refIndex Index of input that should be after the moved input. - */ -Blockly.BlockSvg.prototype.moveNumberedInputBefore = function( - inputIndex, refIndex) { - Blockly.BlockSvg.superClass_.moveNumberedInputBefore.call(this, inputIndex, - refIndex); - - if (this.rendered) { - this.render(); - // Moving an input will cause the block to change shape. - this.bumpNeighbours_(); - } -}; - -/** - * Add a value input, statement input or local variable to this block. - * @param {number} type Either Blockly.INPUT_VALUE or Blockly.NEXT_STATEMENT or - * Blockly.DUMMY_INPUT. - * @param {string} name Language-neutral identifier which may used to find this - * input again. Should be unique to this block. - * @return {!Blockly.Input} The input object created. - * @private - */ -Blockly.BlockSvg.prototype.appendInput_ = function(type, name) { - var input = Blockly.BlockSvg.superClass_.appendInput_.call(this, type, name); - - if (this.rendered) { - this.render(); - // Adding an input will cause the block to change shape. - this.bumpNeighbours_(); - } - return input; -}; - -/** - * Returns connections originating from this block. - * @param {boolean} all If true, return all connections even hidden ones. - * Otherwise, for a non-rendered block return an empty list, and for a - * collapsed block don't return inputs connections. - * @return {!Array.} Array of connections. - * @package - */ -Blockly.BlockSvg.prototype.getConnections_ = function(all) { - var myConnections = []; - if (all || this.rendered) { - if (this.outputConnection) { - myConnections.push(this.outputConnection); - } - if (this.previousConnection) { - myConnections.push(this.previousConnection); - } - if (this.nextConnection) { - myConnections.push(this.nextConnection); - } - if (all || !this.collapsed_) { - for (var i = 0, input; input = this.inputList[i]; i++) { - if (input.connection) { - myConnections.push(input.connection); - } - } - } - } - return myConnections; -}; - -/** - * Create a connection of the specified type. - * @param {number} type The type of the connection to create. - * @return {!Blockly.RenderedConnection} A new connection of the specified type. - * @private - */ -Blockly.BlockSvg.prototype.makeConnection_ = function(type) { - return new Blockly.RenderedConnection(this, type); -}; - -/** - * Bump unconnected blocks out of alignment. Two blocks which aren't actually - * connected should not coincidentally line up on screen. - * @private - */ -Blockly.BlockSvg.prototype.bumpNeighbours_ = function() { - if (!this.workspace) { - return; // Deleted block. - } - if (Blockly.dragMode_ != Blockly.DRAG_NONE) { - return; // Don't bump blocks during a drag. - } - var rootBlock = this.getRootBlock(); - if (rootBlock.isInFlyout) { - return; // Don't move blocks around in a flyout. - } - // Loop through every connection on this block. - var myConnections = this.getConnections_(false); - for (var i = 0, connection; connection = myConnections[i]; i++) { - - // Spider down from this block bumping all sub-blocks. - if (connection.isConnected() && connection.isSuperior()) { - connection.targetBlock().bumpNeighbours_(); - } - - var neighbours = connection.neighbours_(Blockly.SNAP_RADIUS); - for (var j = 0, otherConnection; otherConnection = neighbours[j]; j++) { - - // If both connections are connected, that's probably fine. But if - // either one of them is unconnected, then there could be confusion. - if (!connection.isConnected() || !otherConnection.isConnected()) { - // Only bump blocks if they are from different tree structures. - if (otherConnection.getSourceBlock().getRootBlock() != rootBlock) { - - // Always bump the inferior block. - if (connection.isSuperior()) { - otherConnection.bumpAwayFrom_(connection); - } else { - connection.bumpAwayFrom_(otherConnection); - } - } - } - } - } -}; - -/** - * Schedule snapping to grid and bumping neighbours to occur after a brief - * delay. - * @package - */ -Blockly.BlockSvg.prototype.scheduleSnapAndBump = function() { - var block = this; - // Ensure that any snap and bump are part of this move's event group. - var group = Blockly.Events.getGroup(); - - setTimeout(function() { - Blockly.Events.setGroup(group); - block.snapToGrid(); - Blockly.Events.setGroup(false); - }, Blockly.BUMP_DELAY / 2); - - setTimeout(function() { - Blockly.Events.setGroup(group); - block.bumpNeighbours_(); - Blockly.Events.setGroup(false); - }, Blockly.BUMP_DELAY); -}; diff --git a/core/block_svg.ts b/core/block_svg.ts new file mode 100644 index 00000000000..b3fdeb2d6b6 --- /dev/null +++ b/core/block_svg.ts @@ -0,0 +1,1861 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Methods for graphically rendering a block as SVG. + * + * @class + */ +// Former goog.module ID: Blockly.BlockSvg + +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_selected.js'; + +import {Block} from './block.js'; +import * as blockAnimations from './block_animations.js'; +import * as browserEvents from './browser_events.js'; +import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js'; +import * as common from './common.js'; +import {config} from './config.js'; +import type {Connection} from './connection.js'; +import {ConnectionType} from './connection_type.js'; +import * as constants from './constants.js'; +import * as ContextMenu from './contextmenu.js'; +import { + ContextMenuOption, + ContextMenuRegistry, + LegacyContextMenuOption, +} from './contextmenu_registry.js'; +import {BlockDragStrategy} from './dragging/block_drag_strategy.js'; +import type {BlockMove} from './events/events_block_move.js'; +import {EventType} from './events/type.js'; +import * as eventUtils from './events/utils.js'; +import {FieldLabel} from './field_label.js'; +import {getFocusManager} from './focus_manager.js'; +import {IconType} from './icons/icon_types.js'; +import {MutatorIcon} from './icons/mutator_icon.js'; +import {WarningIcon} from './icons/warning_icon.js'; +import type {Input} from './inputs/input.js'; +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import {IContextMenu} from './interfaces/i_contextmenu.js'; +import type {ICopyable} from './interfaces/i_copyable.js'; +import {IDeletable} from './interfaces/i_deletable.js'; +import type {IDragStrategy, IDraggable} from './interfaces/i_draggable.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import {IIcon} from './interfaces/i_icon.js'; +import * as internalConstants from './internal_constants.js'; +import {Msg} from './msg.js'; +import * as renderManagement from './render_management.js'; +import {RenderedConnection} from './rendered_connection.js'; +import type {IPathObject} from './renderers/common/i_path_object.js'; +import * as blocks from './serialization/blocks.js'; +import type {BlockStyle} from './theme.js'; +import * as Tooltip from './tooltip.js'; +import {idGenerator} from './utils.js'; +import {Coordinate} from './utils/coordinate.js'; +import * as dom from './utils/dom.js'; +import {Rect} from './utils/rect.js'; +import {Svg} from './utils/svg.js'; +import * as svgMath from './utils/svg_math.js'; +import {FlyoutItemInfo} from './utils/toolbox.js'; +import type {Workspace} from './workspace.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +/** + * Class for a block's SVG representation. + * Not normally called directly, workspace.newBlock() is preferred. + */ +export class BlockSvg + extends Block + implements + IBoundedElement, + IContextMenu, + ICopyable, + IDraggable, + IDeletable, + IFocusableNode +{ + /** + * Constant for identifying rows that are to be rendered inline. + * Don't collide with Blockly.inputTypes. + */ + static readonly INLINE = -1; + + /** + * ID to give the "collapsed warnings" warning. Allows us to remove the + * "collapsed warnings" warning without removing any warnings that belong to + * the block. + */ + static readonly COLLAPSED_WARNING_ID = 'TEMP_COLLAPSED_WARNING_'; + override decompose?: (p1: Workspace) => BlockSvg; + // override compose?: ((p1: BlockSvg) => void)|null; + + /** + * An optional method which saves a record of blocks connected to + * this block so they can be later restored after this block is + * recoomposed (reconfigured). Typically records the connected + * blocks on properties on blocks in the mutator flyout, so that + * rearranging those component blocks will automatically rearrange + * the corresponding connected blocks on this block after this block + * is recomposed. + * + * To keep the saved connection information up-to-date, MutatorIcon + * arranges for an event listener to call this method any time the + * mutator flyout is open and a change occurs on this block's + * workspace. + * + * @param rootBlock The root block in the mutator flyout. + */ + saveConnections?: (rootBlock: BlockSvg) => void; + + customContextMenu?: ( + p1: Array, + ) => void; + + /** + * Height of this block, not including any statement blocks above or below. + * Height is in workspace units. + */ + height = 0; + + /** + * Width of this block, including any connected value blocks. + * Width is in workspace units. + */ + width = 0; + + /** + * Width of this block, not including any connected value blocks. + * Width is in workspace units. + * + * @internal + */ + childlessWidth = 0; + + /** + * Map from IDs for warnings text to PIDs of functions to apply them. + * Used to be able to maintain multiple warnings. + */ + private warningTextDb = new Map>(); + + /** Block's mutator icon (if any). */ + mutator: MutatorIcon | null = null; + + private svgGroup: SVGGElement; + style: BlockStyle; + /** @internal */ + pathObject: IPathObject; + + /** Is this block a BlockSVG? */ + override readonly rendered = true; + + private visuallyDisabled = false; + + override workspace: WorkspaceSvg; + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + override outputConnection!: RenderedConnection; + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + override nextConnection!: RenderedConnection; + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + override previousConnection!: RenderedConnection; + + private translation = ''; + + /** Whether this block is currently being dragged. */ + private dragging = false; + + /** + * The location of the top left of this block (in workspace coordinates) + * relative to either its parent block, or the workspace origin if it has no + * parent. + * + * @internal + */ + relativeCoords = new Coordinate(0, 0); + + private dragStrategy: IDragStrategy = new BlockDragStrategy(this); + + /** + * @param workspace The block's workspace. + * @param prototypeName Name of the language object containing type-specific + * functions for this block. + * @param opt_id Optional ID. Use this ID if provided, otherwise create a new + * ID. + */ + constructor(workspace: WorkspaceSvg, prototypeName: string, opt_id?: string) { + super(workspace, prototypeName, opt_id); + if (!workspace.rendered) { + throw TypeError('Cannot create a rendered block in a headless workspace'); + } + this.workspace = workspace; + this.svgGroup = dom.createSvgElement(Svg.G, {}); + + if (prototypeName) { + dom.addClass(this.svgGroup, prototypeName); + } + /** A block style object. */ + this.style = workspace.getRenderer().getConstants().getBlockStyle(null); + + /** The renderer's path object. */ + this.pathObject = workspace + .getRenderer() + .makePathObject(this.svgGroup, this.style); + + const svgPath = this.pathObject.svgPath; + (svgPath as any).tooltip = this; + Tooltip.bindMouseEvents(svgPath); + + // Expose this block's ID on its top-level SVG group. + this.svgGroup.setAttribute('data-id', this.id); + + // The page-wide unique ID of this Block used for focusing. + svgPath.id = idGenerator.getNextUniqueId(); + + this.doInit_(); + } + + /** + * Create and initialize the SVG representation of the block. + * May be called more than once. + */ + initSvg() { + if (this.initialized) return; + for (const input of this.inputList) { + input.init(); + } + for (const icon of this.getIcons()) { + icon.initView(this.createIconPointerDownListener(icon)); + icon.updateEditable(); + } + this.applyColour(); + this.pathObject.updateMovable(this.isMovable() || this.isInFlyout); + const svg = this.getSvgRoot(); + if (svg) { + browserEvents.conditionalBind(svg, 'pointerdown', this, this.onMouseDown); + } + + if (!svg.parentNode) { + this.workspace.getCanvas().appendChild(svg); + } + this.initialized = true; + } + + /** + * Get the secondary colour of a block. + * + * @returns #RRGGBB string. + */ + getColourSecondary(): string { + return this.style.colourSecondary; + } + + /** + * Get the tertiary colour of a block. + * + * @returns #RRGGBB string. + */ + getColourTertiary(): string { + return this.style.colourTertiary; + } + + /** Selects this block. Highlights the block visually. */ + select() { + this.addSelect(); + common.fireSelectedEvent(this); + } + + /** Unselects this block. Unhighlights the block visually. */ + unselect() { + this.removeSelect(); + common.fireSelectedEvent(null); + } + + /** + * Sets the parent of this block to be a new block or null. + * + * @param newParent New parent block. + * @internal + */ + override setParent(newParent: this | null) { + const oldParent = this.parentBlock_; + if (newParent === oldParent) { + return; + } + + dom.startTextWidthCache(); + super.setParent(newParent); + dom.stopTextWidthCache(); + + const svgRoot = this.getSvgRoot(); + + // Bail early if workspace is clearing, or we aren't rendered. + // We won't need to reattach ourselves anywhere. + if (this.workspace.isClearing || !svgRoot) { + return; + } + + const oldXY = this.getRelativeToSurfaceXY(); + const focusedNode = getFocusManager().getFocusedNode(); + const restoreFocus = this.getSvgRoot().contains( + focusedNode?.getFocusableElement() ?? null, + ); + if (newParent) { + (newParent as BlockSvg).getSvgRoot().appendChild(svgRoot); + // appendChild() clears focus state, so re-focus the previously focused + // node in case it was this block and would otherwise lose its focus. Once + // Element.moveBefore() has better browser support, it should be used + // instead. + if (restoreFocus && focusedNode) { + getFocusManager().focusNode(focusedNode); + } + } else if (oldParent) { + // If we are losing a parent, we want to move our DOM element to the + // root of the workspace. Try to insert it before any top-level + // block being dragged, but note that blocks can have the + // blocklyDragging class even if they're not top blocks (especially + // at start and end of a drag). + const draggingBlockElement = this.workspace + .getCanvas() + .querySelector('.blocklyDragging'); + const draggingParentElement = draggingBlockElement?.parentElement as + | SVGElement + | null + | undefined; + const canvas = this.workspace.getCanvas(); + if (draggingParentElement === canvas) { + canvas.insertBefore(svgRoot, draggingBlockElement); + } else { + canvas.appendChild(svgRoot); + // appendChild() clears focus state, so re-focus the previously focused + // node in case it was this block and would otherwise lose its focus. Once + // Element.moveBefore() has better browser support, it should be used + // instead. + if (restoreFocus && focusedNode) { + getFocusManager().focusNode(focusedNode); + } + } + this.translate(oldXY.x, oldXY.y); + } + + this.applyColour(); + } + + /** + * Return the coordinates of the top-left corner of this block relative to the + * drawing surface's origin (0,0), in workspace units. + * If the block is on the workspace, (0, 0) is the origin of the workspace + * coordinate system. + * This does not change with workspace scale. + * + * @returns Object with .x and .y properties in workspace coordinates. + */ + override getRelativeToSurfaceXY(): Coordinate { + const layerManger = this.workspace.getLayerManager(); + if (!layerManger) { + throw new Error( + 'Cannot calculate position because the workspace has not been appended', + ); + } + let x = 0; + let y = 0; + + let element: SVGElement = this.getSvgRoot(); + if (element) { + do { + // Loop through this block and every parent. + const xy = svgMath.getRelativeXY(element); + x += xy.x; + y += xy.y; + element = element.parentNode as SVGElement; + } while (element && !layerManger.hasLayer(element)); + } + return new Coordinate(x, y); + } + + /** + * Move a block by a relative offset. + * + * @param dx Horizontal offset in workspace units. + * @param dy Vertical offset in workspace units. + * @param reason Why is this move happening? 'drag', 'bump', 'snap', ... + */ + override moveBy(dx: number, dy: number, reason?: string[]) { + if (this.parentBlock_) { + throw Error('Block has parent'); + } + const eventsEnabled = eventUtils.isEnabled(); + let event: BlockMove | null = null; + if (eventsEnabled) { + event = new (eventUtils.get(EventType.BLOCK_MOVE)!)(this) as BlockMove; + if (reason) event.setReason(reason); + } + + const delta = new Coordinate(dx, dy); + const currLoc = this.getRelativeToSurfaceXY(); + const newLoc = Coordinate.sum(currLoc, delta); + this.translate(newLoc.x, newLoc.y); + this.updateComponentLocations(newLoc); + + if (eventsEnabled && event) { + event!.recordNew(); + eventUtils.fire(event); + } + this.workspace.resizeContents(); + } + + /** + * Transforms a block by setting the translation on the transform attribute + * of the block's SVG. + * + * @param x The x coordinate of the translation in workspace units. + * @param y The y coordinate of the translation in workspace units. + */ + translate(x: number, y: number) { + this.translation = `translate(${x}, ${y})`; + this.relativeCoords = new Coordinate(x, y); + this.getSvgRoot().setAttribute('transform', this.getTranslation()); + } + + /** + * Returns the SVG translation of this block. + * + * @internal + */ + getTranslation(): string { + return this.translation; + } + + /** + * Move a block to a position. + * + * @param xy The position to move to in workspace units. + * @param reason Why is this move happening? 'drag', 'bump', 'snap', ... + */ + moveTo(xy: Coordinate, reason?: string[]) { + const curXY = this.getRelativeToSurfaceXY(); + this.moveBy(xy.x - curXY.x, xy.y - curXY.y, reason); + } + + /** + * Move this block during a drag. + * This block must be a top-level block. + * + * @param newLoc The location to translate to, in workspace coordinates. + * @internal + */ + moveDuringDrag(newLoc: Coordinate) { + this.translate(newLoc.x, newLoc.y); + this.getSvgRoot().setAttribute('transform', this.getTranslation()); + this.updateComponentLocations(newLoc); + } + + /** Snap this block to the nearest grid point. */ + snapToGrid() { + if (this.isDeadOrDying()) return; + if (this.getParent()) return; + if (this.isInFlyout) return; + const grid = this.workspace.getGrid(); + if (!grid?.shouldSnap()) return; + const currentXY = this.getRelativeToSurfaceXY(); + const alignedXY = grid.alignXY(currentXY); + if (alignedXY !== currentXY) { + this.moveTo(alignedXY, ['snap']); + } + } + + /** + * Returns the coordinates of a bounding box describing the dimensions of this + * block and any blocks stacked below it. + * Coordinate system: workspace coordinates. + * + * @returns Object with coordinates of the bounding box. + */ + getBoundingRectangle(): Rect { + return this.getBoundingRectangleWithDimensions(this.getHeightWidth()); + } + + /** + * Returns the coordinates of a bounding box describing the dimensions of this + * block alone. + * Coordinate system: workspace coordinates. + * + * @returns Object with coordinates of the bounding box. + */ + getBoundingRectangleWithoutChildren(): Rect { + return this.getBoundingRectangleWithDimensions({ + height: this.height, + width: this.childlessWidth, + }); + } + + private getBoundingRectangleWithDimensions(blockBounds: { + height: number; + width: number; + }) { + const blockXY = this.getRelativeToSurfaceXY(); + let left; + let right; + if (this.RTL) { + left = blockXY.x - blockBounds.width; + right = blockXY.x; + } else { + left = blockXY.x; + right = blockXY.x + blockBounds.width; + } + return new Rect(blockXY.y, blockXY.y + blockBounds.height, left, right); + } + + /** + * Notify every input on this block to mark its fields as dirty. + * A dirty field is a field that needs to be re-rendered. + */ + markDirty() { + this.pathObject.constants = this.workspace.getRenderer().getConstants(); + for (let i = 0, input; (input = this.inputList[i]); i++) { + input.markDirty(); + } + } + + /** + * Set whether the block is collapsed or not. + * + * @param collapsed True if collapsed. + */ + override setCollapsed(collapsed: boolean) { + if (this.collapsed_ === collapsed) { + return; + } + super.setCollapsed(collapsed); + this.updateCollapsed(); + } + + /** + * Traverses child blocks to see if any of them have a warning. + * + * @returns true if any child has a warning, false otherwise. + */ + private childHasWarning(): boolean { + const children = this.getChildren(false); + for (const child of children) { + if (child.getIcon(WarningIcon.TYPE) || child.childHasWarning()) { + return true; + } + } + return false; + } + + /** + * Makes sure that when the block is collapsed, it is rendered correctly + * for that state. + */ + private updateCollapsed() { + const collapsed = this.isCollapsed(); + const collapsedInputName = constants.COLLAPSED_INPUT_NAME; + const collapsedFieldName = constants.COLLAPSED_FIELD_NAME; + + for (let i = 0, input; (input = this.inputList[i]); i++) { + if (input.name !== collapsedInputName) { + input.setVisible(!collapsed); + } + } + + for (const icon of this.getIcons()) { + icon.updateCollapsed(); + } + + if (!collapsed) { + this.updateDisabled(); + this.removeInput(collapsedInputName); + dom.removeClass(this.svgGroup, 'blocklyCollapsed'); + this.setWarningText(null, BlockSvg.COLLAPSED_WARNING_ID); + return; + } + + dom.addClass(this.svgGroup, 'blocklyCollapsed'); + if (this.childHasWarning()) { + this.setWarningText( + Msg['COLLAPSED_WARNINGS_WARNING'], + BlockSvg.COLLAPSED_WARNING_ID, + ); + } + + const text = this.toString(internalConstants.COLLAPSE_CHARS); + const field = this.getField(collapsedFieldName); + if (field) { + field.setValue(text); + return; + } + const input = + this.getInput(collapsedInputName) || + this.appendDummyInput(collapsedInputName); + input.appendField(new FieldLabel(text), collapsedFieldName); + } + + /** + * Handle a pointerdown on an SVG block. + * + * @param e Pointer down event. + */ + private onMouseDown(e: PointerEvent) { + if (this.workspace.isReadOnly()) return; + + const gesture = this.workspace.getGesture(e); + if (gesture) { + gesture.handleBlockStart(e, this); + } + } + + /** + * Load the block's help page in a new window. + * + * @internal + */ + showHelp() { + const url = + typeof this.helpUrl === 'function' ? this.helpUrl() : this.helpUrl; + if (url) { + window.open(url); + } + } + + /** + * Generate the context menu for this block. + * + * @returns Context menu options or null if no menu. + */ + protected generateContextMenu( + e: Event, + ): Array | null { + if (this.workspace.isReadOnly() || !this.contextMenu) { + return null; + } + const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions( + {block: this, focusedNode: this}, + e, + ); + + // Allow the block to add or modify menuOptions. + if (this.customContextMenu) { + this.customContextMenu(menuOptions); + } + + return menuOptions; + } + + /** + * Gets the location in which to show the context menu for this block. + * Use the location of a click if the block was clicked, or a location + * based on the block's fields otherwise. + */ + protected calculateContextMenuLocation(e: Event): Coordinate { + // Open the menu where the user clicked, if they clicked + if (e instanceof PointerEvent) { + return new Coordinate(e.clientX, e.clientY); + } + + // Otherwise, calculate a location. + // Get the location of the top-left corner of the block in + // screen coordinates. + const blockCoords = svgMath.wsToScreenCoordinates( + this.workspace, + this.getRelativeToSurfaceXY(), + ); + + // Prefer a y position below the first field in the block. + const fieldBoundingClientRect = this.inputList + .filter((input) => input.isVisible()) + .flatMap((input) => input.fieldRow) + .find((f) => f.isVisible()) + ?.getSvgRoot() + ?.getBoundingClientRect(); + + const y = + fieldBoundingClientRect && fieldBoundingClientRect.height + ? fieldBoundingClientRect.y + fieldBoundingClientRect.height + : blockCoords.y + this.height; + + return new Coordinate( + this.RTL ? blockCoords.x - 5 : blockCoords.x + 5, + y + 5, + ); + } + + /** + * Show the context menu for this block. + * + * @param e Mouse event. + * @internal + */ + showContextMenu(e: Event) { + const menuOptions = this.generateContextMenu(e); + + const location = this.calculateContextMenuLocation(e); + + if (menuOptions && menuOptions.length) { + ContextMenu.show(e, menuOptions, this.RTL, this.workspace, location); + ContextMenu.setCurrentBlock(this); + } + } + + /** + * Updates the locations of any parts of the block that need to know where + * they are (e.g. connections, icons). + * + * @param blockOrigin The top-left of this block in workspace coordinates. + * @internal + */ + updateComponentLocations(blockOrigin: Coordinate) { + if (!this.dragging) this.updateConnectionLocations(blockOrigin); + this.updateIconLocations(blockOrigin); + this.updateFieldLocations(blockOrigin); + + for (const child of this.getChildren(false)) { + child.updateComponentLocations( + Coordinate.sum(blockOrigin, child.relativeCoords), + ); + } + } + + private updateConnectionLocations(blockOrigin: Coordinate) { + for (const conn of this.getConnections_(false)) { + conn.moveToOffset(blockOrigin); + } + } + + private updateIconLocations(blockOrigin: Coordinate) { + for (const icon of this.getIcons()) { + icon.onLocationChange(blockOrigin); + } + } + + private updateFieldLocations(blockOrigin: Coordinate) { + for (const input of this.inputList) { + for (const field of input.fieldRow) { + field.onLocationChange(blockOrigin); + } + } + } + + /** + * Add a CSS class to the SVG group of this block. + * + * @param className + */ + addClass(className: string) { + dom.addClass(this.svgGroup, className); + } + + /** + * Remove a CSS class from the SVG group of this block. + * + * @param className + */ + removeClass(className: string) { + dom.removeClass(this.svgGroup, className); + } + + /** + * Recursively adds or removes the dragging class to this node and its + * children. + * + * @param adding True if adding, false if removing. + * @internal + */ + setDragging(adding: boolean) { + this.dragging = adding; + if (adding) { + this.translation = ''; + common.draggingConnections.push(...this.getConnections_(true)); + this.addClass('blocklyDragging'); + } else { + common.draggingConnections.length = 0; + this.removeClass('blocklyDragging'); + } + // Recurse through all blocks attached under this one. + for (let i = 0; i < this.childBlocks_.length; i++) { + (this.childBlocks_[i] as BlockSvg).setDragging(adding); + } + } + + /** + * Set whether this block is movable or not. + * + * @param movable True if movable. + */ + override setMovable(movable: boolean) { + super.setMovable(movable); + this.pathObject.updateMovable(movable); + } + + /** + * Set whether this block is editable or not. + * + * @param editable True if editable. + */ + override setEditable(editable: boolean) { + super.setEditable(editable); + + if (editable) { + dom.removeClass(this.svgGroup, 'blocklyNotEditable'); + } else { + dom.addClass(this.svgGroup, 'blocklyNotEditable'); + } + + const icons = this.getIcons(); + for (let i = 0; i < icons.length; i++) { + icons[i].updateEditable(); + } + } + + /** + * Sets whether this block is a shadow block or not. + * This method is internal and should not be called by users of Blockly. To + * create shadow blocks programmatically call connection.setShadowState + * + * @param shadow True if a shadow. + * @internal + */ + override setShadow(shadow: boolean) { + super.setShadow(shadow); + this.applyColour(); + } + + /** + * Set whether this block is an insertion marker block or not. + * Once set this cannot be unset. + * + * @param insertionMarker True if an insertion marker. + * @internal + */ + override setInsertionMarker(insertionMarker: boolean) { + if (this.isInsertionMarker_ === insertionMarker) { + return; // No change. + } + this.isInsertionMarker_ = insertionMarker; + if (this.isInsertionMarker_) { + this.setColour( + this.workspace.getRenderer().getConstants().INSERTION_MARKER_COLOUR, + ); + this.pathObject.updateInsertionMarker(true); + } + } + + /** + * Return the root node of the SVG or null if none exists. + * + * @returns The root SVG node (probably a group). + */ + getSvgRoot(): SVGGElement { + return this.svgGroup; + } + + /** + * Dispose of this block. + * + * @param healStack If true, then try to heal any gap by connecting the next + * statement with the previous statement. Otherwise, dispose of all + * children of this block. + * @param animate If true, show a disposal animation and sound. + */ + override dispose(healStack?: boolean, animate?: boolean) { + this.disposing = true; + + Tooltip.dispose(); + ContextMenu.hide(); + + // If this block (or a descendant) was focused, focus its parent or + // workspace instead. + const focusManager = getFocusManager(); + if ( + this.getSvgRoot().contains( + focusManager.getFocusedNode()?.getFocusableElement() ?? null, + ) + ) { + let parent: BlockSvg | undefined | null = this.getParent(); + if (!parent) { + // In some cases, blocks are disconnected from their parents before + // being deleted. Attempt to infer if there was a parent by checking + // for a connection within a radius of 0. Even if this wasn't a parent, + // it must be adjacent to this block and so is as good an option as any + // to focus after deleting. + const connection = this.outputConnection ?? this.previousConnection; + if (connection) { + const targetConnection = connection.closest( + 0, + new Coordinate(0, 0), + ).connection; + parent = targetConnection?.getSourceBlock(); + } + } + if (parent) { + focusManager.focusNode(parent); + } else { + setTimeout(() => focusManager.focusTree(this.workspace), 0); + } + } + + if (animate) { + this.unplug(healStack); + blockAnimations.disposeUiEffect(this); + } + + super.dispose(!!healStack); + dom.removeNode(this.svgGroup); + } + + /** + * Disposes of this block without doing things required by the top block. + * E.g. does trigger UI effects, remove nodes, etc. + */ + override disposeInternal() { + this.disposing = true; + super.disposeInternal(); + + if (getFocusManager().getFocusedNode() === this) { + this.workspace.cancelCurrentGesture(); + } + + [...this.warningTextDb.values()].forEach((n) => clearTimeout(n)); + this.warningTextDb.clear(); + + this.getIcons().forEach((i) => i.dispose()); + } + + /** + * Delete a block and hide chaff when doing so. The block will not be deleted + * if it's in a flyout. This is called from the context menu and keyboard + * shortcuts as the full delete action. If you are disposing of a block from + * the workspace and don't need to perform flyout checks, handle event + * grouping, or hide chaff, then use `block.dispose()` directly. + */ + checkAndDelete() { + if (this.workspace.isFlyout) { + return; + } + eventUtils.setGroup(true); + this.workspace.hideChaff(); + if (this.outputConnection) { + // Do not attempt to heal rows + // (https://github.com/google/blockly/issues/4832) + this.dispose(false, true); + } else { + this.dispose(/* heal */ true, true); + } + eventUtils.setGroup(false); + } + + /** + * Encode a block for copying. + * + * @param addNextBlocks If true, copy subsequent blocks attached to this one + * as well. + * + * @returns Copy metadata, or null if the block is an insertion marker. + */ + toCopyData(addNextBlocks = false): BlockCopyData | null { + if (this.isInsertionMarker_) { + return null; + } + return { + paster: BlockPaster.TYPE, + blockState: blocks.save(this, { + addCoordinates: true, + addNextBlocks, + saveIds: false, + }) as blocks.State, + typeCounts: common.getBlockTypeCounts(this, true), + }; + } + + /** + * Updates the colour of the block to match the block's state. + * + * @internal + */ + applyColour() { + this.pathObject.applyColour?.(this); + + const icons = this.getIcons(); + for (let i = 0; i < icons.length; i++) { + icons[i].applyColour(); + } + + for (const field of this.getFields()) { + field.applyColour(); + } + } + + /** + * Updates the colour of the block (and children) to match the current + * disabled state. + * + * @internal + */ + updateDisabled() { + const disabled = !this.isEnabled() || this.getInheritedDisabled(); + + if (this.visuallyDisabled === disabled) { + this.getNextBlock()?.updateDisabled(); + return; + } + + this.applyColour(); + this.visuallyDisabled = disabled; + for (const child of this.getChildren(false)) { + child.updateDisabled(); + } + } + + /** + * Set this block's warning text. + * + * @param text The text, or null to delete. + * @param id An optional ID for the warning text to be able to maintain + * multiple warnings. + */ + override setWarningText(text: string | null, id: string = '') { + if (!id) { + // Kill all previous pending processes, this edit supersedes them all. + for (const timeout of this.warningTextDb.values()) { + clearTimeout(timeout); + } + this.warningTextDb.clear(); + } else if (this.warningTextDb.has(id)) { + // Only queue up the latest change. Kill any earlier pending process. + clearTimeout(this.warningTextDb.get(id)!); + this.warningTextDb.delete(id); + } + if (this.workspace.isDragging()) { + // Don't change the warning text during a drag. + // Wait until the drag finishes. + this.warningTextDb.set( + id, + setTimeout(() => { + if (!this.isDeadOrDying()) { + this.warningTextDb.delete(id); + this.setWarningText(text, id); + } + }, 100), + ); + return; + } + if (this.isInFlyout) { + text = null; + } + + const icon = this.getIcon(WarningIcon.TYPE) as WarningIcon | undefined; + if (text) { + // Bubble up to add a warning on top-most collapsed block. + // TODO(#6020): This warning is never removed. + let parent = this.getSurroundParent(); + let collapsedParent = null; + while (parent) { + if (parent.isCollapsed()) { + collapsedParent = parent; + } + parent = parent.getSurroundParent(); + } + if (collapsedParent) { + collapsedParent.setWarningText( + Msg['COLLAPSED_WARNINGS_WARNING'], + BlockSvg.COLLAPSED_WARNING_ID, + ); + } + + if (icon) { + (icon as WarningIcon).addMessage(text, id); + } else { + this.addIcon(new WarningIcon(this).addMessage(text, id)); + } + } else if (icon) { + // Dispose all warnings if no ID is given. + if (!id) { + this.removeIcon(WarningIcon.TYPE); + } else { + // Remove just this warning id's message. + icon.addMessage('', id); + // Then remove the entire icon if there is no longer any text. + if (!icon.getText()) this.removeIcon(WarningIcon.TYPE); + } + } + } + + /** + * Give this block a mutator dialog. + * + * @param mutator A mutator dialog instance or null to remove. + */ + override setMutator(mutator: MutatorIcon | null) { + this.removeIcon(MutatorIcon.TYPE); + if (mutator) this.addIcon(mutator); + } + + override addIcon(icon: T): T { + super.addIcon(icon); + + if (icon instanceof MutatorIcon) this.mutator = icon; + + icon.initView(this.createIconPointerDownListener(icon)); + icon.applyColour(); + icon.updateEditable(); + this.queueRender(); + + return icon; + } + + /** + * Creates a pointer down event listener for the icon to append to its + * root svg. + */ + private createIconPointerDownListener(icon: IIcon) { + return (e: PointerEvent) => { + if (this.isDeadOrDying()) return; + const gesture = this.workspace.getGesture(e); + if (gesture) { + gesture.setStartIcon(icon); + } + }; + } + + override removeIcon(type: IconType): boolean { + const removed = super.removeIcon(type); + + if (type.equals(MutatorIcon.TYPE)) this.mutator = null; + + this.queueRender(); + + return removed; + } + + /** + * Add or remove a reason why the block might be disabled. If a block has + * any reasons to be disabled, then the block itself will be considered + * disabled. A block could be disabled for multiple independent reasons + * simultaneously, such as when the user manually disables it, or the block + * is invalid. + * + * @param disabled If true, then the block should be considered disabled for + * at least the provided reason, otherwise the block is no longer disabled + * for that reason. + * @param reason A language-neutral identifier for a reason why the block + * could be disabled. Call this method again with the same identifier to + * update whether the block is currently disabled for this reason. + */ + override setDisabledReason(disabled: boolean, reason: string): void { + const wasEnabled = this.isEnabled(); + super.setDisabledReason(disabled, reason); + if (this.isEnabled() !== wasEnabled && !this.getInheritedDisabled()) { + this.updateDisabled(); + } + } + + /** + * Add blocklyNotDeletable class when block is not deletable + * Or remove class when block is deletable + */ + override setDeletable(deletable: boolean) { + super.setDeletable(deletable); + + if (deletable) { + dom.removeClass(this.svgGroup, 'blocklyNotDeletable'); + } else { + dom.addClass(this.svgGroup, 'blocklyNotDeletable'); + } + } + + /** + * Set whether the block is highlighted or not. Block highlighting is + * often used to visually mark blocks currently being executed. + * + * @param highlighted True if highlighted. + */ + setHighlighted(highlighted: boolean) { + this.pathObject.updateHighlighted(highlighted); + } + + /** + * Adds the visual "select" effect to the block, but does not actually select + * it or fire an event. + * + * @see BlockSvg#select + */ + addSelect() { + this.pathObject.updateSelected(true); + } + + /** + * Removes the visual "select" effect from the block, but does not actually + * unselect it or fire an event. + * + * @see BlockSvg#unselect + */ + removeSelect() { + this.pathObject.updateSelected(false); + } + + /** + * Update the cursor over this block by adding or removing a class. + * + * @param enable True if the delete cursor should be shown, false otherwise. + * @internal + */ + setDeleteStyle(enable: boolean) { + this.pathObject.updateDraggingDelete(enable); + } + + // Overrides of functions on Blockly.Block that take into account whether the + // block has been rendered. + + /** + * Get the colour of a block. + * + * @returns #RRGGBB string. + */ + override getColour(): string { + return this.style.colourPrimary; + } + + /** + * Change the colour of a block. + * + * @param colour HSV hue value, or #RRGGBB string. + */ + override setColour(colour: number | string) { + super.setColour(colour); + const styleObj = this.workspace + .getRenderer() + .getConstants() + .getBlockStyleForColour(this.colour_); + + this.pathObject.setStyle?.(styleObj.style); + this.style = styleObj.style; + this.styleName_ = styleObj.name; + + this.applyColour(); + } + + /** + * Set the style and colour values of a block. + * + * @param blockStyleName Name of the block style. + * @throws {Error} if the block style does not exist. + */ + override setStyle(blockStyleName: string) { + const blockStyle = this.workspace + .getRenderer() + .getConstants() + .getBlockStyle(blockStyleName); + + if (this.styleName_) { + dom.removeClass(this.svgGroup, this.styleName_); + } + + if (blockStyle) { + this.hat = blockStyle.hat; + this.pathObject.setStyle?.(blockStyle); + // Set colour to match Block. + this.colour_ = blockStyle.colourPrimary; + this.style = blockStyle; + + this.applyColour(); + + dom.addClass(this.svgGroup, blockStyleName); + this.styleName_ = blockStyleName; + } else { + throw Error('Invalid style name: ' + blockStyleName); + } + } + + /** + * Returns the BlockStyle object used to style this block. + * + * @returns This block's style object. + */ + getStyle(): BlockStyle { + return this.style; + } + + /** + * Move this block to the front of the visible workspace. + * tags do not respect z-index so SVG renders them in the + * order that they are in the DOM. By placing this block first within the + * block group's , it will render on top of any other blocks. + * Use sparingly, this method is expensive because it reorders the DOM + * nodes. + * + * @param blockOnly True to only move this block to the front without + * adjusting its parents. + */ + bringToFront(blockOnly = false) { + const previouslyFocused = getFocusManager().getFocusedNode(); + /* eslint-disable-next-line @typescript-eslint/no-this-alias */ + let block: this | null = this; + if (block.isDeadOrDying()) { + return; + } + do { + const root = block.getSvgRoot(); + const parent = root.parentNode; + const childNodes = parent!.childNodes; + // Avoid moving the block if it's already at the bottom. + if (childNodes[childNodes.length - 1] !== root) { + parent!.appendChild(root); + } + if (blockOnly) break; + block = block.getParent(); + } while (block); + if (previouslyFocused) { + // Bringing a block to the front of the stack doesn't fundamentally change + // the logical structure of the page, but it does change element ordering + // which can take automatically take away focus from a node. Ensure focus + // is restored to avoid a discontinuity. + getFocusManager().focusNode(previouslyFocused); + } + } + + /** + * Set whether this block can chain onto the bottom of another block. + * + * @param newBoolean True if there can be a previous statement. + * @param opt_check Statement type or list of statement types. Null/undefined + * if any type could be connected. + */ + override setPreviousStatement( + newBoolean: boolean, + opt_check?: string | string[] | null, + ) { + super.setPreviousStatement(newBoolean, opt_check); + this.queueRender(); + } + + /** + * Set whether another block can chain onto the bottom of this block. + * + * @param newBoolean True if there can be a next statement. + * @param opt_check Statement type or list of statement types. Null/undefined + * if any type could be connected. + */ + override setNextStatement( + newBoolean: boolean, + opt_check?: string | string[] | null, + ) { + super.setNextStatement(newBoolean, opt_check); + this.queueRender(); + } + + /** + * Set whether this block returns a value. + * + * @param newBoolean True if there is an output. + * @param opt_check Returned type or list of returned types. Null or + * undefined if any type could be returned (e.g. variable get). + */ + override setOutput( + newBoolean: boolean, + opt_check?: string | string[] | null, + ) { + super.setOutput(newBoolean, opt_check); + this.queueRender(); + } + + /** + * Set whether value inputs are arranged horizontally or vertically. + * + * @param newBoolean True if inputs are horizontal. + */ + override setInputsInline(newBoolean: boolean) { + super.setInputsInline(newBoolean); + this.queueRender(); + } + + /** + * Remove an input from this block. + * + * @param name The name of the input. + * @param opt_quiet True to prevent error if input is not present. + * @returns True if operation succeeds, false if input is not present and + * opt_quiet is true + * @throws {Error} if the input is not present and opt_quiet is not true. + */ + override removeInput(name: string, opt_quiet?: boolean): boolean { + const removed = super.removeInput(name, opt_quiet); + this.queueRender(); + return removed; + } + + /** + * Move a numbered input to a different location on this block. + * + * @param inputIndex Index of the input to move. + * @param refIndex Index of input that should be after the moved input. + */ + override moveNumberedInputBefore(inputIndex: number, refIndex: number) { + super.moveNumberedInputBefore(inputIndex, refIndex); + this.queueRender(); + } + + /** @override */ + override appendInput(input: Input): Input { + super.appendInput(input); + this.queueRender(); + return input; + } + + /** + * Sets whether this block's connections are tracked in the database or not. + * + * Used by the deserializer to be more efficient. Setting a connection's + * tracked_ value to false keeps it from adding itself to the db when it + * gets its first moveTo call, saving expensive ops for later. + * + * @param track If true, start tracking. If false, stop tracking. + * @internal + */ + setConnectionTracking(track: boolean) { + if (this.previousConnection) { + this.previousConnection.setTracking(track); + } + if (this.outputConnection) { + this.outputConnection.setTracking(track); + } + if (this.nextConnection) { + this.nextConnection.setTracking(track); + const child = this.nextConnection.targetBlock(); + if (child) { + child.setConnectionTracking(track); + } + } + + if (this.collapsed_) { + // When track is true, we don't want to start tracking collapsed + // connections. When track is false, we're already not tracking + // collapsed connections, so no need to update. + return; + } + + for (let i = 0; i < this.inputList.length; i++) { + const conn = this.inputList[i].connection as RenderedConnection; + if (conn) { + conn.setTracking(track); + + // Pass tracking on down the chain. + const block = conn.targetBlock(); + if (block) { + block.setConnectionTracking(track); + } + } + } + } + + /** + * Returns connections originating from this block. + * + * @param all If true, return all connections even hidden ones. + * Otherwise, for a collapsed block don't return inputs connections. + * @returns Array of connections. + * @internal + */ + override getConnections_(all: boolean): RenderedConnection[] { + const myConnections = []; + if (this.outputConnection) { + myConnections.push(this.outputConnection); + } + if (this.previousConnection) { + myConnections.push(this.previousConnection); + } + if (this.nextConnection) { + myConnections.push(this.nextConnection); + } + if (all || !this.collapsed_) { + for (let i = 0, input; (input = this.inputList[i]); i++) { + if (input.connection) { + myConnections.push(input.connection as RenderedConnection); + } + } + } + return myConnections; + } + + /** + * Walks down a stack of blocks and finds the last next connection on the + * stack. + * + * @param ignoreShadows If true,the last connection on a non-shadow block will + * be returned. If false, this will follow shadows to find the last + * connection. + * @returns The last next connection on the stack, or null. + * @internal + */ + override lastConnectionInStack( + ignoreShadows: boolean, + ): RenderedConnection | null { + return super.lastConnectionInStack(ignoreShadows) as RenderedConnection; + } + + /** + * Find the connection on this block that corresponds to the given connection + * on the other block. + * Used to match connections between a block and its insertion marker. + * + * @param otherBlock The other block to match against. + * @param conn The other connection to match. + * @returns The matching connection on this block, or null. + * @internal + */ + override getMatchingConnection( + otherBlock: Block, + conn: Connection, + ): RenderedConnection | null { + return super.getMatchingConnection(otherBlock, conn) as RenderedConnection; + } + + /** + * Create a connection of the specified type. + * + * @param type The type of the connection to create. + * @returns A new connection of the specified type. + * @internal + */ + override makeConnection_(type: ConnectionType): RenderedConnection { + return new RenderedConnection(this, type); + } + + /** + * Return the next statement block directly connected to this block. + * + * @returns The next statement block or null. + */ + override getNextBlock(): BlockSvg | null { + return super.getNextBlock() as BlockSvg; + } + + /** + * Returns the block connected to the previous connection. + * + * @returns The previous statement block or null. + */ + override getPreviousBlock(): BlockSvg | null { + return super.getPreviousBlock() as BlockSvg; + } + + /** + * Bumps unconnected blocks out of alignment. + * + * Two blocks which aren't actually connected should not coincidentally line + * up on screen, because that creates confusion for end-users. + */ + override bumpNeighbours() { + const root = this.getRootBlock(); + if ( + this.isDeadOrDying() || + this.workspace.isDragging() || + root.isInFlyout + ) { + return; + } + + function neighbourIsInStack(neighbour: RenderedConnection) { + return neighbour.getSourceBlock().getRootBlock() === root; + } + + for (const conn of this.getConnections_(false)) { + if (conn.isSuperior()) { + // Recurse down the block stack. + conn.targetBlock()?.bumpNeighbours(); + } + + for (const neighbour of conn.neighbours(config.snapRadius)) { + if (neighbourIsInStack(neighbour)) continue; + if (conn.isConnected() && neighbour.isConnected()) continue; + + if (conn.isSuperior()) { + neighbour.bumpAwayFrom(conn, /* initiatedByThis = */ false); + } else { + conn.bumpAwayFrom(neighbour, /* initiatedByThis = */ true); + } + } + } + } + + /** + * Snap to grid, and then bump neighbouring blocks away at the end of the next + * render. + */ + scheduleSnapAndBump() { + this.snapToGrid(); + this.bumpNeighbours(); + } + + /** + * Position a block so that it doesn't move the target block when connected. + * The block to position is usually either the first block in a dragged stack + * or an insertion marker. + * + * @param sourceConnection The connection on the moving block's stack. + * @param originalOffsetToTarget The connection original offset to the target connection + * @param originalOffsetInBlock The connection original offset in its block + * @internal + */ + positionNearConnection( + sourceConnection: RenderedConnection, + originalOffsetToTarget: {x: number; y: number}, + originalOffsetInBlock: Coordinate, + ) { + // We only need to position the new block if it's before the existing one, + // otherwise its position is set by the previous block. + if ( + sourceConnection.type === ConnectionType.NEXT_STATEMENT || + sourceConnection.type === ConnectionType.INPUT_VALUE + ) { + // First move the block to match the orginal target connection position + let dx = originalOffsetToTarget.x; + let dy = originalOffsetToTarget.y; + // Then adjust its position according to the connection resize + dx += originalOffsetInBlock.x - sourceConnection.getOffsetInBlock().x; + dy += originalOffsetInBlock.y - sourceConnection.getOffsetInBlock().y; + + this.moveBy(dx, dy); + } + } + + /** + * Find all the blocks that are directly nested inside this one. + * Includes value and statement inputs, as well as any following statement. + * Excludes any connection on an output tab or any preceding statement. + * Blocks are optionally sorted by position; top to bottom. + * + * @param ordered Sort the list if true. + * @returns Array of blocks. + */ + override getChildren(ordered: boolean): BlockSvg[] { + return super.getChildren(ordered) as BlockSvg[]; + } + + /** + * Triggers a rerender after a delay to allow for batching. + * + * @returns A promise that resolves after the currently queued renders have + * been completed. Used for triggering other behavior that relies on + * updated size/position location for the block. + * @internal + */ + queueRender(): Promise { + return renderManagement.queueRender(this); + } + + /** + * Immediately lays out and reflows a block based on its contents and + * settings. + */ + render() { + this.queueRender(); + renderManagement.triggerQueuedRenders(); + } + + /** + * Renders this block in a way that's compatible with the more efficient + * render management system. + * + * @internal + */ + renderEfficiently() { + dom.startTextWidthCache(); + + if (this.isCollapsed()) { + this.updateCollapsed(); + } + + if (!this.isEnabled()) { + this.updateDisabled(); + } + + this.workspace.getRenderer().render(this); + this.tightenChildrenEfficiently(); + + dom.stopTextWidthCache(); + } + + /** + * Tightens all children of this block so they are snuggly rendered against + * their parent connections. + * + * Does not update connection locations, so that they can be updated more + * efficiently by the render management system. + * + * @internal + */ + tightenChildrenEfficiently() { + for (const input of this.inputList) { + const conn = input.connection as RenderedConnection; + if (conn) conn.tightenEfficiently(); + } + if (this.nextConnection) this.nextConnection.tightenEfficiently(); + } + + /** + * Returns a bounding box describing the dimensions of this block + * and any blocks stacked below it. + * + * @returns Object with height and width properties in workspace units. + * @internal + */ + getHeightWidth(): {height: number; width: number} { + let height = this.height; + let width = this.width; + // Recursively add size of subsequent blocks. + const nextBlock = this.getNextBlock(); + if (nextBlock) { + const nextHeightWidth = nextBlock.getHeightWidth(); + const tabHeight = this.workspace + .getRenderer() + .getConstants().NOTCH_HEIGHT; + height += nextHeightWidth.height - tabHeight; + width = Math.max(width, nextHeightWidth.width); + } + return {height, width}; + } + + /** + * Visual effect to show that if the dragging block is dropped, this block + * will be replaced. If a shadow block, it will disappear. Otherwise it will + * bump. + * + * @param add True if highlighting should be added. + * @internal + */ + fadeForReplacement(add: boolean) { + // TODO (7204): Remove these internal methods. + (this.pathObject as AnyDuringMigration).updateReplacementFade(add); + } + + /** + * Visual effect to show that if the dragging block is dropped it will connect + * to this input. + * + * @param conn The connection on the input to highlight. + * @param add True if highlighting should be added. + * @internal + */ + highlightShapeForInput(conn: RenderedConnection, add: boolean) { + // TODO (7204): Remove these internal methods. + (this.pathObject as AnyDuringMigration).updateShapeForInputHighlight( + conn, + add, + ); + } + + /** + * Returns the drag strategy currently in use by this block. + * + * @internal + * @returns This block's drag strategy. + */ + getDragStrategy(): IDragStrategy { + return this.dragStrategy; + } + + /** Sets the drag strategy for this block. */ + setDragStrategy(dragStrategy: IDragStrategy) { + this.dragStrategy = dragStrategy; + } + + /** Returns whether this block is copyable or not. */ + isCopyable(): boolean { + return this.isOwnDeletable() && this.isOwnMovable(); + } + + /** Returns whether this block is movable or not. */ + override isMovable(): boolean { + return this.dragStrategy.isMovable(); + } + + /** Starts a drag on the block. */ + startDrag(e?: PointerEvent): void { + this.dragStrategy.startDrag(e); + } + + /** Drags the block to the given location. */ + drag(newLoc: Coordinate, e?: PointerEvent): void { + this.dragStrategy.drag(newLoc, e); + } + + /** Ends the drag on the block. */ + endDrag(e?: PointerEvent): void { + this.dragStrategy.endDrag(e); + } + + /** Moves the block back to where it was at the start of a drag. */ + revertDrag(): void { + this.dragStrategy.revertDrag(); + } + + /** + * Returns a representation of this block that can be displayed in a flyout. + */ + toFlyoutInfo(): FlyoutItemInfo[] { + const json: FlyoutItemInfo = { + kind: 'BLOCK', + ...blocks.save(this), + }; + + const toRemove = new Set(['id', 'height', 'width', 'pinned', 'enabled']); + + // Traverse the JSON recursively. + const traverseJson = function (json: {[key: string]: unknown}) { + for (const key in json) { + if (toRemove.has(key)) { + delete json[key]; + } else if (typeof json[key] === 'object') { + traverseJson(json[key] as {[key: string]: unknown}); + } + } + }; + + traverseJson(json as unknown as {[key: string]: unknown}); + return [json]; + } + + override jsonInit(json: AnyDuringMigration): void { + super.jsonInit(json); + + if (json['classes']) { + this.addClass( + Array.isArray(json['classes']) + ? json['classes'].join(' ') + : json['classes'], + ); + } + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + return this.pathObject.svgPath; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.workspace; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + this.select(); + this.workspace.scrollBoundsIntoView( + this.getBoundingRectangleWithoutChildren(), + ); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void { + this.unselect(); + } + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } +} diff --git a/core/blockly.js b/core/blockly.js deleted file mode 100644 index 8b708340127..00000000000 --- a/core/blockly.js +++ /dev/null @@ -1,541 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2011 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Core JavaScript library for Blockly. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -/** - * The top level namespace used to access the Blockly library. - * @namespace Blockly - **/ -goog.provide('Blockly'); - -goog.require('Blockly.BlockSvg.render'); -goog.require('Blockly.Events'); -goog.require('Blockly.FieldAngle'); -goog.require('Blockly.FieldCheckbox'); -goog.require('Blockly.FieldColour'); -// Date picker commented out since it increases footprint by 60%. -// Add it only if you need it. -//goog.require('Blockly.FieldDate'); -goog.require('Blockly.FieldDropdown'); -goog.require('Blockly.FieldImage'); -goog.require('Blockly.FieldTextInput'); -goog.require('Blockly.FieldNumber'); -goog.require('Blockly.FieldVariable'); -goog.require('Blockly.Generator'); -goog.require('Blockly.Msg'); -goog.require('Blockly.Procedures'); -goog.require('Blockly.Toolbox'); -goog.require('Blockly.Touch'); -goog.require('Blockly.WidgetDiv'); -goog.require('Blockly.WorkspaceSvg'); -goog.require('Blockly.constants'); -goog.require('Blockly.inject'); -goog.require('Blockly.utils'); -goog.require('goog.color'); -goog.require('goog.userAgent'); - - -// Turn off debugging when compiled. -var CLOSURE_DEFINES = {'goog.DEBUG': false}; - -/** - * The main workspace most recently used. - * Set by Blockly.WorkspaceSvg.prototype.markFocused - * @type {Blockly.Workspace} - */ -Blockly.mainWorkspace = null; - -/** - * Currently selected block. - * @type {Blockly.Block} - */ -Blockly.selected = null; - -/** - * All of the connections on blocks that are currently being dragged. - * @type {!Array.} - * @private - */ -Blockly.draggingConnections_ = []; - -/** - * Contents of the local clipboard. - * @type {Element} - * @private - */ -Blockly.clipboardXml_ = null; - -/** - * Source of the local clipboard. - * @type {Blockly.WorkspaceSvg} - * @private - */ -Blockly.clipboardSource_ = null; - -/** - * Cached value for whether 3D is supported. - * @type {!boolean} - * @private - */ -Blockly.cache3dSupported_ = null; - -/** - * Convert a hue (HSV model) into an RGB hex triplet. - * @param {number} hue Hue on a colour wheel (0-360). - * @return {string} RGB code, e.g. '#5ba65b'. - */ -Blockly.hueToRgb = function(hue) { - return goog.color.hsvToHex(hue, Blockly.HSV_SATURATION, - Blockly.HSV_VALUE * 255); -}; - -/** - * Returns the dimensions of the specified SVG image. - * @param {!Element} svg SVG image. - * @return {!Object} Contains width and height properties. - */ -Blockly.svgSize = function(svg) { - return {width: svg.cachedWidth_, - height: svg.cachedHeight_}; -}; - -/** - * Size the workspace when the contents change. This also updates - * scrollbars accordingly. - * @param {!Blockly.WorkspaceSvg} workspace The workspace to resize. - */ -Blockly.resizeSvgContents = function(workspace) { - workspace.resizeContents(); -}; - -/** - * Size the SVG image to completely fill its container. Call this when the view - * actually changes sizes (e.g. on a window resize/device orientation change). - * See Blockly.resizeSvgContents to resize the workspace when the contents - * change (e.g. when a block is added or removed). - * Record the height/width of the SVG image. - * @param {!Blockly.WorkspaceSvg} workspace Any workspace in the SVG. - */ -Blockly.svgResize = function(workspace) { - var mainWorkspace = workspace; - while (mainWorkspace.options.parentWorkspace) { - mainWorkspace = mainWorkspace.options.parentWorkspace; - } - var svg = mainWorkspace.getParentSvg(); - var div = svg.parentNode; - if (!div) { - // Workspace deleted, or something. - return; - } - var width = div.offsetWidth; - var height = div.offsetHeight; - if (svg.cachedWidth_ != width) { - svg.setAttribute('width', width + 'px'); - svg.cachedWidth_ = width; - } - if (svg.cachedHeight_ != height) { - svg.setAttribute('height', height + 'px'); - svg.cachedHeight_ = height; - } - mainWorkspace.resize(); -}; - -/** - * Handle a key-down on SVG drawing surface. - * @param {!Event} e Key down event. - * @private - */ -Blockly.onKeyDown_ = function(e) { - if (Blockly.mainWorkspace.options.readOnly || Blockly.utils.isTargetInput(e)) { - // No key actions on readonly workspaces. - // When focused on an HTML text input widget, don't trap any keys. - return; - } - var deleteBlock = false; - if (e.keyCode == 27) { - // Pressing esc closes the context menu. - Blockly.hideChaff(); - } else if (e.keyCode == 8 || e.keyCode == 46) { - // Delete or backspace. - // Stop the browser from going back to the previous page. - // Do this first to prevent an error in the delete code from resulting in - // data loss. - e.preventDefault(); - // Don't delete while dragging. Jeez. - if (Blockly.mainWorkspace.isDragging()) { - return; - } - if (Blockly.selected && Blockly.selected.isDeletable()) { - deleteBlock = true; - } - } else if (e.altKey || e.ctrlKey || e.metaKey) { - // Don't use meta keys during drags. - if (Blockly.mainWorkspace.isDragging()) { - return; - } - if (Blockly.selected && - Blockly.selected.isDeletable() && Blockly.selected.isMovable()) { - if (e.keyCode == 67) { - // 'c' for copy. - Blockly.hideChaff(); - Blockly.copy_(Blockly.selected); - } else if (e.keyCode == 88) { - // 'x' for cut. - Blockly.copy_(Blockly.selected); - deleteBlock = true; - } - } - if (e.keyCode == 86) { - // 'v' for paste. - if (Blockly.clipboardXml_) { - Blockly.Events.setGroup(true); - Blockly.clipboardSource_.paste(Blockly.clipboardXml_); - Blockly.Events.setGroup(false); - } - } else if (e.keyCode == 90) { - // 'z' for undo 'Z' is for redo. - Blockly.hideChaff(); - Blockly.mainWorkspace.undo(e.shiftKey); - } - } - if (deleteBlock) { - // Common code for delete and cut. - Blockly.Events.setGroup(true); - Blockly.hideChaff(); - Blockly.selected.dispose(/* heal */ true, true); - Blockly.Events.setGroup(false); - } -}; - -/** - * Copy a block onto the local clipboard. - * @param {!Blockly.Block} block Block to be copied. - * @private - */ -Blockly.copy_ = function(block) { - var xmlBlock = Blockly.Xml.blockToDom(block); - // Copy only the selected block and internal blocks. - Blockly.Xml.deleteNext(xmlBlock); - // Encode start position in XML. - var xy = block.getRelativeToSurfaceXY(); - xmlBlock.setAttribute('x', block.RTL ? -xy.x : xy.x); - xmlBlock.setAttribute('y', xy.y); - Blockly.clipboardXml_ = xmlBlock; - Blockly.clipboardSource_ = block.workspace; -}; - -/** - * Duplicate this block and its children. - * @param {!Blockly.Block} block Block to be copied. - * @private - */ -Blockly.duplicate_ = function(block) { - // Save the clipboard. - var clipboardXml = Blockly.clipboardXml_; - var clipboardSource = Blockly.clipboardSource_; - - // Create a duplicate via a copy/paste operation. - Blockly.copy_(block); - block.workspace.paste(Blockly.clipboardXml_); - - // Restore the clipboard. - Blockly.clipboardXml_ = clipboardXml; - Blockly.clipboardSource_ = clipboardSource; -}; - -/** - * Cancel the native context menu, unless the focus is on an HTML input widget. - * @param {!Event} e Mouse down event. - * @private - */ -Blockly.onContextMenu_ = function(e) { - if (!Blockly.utils.isTargetInput(e)) { - // When focused on an HTML text input widget, don't cancel the context menu. - e.preventDefault(); - } -}; - -/** - * Close tooltips, context menus, dropdown selections, etc. - * @param {boolean=} opt_allowToolbox If true, don't close the toolbox. - */ -Blockly.hideChaff = function(opt_allowToolbox) { - Blockly.Tooltip.hide(); - Blockly.WidgetDiv.hide(); - if (!opt_allowToolbox) { - var workspace = Blockly.getMainWorkspace(); - if (workspace.toolbox_ && - workspace.toolbox_.flyout_ && - workspace.toolbox_.flyout_.autoClose) { - workspace.toolbox_.clearSelection(); - } - } -}; - -/** - * When something in Blockly's workspace changes, call a function. - * @param {!Function} func Function to call. - * @return {!Array.} Opaque data that can be passed to - * removeChangeListener. - * @deprecated April 2015 - */ -Blockly.addChangeListener = function(func) { - // Backwards compatibility from before there could be multiple workspaces. - console.warn('Deprecated call to Blockly.addChangeListener, ' + - 'use workspace.addChangeListener instead.'); - return Blockly.getMainWorkspace().addChangeListener(func); -}; - -/** - * Returns the main workspace. Returns the last used main workspace (based on - * focus). Try not to use this function, particularly if there are multiple - * Blockly instances on a page. - * @return {!Blockly.Workspace} The main workspace. - */ -Blockly.getMainWorkspace = function() { - return Blockly.mainWorkspace; -}; - -/** - * Wrapper to window.alert() that app developers may override to - * provide alternatives to the modal browser window. - * @param {string} message The message to display to the user. - * @param {function()=} opt_callback The callback when the alert is dismissed. - */ -Blockly.alert = function(message, opt_callback) { - window.alert(message); - if (opt_callback) { - opt_callback(); - } -}; - -/** - * Wrapper to window.confirm() that app developers may override to - * provide alternatives to the modal browser window. - * @param {string} message The message to display to the user. - * @param {!function(boolean)} callback The callback for handling user response. - */ -Blockly.confirm = function(message, callback) { - callback(window.confirm(message)); -}; - -/** - * Wrapper to window.prompt() that app developers may override to provide - * alternatives to the modal browser window. Built-in browser prompts are - * often used for better text input experience on mobile device. We strongly - * recommend testing mobile when overriding this. - * @param {string} message The message to display to the user. - * @param {string} defaultValue The value to initialize the prompt with. - * @param {!function(string)} callback The callback for handling user response. - */ -Blockly.prompt = function(message, defaultValue, callback) { - callback(window.prompt(message, defaultValue)); -}; - -/** - * Helper function for defining a block from JSON. The resulting function has - * the correct value of jsonDef at the point in code where jsonInit is called. - * @param {!Object} jsonDef The JSON definition of a block. - * @return {function()} A function that calls jsonInit with the correct value - * of jsonDef. - * @private - */ -Blockly.jsonInitFactory_ = function(jsonDef) { - return function() { - this.jsonInit(jsonDef); - }; -}; - -/** - * Define blocks from an array of JSON block definitions, as might be generated - * by the Blockly Developer Tools. - * @param {!Array.} jsonArray An array of JSON block definitions. - */ -Blockly.defineBlocksWithJsonArray = function(jsonArray) { - for (var i = 0, elem; elem = jsonArray[i]; i++) { - var typename = elem.type; - if (typename == null || typename === '') { - console.warn('Block definition #' + i + - ' in JSON array is missing a type attribute. Skipping.'); - } else { - if (Blockly.Blocks[typename]) { - console.warn('Block definition #' + i + - ' in JSON array overwrites prior definition of "' + typename + '".'); - } - Blockly.Blocks[typename] = { - init: Blockly.jsonInitFactory_(elem) - }; - } - } -}; - -/** - * Bind an event to a function call. When calling the function, verifies that - * it belongs to the touch stream that is currently being processed, and splits - * multitouch events into multiple events as needed. - * @param {!Node} node Node upon which to listen. - * @param {string} name Event name to listen to (e.g. 'mousedown'). - * @param {Object} thisObject The value of 'this' in the function. - * @param {!Function} func Function to call when event is triggered. - * @param {boolean} opt_noCaptureIdentifier True if triggering on this event - * should not block execution of other event handlers on this touch or other - * simultaneous touches. - * @return {!Array.} Opaque data that can be passed to unbindEvent_. - * @private - */ -Blockly.bindEventWithChecks_ = function(node, name, thisObject, func, - opt_noCaptureIdentifier) { - var handled = false; - var wrapFunc = function(e) { - var captureIdentifier = !opt_noCaptureIdentifier; - // Handle each touch point separately. If the event was a mouse event, this - // will hand back an array with one element, which we're fine handling. - var events = Blockly.Touch.splitEventByTouches(e); - for (var i = 0, event; event = events[i]; i++) { - if (captureIdentifier && !Blockly.Touch.shouldHandleEvent(event)) { - continue; - } - Blockly.Touch.setClientFromTouch(event); - if (thisObject) { - func.call(thisObject, event); - } else { - func(event); - } - handled = true; - } - }; - - node.addEventListener(name, wrapFunc, false); - var bindData = [[node, name, wrapFunc]]; - - // Add equivalent touch event. - if (name in Blockly.Touch.TOUCH_MAP) { - var touchWrapFunc = function(e) { - wrapFunc(e); - // Stop the browser from scrolling/zooming the page. - if (handled) { - e.preventDefault(); - } - }; - for (var i = 0, eventName; - eventName = Blockly.Touch.TOUCH_MAP[name][i]; i++) { - node.addEventListener(eventName, touchWrapFunc, false); - bindData.push([node, eventName, touchWrapFunc]); - } - } - return bindData; -}; - - -/** - * Bind an event to a function call. Handles multitouch events by using the - * coordinates of the first changed touch, and doesn't do any safety checks for - * simultaneous event processing. - * @deprecated in favor of bindEventWithChecks_, but preserved for external - * users. - * @param {!Node} node Node upon which to listen. - * @param {string} name Event name to listen to (e.g. 'mousedown'). - * @param {Object} thisObject The value of 'this' in the function. - * @param {!Function} func Function to call when event is triggered. - * @return {!Array.} Opaque data that can be passed to unbindEvent_. - * @private - */ -Blockly.bindEvent_ = function(node, name, thisObject, func) { - var wrapFunc = function(e) { - if (thisObject) { - func.call(thisObject, e); - } else { - func(e); - } - }; - - node.addEventListener(name, wrapFunc, false); - var bindData = [[node, name, wrapFunc]]; - - // Add equivalent touch event. - if (name in Blockly.Touch.TOUCH_MAP) { - var touchWrapFunc = function(e) { - // Punt on multitouch events. - if (e.changedTouches.length == 1) { - // Map the touch event's properties to the event. - var touchPoint = e.changedTouches[0]; - e.clientX = touchPoint.clientX; - e.clientY = touchPoint.clientY; - } - wrapFunc(e); - - // Stop the browser from scrolling/zooming the page. - e.preventDefault(); - }; - for (var i = 0, eventName; - eventName = Blockly.Touch.TOUCH_MAP[name][i]; i++) { - node.addEventListener(eventName, touchWrapFunc, false); - bindData.push([node, eventName, touchWrapFunc]); - } - } - return bindData; -}; - -/** - * Unbind one or more events event from a function call. - * @param {!Array.} bindData Opaque data from bindEvent_. - * This list is emptied during the course of calling this function. - * @return {!Function} The function call. - * @private - */ -Blockly.unbindEvent_ = function(bindData) { - while (bindData.length) { - var bindDatum = bindData.pop(); - var node = bindDatum[0]; - var name = bindDatum[1]; - var func = bindDatum[2]; - node.removeEventListener(name, func, false); - } - return func; -}; - -/** - * Is the given string a number (includes negative and decimals). - * @param {string} str Input string. - * @return {boolean} True if number, false otherwise. - */ -Blockly.isNumber = function(str) { - return !!str.match(/^\s*-?\d+(\.\d+)?\s*$/); -}; - -// IE9 does not have a console. Create a stub to stop errors. -if (!goog.global['console']) { - goog.global['console'] = { - 'log': function() {}, - 'warn': function() {} - }; -} - -// Export symbols that would otherwise be renamed by Closure compiler. -if (!goog.global['Blockly']) { - goog.global['Blockly'] = {}; -} -goog.global['Blockly']['getMainWorkspace'] = Blockly.getMainWorkspace; -goog.global['Blockly']['addChangeListener'] = Blockly.addChangeListener; diff --git a/core/blockly.ts b/core/blockly.ts new file mode 100644 index 00000000000..99112d790fb --- /dev/null +++ b/core/blockly.ts @@ -0,0 +1,644 @@ +/** + * @license + * Copyright 2011 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly + +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_block_create.js'; +// Unused import preserved for side-effects. Remove if unneeded. +import './events/workspace_events.js'; +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_ui_base.js'; +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_var_create.js'; + +import {Block} from './block.js'; +import * as blockAnimations from './block_animations.js'; +import {BlockFlyoutInflater} from './block_flyout_inflater.js'; +import {BlockSvg} from './block_svg.js'; +import {BlocklyOptions} from './blockly_options.js'; +import {Blocks} from './blocks.js'; +import * as browserEvents from './browser_events.js'; +import * as bubbles from './bubbles.js'; +import {MiniWorkspaceBubble} from './bubbles/mini_workspace_bubble.js'; +import * as bumpObjects from './bump_objects.js'; +import {ButtonFlyoutInflater} from './button_flyout_inflater.js'; +import * as clipboard from './clipboard.js'; +import * as comments from './comments.js'; +import * as common from './common.js'; +import {ComponentManager} from './component_manager.js'; +import {config} from './config.js'; +import {Connection} from './connection.js'; +import {ConnectionChecker} from './connection_checker.js'; +import {ConnectionDB} from './connection_db.js'; +import {ConnectionType} from './connection_type.js'; +import * as constants from './constants.js'; +import * as ContextMenu from './contextmenu.js'; +import * as ContextMenuItems from './contextmenu_items.js'; +import {ContextMenuRegistry} from './contextmenu_registry.js'; +import * as Css from './css.js'; +import {DeleteArea} from './delete_area.js'; +import * as dialog from './dialog.js'; +import {DragTarget} from './drag_target.js'; +import * as dragging from './dragging.js'; +import * as dropDownDiv from './dropdowndiv.js'; +import * as Events from './events/events.js'; +import * as Extensions from './extensions.js'; +import { + Field, + FieldConfig, + FieldValidator, + UnattachedFieldError, +} from './field.js'; +import { + FieldCheckbox, + FieldCheckboxConfig, + FieldCheckboxFromJsonConfig, + FieldCheckboxValidator, +} from './field_checkbox.js'; +import { + FieldDropdown, + FieldDropdownConfig, + FieldDropdownFromJsonConfig, + FieldDropdownValidator, + ImageProperties, + MenuGenerator, + MenuGeneratorFunction, + MenuOption, +} from './field_dropdown.js'; +import { + FieldImage, + FieldImageConfig, + FieldImageFromJsonConfig, +} from './field_image.js'; +import { + FieldLabel, + FieldLabelConfig, + FieldLabelFromJsonConfig, +} from './field_label.js'; +import {FieldLabelSerializable} from './field_label_serializable.js'; +import { + FieldNumber, + FieldNumberConfig, + FieldNumberFromJsonConfig, + FieldNumberValidator, +} from './field_number.js'; +import * as fieldRegistry from './field_registry.js'; +import { + FieldTextInput, + FieldTextInputConfig, + FieldTextInputFromJsonConfig, + FieldTextInputValidator, +} from './field_textinput.js'; +import { + FieldVariable, + FieldVariableConfig, + FieldVariableFromJsonConfig, + FieldVariableValidator, +} from './field_variable.js'; +import {Flyout} from './flyout_base.js'; +import {FlyoutButton} from './flyout_button.js'; +import {HorizontalFlyout} from './flyout_horizontal.js'; +import {FlyoutItem} from './flyout_item.js'; +import {FlyoutMetricsManager} from './flyout_metrics_manager.js'; +import {FlyoutSeparator} from './flyout_separator.js'; +import {VerticalFlyout} from './flyout_vertical.js'; +import { + FocusManager, + ReturnEphemeralFocus, + getFocusManager, +} from './focus_manager.js'; +import {CodeGenerator} from './generator.js'; +import {Gesture} from './gesture.js'; +import {Grid} from './grid.js'; +import * as icons from './icons.js'; +import {inject} from './inject.js'; +import * as inputs from './inputs.js'; +import {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import {LabelFlyoutInflater} from './label_flyout_inflater.js'; +import {SeparatorFlyoutInflater} from './separator_flyout_inflater.js'; +import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; + +import {Input} from './inputs/input.js'; +import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js'; +import {IAutoHideable} from './interfaces/i_autohideable.js'; +import {IBoundedElement} from './interfaces/i_bounded_element.js'; +import {IBubble} from './interfaces/i_bubble.js'; +import {ICollapsibleToolboxItem} from './interfaces/i_collapsible_toolbox_item.js'; +import {IComponent} from './interfaces/i_component.js'; +import {IConnectionChecker} from './interfaces/i_connection_checker.js'; +import {IConnectionPreviewer} from './interfaces/i_connection_previewer.js'; +import {IContextMenu} from './interfaces/i_contextmenu.js'; +import {ICopyData, ICopyable, isCopyable} from './interfaces/i_copyable.js'; +import {IDeletable, isDeletable} from './interfaces/i_deletable.js'; +import {IDeleteArea} from './interfaces/i_delete_area.js'; +import {IDragTarget} from './interfaces/i_drag_target.js'; +import { + IDragStrategy, + IDraggable, + isDraggable, +} from './interfaces/i_draggable.js'; +import {IDragger} from './interfaces/i_dragger.js'; +import {IFlyout} from './interfaces/i_flyout.js'; +import {IFocusableNode} from './interfaces/i_focusable_node.js'; +import {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import {IHasBubble, hasBubble} from './interfaces/i_has_bubble.js'; +import {IIcon, isIcon} from './interfaces/i_icon.js'; +import {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js'; +import {IMetricsManager} from './interfaces/i_metrics_manager.js'; +import {IMovable} from './interfaces/i_movable.js'; +import {IObservable, isObservable} from './interfaces/i_observable.js'; +import {IPaster, isPaster} from './interfaces/i_paster.js'; +import {IPositionable} from './interfaces/i_positionable.js'; +import {IRegistrable} from './interfaces/i_registrable.js'; +import { + IRenderedElement, + isRenderedElement, +} from './interfaces/i_rendered_element.js'; +import {ISelectable, isSelectable} from './interfaces/i_selectable.js'; +import {ISelectableToolboxItem} from './interfaces/i_selectable_toolbox_item.js'; +import {ISerializable, isSerializable} from './interfaces/i_serializable.js'; +import {IStyleable} from './interfaces/i_styleable.js'; +import {IToolbox} from './interfaces/i_toolbox.js'; +import {IToolboxItem} from './interfaces/i_toolbox_item.js'; +import { + IVariableBackedParameterModel, + isVariableBackedParameterModel, +} from './interfaces/i_variable_backed_parameter_model.js'; +import {IVariableMap} from './interfaces/i_variable_map.js'; +import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; +import * as internalConstants from './internal_constants.js'; +import {LineCursor} from './keyboard_nav/line_cursor.js'; +import {Marker} from './keyboard_nav/marker.js'; +import { + KeyboardNavigationController, + keyboardNavigationController, +} from './keyboard_navigation_controller.js'; +import type {LayerManager} from './layer_manager.js'; +import * as layers from './layers.js'; +import {MarkerManager} from './marker_manager.js'; +import {Menu} from './menu.js'; +import {MenuItem} from './menuitem.js'; +import {MetricsManager} from './metrics_manager.js'; +import {Msg, setLocale} from './msg.js'; +import {Names} from './names.js'; +import {Options} from './options.js'; +import * as uiPosition from './positionable_helpers.js'; +import * as Procedures from './procedures.js'; +import * as registry from './registry.js'; +import * as renderManagement from './render_management.js'; +import {RenderedConnection} from './rendered_connection.js'; +import * as blockRendering from './renderers/common/block_rendering.js'; +import * as geras from './renderers/geras/geras.js'; +import * as thrasos from './renderers/thrasos/thrasos.js'; +import * as zelos from './renderers/zelos/zelos.js'; +import {Scrollbar} from './scrollbar.js'; +import {ScrollbarPair} from './scrollbar_pair.js'; +import * as serialization from './serialization.js'; +import * as ShortcutItems from './shortcut_items.js'; +import {ShortcutRegistry} from './shortcut_registry.js'; +import {Theme} from './theme.js'; +import * as Themes from './theme/themes.js'; +import {ThemeManager} from './theme_manager.js'; +import {ToolboxCategory} from './toolbox/category.js'; +import {CollapsibleToolboxCategory} from './toolbox/collapsible_category.js'; +import {ToolboxSeparator} from './toolbox/separator.js'; +import {Toolbox} from './toolbox/toolbox.js'; +import {ToolboxItem} from './toolbox/toolbox_item.js'; +import * as Tooltip from './tooltip.js'; +import * as Touch from './touch.js'; +import {Trashcan} from './trashcan.js'; +import * as utils from './utils.js'; +import * as toolbox from './utils/toolbox.js'; +import {VariableMap} from './variable_map.js'; +import {VariableModel} from './variable_model.js'; +import * as Variables from './variables.js'; +import * as VariablesDynamic from './variables_dynamic.js'; +import * as WidgetDiv from './widgetdiv.js'; +import {Workspace} from './workspace.js'; +import {WorkspaceAudio} from './workspace_audio.js'; +import {WorkspaceDragger} from './workspace_dragger.js'; +import {WorkspaceSvg} from './workspace_svg.js'; +import * as Xml from './xml.js'; +import {ZoomControls} from './zoom_controls.js'; + +/** + * Blockly core version. + * This constant is overridden by the build script (npm run build) to the value + * of the version in package.json. This is done by the Closure Compiler in the + * buildCompressed gulp task. + * For local builds, you can pass --define='Blockly.VERSION=X.Y.Z' to the + * compiler to override this constant. + * + * @define {string} + */ +export const VERSION = 'uncompiled'; + +/* + * Top-level functions and properties on the Blockly namespace. + * These are used only in external code. Do not reference these + * from internal code as importing from this file can cause circular + * dependencies. Do not add new functions here. There is probably a better + * namespace to put new functions on. + */ + +/* + * Aliases for constants used for connection and input types. + */ + +/** + * @see ConnectionType.INPUT_VALUE + */ +export const INPUT_VALUE = ConnectionType.INPUT_VALUE; + +/** + * @see ConnectionType.OUTPUT_VALUE + */ +export const OUTPUT_VALUE = ConnectionType.OUTPUT_VALUE; + +/** + * @see ConnectionType.NEXT_STATEMENT + */ +export const NEXT_STATEMENT = ConnectionType.NEXT_STATEMENT; + +/** + * @see ConnectionType.PREVIOUS_STATEMENT + */ +export const PREVIOUS_STATEMENT = ConnectionType.PREVIOUS_STATEMENT; + +/** Aliases for toolbox positions. */ + +/** + * @see toolbox.Position.TOP + */ +export const TOOLBOX_AT_TOP = toolbox.Position.TOP; + +/** + * @see toolbox.Position.BOTTOM + */ +export const TOOLBOX_AT_BOTTOM = toolbox.Position.BOTTOM; + +/** + * @see toolbox.Position.LEFT + */ +export const TOOLBOX_AT_LEFT = toolbox.Position.LEFT; + +/** + * @see toolbox.Position.RIGHT + */ +export const TOOLBOX_AT_RIGHT = toolbox.Position.RIGHT; + +/* + * Other aliased functions. + */ + +/** + * Size the SVG image to completely fill its container. Call this when the view + * actually changes sizes (e.g. on a window resize/device orientation change). + * See workspace.resizeContents to resize the workspace when the contents + * change (e.g. when a block is added or removed). + * Record the height/width of the SVG image. + * + * @param workspace Any workspace in the SVG. + * @see Blockly.common.svgResize + */ +export const svgResize = common.svgResize; + +/** + * Close tooltips, context menus, dropdown selections, etc. + * + * @param opt_onlyClosePopups Whether only popups should be closed. + * @see Blockly.WorkspaceSvg.hideChaff + */ +export function hideChaff(opt_onlyClosePopups?: boolean) { + (common.getMainWorkspace() as WorkspaceSvg).hideChaff(opt_onlyClosePopups); +} + +/** + * Returns the main workspace. Returns the last used main workspace (based on + * focus). Try not to use this function, particularly if there are multiple + * Blockly instances on a page. + * + * @see Blockly.common.getMainWorkspace + */ +export const getMainWorkspace = common.getMainWorkspace; + +/** + * Returns the currently selected copyable object. + */ +export const getSelected = common.getSelected; + +/** + * Define blocks from an array of JSON block definitions, as might be generated + * by the Blockly Developer Tools. + * + * @param jsonArray An array of JSON block definitions. + * @see Blockly.common.defineBlocksWithJsonArray + */ +export const defineBlocksWithJsonArray = common.defineBlocksWithJsonArray; + +/** + * Set the parent container. This is the container element that the WidgetDiv, + * dropDownDiv, and Tooltip are rendered into the first time `Blockly.inject` + * is called. + * This method is a NOP if called after the first `Blockly.inject`. + * + * @param container The container element. + * @see Blockly.common.setParentContainer + */ +export const setParentContainer = common.setParentContainer; + +// Aliases to allow external code to access these values for legacy reasons. +export const COLLAPSE_CHARS = internalConstants.COLLAPSE_CHARS; +export const OPPOSITE_TYPE = internalConstants.OPPOSITE_TYPE; +export const RENAME_VARIABLE_ID = internalConstants.RENAME_VARIABLE_ID; +export const DELETE_VARIABLE_ID = internalConstants.DELETE_VARIABLE_ID; +export const COLLAPSED_INPUT_NAME = constants.COLLAPSED_INPUT_NAME; +export const COLLAPSED_FIELD_NAME = constants.COLLAPSED_FIELD_NAME; + +/** + * String for use in the "custom" attribute of a category in toolbox XML. + * This string indicates that the category should be dynamically populated with + * variable blocks. + */ +export const VARIABLE_CATEGORY_NAME: string = Variables.CATEGORY_NAME; + +/** + * String for use in the "custom" attribute of a category in toolbox XML. + * This string indicates that the category should be dynamically populated with + * variable blocks. + */ +export const VARIABLE_DYNAMIC_CATEGORY_NAME: string = + VariablesDynamic.CATEGORY_NAME; +/** + * String for use in the "custom" attribute of a category in toolbox XML. + * This string indicates that the category should be dynamically populated with + * procedure blocks. + */ +export const PROCEDURE_CATEGORY_NAME: string = Procedures.CATEGORY_NAME; + +// Context for why we need to monkey-patch in these functions (internal): +// https://docs.google.com/document/d/1MbO0LEA-pAyx1ErGLJnyUqTLrcYTo-5zga9qplnxeXo/edit?usp=sharing&resourcekey=0-5h_32-i-dHwHjf_9KYEVKg + +// clang-format off +Workspace.prototype.newBlock = function ( + prototypeName: string, + opt_id?: string, +): Block { + return new Block(this, prototypeName, opt_id); +}; + +WorkspaceSvg.prototype.newBlock = function ( + prototypeName: string, + opt_id?: string, +): BlockSvg { + return new BlockSvg(this, prototypeName, opt_id); +}; + +Workspace.prototype.newComment = function ( + id?: string, +): comments.WorkspaceComment { + return new comments.WorkspaceComment(this, id); +}; + +WorkspaceSvg.prototype.newComment = function ( + id?: string, +): comments.RenderedWorkspaceComment { + return new comments.RenderedWorkspaceComment(this, id); +}; + +WorkspaceSvg.newTrashcan = function (workspace: WorkspaceSvg): Trashcan { + return new Trashcan(workspace); +}; + +MiniWorkspaceBubble.prototype.newWorkspaceSvg = function ( + options: Options, +): WorkspaceSvg { + return new WorkspaceSvg(options); +}; + +Names.prototype.populateProcedures = function ( + this: Names, + workspace: Workspace, +) { + const procedures = Procedures.allProcedures(workspace); + // Flatten the return vs no-return procedure lists. + const flattenedProcedures = procedures[0].concat(procedures[1]); + for (let i = 0; i < flattenedProcedures.length; i++) { + this.getName(flattenedProcedures[i][0], Names.NameType.PROCEDURE); + } +}; +// clang-format on + +export * from './flyout_navigator.js'; +export * from './interfaces/i_navigation_policy.js'; +export * from './keyboard_nav/block_navigation_policy.js'; +export * from './keyboard_nav/connection_navigation_policy.js'; +export * from './keyboard_nav/field_navigation_policy.js'; +export * from './keyboard_nav/flyout_button_navigation_policy.js'; +export * from './keyboard_nav/flyout_navigation_policy.js'; +export * from './keyboard_nav/flyout_separator_navigation_policy.js'; +export * from './keyboard_nav/workspace_navigation_policy.js'; +export * from './navigator.js'; +export * from './toast.js'; + +// Re-export submodules that no longer declareLegacyNamespace. +export { + Block, + BlockSvg, + BlocklyOptions, + Blocks, + CollapsibleToolboxCategory, + ComponentManager, + Connection, + ConnectionChecker, + ConnectionDB, + ConnectionType, + ContextMenu, + ContextMenuItems, + ContextMenuRegistry, + Css, + DeleteArea, + DragTarget, + Events, + Extensions, + LineCursor, + Procedures, + ShortcutItems, + Themes, + Tooltip, + Touch, + Variables, + VariablesDynamic, + WidgetDiv, + Xml, + blockAnimations, + blockRendering, + browserEvents, + bubbles, + bumpObjects, + clipboard, + comments, + common, + constants, + dialog, + dragging, + fieldRegistry, + geras, + Procedures as procedures, + registry, + thrasos, + uiPosition, + utils, + zelos, +}; +export const DropDownDiv = dropDownDiv; +export { + BlockFlyoutInflater, + ButtonFlyoutInflater, + CodeGenerator, + Field, + FieldCheckbox, + FieldCheckboxConfig, + FieldCheckboxFromJsonConfig, + FieldCheckboxValidator, + FieldConfig, + FieldDropdown, + FieldDropdownConfig, + FieldDropdownFromJsonConfig, + FieldDropdownValidator, + FieldImage, + FieldImageConfig, + FieldImageFromJsonConfig, + FieldLabel, + FieldLabelConfig, + FieldLabelFromJsonConfig, + FieldLabelSerializable, + FieldNumber, + FieldNumberConfig, + FieldNumberFromJsonConfig, + FieldNumberValidator, + FieldTextInput, + FieldTextInputConfig, + FieldTextInputFromJsonConfig, + FieldTextInputValidator, + FieldValidator, + FieldVariable, + FieldVariableConfig, + FieldVariableFromJsonConfig, + FieldVariableValidator, + Flyout, + FlyoutButton, + FlyoutItem, + FlyoutMetricsManager, + FlyoutSeparator, + FocusManager, + FocusableTreeTraverser, + CodeGenerator as Generator, + Gesture, + Grid, + HorizontalFlyout, + IAutoHideable, + IBoundedElement, + IBubble, + ICollapsibleToolboxItem, + IComponent, + IConnectionChecker, + IConnectionPreviewer, + IContextMenu, + ICopyData, + ICopyable, + IDeletable, + IDeleteArea, + IDragStrategy, + IDragTarget, + IDraggable, + IDragger, + IFlyout, + IFlyoutInflater, + IFocusableNode, + IFocusableTree, + IHasBubble, + IIcon, + IKeyboardAccessible, + IMetricsManager, + IMovable, + IObservable, + IPaster, + IPositionable, + IRegistrable, + IRenderedElement, + ISelectable, + ISelectableToolboxItem, + ISerializable, + IStyleable, + IToolbox, + IToolboxItem, + IVariableBackedParameterModel, + IVariableMap, + IVariableModel, + IVariableState, + ImageProperties, + Input, + InsertionMarkerPreviewer, + KeyboardNavigationController, + LabelFlyoutInflater, + LayerManager, + Marker, + MarkerManager, + Menu, + MenuGenerator, + MenuGeneratorFunction, + MenuItem, + MenuOption, + MetricsManager, + Msg, + Names, + Options, + RenderedConnection, + ReturnEphemeralFocus, + Scrollbar, + ScrollbarPair, + SeparatorFlyoutInflater, + ShortcutRegistry, + Theme, + ThemeManager, + Toolbox, + ToolboxCategory, + ToolboxItem, + ToolboxSeparator, + Trashcan, + UnattachedFieldError, + VariableMap, + VariableModel, + VerticalFlyout, + Workspace, + WorkspaceAudio, + WorkspaceDragger, + WorkspaceSvg, + ZoomControls, + config, + getFocusManager, + hasBubble, + icons, + inject, + inputs, + isCopyable, + isDeletable, + isDraggable, + isIcon, + isObservable, + isPaster, + isRenderedElement, + isSelectable, + isSerializable, + isVariableBackedParameterModel, + keyboardNavigationController, + layers, + renderManagement, + serialization, + setLocale, +}; diff --git a/core/blockly_options.ts b/core/blockly_options.ts new file mode 100644 index 00000000000..dd18dbfee5d --- /dev/null +++ b/core/blockly_options.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.BlocklyOptions + +import type {ITheme, Theme} from './theme.js'; +import type {ToolboxDefinition} from './utils/toolbox.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +/** + * Blockly options. + */ +export interface BlocklyOptions { + collapse?: boolean; + comments?: boolean; + css?: boolean; + disable?: boolean; + grid?: GridOptions; + horizontalLayout?: boolean; + maxBlocks?: number; + maxInstances?: {[blockType: string]: number}; + media?: string; + modalInputs?: boolean; + move?: MoveOptions; + oneBasedIndex?: boolean; + readOnly?: boolean; + renderer?: string; + rendererOverrides?: {[rendererConstant: string]: any}; + rtl?: boolean; + scrollbars?: ScrollbarOptions | boolean; + sounds?: boolean; + theme?: Theme | string | ITheme; + toolbox?: string | ToolboxDefinition | Element; + toolboxPosition?: string; + trashcan?: boolean; + maxTrashcanContents?: number; + plugins?: {[key: string]: (new (...p1: any[]) => any) | string}; + zoom?: ZoomOptions; + parentWorkspace?: WorkspaceSvg; +} + +export interface GridOptions { + colour?: string; + length?: number; + snap?: boolean; + spacing?: number; +} + +export interface MoveOptions { + drag?: boolean; + scrollbars?: boolean | ScrollbarOptions; + wheel?: boolean; +} + +export interface ScrollbarOptions { + horizontal?: boolean; + vertical?: boolean; +} + +export interface ZoomOptions { + controls?: boolean; + maxScale?: number; + minScale?: number; + pinch?: boolean; + scaleSpeed?: number; + startScale?: number; + wheel?: boolean; +} diff --git a/core/blocks.js b/core/blocks.js deleted file mode 100644 index 5b78050aa7f..00000000000 --- a/core/blocks.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2013 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 mapping of block type names to block prototype objects. - * @author spertus@google.com (Ellen Spertus) - */ -'use strict'; - -/** - * A mapping of block type names to block prototype objects. - * @name Blockly.Blocks - */ -goog.provide('Blockly.Blocks'); - -/* - * A mapping of block type names to block prototype objects. - * @type {!Object} - */ -Blockly.Blocks = new Object(null); diff --git a/core/blocks.ts b/core/blocks.ts new file mode 100644 index 00000000000..33bac110b04 --- /dev/null +++ b/core/blocks.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2013 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.blocks + +/** + * A block definition. For now this very loose, but it can potentially + * be refined e.g. by replacing this typedef with a class definition. + */ +export type BlockDefinition = AnyDuringMigration; + +/** + * A mapping of block type names to block prototype objects. + */ +export const Blocks: {[key: string]: BlockDefinition} = Object.create(null); diff --git a/core/browser_events.ts b/core/browser_events.ts new file mode 100644 index 00000000000..8176fe10ff3 --- /dev/null +++ b/core/browser_events.ts @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.browserEvents + +// Theoretically we could figure out a way to type the event params correctly, +// but it's not high priority. +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ + +import * as Touch from './touch.js'; +import * as userAgent from './utils/useragent.js'; + +/** + * Blockly opaque event data used to unbind events when using + * `bind` and `conditionalBind`. + */ +export type Data = [EventTarget, string, (e: Event) => void][]; + +/** + * The multiplier for scroll wheel deltas using the line delta mode. + * See https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent/deltaMode + * for more information on deltaMode. + */ +const LINE_MODE_MULTIPLIER = 40; + +/** + * The multiplier for scroll wheel deltas using the page delta mode. + * See https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent/deltaMode + * for more information on deltaMode. + */ +const PAGE_MODE_MULTIPLIER = 125; + +/** + * Bind an event handler that can be ignored if it is not part of the active + * touch stream. + * Use this for events that either start or continue a multi-part gesture (e.g. + * mousedown or mousemove, which may be part of a drag or click). + * + * @param node Node upon which to listen. + * @param name Event name to listen to (e.g. 'mousedown'). + * @param thisObject The value of 'this' in the function. + * @param func Function to call when event is triggered. + * @param opt_noCaptureIdentifier True if triggering on this event should not + * block execution of other event handlers on this touch or other + * simultaneous touches. False by default. + * @returns Opaque data that can be passed to unbindEvent_. + */ +export function conditionalBind( + node: EventTarget, + name: string, + thisObject: object | null, + func: Function, + opt_noCaptureIdentifier?: boolean, +): Data { + /** + * + * @param e + */ + function wrapFunc(e: Event) { + const captureIdentifier = !opt_noCaptureIdentifier; + + if (!(captureIdentifier && !Touch.shouldHandleEvent(e))) { + if (thisObject) { + func.call(thisObject, e); + } else { + func(e); + } + } + } + + const bindData: Data = []; + if (name in Touch.TOUCH_MAP) { + for (let i = 0; i < Touch.TOUCH_MAP[name].length; i++) { + const type = Touch.TOUCH_MAP[name][i]; + node.addEventListener(type, wrapFunc, false); + bindData.push([node, type, wrapFunc]); + } + } else { + node.addEventListener(name, wrapFunc, false); + bindData.push([node, name, wrapFunc]); + } + return bindData; +} + +/** + * Bind an event handler that should be called regardless of whether it is part + * of the active touch stream. + * Use this for events that are not part of a multi-part gesture (e.g. + * mouseover for tooltips). + * + * @param node Node upon which to listen. + * @param name Event name to listen to (e.g. 'mousedown'). + * @param thisObject The value of 'this' in the function. + * @param func Function to call when event is triggered. + * @returns Opaque data that can be passed to unbindEvent_. + */ +export function bind( + node: EventTarget, + name: string, + thisObject: object | null, + func: Function, +): Data { + /** + * + * @param e + */ + function wrapFunc(e: Event) { + if (thisObject) { + func.call(thisObject, e); + } else { + func(e); + } + } + + const bindData: Data = []; + if (name in Touch.TOUCH_MAP) { + for (let i = 0; i < Touch.TOUCH_MAP[name].length; i++) { + const type = Touch.TOUCH_MAP[name][i]; + node.addEventListener(type, wrapFunc, false); + bindData.push([node, type, wrapFunc]); + } + } else { + node.addEventListener(name, wrapFunc, false); + bindData.push([node, name, wrapFunc]); + } + return bindData; +} + +/** + * Unbind one or more events event from a function call. + * + * @param bindData Opaque data from bindEvent_. + * This list is emptied during the course of calling this function. + * @returns The function call. + */ +export function unbind(bindData: Data): (e: Event) => void { + // Accessing an element of the last property of the array is unsafe if the + // bindData is an empty array. But that should never happen because developers + // should only pass Data from bind or conditionalBind. + const callback = bindData[bindData.length - 1][2]; + while (bindData.length) { + const [node, name, func] = bindData.pop()!; + node.removeEventListener(name, func, false); + } + return callback; +} + +/** + * Returns true if this event is targeting a text input widget? + * + * @param e An event. + * @returns True if text input. + */ +export function isTargetInput(e: Event): boolean { + if (e.target instanceof HTMLElement) { + if ( + e.target.isContentEditable || + e.target.getAttribute('data-is-text-input') === 'true' + ) { + return true; + } + + if (e.target instanceof HTMLInputElement) { + const target = e.target; + return ( + target.type === 'text' || + target.type === 'number' || + target.type === 'email' || + target.type === 'password' || + target.type === 'search' || + target.type === 'tel' || + target.type === 'url' + ); + } + + if (e.target instanceof HTMLTextAreaElement) { + return true; + } + } + + return false; +} + +/** + * Returns true this event is a right-click. + * + * @param e Mouse event. + * @returns True if right-click. + */ +export function isRightButton(e: MouseEvent): boolean { + if (e.ctrlKey && userAgent.MAC) { + // Control-clicking on Mac OS X is treated as a right-click. + // WebKit on Mac OS X fails to change button to 2 (but Gecko does). + return true; + } + return e.button === 2; +} + +/** + * Returns the converted coordinates of the given mouse event. + * The origin (0,0) is the top-left corner of the Blockly SVG. + * + * @param e Mouse event. + * @param svg SVG element. + * @param matrix Inverted screen CTM to use. + * @returns Object with .x and .y properties. + */ +export function mouseToSvg( + e: MouseEvent, + svg: SVGSVGElement, + matrix: SVGMatrix | null, +): SVGPoint { + const svgPoint = svg.createSVGPoint(); + svgPoint.x = e.clientX; + svgPoint.y = e.clientY; + + if (!matrix) { + matrix = svg.getScreenCTM()!.inverse(); + } + return svgPoint.matrixTransform(matrix); +} + +/** + * Returns the scroll delta of a mouse event in pixel units. + * + * @param e Mouse event. + * @returns Scroll delta object with .x and .y properties. + */ +export function getScrollDeltaPixels(e: WheelEvent): {x: number; y: number} { + switch (e.deltaMode) { + case 0x00: // Pixel mode. + default: + return {x: e.deltaX, y: e.deltaY}; + case 0x01: // Line mode. + return { + x: e.deltaX * LINE_MODE_MULTIPLIER, + y: e.deltaY * LINE_MODE_MULTIPLIER, + }; + case 0x02: // Page mode. + return { + x: e.deltaX * PAGE_MODE_MULTIPLIER, + y: e.deltaY * PAGE_MODE_MULTIPLIER, + }; + } +} diff --git a/core/bubble.js b/core/bubble.js deleted file mode 100644 index 1cd9443fd6a..00000000000 --- a/core/bubble.js +++ /dev/null @@ -1,586 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Object representing a UI bubble. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Bubble'); - -goog.require('Blockly.Touch'); -goog.require('Blockly.Workspace'); -goog.require('goog.dom'); -goog.require('goog.math'); -goog.require('goog.math.Coordinate'); -goog.require('goog.userAgent'); - - -/** - * Class for UI bubble. - * @param {!Blockly.WorkspaceSvg} workspace The workspace on which to draw the - * bubble. - * @param {!Element} content SVG content for the bubble. - * @param {Element} shape SVG element to avoid eclipsing. - * @param {!goog.math.Coodinate} anchorXY Absolute position of bubble's anchor - * point. - * @param {?number} bubbleWidth Width of bubble, or null if not resizable. - * @param {?number} bubbleHeight Height of bubble, or null if not resizable. - * @constructor - */ -Blockly.Bubble = function(workspace, content, shape, anchorXY, - bubbleWidth, bubbleHeight) { - this.workspace_ = workspace; - this.content_ = content; - this.shape_ = shape; - - var angle = Blockly.Bubble.ARROW_ANGLE; - if (this.workspace_.RTL) { - angle = -angle; - } - this.arrow_radians_ = goog.math.toRadians(angle); - - var canvas = workspace.getBubbleCanvas(); - canvas.appendChild(this.createDom_(content, !!(bubbleWidth && bubbleHeight))); - - this.setAnchorLocation(anchorXY); - if (!bubbleWidth || !bubbleHeight) { - var bBox = /** @type {SVGLocatable} */ (this.content_).getBBox(); - bubbleWidth = bBox.width + 2 * Blockly.Bubble.BORDER_WIDTH; - bubbleHeight = bBox.height + 2 * Blockly.Bubble.BORDER_WIDTH; - } - this.setBubbleSize(bubbleWidth, bubbleHeight); - - // Render the bubble. - this.positionBubble_(); - this.renderArrow_(); - this.rendered_ = true; - - if (!workspace.options.readOnly) { - Blockly.bindEventWithChecks_(this.bubbleBack_, 'mousedown', this, - this.bubbleMouseDown_); - if (this.resizeGroup_) { - Blockly.bindEventWithChecks_(this.resizeGroup_, 'mousedown', this, - this.resizeMouseDown_); - } - } -}; - -/** - * Width of the border around the bubble. - */ -Blockly.Bubble.BORDER_WIDTH = 6; - -/** - * Determines the thickness of the base of the arrow in relation to the size - * of the bubble. Higher numbers result in thinner arrows. - */ -Blockly.Bubble.ARROW_THICKNESS = 5; - -/** - * The number of degrees that the arrow bends counter-clockwise. - */ -Blockly.Bubble.ARROW_ANGLE = 20; - -/** - * The sharpness of the arrow's bend. Higher numbers result in smoother arrows. - */ -Blockly.Bubble.ARROW_BEND = 4; - -/** - * Distance between arrow point and anchor point. - */ -Blockly.Bubble.ANCHOR_RADIUS = 8; - -/** - * Wrapper function called when a mouseUp occurs during a drag operation. - * @type {Array.} - * @private - */ -Blockly.Bubble.onMouseUpWrapper_ = null; - -/** - * Wrapper function called when a mouseMove occurs during a drag operation. - * @type {Array.} - * @private - */ -Blockly.Bubble.onMouseMoveWrapper_ = null; - -/** - * Function to call on resize of bubble. - * @type {Function} - */ -Blockly.Bubble.prototype.resizeCallback_ = null; - -/** - * Stop binding to the global mouseup and mousemove events. - * @private - */ -Blockly.Bubble.unbindDragEvents_ = function() { - if (Blockly.Bubble.onMouseUpWrapper_) { - Blockly.unbindEvent_(Blockly.Bubble.onMouseUpWrapper_); - Blockly.Bubble.onMouseUpWrapper_ = null; - } - if (Blockly.Bubble.onMouseMoveWrapper_) { - Blockly.unbindEvent_(Blockly.Bubble.onMouseMoveWrapper_); - Blockly.Bubble.onMouseMoveWrapper_ = null; - } -}; - -/* - * Handle a mouse-up event while dragging a bubble's border or resize handle. - * @param {!Event} e Mouse up event. - * @private - */ -Blockly.Bubble.bubbleMouseUp_ = function(/*e*/) { - Blockly.Touch.clearTouchIdentifier(); - Blockly.Bubble.unbindDragEvents_(); -}; - -/** - * Flag to stop incremental rendering during construction. - * @private - */ -Blockly.Bubble.prototype.rendered_ = false; - -/** - * Absolute coordinate of anchor point. - * @type {goog.math.Coordinate} - * @private - */ -Blockly.Bubble.prototype.anchorXY_ = null; - -/** - * Relative X coordinate of bubble with respect to the anchor's centre. - * In RTL mode the initial value is negated. - * @private - */ -Blockly.Bubble.prototype.relativeLeft_ = 0; - -/** - * Relative Y coordinate of bubble with respect to the anchor's centre. - * @private - */ -Blockly.Bubble.prototype.relativeTop_ = 0; - -/** - * Width of bubble. - * @private - */ -Blockly.Bubble.prototype.width_ = 0; - -/** - * Height of bubble. - * @private - */ -Blockly.Bubble.prototype.height_ = 0; - -/** - * Automatically position and reposition the bubble. - * @private - */ -Blockly.Bubble.prototype.autoLayout_ = true; - -/** - * Create the bubble's DOM. - * @param {!Element} content SVG content for the bubble. - * @param {boolean} hasResize Add diagonal resize gripper if true. - * @return {!Element} The bubble's SVG group. - * @private - */ -Blockly.Bubble.prototype.createDom_ = function(content, hasResize) { - /* Create the bubble. Here's the markup that will be generated: - - - - - - - - - - - [...content goes here...] - - */ - this.bubbleGroup_ = Blockly.utils.createSvgElement('g', {}, null); - var filter = - {'filter': 'url(#' + this.workspace_.options.embossFilterId + ')'}; - if (goog.userAgent.getUserAgentString().indexOf('JavaFX') != -1) { - // Multiple reports that JavaFX can't handle filters. UserAgent: - // Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.44 - // (KHTML, like Gecko) JavaFX/8.0 Safari/537.44 - // https://github.com/google/blockly/issues/99 - filter = {}; - } - var bubbleEmboss = Blockly.utils.createSvgElement('g', - filter, this.bubbleGroup_); - this.bubbleArrow_ = Blockly.utils.createSvgElement('path', {}, bubbleEmboss); - this.bubbleBack_ = Blockly.utils.createSvgElement('rect', - {'class': 'blocklyDraggable', 'x': 0, 'y': 0, - 'rx': Blockly.Bubble.BORDER_WIDTH, 'ry': Blockly.Bubble.BORDER_WIDTH}, - bubbleEmboss); - if (hasResize) { - this.resizeGroup_ = Blockly.utils.createSvgElement('g', - {'class': this.workspace_.RTL ? - 'blocklyResizeSW' : 'blocklyResizeSE'}, - this.bubbleGroup_); - var resizeSize = 2 * Blockly.Bubble.BORDER_WIDTH; - Blockly.utils.createSvgElement('polygon', - {'points': '0,x x,x x,0'.replace(/x/g, resizeSize.toString())}, - this.resizeGroup_); - Blockly.utils.createSvgElement('line', - {'class': 'blocklyResizeLine', - 'x1': resizeSize / 3, 'y1': resizeSize - 1, - 'x2': resizeSize - 1, 'y2': resizeSize / 3}, this.resizeGroup_); - Blockly.utils.createSvgElement('line', - {'class': 'blocklyResizeLine', - 'x1': resizeSize * 2 / 3, 'y1': resizeSize - 1, - 'x2': resizeSize - 1, 'y2': resizeSize * 2 / 3}, this.resizeGroup_); - } else { - this.resizeGroup_ = null; - } - this.bubbleGroup_.appendChild(content); - return this.bubbleGroup_; -}; - -/** - * Handle a mouse-down on bubble's border. - * @param {!Event} e Mouse down event. - * @private - */ -Blockly.Bubble.prototype.bubbleMouseDown_ = function(e) { - this.promote_(); - Blockly.Bubble.unbindDragEvents_(); - if (Blockly.utils.isRightButton(e)) { - // No right-click. - e.stopPropagation(); - return; - } else if (Blockly.utils.isTargetInput(e)) { - // When focused on an HTML text input widget, don't trap any events. - return; - } - // Left-click (or middle click) - this.workspace_.startDrag(e, new goog.math.Coordinate( - this.workspace_.RTL ? -this.relativeLeft_ : this.relativeLeft_, - this.relativeTop_)); - - Blockly.Bubble.onMouseUpWrapper_ = Blockly.bindEventWithChecks_(document, - 'mouseup', this, Blockly.Bubble.bubbleMouseUp_); - Blockly.Bubble.onMouseMoveWrapper_ = Blockly.bindEventWithChecks_(document, - 'mousemove', this, this.bubbleMouseMove_); - Blockly.hideChaff(); - // This event has been handled. No need to bubble up to the document. - e.stopPropagation(); -}; - -/** - * Drag this bubble to follow the mouse. - * @param {!Event} e Mouse move event. - * @private - */ -Blockly.Bubble.prototype.bubbleMouseMove_ = function(e) { - this.autoLayout_ = false; - var newXY = this.workspace_.moveDrag(e); - this.relativeLeft_ = this.workspace_.RTL ? -newXY.x : newXY.x; - this.relativeTop_ = newXY.y; - this.positionBubble_(); - this.renderArrow_(); -}; - -/** - * Handle a mouse-down on bubble's resize corner. - * @param {!Event} e Mouse down event. - * @private - */ -Blockly.Bubble.prototype.resizeMouseDown_ = function(e) { - this.promote_(); - Blockly.Bubble.unbindDragEvents_(); - if (Blockly.utils.isRightButton(e)) { - // No right-click. - e.stopPropagation(); - return; - } - // Left-click (or middle click) - this.workspace_.startDrag(e, new goog.math.Coordinate( - this.workspace_.RTL ? -this.width_ : this.width_, this.height_)); - - Blockly.Bubble.onMouseUpWrapper_ = Blockly.bindEventWithChecks_(document, - 'mouseup', this, Blockly.Bubble.bubbleMouseUp_); - Blockly.Bubble.onMouseMoveWrapper_ = Blockly.bindEventWithChecks_(document, - 'mousemove', this, this.resizeMouseMove_); - Blockly.hideChaff(); - // This event has been handled. No need to bubble up to the document. - e.stopPropagation(); -}; - -/** - * Resize this bubble to follow the mouse. - * @param {!Event} e Mouse move event. - * @private - */ -Blockly.Bubble.prototype.resizeMouseMove_ = function(e) { - this.autoLayout_ = false; - var newXY = this.workspace_.moveDrag(e); - this.setBubbleSize(this.workspace_.RTL ? -newXY.x : newXY.x, newXY.y); - if (this.workspace_.RTL) { - // RTL requires the bubble to move its left edge. - this.positionBubble_(); - } -}; - -/** - * Register a function as a callback event for when the bubble is resized. - * @param {!Function} callback The function to call on resize. - */ -Blockly.Bubble.prototype.registerResizeEvent = function(callback) { - this.resizeCallback_ = callback; -}; - -/** - * Move this bubble to the top of the stack. - * @private - */ -Blockly.Bubble.prototype.promote_ = function() { - var svgGroup = this.bubbleGroup_.parentNode; - svgGroup.appendChild(this.bubbleGroup_); -}; - -/** - * Notification that the anchor has moved. - * Update the arrow and bubble accordingly. - * @param {!goog.math.Coordinate} xy Absolute location. - */ -Blockly.Bubble.prototype.setAnchorLocation = function(xy) { - this.anchorXY_ = xy; - if (this.rendered_) { - this.positionBubble_(); - } -}; - -/** - * Position the bubble so that it does not fall off-screen. - * @private - */ -Blockly.Bubble.prototype.layoutBubble_ = function() { - // Compute the preferred bubble location. - var relativeLeft = -this.width_ / 4; - var relativeTop = -this.height_ - Blockly.BlockSvg.MIN_BLOCK_Y; - // Prevent the bubble from being off-screen. - var metrics = this.workspace_.getMetrics(); - metrics.viewWidth /= this.workspace_.scale; - metrics.viewLeft /= this.workspace_.scale; - var anchorX = this.anchorXY_.x; - if (this.workspace_.RTL) { - if (anchorX - metrics.viewLeft - relativeLeft - this.width_ < - Blockly.Scrollbar.scrollbarThickness) { - // Slide the bubble right until it is onscreen. - relativeLeft = anchorX - metrics.viewLeft - this.width_ - - Blockly.Scrollbar.scrollbarThickness; - } else if (anchorX - metrics.viewLeft - relativeLeft > - metrics.viewWidth) { - // Slide the bubble left until it is onscreen. - relativeLeft = anchorX - metrics.viewLeft - metrics.viewWidth; - } - } else { - if (anchorX + relativeLeft < metrics.viewLeft) { - // Slide the bubble right until it is onscreen. - relativeLeft = metrics.viewLeft - anchorX; - } else if (metrics.viewLeft + metrics.viewWidth < - anchorX + relativeLeft + this.width_ + - Blockly.BlockSvg.SEP_SPACE_X + - Blockly.Scrollbar.scrollbarThickness) { - // Slide the bubble left until it is onscreen. - relativeLeft = metrics.viewLeft + metrics.viewWidth - anchorX - - this.width_ - Blockly.Scrollbar.scrollbarThickness; - } - } - if (this.anchorXY_.y + relativeTop < metrics.viewTop) { - // Slide the bubble below the block. - var bBox = /** @type {SVGLocatable} */ (this.shape_).getBBox(); - relativeTop = bBox.height; - } - this.relativeLeft_ = relativeLeft; - this.relativeTop_ = relativeTop; -}; - -/** - * Move the bubble to a location relative to the anchor's centre. - * @private - */ -Blockly.Bubble.prototype.positionBubble_ = function() { - var left = this.anchorXY_.x; - if (this.workspace_.RTL) { - left -= this.relativeLeft_ + this.width_; - } else { - left += this.relativeLeft_; - } - var top = this.relativeTop_ + this.anchorXY_.y; - this.bubbleGroup_.setAttribute('transform', - 'translate(' + left + ',' + top + ')'); -}; - -/** - * Get the dimensions of this bubble. - * @return {!Object} Object with width and height properties. - */ -Blockly.Bubble.prototype.getBubbleSize = function() { - return {width: this.width_, height: this.height_}; -}; - -/** - * Size this bubble. - * @param {number} width Width of the bubble. - * @param {number} height Height of the bubble. - */ -Blockly.Bubble.prototype.setBubbleSize = function(width, height) { - var doubleBorderWidth = 2 * Blockly.Bubble.BORDER_WIDTH; - // Minimum size of a bubble. - width = Math.max(width, doubleBorderWidth + 45); - height = Math.max(height, doubleBorderWidth + 20); - this.width_ = width; - this.height_ = height; - this.bubbleBack_.setAttribute('width', width); - this.bubbleBack_.setAttribute('height', height); - if (this.resizeGroup_) { - if (this.workspace_.RTL) { - // Mirror the resize group. - var resizeSize = 2 * Blockly.Bubble.BORDER_WIDTH; - this.resizeGroup_.setAttribute('transform', 'translate(' + - resizeSize + ',' + (height - doubleBorderWidth) + ') scale(-1 1)'); - } else { - this.resizeGroup_.setAttribute('transform', 'translate(' + - (width - doubleBorderWidth) + ',' + - (height - doubleBorderWidth) + ')'); - } - } - if (this.rendered_) { - if (this.autoLayout_) { - this.layoutBubble_(); - } - this.positionBubble_(); - this.renderArrow_(); - } - // Allow the contents to resize. - if (this.resizeCallback_) { - this.resizeCallback_(); - } -}; - -/** - * Draw the arrow between the bubble and the origin. - * @private - */ -Blockly.Bubble.prototype.renderArrow_ = function() { - var steps = []; - // Find the relative coordinates of the center of the bubble. - var relBubbleX = this.width_ / 2; - var relBubbleY = this.height_ / 2; - // Find the relative coordinates of the center of the anchor. - var relAnchorX = -this.relativeLeft_; - var relAnchorY = -this.relativeTop_; - if (relBubbleX == relAnchorX && relBubbleY == relAnchorY) { - // Null case. Bubble is directly on top of the anchor. - // Short circuit this rather than wade through divide by zeros. - steps.push('M ' + relBubbleX + ',' + relBubbleY); - } else { - // Compute the angle of the arrow's line. - var rise = relAnchorY - relBubbleY; - var run = relAnchorX - relBubbleX; - if (this.workspace_.RTL) { - run *= -1; - } - var hypotenuse = Math.sqrt(rise * rise + run * run); - var angle = Math.acos(run / hypotenuse); - if (rise < 0) { - angle = 2 * Math.PI - angle; - } - // Compute a line perpendicular to the arrow. - var rightAngle = angle + Math.PI / 2; - if (rightAngle > Math.PI * 2) { - rightAngle -= Math.PI * 2; - } - var rightRise = Math.sin(rightAngle); - var rightRun = Math.cos(rightAngle); - - // Calculate the thickness of the base of the arrow. - var bubbleSize = this.getBubbleSize(); - var thickness = (bubbleSize.width + bubbleSize.height) / - Blockly.Bubble.ARROW_THICKNESS; - thickness = Math.min(thickness, bubbleSize.width, bubbleSize.height) / 4; - - // Back the tip of the arrow off of the anchor. - var backoffRatio = 1 - Blockly.Bubble.ANCHOR_RADIUS / hypotenuse; - relAnchorX = relBubbleX + backoffRatio * run; - relAnchorY = relBubbleY + backoffRatio * rise; - - // Coordinates for the base of the arrow. - var baseX1 = relBubbleX + thickness * rightRun; - var baseY1 = relBubbleY + thickness * rightRise; - var baseX2 = relBubbleX - thickness * rightRun; - var baseY2 = relBubbleY - thickness * rightRise; - - // Distortion to curve the arrow. - var swirlAngle = angle + this.arrow_radians_; - if (swirlAngle > Math.PI * 2) { - swirlAngle -= Math.PI * 2; - } - var swirlRise = Math.sin(swirlAngle) * - hypotenuse / Blockly.Bubble.ARROW_BEND; - var swirlRun = Math.cos(swirlAngle) * - hypotenuse / Blockly.Bubble.ARROW_BEND; - - steps.push('M' + baseX1 + ',' + baseY1); - steps.push('C' + (baseX1 + swirlRun) + ',' + (baseY1 + swirlRise) + - ' ' + relAnchorX + ',' + relAnchorY + - ' ' + relAnchorX + ',' + relAnchorY); - steps.push('C' + relAnchorX + ',' + relAnchorY + - ' ' + (baseX2 + swirlRun) + ',' + (baseY2 + swirlRise) + - ' ' + baseX2 + ',' + baseY2); - } - steps.push('z'); - this.bubbleArrow_.setAttribute('d', steps.join(' ')); -}; - -/** - * Change the colour of a bubble. - * @param {string} hexColour Hex code of colour. - */ -Blockly.Bubble.prototype.setColour = function(hexColour) { - this.bubbleBack_.setAttribute('fill', hexColour); - this.bubbleArrow_.setAttribute('fill', hexColour); -}; - -/** - * Dispose of this bubble. - */ -Blockly.Bubble.prototype.dispose = function() { - Blockly.Bubble.unbindDragEvents_(); - // Dispose of and unlink the bubble. - goog.dom.removeNode(this.bubbleGroup_); - this.bubbleGroup_ = null; - this.bubbleArrow_ = null; - this.bubbleBack_ = null; - this.resizeGroup_ = null; - this.workspace_ = null; - this.content_ = null; - this.shape_ = null; -}; diff --git a/core/bubbles.ts b/core/bubbles.ts new file mode 100644 index 00000000000..a49c2ae3581 --- /dev/null +++ b/core/bubbles.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Bubble} from './bubbles/bubble.js'; +import {MiniWorkspaceBubble} from './bubbles/mini_workspace_bubble.js'; +import {TextBubble} from './bubbles/text_bubble.js'; +import {TextInputBubble} from './bubbles/textinput_bubble.js'; + +export {Bubble, MiniWorkspaceBubble, TextBubble, TextInputBubble}; diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts new file mode 100644 index 00000000000..742d300adf1 --- /dev/null +++ b/core/bubbles/bubble.ts @@ -0,0 +1,732 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import * as common from '../common.js'; +import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js'; +import {getFocusManager} from '../focus_manager.js'; +import {IBubble} from '../interfaces/i_bubble.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; +import type {IHasBubble} from '../interfaces/i_has_bubble.js'; +import {ISelectable} from '../interfaces/i_selectable.js'; +import {ContainerRegion} from '../metrics_manager.js'; +import {Scrollbar} from '../scrollbar.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as dom from '../utils/dom.js'; +import * as idGenerator from '../utils/idgenerator.js'; +import * as math from '../utils/math.js'; +import {Rect} from '../utils/rect.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +/** + * The abstract pop-up bubble class. This creates a UI that looks like a speech + * bubble, where it has a "tail" that points to the block, and a "head" that + * displays arbitrary svg elements. + */ +export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { + /** The width of the border around the bubble. */ + static readonly BORDER_WIDTH = 6; + + /** Double the width of the border around the bubble. */ + static readonly DOUBLE_BORDER = this.BORDER_WIDTH * 2; + + /** The minimum size the bubble can have. */ + static readonly MIN_SIZE = this.DOUBLE_BORDER; + + /** + * The thickness of the base of the tail in relation to the size of the + * bubble. Higher numbers result in thinner tails. + */ + static readonly TAIL_THICKNESS = 1; + + /** The number of degrees that the tail bends counter-clockwise. */ + static readonly TAIL_ANGLE = 20; + + /** + * The sharpness of the tail's bend. Higher numbers result in smoother + * tails. + */ + static readonly TAIL_BEND = 4; + + /** Distance between arrow point and anchor point. */ + static readonly ANCHOR_RADIUS = 8; + + public id: string; + + /** The SVG group containing all parts of the bubble. */ + protected svgRoot: SVGGElement; + + /** The SVG path for the arrow from the anchor to the bubble. */ + private tail: SVGPathElement; + + /** The SVG background rect for the main body of the bubble. */ + private background: SVGRectElement; + + /** The SVG group containing the contents of the bubble. */ + protected contentContainer: SVGGElement; + + /** + * The size of the bubble (including background and contents but not tail). + */ + private size = new Size(0, 0); + + /** The colour of the background of the bubble. */ + private colour = '#ffffff'; + + /** True if the bubble has been disposed, false otherwise. */ + public disposed = false; + + /** The position of the top of the bubble relative to its anchor. */ + private relativeTop = 0; + + /** The position of the left of the bubble realtive to its anchor. */ + private relativeLeft = 0; + + private dragStrategy = new BubbleDragStrategy(this, this.workspace); + + private focusableElement: SVGElement | HTMLElement; + + /** + * @param workspace The workspace this bubble belongs to. + * @param anchor The anchor location of the thing this bubble is attached to. + * The tail of the bubble will point to this location. + * @param ownerRect An optional rect we don't want the bubble to overlap with + * when automatically positioning. + * @param overriddenFocusableElement An optional replacement to the focusable + * element that's represented by this bubble (as a focusable node). This + * element will have its ID overwritten. If not provided, the focusable + * element of this node will default to the bubble's SVG root. + * @param owner The object responsible for hosting/spawning this bubble. + */ + constructor( + public readonly workspace: WorkspaceSvg, + protected anchor: Coordinate, + protected ownerRect?: Rect, + overriddenFocusableElement?: SVGElement | HTMLElement, + protected owner?: IHasBubble & IFocusableNode, + ) { + this.id = idGenerator.getNextUniqueId(); + this.svgRoot = dom.createSvgElement( + Svg.G, + {'class': 'blocklyBubble'}, + workspace.getBubbleCanvas(), + ); + const embossGroup = dom.createSvgElement( + Svg.G, + {'class': 'blocklyEmboss'}, + this.svgRoot, + ); + this.tail = dom.createSvgElement( + Svg.PATH, + {'class': 'blocklyBubbleTail'}, + embossGroup, + ); + this.background = dom.createSvgElement( + Svg.RECT, + { + 'class': 'blocklyDraggable', + 'x': 0, + 'y': 0, + 'rx': Bubble.BORDER_WIDTH, + 'ry': Bubble.BORDER_WIDTH, + }, + embossGroup, + ); + this.contentContainer = dom.createSvgElement(Svg.G, {}, this.svgRoot); + + this.focusableElement = overriddenFocusableElement ?? this.svgRoot; + this.focusableElement.setAttribute('id', this.id); + + browserEvents.conditionalBind( + this.background, + 'pointerdown', + this, + this.onMouseDown, + ); + + browserEvents.conditionalBind( + this.focusableElement, + 'keydown', + this, + this.onKeyDown, + ); + } + + /** Dispose of this bubble. */ + dispose() { + dom.removeNode(this.svgRoot); + this.disposed = true; + } + + /** + * Set the location the tail of this bubble points to. + * + * @param anchor The location the tail of this bubble points to. + * @param relayout If true, reposition the bubble from scratch so that it is + * optimally visible. If false, reposition it so it maintains the same + * position relative to the anchor. + */ + setAnchorLocation(anchor: Coordinate, relayout = false) { + this.anchor = anchor; + if (relayout) { + this.positionByRect(this.ownerRect); + } else { + this.positionRelativeToAnchor(); + } + this.renderTail(); + } + + /** Sets the position of this bubble relative to its anchor. */ + setPositionRelativeToAnchor(left: number, top: number) { + this.relativeLeft = left; + this.relativeTop = top; + this.positionRelativeToAnchor(); + this.renderTail(); + } + + /** @returns the size of this bubble. */ + protected getSize() { + return this.size; + } + + /** + * Sets the size of this bubble, including the border. + * + * @param size Sets the size of this bubble, including the border. + * @param relayout If true, reposition the bubble from scratch so that it is + * optimally visible. If false, reposition it so it maintains the same + * position relative to the anchor. + */ + protected setSize(size: Size, relayout = false) { + size.width = Math.max(size.width, Bubble.MIN_SIZE); + size.height = Math.max(size.height, Bubble.MIN_SIZE); + this.size = size; + + this.background.setAttribute('width', `${size.width}`); + this.background.setAttribute('height', `${size.height}`); + + if (relayout) { + this.positionByRect(this.ownerRect); + } else { + this.positionRelativeToAnchor(); + } + this.renderTail(); + } + + /** Returns the colour of the background and tail of this bubble. */ + protected getColour(): string { + return this.colour; + } + + /** Sets the colour of the background and tail of this bubble. */ + public setColour(colour: string) { + this.colour = colour; + this.tail.setAttribute('fill', colour); + this.background.setAttribute('fill', colour); + } + + /** + * Passes the pointer event off to the gesture system and ensures the bubble + * is focused. + */ + private onMouseDown(e: PointerEvent) { + this.workspace.getGesture(e)?.handleBubbleStart(e, this); + getFocusManager().focusNode(this); + } + + /** + * Handles key events when this bubble is focused. By default, closes the + * bubble on Escape. + * + * @param e The keyboard event to handle. + */ + protected onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape' && this.owner) { + this.owner.setBubbleVisible(false); + getFocusManager().focusNode(this.owner); + } + } + + /** Positions the bubble relative to its anchor. Does not render its tail. */ + protected positionRelativeToAnchor() { + let left = this.anchor.x; + if (this.workspace.RTL) { + left -= this.relativeLeft + this.size.width; + } else { + left += this.relativeLeft; + } + const top = this.relativeTop + this.anchor.y; + this.moveTo(left, top); + } + + /** + * Moves the bubble to the given coordinates. + * + * @internal + */ + moveTo(x: number, y: number) { + this.svgRoot.setAttribute('transform', `translate(${x}, ${y})`); + } + + /** + * Positions the bubble "optimally" so that the most of it is visible and + * it does not overlap the rect (if provided). + */ + protected positionByRect(rect = new Rect(0, 0, 0, 0)) { + const viewMetrics = this.workspace.getMetricsManager().getViewMetrics(true); + + const optimalLeft = this.getOptimalRelativeLeft(viewMetrics); + const optimalTop = this.getOptimalRelativeTop(viewMetrics); + + const topPosition = { + x: optimalLeft, + y: (-this.size.height - + this.workspace.getRenderer().getConstants().MIN_BLOCK_HEIGHT) as number, + }; + const startPosition = {x: -this.size.width - 30, y: optimalTop}; + const endPosition = {x: rect.getWidth(), y: optimalTop}; + const bottomPosition = {x: optimalLeft, y: rect.getHeight()}; + + const closerPosition = + rect.getWidth() < rect.getHeight() ? endPosition : bottomPosition; + const fartherPosition = + rect.getWidth() < rect.getHeight() ? bottomPosition : endPosition; + + const topPositionOverlap = this.getOverlap(topPosition, viewMetrics); + const startPositionOverlap = this.getOverlap(startPosition, viewMetrics); + const closerPositionOverlap = this.getOverlap(closerPosition, viewMetrics); + const fartherPositionOverlap = this.getOverlap( + fartherPosition, + viewMetrics, + ); + + // Set the position to whichever position shows the most of the bubble, + // with tiebreaks going in the order: top > start > close > far. + const mostOverlap = Math.max( + topPositionOverlap, + startPositionOverlap, + closerPositionOverlap, + fartherPositionOverlap, + ); + if (topPositionOverlap === mostOverlap) { + this.relativeLeft = topPosition.x; + this.relativeTop = topPosition.y; + this.positionRelativeToAnchor(); + return; + } + if (startPositionOverlap === mostOverlap) { + this.relativeLeft = startPosition.x; + this.relativeTop = startPosition.y; + this.positionRelativeToAnchor(); + return; + } + if (closerPositionOverlap === mostOverlap) { + this.relativeLeft = closerPosition.x; + this.relativeTop = closerPosition.y; + this.positionRelativeToAnchor(); + return; + } + // TODO: I believe relativeLeft_ should actually be called relativeStart_ + // and then the math should be fixed to reflect this. (hopefully it'll + // make it look simpler) + this.relativeLeft = fartherPosition.x; + this.relativeTop = fartherPosition.y; + this.positionRelativeToAnchor(); + } + + /** + * Calculate the what percentage of the bubble overlaps with the visible + * workspace (what percentage of the bubble is visible). + * + * @param relativeMin The position of the top-left corner of the bubble + * relative to the anchor point. + * @param viewMetrics The view metrics of the workspace the bubble will appear + * in. + * @returns The percentage of the bubble that is visible. + */ + private getOverlap( + relativeMin: {x: number; y: number}, + viewMetrics: ContainerRegion, + ): number { + // The position of the top-left corner of the bubble in workspace units. + const bubbleMin = { + x: this.workspace.RTL + ? this.anchor.x - relativeMin.x - this.size.width + : relativeMin.x + this.anchor.x, + y: relativeMin.y + this.anchor.y, + }; + // The position of the bottom-right corner of the bubble in workspace units. + const bubbleMax = { + x: bubbleMin.x + this.size.width, + y: bubbleMin.y + this.size.height, + }; + + // We could adjust these values to account for the scrollbars, but the + // bubbles should have been adjusted to not collide with them anyway, so + // giving the workspace a slightly larger "bounding box" shouldn't affect + // the calculation. + + // The position of the top-left corner of the workspace. + const workspaceMin = {x: viewMetrics.left, y: viewMetrics.top}; + // The position of the bottom-right corner of the workspace. + const workspaceMax = { + x: viewMetrics.left + viewMetrics.width, + y: viewMetrics.top + viewMetrics.height, + }; + + const overlapWidth = + Math.min(bubbleMax.x, workspaceMax.x) - + Math.max(bubbleMin.x, workspaceMin.x); + const overlapHeight = + Math.min(bubbleMax.y, workspaceMax.y) - + Math.max(bubbleMin.y, workspaceMin.y); + return Math.max( + 0, + Math.min( + 1, + (overlapWidth * overlapHeight) / (this.size.width * this.size.height), + ), + ); + } + + /** + * Calculate what the optimal horizontal position of the top-left corner of + * the bubble is (relative to the anchor point) so that the most area of the + * bubble is shown. + * + * @param viewMetrics The view metrics of the workspace the bubble will appear + * in. + * @returns The optimal horizontal position of the top-left corner of the + * bubble. + */ + private getOptimalRelativeLeft(viewMetrics: ContainerRegion): number { + // By default, show the bubble just a bit to the left of the anchor. + let relativeLeft = -this.size.width / 4; + + // No amount of sliding left or right will give us better overlap. + if (this.size.width > viewMetrics.width) return relativeLeft; + + const workspaceRect = this.getWorkspaceViewRect(viewMetrics); + + if (this.workspace.RTL) { + // Bubble coordinates are flipped in RTL. + const bubbleRight = this.anchor.x - relativeLeft; + const bubbleLeft = bubbleRight - this.size.width; + + if (bubbleLeft < workspaceRect.left) { + // Slide the bubble right until it is onscreen. + relativeLeft = -(workspaceRect.left - this.anchor.x + this.size.width); + } else if (bubbleRight > workspaceRect.right) { + // Slide the bubble left until it is onscreen. + relativeLeft = -(workspaceRect.right - this.anchor.x); + } + } else { + const bubbleLeft = relativeLeft + this.anchor.x; + const bubbleRight = bubbleLeft + this.size.width; + + if (bubbleLeft < workspaceRect.left) { + // Slide the bubble right until it is onscreen. + relativeLeft = workspaceRect.left - this.anchor.x; + } else if (bubbleRight > workspaceRect.right) { + // Slide the bubble left until it is onscreen. + relativeLeft = workspaceRect.right - this.anchor.x - this.size.width; + } + } + + return relativeLeft; + } + + /** + * Calculate what the optimal vertical position of the top-left corner of + * the bubble is (relative to the anchor point) so that the most area of the + * bubble is shown. + * + * @param viewMetrics The view metrics of the workspace the bubble will appear + * in. + * @returns The optimal vertical position of the top-left corner of the + * bubble. + */ + private getOptimalRelativeTop(viewMetrics: ContainerRegion): number { + // By default, show the bubble just a bit higher than the anchor. + let relativeTop = -this.size.height / 4; + + // No amount of sliding up or down will give us better overlap. + if (this.size.height > viewMetrics.height) return relativeTop; + + const top = this.anchor.y + relativeTop; + const bottom = top + this.size.height; + const workspaceRect = this.getWorkspaceViewRect(viewMetrics); + + if (top < workspaceRect.top) { + // Slide the bubble down until it is onscreen. + relativeTop = workspaceRect.top - this.anchor.y; + } else if (bottom > workspaceRect.bottom) { + // Slide the bubble up until it is onscreen. + relativeTop = workspaceRect.bottom - this.anchor.y - this.size.height; + } + + return relativeTop; + } + + /** + * @returns a rect defining the bounds of the workspace's view in workspace + * coordinates. + */ + private getWorkspaceViewRect(viewMetrics: ContainerRegion): Rect { + const top = viewMetrics.top; + let bottom = viewMetrics.top + viewMetrics.height; + let left = viewMetrics.left; + let right = viewMetrics.left + viewMetrics.width; + + bottom -= this.getScrollbarThickness(); + if (this.workspace.RTL) { + left -= this.getScrollbarThickness(); + } else { + right -= this.getScrollbarThickness(); + } + + return new Rect(top, bottom, left, right); + } + + /** @returns the scrollbar thickness in workspace units. */ + private getScrollbarThickness() { + return Scrollbar.scrollbarThickness / this.workspace.scale; + } + + /** Draws the tail of the bubble. */ + private renderTail() { + const steps = []; + // Find the relative coordinates of the center of the bubble. + const relBubbleX = this.size.width / 2; + const relBubbleY = this.size.height / 2; + // Find the relative coordinates of the center of the anchor. + let relAnchorX = -this.relativeLeft; + let relAnchorY = -this.relativeTop; + if (relBubbleX === relAnchorX && relBubbleY === relAnchorY) { + // Null case. Bubble is directly on top of the anchor. + // Short circuit this rather than wade through divide by zeros. + steps.push('M ' + relBubbleX + ',' + relBubbleY); + } else { + // Compute the angle of the tail's line. + const rise = relAnchorY - relBubbleY; + let run = relAnchorX - relBubbleX; + if (this.workspace.RTL) { + run *= -1; + } + const hypotenuse = Math.sqrt(rise * rise + run * run); + let angle = Math.acos(run / hypotenuse); + if (rise < 0) { + angle = 2 * Math.PI - angle; + } + // Compute a line perpendicular to the tail. + let rightAngle = angle + Math.PI / 2; + if (rightAngle > Math.PI * 2) { + rightAngle -= Math.PI * 2; + } + const rightRise = Math.sin(rightAngle); + const rightRun = Math.cos(rightAngle); + + // Calculate the thickness of the base of the tail. + let thickness = + (this.size.width + this.size.height) / Bubble.TAIL_THICKNESS; + thickness = Math.min(thickness, this.size.width, this.size.height) / 4; + + // Back the tip of the tail off of the anchor. + const backoffRatio = 1 - Bubble.ANCHOR_RADIUS / hypotenuse; + relAnchorX = relBubbleX + backoffRatio * run; + relAnchorY = relBubbleY + backoffRatio * rise; + + // Coordinates for the base of the tail. + const baseX1 = relBubbleX + thickness * rightRun; + const baseY1 = relBubbleY + thickness * rightRise; + const baseX2 = relBubbleX - thickness * rightRun; + const baseY2 = relBubbleY - thickness * rightRise; + + // Distortion to curve the tail. + const radians = math.toRadians( + this.workspace.RTL ? -Bubble.TAIL_ANGLE : Bubble.TAIL_ANGLE, + ); + let swirlAngle = angle + radians; + if (swirlAngle > Math.PI * 2) { + swirlAngle -= Math.PI * 2; + } + const swirlRise = (Math.sin(swirlAngle) * hypotenuse) / Bubble.TAIL_BEND; + const swirlRun = (Math.cos(swirlAngle) * hypotenuse) / Bubble.TAIL_BEND; + + steps.push('M' + baseX1 + ',' + baseY1); + steps.push( + 'C' + + (baseX1 + swirlRun) + + ',' + + (baseY1 + swirlRise) + + ' ' + + relAnchorX + + ',' + + relAnchorY + + ' ' + + relAnchorX + + ',' + + relAnchorY, + ); + steps.push( + 'C' + + relAnchorX + + ',' + + relAnchorY + + ' ' + + (baseX2 + swirlRun) + + ',' + + (baseY2 + swirlRise) + + ' ' + + baseX2 + + ',' + + baseY2, + ); + } + steps.push('z'); + this.tail?.setAttribute('d', steps.join(' ')); + } + /** + * Move this bubble to the front of the visible workspace. + * + * @returns Whether or not the bubble has been moved. + * @internal + */ + bringToFront(): boolean { + const svgGroup = this.svgRoot?.parentNode; + if (this.svgRoot && svgGroup?.lastChild !== this.svgRoot) { + svgGroup?.appendChild(this.svgRoot); + return true; + } + return false; + } + + /** @internal */ + getRelativeToSurfaceXY(): Coordinate { + return new Coordinate( + this.workspace.RTL + ? -this.relativeLeft + this.anchor.x - this.size.width + : this.anchor.x + this.relativeLeft, + this.anchor.y + this.relativeTop, + ); + } + + /** @internal */ + getSvgRoot(): SVGElement { + return this.svgRoot; + } + + /** + * Move this bubble during a drag. + * + * @param newLoc The location to translate to, in workspace coordinates. + * @internal + */ + moveDuringDrag(newLoc: Coordinate) { + this.moveTo(newLoc.x, newLoc.y); + if (this.workspace.RTL) { + this.relativeLeft = this.anchor.x - newLoc.x - this.size.width; + } else { + this.relativeLeft = newLoc.x - this.anchor.x; + } + this.relativeTop = newLoc.y - this.anchor.y; + this.renderTail(); + } + + setDragging(_start: boolean) { + // NOOP in base class. + } + + /** @internal */ + setDeleteStyle(_enable: boolean) { + // NOOP in base class. + } + + /** @internal */ + isDeletable(): boolean { + return false; + } + + /** @internal */ + showContextMenu(_e: Event) { + // NOOP in base class. + } + + /** Returns whether this bubble is movable or not. */ + isMovable(): boolean { + return true; + } + + /** Starts a drag on the bubble. */ + startDrag(): void { + this.dragStrategy.startDrag(); + } + + /** Drags the bubble to the given location. */ + drag(newLoc: Coordinate): void { + this.dragStrategy.drag(newLoc); + } + + /** Ends the drag on the bubble. */ + endDrag(): void { + this.dragStrategy.endDrag(); + } + + /** Moves the bubble back to where it was at the start of a drag. */ + revertDrag(): void { + this.dragStrategy.revertDrag(); + } + + select(): void { + // Bubbles don't have any visual for being selected. + common.fireSelectedEvent(this); + } + + unselect(): void { + // Bubbles don't have any visual for being selected. + common.fireSelectedEvent(null); + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + return this.focusableElement; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.workspace; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + this.select(); + this.bringToFront(); + const xy = this.getRelativeToSurfaceXY(); + const size = this.getSize(); + const bounds = new Rect(xy.y, xy.y + size.height, xy.x, xy.x + size.width); + this.workspace.scrollBoundsIntoView(bounds); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void { + this.unselect(); + } + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } + + /** + * Returns the object that owns/hosts this bubble, if any. + */ + getOwner(): (IHasBubble & IFocusableNode) | undefined { + return this.owner; + } +} diff --git a/core/bubbles/mini_workspace_bubble.ts b/core/bubbles/mini_workspace_bubble.ts new file mode 100644 index 00000000000..194cb41f35d --- /dev/null +++ b/core/bubbles/mini_workspace_bubble.ts @@ -0,0 +1,292 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlocklyOptions} from '../blockly_options.js'; +import {Abstract as AbstractEvent} from '../events/events_abstract.js'; +import {Options} from '../options.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as dom from '../utils/dom.js'; +import type {Rect} from '../utils/rect.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {Bubble} from './bubble.js'; + +/** + * A bubble that contains a mini-workspace which can hold arbitrary blocks. + * Used by the mutator icon. + */ +export class MiniWorkspaceBubble extends Bubble { + /** + * The minimum amount of change to the mini workspace view to trigger + * resizing the bubble. + */ + private static readonly MINIMUM_VIEW_CHANGE = 10; + + /** + * An arbitrary margin of whitespace to put around the blocks in the + * workspace. + */ + private static readonly MARGIN = Bubble.DOUBLE_BORDER * 3; + + /** The root svg element containing the workspace. */ + private svgDialog: SVGElement; + + /** The workspace that gets shown within this bubble. */ + private miniWorkspace: WorkspaceSvg; + + /** + * Should this bubble automatically reposition itself when it resizes? + * Becomes false after this bubble is first dragged. + */ + private autoLayout = true; + + /** @internal */ + constructor( + workspaceOptions: BlocklyOptions, + public readonly workspace: WorkspaceSvg, + protected anchor: Coordinate, + protected ownerRect?: Rect, + ) { + super(workspace, anchor, ownerRect); + const options = new Options(workspaceOptions); + this.validateWorkspaceOptions(options); + + this.svgDialog = dom.createSvgElement( + Svg.SVG, + { + 'x': Bubble.BORDER_WIDTH, + 'y': Bubble.BORDER_WIDTH, + }, + this.contentContainer, + ); + workspaceOptions.parentWorkspace = this.workspace; + this.miniWorkspace = this.newWorkspaceSvg(new Options(workspaceOptions)); + // TODO (#7422): Change this to `internalIsMiniWorkspace` or something. Not + // all mini workspaces are necessarily mutators. + this.miniWorkspace.internalIsMutator = true; + const background = this.miniWorkspace.createDom('blocklyMutatorBackground'); + this.svgDialog.appendChild(background); + if (options.languageTree) { + background.insertBefore( + this.miniWorkspace.addFlyout(Svg.G), + this.miniWorkspace.getCanvas(), + ); + const flyout = this.miniWorkspace.getFlyout(); + flyout?.init(this.miniWorkspace); + flyout?.show(options.languageTree); + } + + dom.addClass(this.svgRoot, 'blocklyMiniWorkspaceBubble'); + this.miniWorkspace.addChangeListener(this.onWorkspaceChange.bind(this)); + this.miniWorkspace + .getFlyout() + ?.getWorkspace() + ?.addChangeListener(this.onWorkspaceChange.bind(this)); + this.updateBubbleSize(); + } + + dispose() { + this.miniWorkspace.dispose(); + super.dispose(); + } + + /** @internal */ + getWorkspace(): WorkspaceSvg { + return this.miniWorkspace; + } + + /** Adds a change listener to the mini workspace. */ + addWorkspaceChangeListener(listener: (e: AbstractEvent) => void) { + this.miniWorkspace.addChangeListener(listener); + } + + /** + * Validates the workspace options to make sure folks aren't trying to + * enable things the miniworkspace doesn't support. + */ + private validateWorkspaceOptions(options: Options) { + if (options.hasCategories) { + throw new Error( + 'The miniworkspace bubble does not support toolboxes with categories', + ); + } + if (options.hasTrashcan) { + throw new Error('The miniworkspace bubble does not support trashcans'); + } + if ( + options.zoomOptions.controls || + options.zoomOptions.wheel || + options.zoomOptions.pinch + ) { + throw new Error('The miniworkspace bubble does not support zooming'); + } + if ( + options.moveOptions.scrollbars || + options.moveOptions.wheel || + options.moveOptions.drag + ) { + throw new Error( + 'The miniworkspace bubble does not scrolling/moving the workspace', + ); + } + if (options.horizontalLayout) { + throw new Error( + 'The miniworkspace bubble does not support horizontal layouts', + ); + } + } + + private onWorkspaceChange() { + this.bumpBlocksIntoBounds(); + this.updateBubbleSize(); + } + + /** + * Bumps blocks that are above the top or outside the start-side of the + * workspace back within the workspace. + * + * Blocks that are below the bottom or outside the end-side of the workspace + * are dealt with by resizing the workspace to show them. + */ + private bumpBlocksIntoBounds() { + if ( + this.miniWorkspace.isDragging() && + !this.miniWorkspace.keyboardMoveInProgress + ) + return; + + const MARGIN = 20; + + for (const block of this.miniWorkspace.getTopBlocks(false)) { + const blockXY = block.getRelativeToSurfaceXY(); + + // Bump any block that's above the top back inside. + if (blockXY.y < MARGIN) { + block.moveBy(0, MARGIN - blockXY.y); + } + // Bump any block overlapping the flyout back inside. + if (block.RTL) { + let right = -MARGIN; + const flyout = this.miniWorkspace.getFlyout(); + if (flyout) { + right -= flyout.getWidth(); + } + if (blockXY.x > right) { + block.moveBy(right - blockXY.x, 0); + } + } else if (blockXY.x < MARGIN) { + block.moveBy(MARGIN - blockXY.x, 0); + } + } + } + + /** + * Updates the size of this bubble to account for the size of the + * mini workspace. + */ + private updateBubbleSize() { + if ( + this.miniWorkspace.isDragging() && + !this.miniWorkspace.keyboardMoveInProgress + ) + return; + + // Disable autolayout if a keyboard move is in progress to prevent the + // mutator bubble from jumping around. + this.autoLayout &&= !this.miniWorkspace.keyboardMoveInProgress; + + const currSize = this.getSize(); + const newSize = this.calculateWorkspaceSize(); + if ( + Math.abs(currSize.width - newSize.width) < + MiniWorkspaceBubble.MINIMUM_VIEW_CHANGE && + Math.abs(currSize.height - newSize.height) < + MiniWorkspaceBubble.MINIMUM_VIEW_CHANGE + ) { + // Only resize if the size has noticeably changed. + return; + } + this.svgDialog.setAttribute('width', `${newSize.width}px`); + this.svgDialog.setAttribute('height', `${newSize.height}px`); + this.miniWorkspace.setCachedParentSvgSize(newSize.width, newSize.height); + if (this.miniWorkspace.RTL) { + // Scroll the workspace to always left-align. + this.miniWorkspace + .getCanvas() + .setAttribute('transform', `translate(${newSize.width}, 0)`); + } + this.setSize( + new Size( + newSize.width + Bubble.DOUBLE_BORDER, + newSize.height + Bubble.DOUBLE_BORDER, + ), + this.autoLayout, + ); + this.miniWorkspace.resize(); + this.miniWorkspace.recordDragTargets(); + } + + /** + * Calculates the size of the mini workspace for use in resizing the bubble. + */ + private calculateWorkspaceSize(): Size { + const workspaceSize = this.miniWorkspace.getCanvas().getBBox(); + let width = workspaceSize.width + MiniWorkspaceBubble.MARGIN; + let height = workspaceSize.height + MiniWorkspaceBubble.MARGIN; + + const flyout = this.miniWorkspace.getFlyout(); + if (flyout) { + const flyoutScrollMetrics = flyout + .getWorkspace() + .getMetricsManager() + .getScrollMetrics(); + height = Math.max(height, flyoutScrollMetrics.height + 20); + width += flyout.getWidth(); + } + return new Size(width, height); + } + + /** Reapplies styles to all of the blocks in the mini workspace. */ + updateBlockStyles() { + for (const block of this.miniWorkspace.getAllBlocks(false)) { + block.setStyle(block.getStyleName()); + } + + const flyoutWs = this.miniWorkspace.getFlyout()?.getWorkspace(); + if (flyoutWs) { + for (const block of flyoutWs.getAllBlocks(false)) { + block.setStyle(block.getStyleName()); + } + } + } + + /** + * Move this bubble during a drag. + * + * @param newLoc The location to translate to, in workspace coordinates. + * @internal + */ + moveDuringDrag(newLoc: Coordinate): void { + super.moveDuringDrag(newLoc); + this.autoLayout = false; + } + + /** @internal */ + moveTo(x: number, y: number): void { + super.moveTo(x, y); + this.miniWorkspace.recordDragTargets(); + } + + /** @internal */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + newWorkspaceSvg(options: Options): WorkspaceSvg { + throw new Error( + 'The implementation of newWorkspaceSvg should be ' + + 'monkey-patched in by blockly.ts', + ); + } +} diff --git a/core/bubbles/text_bubble.ts b/core/bubbles/text_bubble.ts new file mode 100644 index 00000000000..99299fa50e8 --- /dev/null +++ b/core/bubbles/text_bubble.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Coordinate} from '../utils/coordinate.js'; +import * as dom from '../utils/dom.js'; +import {Rect} from '../utils/rect.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; +import {Bubble} from './bubble.js'; + +/** + * A bubble that displays non-editable text. Used by the warning icon. + */ +export class TextBubble extends Bubble { + private paragraph: SVGGElement; + + constructor( + private text: string, + public readonly workspace: WorkspaceSvg, + protected anchor: Coordinate, + protected ownerRect?: Rect, + ) { + super(workspace, anchor, ownerRect); + this.paragraph = this.stringToSvg(text, this.contentContainer); + this.updateBubbleSize(); + dom.addClass(this.svgRoot, 'blocklyTextBubble'); + } + + /** @returns the current text of this text bubble. */ + getText(): string { + return this.text; + } + + /** Sets the current text of this text bubble, and updates the display. */ + setText(text: string) { + this.text = text; + dom.removeNode(this.paragraph); + this.paragraph = this.stringToSvg(text, this.contentContainer); + this.updateBubbleSize(); + } + + /** + * Converts the given string into an svg containing that string, + * broken up by newlines. + */ + private stringToSvg(text: string, container: SVGGElement) { + const paragraph = this.createParagraph(container); + const fragments = this.createTextFragments(paragraph, text); + if (this.workspace.RTL) + this.rightAlignTextFragments(paragraph.getBBox().width, fragments); + return paragraph; + } + + /** Creates the paragraph container for this bubble's view's text fragments. */ + private createParagraph(container: SVGGElement): SVGGElement { + return dom.createSvgElement( + Svg.G, + { + 'class': 'blocklyText blocklyBubbleText blocklyNoPointerEvents', + 'transform': `translate(0,${Bubble.BORDER_WIDTH})`, + 'style': `direction: ${this.workspace.RTL ? 'rtl' : 'ltr'}`, + }, + container, + ); + } + + /** Creates the text fragments visualizing the text of this bubble. */ + private createTextFragments( + parent: SVGGElement, + text: string, + ): SVGTextElement[] { + let lineNum = 1; + return text.split('\n').map((line) => { + const fragment = dom.createSvgElement( + Svg.TEXT, + {'y': `${lineNum}em`, 'x': Bubble.BORDER_WIDTH}, + parent, + ); + const textNode = document.createTextNode(line); + fragment.appendChild(textNode); + lineNum += 1; + return fragment; + }); + } + + /** Right aligns the given text fragments. */ + private rightAlignTextFragments( + maxWidth: number, + fragments: SVGTextElement[], + ) { + for (const text of fragments) { + text.setAttribute('text-anchor', 'start'); + text.setAttribute('x', `${maxWidth + Bubble.BORDER_WIDTH}`); + } + } + + /** Updates the size of this bubble to account for the size of the text. */ + private updateBubbleSize() { + const bbox = this.paragraph.getBBox(); + this.setSize( + new Size( + bbox.width + Bubble.BORDER_WIDTH * 2, + bbox.height + Bubble.BORDER_WIDTH * 2, + ), + true, + ); + } +} diff --git a/core/bubbles/textinput_bubble.ts b/core/bubbles/textinput_bubble.ts new file mode 100644 index 00000000000..0bad5fabce6 --- /dev/null +++ b/core/bubbles/textinput_bubble.ts @@ -0,0 +1,296 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CommentEditor} from '../comments/comment_editor.js'; +import * as Css from '../css.js'; +import {getFocusManager} from '../focus_manager.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {IHasBubble} from '../interfaces/i_has_bubble.js'; +import * as touch from '../touch.js'; +import {browserEvents} from '../utils.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as dom from '../utils/dom.js'; +import * as drag from '../utils/drag.js'; +import {Rect} from '../utils/rect.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; +import {Bubble} from './bubble.js'; + +/** + * A bubble that displays editable text. It can also be resized by the user. + * Used by the comment icon. + */ +export class TextInputBubble extends Bubble { + /** The group containing the lines indicating the bubble is resizable. */ + private resizeGroup: SVGGElement; + + /** + * Event data associated with the listener for pointer up events on the + * resize group. + */ + private resizePointerUpListener: browserEvents.Data | null = null; + + /** + * Event data associated with the listener for pointer move events on the + * resize group. + */ + private resizePointerMoveListener: browserEvents.Data | null = null; + + /** Functions listening for changes to the size of this bubble. */ + private sizeChangeListeners: (() => void)[] = []; + + /** Functions listening for changes to the location of this bubble. */ + private locationChangeListeners: (() => void)[] = []; + + /** The default size of this bubble, including borders. */ + private readonly DEFAULT_SIZE = new Size( + 160 + Bubble.DOUBLE_BORDER, + 80 + Bubble.DOUBLE_BORDER, + ); + + /** The minimum size of this bubble, including borders. */ + private readonly MIN_SIZE = new Size( + 45 + Bubble.DOUBLE_BORDER, + 20 + Bubble.DOUBLE_BORDER, + ); + + private editable = true; + + /** View responsible for supporting text editing. */ + private editor: CommentEditor; + + /** + * @param workspace The workspace this bubble belongs to. + * @param anchor The anchor location of the thing this bubble is attached to. + * The tail of the bubble will point to this location. + * @param ownerRect An optional rect we don't want the bubble to overlap with + * when automatically positioning. + * @param owner The object that owns/hosts this bubble. + */ + constructor( + public readonly workspace: WorkspaceSvg, + protected anchor: Coordinate, + protected ownerRect?: Rect, + protected owner?: IHasBubble & IFocusableNode, + ) { + super(workspace, anchor, ownerRect, undefined, owner); + dom.addClass(this.svgRoot, 'blocklyTextInputBubble'); + this.editor = new CommentEditor(workspace, this.id, () => { + getFocusManager().focusNode(this); + }); + this.contentContainer.appendChild(this.editor.getDom()); + this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace); + this.setSize(this.DEFAULT_SIZE, true); + } + + /** @returns the text of this bubble. */ + getText(): string { + return this.editor.getText(); + } + + /** Sets the text of this bubble. Calls change listeners. */ + setText(text: string) { + this.editor.setText(text); + } + + /** Sets whether or not the text in the bubble is editable. */ + setEditable(editable: boolean) { + this.editable = editable; + this.editor.setEditable(editable); + } + + /** Returns whether or not the text in the bubble is editable. */ + isEditable(): boolean { + return this.editable; + } + + /** Adds a change listener to be notified when this bubble's text changes. */ + addTextChangeListener(listener: () => void) { + this.editor.addTextChangeListener(listener); + } + + /** Adds a change listener to be notified when this bubble's size changes. */ + addSizeChangeListener(listener: () => void) { + this.sizeChangeListeners.push(listener); + } + + /** Adds a change listener to be notified when this bubble's location changes. */ + addLocationChangeListener(listener: () => void) { + this.locationChangeListeners.push(listener); + } + + /** Creates the resize handler elements and binds events to them. */ + private createResizeHandle( + container: SVGGElement, + workspace: WorkspaceSvg, + ): SVGGElement { + const resizeHandle = dom.createSvgElement( + Svg.IMAGE, + { + 'class': 'blocklyResizeHandle', + 'href': `${workspace.options.pathToMedia}resize-handle.svg`, + }, + container, + ); + + browserEvents.conditionalBind( + resizeHandle, + 'pointerdown', + this, + this.onResizePointerDown, + ); + + return resizeHandle; + } + + /** + * Sets the size of this bubble, including the border. + * + * @param size Sets the size of this bubble, including the border. + * @param relayout If true, reposition the bubble from scratch so that it is + * optimally visible. If false, reposition it so it maintains the same + * position relative to the anchor. + */ + setSize(size: Size, relayout = false) { + size.width = Math.max(size.width, this.MIN_SIZE.width); + size.height = Math.max(size.height, this.MIN_SIZE.height); + + const widthMinusBorder = size.width - Bubble.DOUBLE_BORDER; + const heightMinusBorder = size.height - Bubble.DOUBLE_BORDER; + this.editor.updateSize( + new Size(widthMinusBorder, heightMinusBorder), + new Size(0, 0), + ); + this.editor.getDom().setAttribute('x', `${Bubble.DOUBLE_BORDER / 2}`); + this.editor.getDom().setAttribute('y', `${Bubble.DOUBLE_BORDER / 2}`); + + this.resizeGroup.setAttribute('y', `${heightMinusBorder}`); + if (this.workspace.RTL) { + this.resizeGroup.setAttribute('x', `${-Bubble.DOUBLE_BORDER}`); + } else { + this.resizeGroup.setAttribute('x', `${widthMinusBorder}`); + } + + super.setSize(size, relayout); + this.onSizeChange(); + } + + /** @returns the size of this bubble. */ + getSize(): Size { + // Overridden to be public. + return super.getSize(); + } + + override moveDuringDrag(newLoc: Coordinate) { + super.moveDuringDrag(newLoc); + this.onLocationChange(); + } + + override setPositionRelativeToAnchor(left: number, top: number) { + super.setPositionRelativeToAnchor(left, top); + this.onLocationChange(); + } + + protected override positionByRect(rect = new Rect(0, 0, 0, 0)) { + super.positionByRect(rect); + this.onLocationChange(); + } + + /** Handles mouse down events on the resize target. */ + private onResizePointerDown(e: PointerEvent) { + this.bringToFront(); + if (browserEvents.isRightButton(e)) { + e.stopPropagation(); + return; + } + + drag.start( + this.workspace, + e, + new Coordinate( + this.workspace.RTL ? -this.getSize().width : this.getSize().width, + this.getSize().height, + ), + ); + + this.resizePointerUpListener = browserEvents.conditionalBind( + document, + 'pointerup', + this, + this.onResizePointerUp, + ); + this.resizePointerMoveListener = browserEvents.conditionalBind( + document, + 'pointermove', + this, + this.onResizePointerMove, + ); + this.workspace.hideChaff(); + // This event has been handled. No need to bubble up to the document. + e.stopPropagation(); + } + + /** Handles pointer up events on the resize target. */ + private onResizePointerUp(_e: PointerEvent) { + touch.clearTouchIdentifier(); + if (this.resizePointerUpListener) { + browserEvents.unbind(this.resizePointerUpListener); + this.resizePointerUpListener = null; + } + if (this.resizePointerMoveListener) { + browserEvents.unbind(this.resizePointerMoveListener); + this.resizePointerMoveListener = null; + } + } + + /** Handles pointer move events on the resize target. */ + private onResizePointerMove(e: PointerEvent) { + const delta = drag.move(this.workspace, e); + this.setSize( + new Size(this.workspace.RTL ? -delta.x : delta.x, delta.y), + false, + ); + this.onSizeChange(); + } + + /** Handles a size change event for the text area. Calls event listeners. */ + private onSizeChange() { + for (const listener of this.sizeChangeListeners) { + listener(); + } + } + + /** Handles a location change event for the text area. Calls event listeners. */ + private onLocationChange() { + for (const listener of this.locationChangeListeners) { + listener(); + } + } + + /** + * Returns the text editor component of this bubble. + * + * @internal + */ + getEditor() { + return this.editor; + } +} + +Css.register(` +.blocklyTextInputBubble .blocklyTextarea { + background-color: var(--commentFillColour); + border: 0; + box-sizing: border-box; + display: block; + outline: 0; + padding: 5px; + resize: none; + width: 100%; + height: 100%; +} +`); diff --git a/core/bump_objects.ts b/core/bump_objects.ts new file mode 100644 index 00000000000..2aae257dde3 --- /dev/null +++ b/core/bump_objects.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.bumpObjects + +import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; +import type {Abstract} from './events/events_abstract.js'; +import type {BlockCreate} from './events/events_block_create.js'; +import type {BlockMove} from './events/events_block_move.js'; +import type {CommentCreate} from './events/events_comment_create.js'; +import type {CommentMove} from './events/events_comment_move.js'; +import type {CommentResize} from './events/events_comment_resize.js'; +import {isViewportChange} from './events/predicates.js'; +import {BUMP_EVENTS, EventType} from './events/type.js'; +import * as eventUtils from './events/utils.js'; +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import type {ContainerRegion} from './metrics_manager.js'; +import * as mathUtils from './utils/math.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +/** + * Bumps the given object that has passed out of bounds. + * + * @param workspace The workspace containing the object. + * @param bounds The region to bump an object into. For example, pass + * ScrollMetrics to bump a block into the scrollable region of the + * workspace, or pass ViewMetrics to bump a block into the visible region of + * the workspace. This should be specified in workspace coordinates. + * @param object The object to bump. + * @returns True if object was bumped. + */ +function bumpObjectIntoBounds( + workspace: WorkspaceSvg, + bounds: ContainerRegion, + object: IBoundedElement, +): boolean { + // Compute new top/left position for object. + const objectMetrics = object.getBoundingRectangle(); + const height = objectMetrics.bottom - objectMetrics.top; + const width = objectMetrics.right - objectMetrics.left; + + const topClamp = bounds.top; + const boundsBottom = bounds.top + bounds.height; + const bottomClamp = boundsBottom - height; + // If the object is taller than the workspace we want to + // top-align the block + const newYPosition = mathUtils.clamp( + topClamp, + objectMetrics.top, + bottomClamp, + ); + const deltaY = newYPosition - objectMetrics.top; + + // Note: Even in RTL mode the "anchor" of the object is the + // top-left corner of the object. + let leftClamp = bounds.left; + const boundsRight = bounds.left + bounds.width; + let rightClamp = boundsRight - width; + if (workspace.RTL) { + // If the object is wider than the workspace and we're in RTL + // mode we want to right-align the block, which means setting + // the left clamp to match. + leftClamp = Math.min(rightClamp, leftClamp); + } else { + // If the object is wider than the workspace and we're in LTR + // mode we want to left-align the block, which means setting + // the right clamp to match. + rightClamp = Math.max(leftClamp, rightClamp); + } + const newXPosition = mathUtils.clamp( + leftClamp, + objectMetrics.left, + rightClamp, + ); + const deltaX = newXPosition - objectMetrics.left; + + if (deltaX || deltaY) { + object.moveBy(deltaX, deltaY, ['inbounds']); + return true; + } + return false; +} +export const bumpIntoBounds = bumpObjectIntoBounds; + +/** + * Creates a handler for bumping objects when they cross fixed bounds. + * + * @param workspace The workspace to handle. + * @returns The event handler. + */ +export function bumpIntoBoundsHandler( + workspace: WorkspaceSvg, +): (p1: Abstract) => void { + return (e) => { + const metricsManager = workspace.getMetricsManager(); + if (!metricsManager.hasFixedEdges() || workspace.isDragging()) { + return; + } + + if (BUMP_EVENTS.includes(e.type ?? '')) { + const scrollMetricsInWsCoords = metricsManager.getScrollMetrics(true); + + // Triggered by move/create event + const object = extractObjectFromEvent( + workspace, + e as eventUtils.BumpEvent, + ); + if (!object) { + return; + } + // Handle undo. + const existingGroup = eventUtils.getGroup() || false; + eventUtils.setGroup(e.group); + + const wasBumped = bumpObjectIntoBounds( + workspace, + scrollMetricsInWsCoords, + object as IBoundedElement, + ); + + if (wasBumped && !e.group) { + console.warn( + 'Moved object in bounds but there was no' + + ' event group. This may break undo.', + ); + } + eventUtils.setGroup(existingGroup); + } else if (isViewportChange(e)) { + if (e.scale && e.oldScale && e.scale > e.oldScale) { + bumpTopObjectsIntoBounds(workspace); + } + } + }; +} + +/** + * Extracts the object from the given event. + * + * @param workspace The workspace the event originated + * from. + * @param e An event containing an object. + * @returns The extracted + * object. + */ +function extractObjectFromEvent( + workspace: WorkspaceSvg, + e: eventUtils.BumpEvent, +): IBoundedElement | null { + let object = null; + switch (e.type) { + case EventType.BLOCK_CREATE: + case EventType.BLOCK_MOVE: + object = workspace.getBlockById((e as BlockCreate | BlockMove).blockId!); + if (object) { + object = object.getRootBlock(); + } + break; + case EventType.COMMENT_CREATE: + case EventType.COMMENT_MOVE: + case EventType.COMMENT_RESIZE: + object = workspace.getCommentById( + (e as CommentCreate | CommentMove | CommentResize).commentId!, + ) as RenderedWorkspaceComment; + break; + } + return object; +} + +/** + * Bumps the top objects in the given workspace into bounds. + * + * @param workspace The workspace. + */ +export function bumpTopObjectsIntoBounds(workspace: WorkspaceSvg) { + const metricsManager = workspace.getMetricsManager(); + if (!metricsManager.hasFixedEdges() || workspace.isDragging()) { + return; + } + + const scrollMetricsInWsCoords = metricsManager.getScrollMetrics(true); + const topBlocks = workspace.getTopBoundedElements(); + for (let i = 0, block; (block = topBlocks[i]); i++) { + bumpObjectIntoBounds(workspace, scrollMetricsInWsCoords, block); + } +} diff --git a/core/button_flyout_inflater.ts b/core/button_flyout_inflater.ts new file mode 100644 index 00000000000..4f083f015f7 --- /dev/null +++ b/core/button_flyout_inflater.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FlyoutButton} from './flyout_button.js'; +import {FlyoutItem} from './flyout_item.js'; +import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import * as registry from './registry.js'; +import {ButtonOrLabelInfo} from './utils/toolbox.js'; + +const BUTTON_TYPE = 'button'; + +/** + * Class responsible for creating buttons for flyouts. + */ +export class ButtonFlyoutInflater implements IFlyoutInflater { + /** + * Inflates a flyout button from the given state and adds it to the flyout. + * + * @param state A JSON representation of a flyout button. + * @param flyout The flyout to create the button on. + * @returns A newly created FlyoutButton. + */ + load(state: object, flyout: IFlyout): FlyoutItem { + const button = new FlyoutButton( + flyout.getWorkspace(), + flyout.targetWorkspace!, + state as ButtonOrLabelInfo, + false, + ); + button.show(); + + return new FlyoutItem(button, BUTTON_TYPE); + } + + /** + * Returns the amount of space that should follow this button. + * + * @param state A JSON representation of a flyout button. + * @param defaultGap The default spacing for flyout items. + * @returns The amount of space that should follow this button. + */ + gapForItem(state: object, defaultGap: number): number { + return defaultGap; + } + + /** + * Disposes of the given button. + * + * @param item The flyout button to dispose of. + */ + disposeItem(item: FlyoutItem): void { + const element = item.getElement(); + if (element instanceof FlyoutButton) { + element.dispose(); + } + } + + /** + * Returns the type of items this inflater is responsible for creating. + * + * @returns An identifier for the type of items this inflater creates. + */ + getType() { + return BUTTON_TYPE; + } +} + +registry.register( + registry.Type.FLYOUT_INFLATER, + BUTTON_TYPE, + ButtonFlyoutInflater, +); diff --git a/core/clipboard.ts b/core/clipboard.ts new file mode 100644 index 00000000000..c7b22dfc7a8 --- /dev/null +++ b/core/clipboard.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.clipboard + +import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js'; +import * as registry from './clipboard/registry.js'; +import type {ICopyData, ICopyable} from './interfaces/i_copyable.js'; +import {isSelectable} from './interfaces/i_selectable.js'; +import * as globalRegistry from './registry.js'; +import {Coordinate} from './utils/coordinate.js'; +import {WorkspaceSvg} from './workspace_svg.js'; + +/** Metadata about the object that is currently on the clipboard. */ +let stashedCopyData: ICopyData | null = null; + +let stashedWorkspace: WorkspaceSvg | null = null; + +let stashedCoordinates: Coordinate | undefined = undefined; + +/** + * Copy a copyable item, and record its data and the workspace it was + * copied from. + * + * This function does not perform any checks to ensure the copy + * should be allowed, e.g. to ensure the block is deletable. Such + * checks should be done before calling this function. + * + * Note that if the copyable item is not an `ISelectable` or its + * `workspace` property is not a `WorkspaceSvg`, the copy will be + * successful, but there will be no saved workspace data. This will + * impact the ability to paste the data unless you explictily pass + * a workspace into the paste method. + * + * @param toCopy item to copy. + * @param location location to save as a potential paste location. + * @returns the copied data if copy was successful, otherwise null. + */ +export function copy( + toCopy: ICopyable, + location?: Coordinate, +): T | null { + const data = toCopy.toCopyData(); + stashedCopyData = data; + if (isSelectable(toCopy) && toCopy.workspace instanceof WorkspaceSvg) { + stashedWorkspace = toCopy.workspace; + } else { + stashedWorkspace = null; + } + + stashedCoordinates = location; + return data; +} + +/** + * Gets the copy data for the last item copied. This is useful if you + * are implementing custom copy/paste behavior. If you want the default + * behavior, just use the copy and paste methods directly. + * + * @returns copy data for the last item copied, or null if none set. + */ +export function getLastCopiedData() { + return stashedCopyData; +} + +/** + * Sets the last copied item. You should call this method if you implement + * custom copy behavior, so that other callers are working with the correct + * data. This method is called automatically if you use the built-in copy + * method. + * + * @param copyData copy data for the last item copied. + */ +export function setLastCopiedData(copyData: ICopyData) { + stashedCopyData = copyData; +} + +/** + * Gets the workspace that was last copied from. This is useful if you + * are implementing custom copy/paste behavior and want to paste on the + * same workspace that was copied from. If you want the default behavior, + * just use the copy and paste methods directly. + * + * @returns workspace that was last copied from, or null if none set. + */ +export function getLastCopiedWorkspace() { + return stashedWorkspace; +} + +/** + * Sets the workspace that was last copied from. You should call this method + * if you implement custom copy behavior, so that other callers are working + * with the correct data. This method is called automatically if you use the + * built-in copy method. + * + * @param workspace workspace that was last copied from. + */ +export function setLastCopiedWorkspace(workspace: WorkspaceSvg) { + stashedWorkspace = workspace; +} + +/** + * Gets the location that was last copied from. This is useful if you + * are implementing custom copy/paste behavior. If you want the + * default behavior, just use the copy and paste methods directly. + * + * @returns last saved location, or null if none set. + */ +export function getLastCopiedLocation() { + return stashedCoordinates; +} + +/** + * Sets the location that was last copied from. You should call this method + * if you implement custom copy behavior, so that other callers are working + * with the correct data. This method is called automatically if you use the + * built-in copy method. + * + * @param location last saved location, which can be used to paste at. + */ +export function setLastCopiedLocation(location: Coordinate) { + stashedCoordinates = location; +} + +/** + * Paste a pasteable element into the given workspace. + * + * This function does not perform any checks to ensure the paste + * is allowed, e.g. that the workspace is rendered or the block + * is pasteable. Such checks should be done before calling this + * function. + * + * @param copyData The data to paste into the workspace. + * @param workspace The workspace to paste the data into. + * @param coordinate The location to paste the thing at. + * @returns The pasted thing if the paste was successful, null otherwise. + */ +export function paste( + copyData: T, + workspace: WorkspaceSvg, + coordinate?: Coordinate, +): ICopyable | null; + +/** + * Pastes the last copied ICopyable into the last copied-from workspace. + * + * @returns the pasted thing if the paste was successful, null otherwise. + */ +export function paste(): ICopyable | null; + +/** + * Pastes the given data into the workspace, or the last copied ICopyable if + * no data is passed. + * + * @param copyData The data to paste into the workspace. + * @param workspace The workspace to paste the data into. + * @param coordinate The location to paste the thing at. + * @returns The pasted thing if the paste was successful, null otherwise. + */ +export function paste( + copyData?: T, + workspace?: WorkspaceSvg, + coordinate?: Coordinate, +): ICopyable | null { + if (!copyData || !workspace) { + if (!stashedCopyData || !stashedWorkspace) return null; + return pasteFromData(stashedCopyData, stashedWorkspace, stashedCoordinates); + } + return pasteFromData(copyData, workspace, coordinate); +} + +/** + * Paste a pasteable element into the workspace. + * + * @param copyData The data to paste into the workspace. + * @param workspace The workspace to paste the data into. + * @param coordinate The location to paste the thing at. + * @returns The pasted thing if the paste was successful, null otherwise. + */ +function pasteFromData( + copyData: T, + workspace: WorkspaceSvg, + coordinate?: Coordinate, +): ICopyable | null { + workspace = workspace.isMutator + ? workspace + : // Use the parent workspace if it exists (e.g. for pasting into flyouts) + (workspace.options.parentWorkspace ?? workspace); + return (globalRegistry + .getObject(globalRegistry.Type.PASTER, copyData.paster, false) + ?.paste(copyData, workspace, coordinate) ?? null) as ICopyable | null; +} + +export {BlockCopyData, BlockPaster, registry}; diff --git a/core/clipboard/block_paster.ts b/core/clipboard/block_paster.ts new file mode 100644 index 00000000000..e782cc0b004 --- /dev/null +++ b/core/clipboard/block_paster.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from '../block_svg.js'; +import {IFocusableNode} from '../blockly.js'; +import {config} from '../config.js'; +import {EventType} from '../events/type.js'; +import * as eventUtils from '../events/utils.js'; +import {getFocusManager} from '../focus_manager.js'; +import {ICopyData} from '../interfaces/i_copyable.js'; +import {IPaster} from '../interfaces/i_paster.js'; +import * as renderManagement from '../render_management.js'; +import {State, append} from '../serialization/blocks.js'; +import {Coordinate} from '../utils/coordinate.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; +import * as registry from './registry.js'; + +export class BlockPaster implements IPaster { + static TYPE = 'block'; + + paste( + copyData: BlockCopyData, + workspace: WorkspaceSvg, + coordinate?: Coordinate, + ): BlockSvg | null { + if (!workspace.isCapacityAvailable(copyData.typeCounts!)) return null; + + if (coordinate) { + copyData.blockState['x'] = coordinate.x; + copyData.blockState['y'] = coordinate.y; + } + + // After appending the block to the workspace, it will be bumped from its neighbors + // However, the algorithm for deciding where to paste a block depends on + // the starting position of the copied block, so we'll pass those coordinates along + const initialCoordinates = + coordinate || + new Coordinate( + copyData.blockState['x'] || 0, + copyData.blockState['y'] || 0, + ); + + eventUtils.disable(); + let block; + try { + block = append(copyData.blockState, workspace) as BlockSvg; + moveBlockToNotConflict(block, initialCoordinates); + } finally { + eventUtils.enable(); + } + + if (!block) return block; + + if (eventUtils.isEnabled() && !block.isShadow()) { + eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(block)); + } + + // Sometimes there's a delay before the block is fully created and ready for + // focusing, so wait slightly before focusing the newly pasted block. + const nodeToFocus: IFocusableNode = block; + renderManagement + .finishQueuedRenders() + .then(() => getFocusManager().focusNode(nodeToFocus)); + return block; + } +} + +/** + * Moves the given block to a location where it does not: (1) overlap exactly + * with any other blocks, or (2) look like it is connected to any other blocks. + * + * Exported for testing. + * + * @param block The block to move to an unambiguous location. + * @param originalPosition The initial coordinate to start searching from, + * likely the position of the copied block. + * @internal + */ +export function moveBlockToNotConflict( + block: BlockSvg, + originalPosition: Coordinate, +) { + if (block.workspace.RTL) { + originalPosition.x = block.workspace.getWidth() - originalPosition.x; + } + const workspace = block.workspace; + const snapRadius = config.snapRadius; + const bumpOffset = Coordinate.difference( + originalPosition, + block.getRelativeToSurfaceXY(), + ); + const offset = new Coordinate(0, 0); + // getRelativeToSurfaceXY is really expensive, so we want to cache this. + const otherCoords = workspace + .getAllBlocks(false) + .filter((otherBlock) => otherBlock.id != block.id) + .map((b) => b.getRelativeToSurfaceXY()); + + while ( + blockOverlapsOtherExactly( + Coordinate.sum(originalPosition, offset), + otherCoords, + ) || + blockIsInSnapRadius(block, Coordinate.sum(bumpOffset, offset), snapRadius) + ) { + if (workspace.RTL) { + offset.translate(-snapRadius, snapRadius * 2); + } else { + offset.translate(snapRadius, snapRadius * 2); + } + } + + block!.moveTo(Coordinate.sum(originalPosition, offset)); +} + +/** + * @returns true if the given block coordinates are less than a delta of 1 from + * any of the other coordinates. + */ +function blockOverlapsOtherExactly( + coord: Coordinate, + otherCoords: Coordinate[], +): boolean { + return otherCoords.some( + (otherCoord) => + Math.abs(otherCoord.x - coord.x) <= 1 && + Math.abs(otherCoord.y - coord.y) <= 1, + ); +} + +/** + * @returns true if the given block (when offset by the given amount) is close + * enough to any other connections (within the snap radius) that it looks + * like they could connect. + */ +function blockIsInSnapRadius( + block: BlockSvg, + offset: Coordinate, + snapRadius: number, +): boolean { + return block + .getConnections_(false) + .some((connection) => !!connection.closest(snapRadius, offset).connection); +} + +export interface BlockCopyData extends ICopyData { + blockState: State; + typeCounts: {[key: string]: number}; +} + +registry.register(BlockPaster.TYPE, new BlockPaster()); diff --git a/core/clipboard/registry.ts b/core/clipboard/registry.ts new file mode 100644 index 00000000000..1257f5bdbb5 --- /dev/null +++ b/core/clipboard/registry.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ICopyable, ICopyData} from '../interfaces/i_copyable.js'; +import type {IPaster} from '../interfaces/i_paster.js'; +import * as registry from '../registry.js'; + +/** + * Registers the given paster so that it cna be used for pasting. + * + * @param type The type of the paster to register, e.g. 'block', 'comment', etc. + * @param paster The paster to register. + */ +export function register>( + type: string, + paster: IPaster, +) { + registry.register(registry.Type.PASTER, type, paster); +} + +/** + * Unregisters the paster associated with the given type. + * + * @param type The type of the paster to unregister. + */ +export function unregister(type: string) { + registry.unregister(registry.Type.PASTER, type); +} diff --git a/core/clipboard/workspace_comment_paster.ts b/core/clipboard/workspace_comment_paster.ts new file mode 100644 index 00000000000..00c56681dd3 --- /dev/null +++ b/core/clipboard/workspace_comment_paster.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; +import {EventType} from '../events/type.js'; +import * as eventUtils from '../events/utils.js'; +import {getFocusManager} from '../focus_manager.js'; +import {ICopyData} from '../interfaces/i_copyable.js'; +import {IPaster} from '../interfaces/i_paster.js'; +import * as commentSerialiation from '../serialization/workspace_comments.js'; +import {Coordinate} from '../utils/coordinate.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; +import * as registry from './registry.js'; + +export class WorkspaceCommentPaster + implements IPaster +{ + static TYPE = 'workspace-comment'; + + paste( + copyData: WorkspaceCommentCopyData, + workspace: WorkspaceSvg, + coordinate?: Coordinate, + ): RenderedWorkspaceComment | null { + const state = copyData.commentState; + + if (coordinate) { + state['x'] = coordinate.x; + state['y'] = coordinate.y; + } + + eventUtils.disable(); + let comment; + try { + comment = commentSerialiation.append( + state, + workspace, + ) as RenderedWorkspaceComment; + moveCommentToNotConflict(comment); + } finally { + eventUtils.enable(); + } + + if (!comment) return null; + + if (eventUtils.isEnabled()) { + eventUtils.fire(new (eventUtils.get(EventType.COMMENT_CREATE))(comment)); + } + getFocusManager().focusNode(comment); + return comment; + } +} + +function moveCommentToNotConflict(comment: RenderedWorkspaceComment) { + const workspace = comment.workspace; + const translateDistance = 30; + const coord = comment.getRelativeToSurfaceXY(); + const offset = new Coordinate(0, 0); + // getRelativeToSurfaceXY is really expensive, so we want to cache this. + const otherCoords = workspace + .getTopComments(false) + .filter((otherComment) => otherComment.id !== comment.id) + .map((c) => c.getRelativeToSurfaceXY()); + + while ( + commentOverlapsOtherExactly(Coordinate.sum(coord, offset), otherCoords) + ) { + offset.translate( + workspace.RTL ? -translateDistance : translateDistance, + translateDistance, + ); + } + + comment.moveTo(Coordinate.sum(coord, offset)); +} + +function commentOverlapsOtherExactly( + coord: Coordinate, + otherCoords: Coordinate[], +): boolean { + return otherCoords.some( + (otherCoord) => + Math.abs(otherCoord.x - coord.x) <= 1 && + Math.abs(otherCoord.y - coord.y) <= 1, + ); +} + +export interface WorkspaceCommentCopyData extends ICopyData { + commentState: commentSerialiation.State; +} + +registry.register(WorkspaceCommentPaster.TYPE, new WorkspaceCommentPaster()); diff --git a/core/comment.js b/core/comment.js deleted file mode 100644 index f0d5f35374d..00000000000 --- a/core/comment.js +++ /dev/null @@ -1,278 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2011 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Object representing a code comment. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Comment'); - -goog.require('Blockly.Bubble'); -goog.require('Blockly.Icon'); -goog.require('goog.userAgent'); - - -/** - * Class for a comment. - * @param {!Blockly.Block} block The block associated with this comment. - * @extends {Blockly.Icon} - * @constructor - */ -Blockly.Comment = function(block) { - Blockly.Comment.superClass_.constructor.call(this, block); - this.createIcon(); -}; -goog.inherits(Blockly.Comment, Blockly.Icon); - -/** - * Comment text (if bubble is not visible). - * @private - */ -Blockly.Comment.prototype.text_ = ''; - -/** - * Width of bubble. - * @private - */ -Blockly.Comment.prototype.width_ = 160; - -/** - * Height of bubble. - * @private - */ -Blockly.Comment.prototype.height_ = 80; - -/** - * Draw the comment icon. - * @param {!Element} group The icon group. - * @private - */ -Blockly.Comment.prototype.drawIcon_ = function(group) { - // Circle. - Blockly.utils.createSvgElement('circle', - {'class': 'blocklyIconShape', 'r': '8', 'cx': '8', 'cy': '8'}, - group); - // Can't use a real '?' text character since different browsers and operating - // systems render it differently. - // Body of question mark. - Blockly.utils.createSvgElement('path', - {'class': 'blocklyIconSymbol', - 'd': 'm6.8,10h2c0.003,-0.617 0.271,-0.962 0.633,-1.266 2.875,-2.405 0.607,-5.534 -3.765,-3.874v1.7c3.12,-1.657 3.698,0.118 2.336,1.25 -1.201,0.998 -1.201,1.528 -1.204,2.19z'}, - group); - // Dot of question mark. - Blockly.utils.createSvgElement('rect', - {'class': 'blocklyIconSymbol', - 'x': '6.8', 'y': '10.78', 'height': '2', 'width': '2'}, - group); -}; - -/** - * Create the editor for the comment's bubble. - * @return {!Element} The top-level node of the editor. - * @private - */ -Blockly.Comment.prototype.createEditor_ = function() { - /* Create the editor. Here's the markup that will be generated: - - - - - - */ - this.foreignObject_ = Blockly.utils.createSvgElement('foreignObject', - {'x': Blockly.Bubble.BORDER_WIDTH, 'y': Blockly.Bubble.BORDER_WIDTH}, - null); - var body = document.createElementNS(Blockly.HTML_NS, 'body'); - body.setAttribute('xmlns', Blockly.HTML_NS); - body.className = 'blocklyMinimalBody'; - var textarea = document.createElementNS(Blockly.HTML_NS, 'textarea'); - textarea.className = 'blocklyCommentTextarea'; - textarea.setAttribute('dir', this.block_.RTL ? 'RTL' : 'LTR'); - body.appendChild(textarea); - this.textarea_ = textarea; - this.foreignObject_.appendChild(body); - Blockly.bindEventWithChecks_(textarea, 'mouseup', this, this.textareaFocus_); - // Don't zoom with mousewheel. - Blockly.bindEventWithChecks_(textarea, 'wheel', this, function(e) { - e.stopPropagation(); - }); - Blockly.bindEventWithChecks_(textarea, 'change', this, function(e) { - if (this.text_ != textarea.value) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this.block_, 'comment', null, this.text_, textarea.value)); - this.text_ = textarea.value; - } - }); - setTimeout(function() { - textarea.focus(); - }, 0); - return this.foreignObject_; -}; - -/** - * Add or remove editability of the comment. - * @override - */ -Blockly.Comment.prototype.updateEditable = function() { - if (this.isVisible()) { - // Toggling visibility will force a rerendering. - this.setVisible(false); - this.setVisible(true); - } - // Allow the icon to update. - Blockly.Icon.prototype.updateEditable.call(this); -}; - -/** - * Callback function triggered when the bubble has resized. - * Resize the text area accordingly. - * @private - */ -Blockly.Comment.prototype.resizeBubble_ = function() { - if (this.isVisible()) { - var size = this.bubble_.getBubbleSize(); - var doubleBorderWidth = 2 * Blockly.Bubble.BORDER_WIDTH; - this.foreignObject_.setAttribute('width', size.width - doubleBorderWidth); - this.foreignObject_.setAttribute('height', size.height - doubleBorderWidth); - this.textarea_.style.width = (size.width - doubleBorderWidth - 4) + 'px'; - this.textarea_.style.height = (size.height - doubleBorderWidth - 4) + 'px'; - } -}; - -/** - * Show or hide the comment bubble. - * @param {boolean} visible True if the bubble should be visible. - */ -Blockly.Comment.prototype.setVisible = function(visible) { - if (visible == this.isVisible()) { - // No change. - return; - } - Blockly.Events.fire( - new Blockly.Events.Ui(this.block_, 'commentOpen', !visible, visible)); - if ((!this.block_.isEditable() && !this.textarea_) || goog.userAgent.IE) { - // Steal the code from warnings to make an uneditable text bubble. - // MSIE does not support foreignobject; textareas are impossible. - // http://msdn.microsoft.com/en-us/library/hh834675%28v=vs.85%29.aspx - // Always treat comments in IE as uneditable. - Blockly.Warning.prototype.setVisible.call(this, visible); - return; - } - // Save the bubble stats before the visibility switch. - var text = this.getText(); - var size = this.getBubbleSize(); - if (visible) { - // Create the bubble. - this.bubble_ = new Blockly.Bubble( - /** @type {!Blockly.WorkspaceSvg} */ (this.block_.workspace), - this.createEditor_(), this.block_.svgPath_, - this.iconXY_, this.width_, this.height_); - this.bubble_.registerResizeEvent(this.resizeBubble_.bind(this)); - this.updateColour(); - } else { - // Dispose of the bubble. - this.bubble_.dispose(); - this.bubble_ = null; - this.textarea_ = null; - this.foreignObject_ = null; - } - // Restore the bubble stats after the visibility switch. - this.setText(text); - this.setBubbleSize(size.width, size.height); -}; - -/** - * Bring the comment to the top of the stack when clicked on. - * @param {!Event} e Mouse up event. - * @private - */ -Blockly.Comment.prototype.textareaFocus_ = function(e) { - // Ideally this would be hooked to the focus event for the comment. - // However doing so in Firefox swallows the cursor for unknown reasons. - // So this is hooked to mouseup instead. No big deal. - this.bubble_.promote_(); - // Since the act of moving this node within the DOM causes a loss of focus, - // we need to reapply the focus. - this.textarea_.focus(); -}; - -/** - * Get the dimensions of this comment's bubble. - * @return {!Object} Object with width and height properties. - */ -Blockly.Comment.prototype.getBubbleSize = function() { - if (this.isVisible()) { - return this.bubble_.getBubbleSize(); - } else { - return {width: this.width_, height: this.height_}; - } -}; - -/** - * Size this comment's bubble. - * @param {number} width Width of the bubble. - * @param {number} height Height of the bubble. - */ -Blockly.Comment.prototype.setBubbleSize = function(width, height) { - if (this.textarea_) { - this.bubble_.setBubbleSize(width, height); - } else { - this.width_ = width; - this.height_ = height; - } -}; - -/** - * Returns this comment's text. - * @return {string} Comment text. - */ -Blockly.Comment.prototype.getText = function() { - return this.textarea_ ? this.textarea_.value : this.text_; -}; - -/** - * Set this comment's text. - * @param {string} text Comment text. - */ -Blockly.Comment.prototype.setText = function(text) { - if (this.text_ != text) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this.block_, 'comment', null, this.text_, text)); - this.text_ = text; - } - if (this.textarea_) { - this.textarea_.value = text; - } -}; - -/** - * Dispose of this comment. - */ -Blockly.Comment.prototype.dispose = function() { - if (Blockly.Events.isEnabled()) { - this.setText(''); // Fire event to delete comment. - } - this.block_.comment = null; - Blockly.Icon.prototype.dispose.call(this); -}; diff --git a/core/comments.ts b/core/comments.ts new file mode 100644 index 00000000000..179ab4a33d0 --- /dev/null +++ b/core/comments.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export {CollapseCommentBarButton} from './comments/collapse_comment_bar_button.js'; +export {CommentBarButton} from './comments/comment_bar_button.js'; +export {CommentEditor} from './comments/comment_editor.js'; +export {CommentView} from './comments/comment_view.js'; +export {DeleteCommentBarButton} from './comments/delete_comment_bar_button.js'; +export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; +export {WorkspaceComment} from './comments/workspace_comment.js'; diff --git a/core/comments/collapse_comment_bar_button.ts b/core/comments/collapse_comment_bar_button.ts new file mode 100644 index 00000000000..304e2af8125 --- /dev/null +++ b/core/comments/collapse_comment_bar_button.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import * as touch from '../touch.js'; +import * as dom from '../utils/dom.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {CommentBarButton} from './comment_bar_button.js'; +import type {CommentView} from './comment_view.js'; + +/** + * Magic string appended to the comment ID to create a unique ID for this button. + */ +export const COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER = + '_collapse_bar_button'; + +/** + * Button that toggles the collapsed state of a comment. + */ +export class CollapseCommentBarButton extends CommentBarButton { + /** + * Opaque ID used to unbind event handlers during disposal. + */ + private readonly bindId: browserEvents.Data; + + /** + * SVG image displayed on this button. + */ + protected override readonly icon: SVGImageElement; + + /** + * Creates a new CollapseCommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is displayed on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + protected readonly commentView: CommentView, + ) { + super(id, workspace, container, commentView); + + this.icon = dom.createSvgElement( + Svg.IMAGE, + { + 'class': 'blocklyFoldoutIcon', + 'href': `${this.workspace.options.pathToMedia}foldout-icon.svg`, + 'id': `${this.id}${COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER}`, + }, + this.container, + ); + this.bindId = browserEvents.conditionalBind( + this.icon, + 'pointerdown', + this, + this.performAction.bind(this), + ); + } + + /** + * Disposes of this button. + */ + dispose() { + browserEvents.unbind(this.bindId); + } + + /** + * Adjusts the positioning of this button within its container. + */ + override reposition() { + const margin = this.getMargin(); + this.icon.setAttribute('y', `${margin}`); + this.icon.setAttribute('x', `${margin}`); + } + + /** + * Toggles the collapsed state of the parent comment. + * + * @param e The event that triggered this action. + */ + override performAction(e?: Event) { + touch.clearTouchIdentifier(); + + this.getCommentView().bringToFront(); + if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) { + e.stopPropagation(); + return; + } + + this.getCommentView().setCollapsed(!this.getCommentView().isCollapsed()); + this.workspace.hideChaff(); + + e?.stopPropagation(); + } +} diff --git a/core/comments/comment_bar_button.ts b/core/comments/comment_bar_button.ts new file mode 100644 index 00000000000..be130b0e335 --- /dev/null +++ b/core/comments/comment_bar_button.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {Rect} from '../utils/rect.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import type {CommentView} from './comment_view.js'; + +/** + * Button displayed on a comment's top bar. + */ +export abstract class CommentBarButton implements IFocusableNode { + /** + * SVG image displayed on this button. + */ + protected abstract readonly icon: SVGImageElement; + + /** + * Creates a new CommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + protected readonly commentView: CommentView, + ) {} + + /** + * Returns whether or not this button is currently visible. + */ + isVisible(): boolean { + return this.icon.checkVisibility(); + } + + /** + * Returns the parent comment view of this comment bar button. + */ + getCommentView(): CommentView { + return this.commentView; + } + + /** Adjusts the position of this button within its parent container. */ + abstract reposition(): void; + + /** Perform the action this button should take when it is acted on. */ + abstract performAction(e?: Event): void; + + /** + * Returns the dimensions of this button in workspace coordinates. + * + * @param includeMargin True to include the margin when calculating the size. + * @returns The size of this button. + */ + getSize(includeMargin = false): Rect { + const bounds = this.icon.getBBox(); + const rect = Rect.from(bounds); + if (includeMargin) { + const margin = this.getMargin(); + rect.left -= margin; + rect.top -= margin; + rect.bottom += margin; + rect.right += margin; + } + return rect; + } + + /** Returns the margin in workspace coordinates surrounding this button. */ + getMargin(): number { + return (this.container.getBBox().height - this.icon.getBBox().height) / 2; + } + + /** Returns a DOM element representing this button that can receive focus. */ + getFocusableElement() { + return this.icon; + } + + /** Returns the workspace this button is a child of. */ + getFocusableTree() { + return this.workspace; + } + + /** Called when this button's focusable DOM element gains focus. */ + onNodeFocus() { + const commentView = this.getCommentView(); + const xy = commentView.getRelativeToSurfaceXY(); + const size = commentView.getSize(); + const bounds = new Rect(xy.y, xy.y + size.height, xy.x, xy.x + size.width); + commentView.workspace.scrollBoundsIntoView(bounds); + } + + /** Called when this button's focusable DOM element loses focus. */ + onNodeBlur() {} + + /** Returns whether this button can be focused. True if it is visible. */ + canBeFocused() { + return this.isVisible(); + } +} diff --git a/core/comments/comment_editor.ts b/core/comments/comment_editor.ts new file mode 100644 index 00000000000..b4c741ba1ad --- /dev/null +++ b/core/comments/comment_editor.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import {getFocusManager} from '../focus_manager.js'; +import {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {IFocusableTree} from '../interfaces/i_focusable_tree.js'; +import * as touch from '../touch.js'; +import * as dom from '../utils/dom.js'; +import {Rect} from '../utils/rect.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import * as svgMath from '../utils/svg_math.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +/** + * String added to the ID of a workspace comment to identify + * the focusable node for the comment editor. + */ +export const COMMENT_EDITOR_FOCUS_IDENTIFIER = '_comment_textarea_'; + +/** The part of a comment that can be typed into. */ +export class CommentEditor implements IFocusableNode { + id?: string; + /** The foreignObject containing the HTML text area. */ + private foreignObject: SVGForeignObjectElement; + + /** The text area where the user can type. */ + private textArea: HTMLTextAreaElement; + + /** Listeners for changes to text. */ + private textChangeListeners: Array< + (oldText: string, newText: string) => void + > = []; + + /** The current text of the comment. Updates on text area change. */ + private text: string = ''; + + constructor( + public workspace: WorkspaceSvg, + commentId?: string, + private onFinishEditing?: () => void, + ) { + this.foreignObject = dom.createSvgElement(Svg.FOREIGNOBJECT, { + 'class': 'blocklyCommentForeignObject', + }); + const body = document.createElementNS(dom.HTML_NS, 'body'); + body.setAttribute('xmlns', dom.HTML_NS); + body.className = 'blocklyMinimalBody'; + this.textArea = document.createElementNS( + dom.HTML_NS, + 'textarea', + ) as HTMLTextAreaElement; + this.textArea.setAttribute('tabindex', '-1'); + this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR'); + dom.addClass(this.textArea, 'blocklyCommentText'); + dom.addClass(this.textArea, 'blocklyTextarea'); + dom.addClass(this.textArea, 'blocklyText'); + body.appendChild(this.textArea); + this.foreignObject.appendChild(body); + + if (commentId) { + this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER; + this.textArea.setAttribute('id', this.id); + } + + // Register browser event listeners for the user typing in the textarea. + browserEvents.conditionalBind( + this.textArea, + 'change', + this, + this.onTextChange, + ); + + // Register listener for pointerdown to focus the textarea. + browserEvents.conditionalBind( + this.textArea, + 'pointerdown', + this, + (e: PointerEvent) => { + // don't allow this event to bubble up + // and steal focus away from the editor/comment. + e.stopPropagation(); + getFocusManager().focusNode(this); + touch.clearTouchIdentifier(); + }, + ); + + // Don't zoom with mousewheel; let it scroll instead. + browserEvents.conditionalBind(this.textArea, 'wheel', this, (e: Event) => { + e.stopPropagation(); + }); + + // Register listener for keydown events that would finish editing. + browserEvents.conditionalBind( + this.textArea, + 'keydown', + this, + this.handleKeyDown, + ); + } + + /** Gets the dom structure for this comment editor. */ + getDom(): SVGForeignObjectElement { + return this.foreignObject; + } + + /** Gets the current text of the comment. */ + getText(): string { + return this.text; + } + + /** Sets the current text of the comment and fires change listeners. */ + setText(text: string) { + this.textArea.value = text; + this.onTextChange(); + } + + /** + * Triggers listeners when the text of the comment changes, either + * programmatically or manually by the user. + */ + private onTextChange() { + const oldText = this.text; + this.text = this.textArea.value; + // Loop through listeners backwards in case they remove themselves. + for (let i = this.textChangeListeners.length - 1; i >= 0; i--) { + this.textChangeListeners[i](oldText, this.text); + } + } + + /** + * Do something when the user indicates they've finished editing. + * + * @param e Keyboard event. + */ + private handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { + if (this.onFinishEditing) this.onFinishEditing(); + e.stopPropagation(); + } + } + + /** Registers a callback that listens for text changes. */ + addTextChangeListener(listener: (oldText: string, newText: string) => void) { + this.textChangeListeners.push(listener); + } + + /** Removes the given listener from the list of text change listeners. */ + removeTextChangeListener(listener: () => void) { + this.textChangeListeners.splice( + this.textChangeListeners.indexOf(listener), + 1, + ); + } + + /** Sets the placeholder text displayed for an empty comment. */ + setPlaceholderText(text: string) { + this.textArea.placeholder = text; + } + + /** Sets whether the textarea is editable. If not, the textarea will be readonly. */ + setEditable(isEditable: boolean) { + if (isEditable) { + this.textArea.removeAttribute('readonly'); + } else { + this.textArea.setAttribute('readonly', 'true'); + } + } + + /** Update the size of the comment editor element. */ + updateSize(size: Size, topBarSize: Size) { + this.foreignObject.setAttribute( + 'height', + `${size.height - topBarSize.height}`, + ); + this.foreignObject.setAttribute('width', `${size.width}`); + this.foreignObject.setAttribute('y', `${topBarSize.height}`); + if (this.workspace.RTL) { + this.foreignObject.setAttribute('x', `${-size.width}`); + } + } + + getFocusableElement(): HTMLElement | SVGElement { + return this.textArea; + } + getFocusableTree(): IFocusableTree { + return this.workspace; + } + onNodeFocus(): void { + const bbox = Rect.from(this.foreignObject.getBoundingClientRect()); + this.workspace.scrollBoundsIntoView( + Rect.createFromPoint( + svgMath.screenToWsCoordinates(this.workspace, bbox.getOrigin()), + bbox.getWidth(), + bbox.getHeight(), + ), + ); + } + onNodeBlur(): void {} + canBeFocused(): boolean { + if (this.id) return true; + return false; + } +} diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts new file mode 100644 index 00000000000..b1cd628f8dd --- /dev/null +++ b/core/comments/comment_view.ts @@ -0,0 +1,773 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import * as css from '../css.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node'; +import {IRenderedElement} from '../interfaces/i_rendered_element.js'; +import * as layers from '../layers.js'; +import * as touch from '../touch.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as dom from '../utils/dom.js'; +import * as drag from '../utils/drag.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; +import {CollapseCommentBarButton} from './collapse_comment_bar_button.js'; +import {CommentBarButton} from './comment_bar_button.js'; +import {CommentEditor} from './comment_editor.js'; +import {DeleteCommentBarButton} from './delete_comment_bar_button.js'; + +export class CommentView implements IRenderedElement { + /** The root group element of the comment view. */ + private svgRoot: SVGGElement; + + /** + * The SVG rect element that we use to create a highlight around the comment. + */ + private highlightRect: SVGRectElement; + + /** The group containing all of the top bar elements. */ + private topBarGroup: SVGGElement; + + /** The rect background for the top bar. */ + private topBarBackground: SVGRectElement; + + /** The delete button that goes in the top bar. */ + private deleteButton: DeleteCommentBarButton; + + /** The foldout button that goes in the top bar. */ + private foldoutButton: CollapseCommentBarButton; + + /** The text element that goes in the top bar. */ + private textPreview: SVGTextElement; + + /** The actual text node in the text preview. */ + private textPreviewNode: Text; + + /** The resize handle element. */ + private resizeHandle: SVGImageElement; + + /** The part of the comment view that contains the textarea to edit the comment. */ + private commentEditor: CommentEditor; + + /** The current size of the comment in workspace units. */ + private size: Size; + + /** Whether the comment is collapsed or not. */ + private collapsed: boolean = false; + + /** Whether the comment is editable or not. */ + private editable: boolean = true; + + /** The current location of the comment in workspace coordinates. */ + private location: Coordinate = new Coordinate(0, 0); + + /** Listeners for changes to size. */ + private sizeChangeListeners: Array<(oldSize: Size, newSize: Size) => void> = + []; + + /** Listeners for disposal. */ + private disposeListeners: Array<() => void> = []; + + /** Listeners for collapsing. */ + private collapseChangeListeners: Array<(newCollapse: boolean) => void> = []; + + /** + * Event data for the pointer up event on the resize handle. Used to + * unregister the listener. + */ + private resizePointerUpListener: browserEvents.Data | null = null; + + /** + * Event data for the pointer move event on the resize handle. Used to + * unregister the listener. + */ + private resizePointerMoveListener: browserEvents.Data | null = null; + + /** Whether this comment view is currently being disposed or not. */ + protected disposing = false; + + /** Whether this comment view has been disposed or not. */ + protected disposed = false; + + /** Size of this comment when the resize drag was initiated. */ + private preResizeSize?: Size; + + /** The default size of newly created comments. */ + static defaultCommentSize = new Size(120, 100); + + constructor( + readonly workspace: WorkspaceSvg, + readonly commentId: string, + ) { + this.svgRoot = dom.createSvgElement(Svg.G, { + 'class': 'blocklyComment blocklyEditable blocklyDraggable', + }); + + this.highlightRect = this.createHighlightRect(this.svgRoot); + + ({ + topBarGroup: this.topBarGroup, + topBarBackground: this.topBarBackground, + deleteButton: this.deleteButton, + foldoutButton: this.foldoutButton, + textPreview: this.textPreview, + textPreviewNode: this.textPreviewNode, + } = this.createTopBar(this.svgRoot)); + + this.commentEditor = this.createTextArea(); + + this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace); + + // TODO: Remove this comment before merging. + // I think we want comments to exist on the same layer as blocks. + workspace.getLayerManager()?.append(this, layers.BLOCK); + + // Set size to the default size. + this.size = CommentView.defaultCommentSize; + this.setSizeWithoutFiringEvents(this.size); + + // Set default transform (including inverted scale for RTL). + this.moveTo(new Coordinate(0, 0)); + } + + /** + * Creates the rect we use for highlighting the comment when it's selected. + */ + private createHighlightRect(svgRoot: SVGGElement): SVGRectElement { + return dom.createSvgElement( + Svg.RECT, + {'class': 'blocklyCommentHighlight'}, + svgRoot, + ); + } + + /** + * Creates the top bar and the elements visually within it. + * Registers event listeners. + */ + private createTopBar(svgRoot: SVGGElement): { + topBarGroup: SVGGElement; + topBarBackground: SVGRectElement; + deleteButton: DeleteCommentBarButton; + foldoutButton: CollapseCommentBarButton; + textPreview: SVGTextElement; + textPreviewNode: Text; + } { + const topBarGroup = dom.createSvgElement( + Svg.G, + { + 'class': 'blocklyCommentTopbar', + }, + svgRoot, + ); + const topBarBackground = dom.createSvgElement( + Svg.RECT, + { + 'class': 'blocklyCommentTopbarBackground', + }, + topBarGroup, + ); + const deleteButton = new DeleteCommentBarButton( + this.commentId, + this.workspace, + topBarGroup, + this, + ); + const foldoutButton = new CollapseCommentBarButton( + this.commentId, + this.workspace, + topBarGroup, + this, + ); + this.addDisposeListener(() => { + deleteButton.dispose(); + foldoutButton.dispose(); + }); + const textPreview = dom.createSvgElement( + Svg.TEXT, + { + 'class': 'blocklyCommentPreview blocklyCommentText blocklyText', + }, + topBarGroup, + ); + const textPreviewNode = document.createTextNode(''); + textPreview.appendChild(textPreviewNode); + + return { + topBarGroup, + topBarBackground, + deleteButton, + foldoutButton, + textPreview, + textPreviewNode, + }; + } + + /** + * Creates the text area where users can type. Registers event listeners. + */ + private createTextArea() { + // When the user is done editing comment, focus the entire comment. + const onFinishEditing = () => this.svgRoot.focus(); + const commentEditor = new CommentEditor( + this.workspace, + this.commentId, + onFinishEditing, + ); + + this.svgRoot.appendChild(commentEditor.getDom()); + + commentEditor.addTextChangeListener((oldText, newText) => { + this.updateTextPreview(newText); + // Update size in case our minimum size increased. + this.setSize(this.size); + }); + + return commentEditor; + } + + /** + * + * @returns The FocusableNode representing the editor portion of this comment. + */ + getEditorFocusableNode(): IFocusableNode { + return this.commentEditor; + } + + /** Creates the DOM elements for the comment resize handle. */ + private createResizeHandle( + svgRoot: SVGGElement, + workspace: WorkspaceSvg, + ): SVGImageElement { + const resizeHandle = dom.createSvgElement( + Svg.IMAGE, + { + 'class': 'blocklyResizeHandle', + 'href': `${workspace.options.pathToMedia}resize-handle.svg`, + }, + svgRoot, + ); + + browserEvents.conditionalBind( + resizeHandle, + 'pointerdown', + this, + this.onResizePointerDown, + ); + + return resizeHandle; + } + + /** Returns the root SVG group element of the comment view. */ + getSvgRoot(): SVGGElement { + return this.svgRoot; + } + + /** + * Returns the current size of the comment in workspace units. + * Respects collapsing. + */ + getSize(): Size { + return this.collapsed ? this.topBarBackground.getBBox() : this.size; + } + + /** + * Sets the size of the comment in workspace units, and updates the view + * elements to reflect the new size. + */ + setSizeWithoutFiringEvents(size: Size) { + const topBarSize = this.topBarBackground.getBBox(); + const textPreviewSize = this.textPreview.getBBox(); + const resizeSize = this.resizeHandle.getBBox(); + + size = Size.max(size, this.calcMinSize(topBarSize)); + this.size = size; + + this.svgRoot.setAttribute('height', `${size.height}`); + this.svgRoot.setAttribute('width', `${size.width}`); + + this.updateHighlightRect(size); + this.updateTopBarSize(size); + this.commentEditor.updateSize(size, topBarSize); + this.deleteButton.reposition(); + this.foldoutButton.reposition(); + this.updateTextPreviewSize(size, topBarSize, textPreviewSize); + this.updateResizeHandlePosition(size, resizeSize); + } + + /** + * Sets the size of the comment in workspace units, updates the view + * elements to reflect the new size, and triggers size change listeners. + */ + setSize(size: Size) { + const oldSize = this.preResizeSize || this.size; + this.setSizeWithoutFiringEvents(size); + this.onSizeChange(oldSize, this.size); + } + + /** + * Calculates the minimum size for the uncollapsed comment based on text + * size and visible icons. + * + * The minimum width is based on the width of the truncated preview text. + * + * The minimum height is based on the height of the top bar. + */ + private calcMinSize(topBarSize: Size): Size { + this.updateTextPreview(this.commentEditor.getText() ?? ''); + const textPreviewWidth = dom.getTextWidth(this.textPreview); + + let width = textPreviewWidth; + if (this.foldoutButton.isVisible()) { + width += this.foldoutButton.getSize(true).getWidth(); + } else if (textPreviewWidth) { + width += 4; // Arbitrary margin before text. + } + if (this.deleteButton.isVisible()) { + width += this.deleteButton.getSize(true).getWidth(); + } else if (textPreviewWidth) { + width += 4; // Arbitrary margin after text. + } + + // Arbitrary additional height. + const height = topBarSize.height + 20; + + return new Size(width, height); + } + + /** Updates the size of the highlight rect to reflect the new size. */ + private updateHighlightRect(size: Size) { + this.highlightRect.setAttribute('height', `${size.height}`); + this.highlightRect.setAttribute('width', `${size.width}`); + if (this.workspace.RTL) { + this.highlightRect.setAttribute('x', `${-size.width}`); + } + } + + /** Updates the size of the top bar to reflect the new size. */ + private updateTopBarSize(size: Size) { + this.topBarBackground.setAttribute('width', `${size.width}`); + } + + /** + * Updates the size and position of the text preview elements to reflect the new size. + */ + private updateTextPreviewSize( + size: Size, + topBarSize: Size, + textPreviewSize: Size, + ) { + const textPreviewMargin = (topBarSize.height - textPreviewSize.height) / 2; + const foldoutSize = this.foldoutButton.getSize(true); + const deleteSize = this.deleteButton.getSize(true); + + const textPreviewWidth = + size.width - foldoutSize.getWidth() - deleteSize.getWidth(); + this.textPreview.setAttribute( + 'x', + `${(this.workspace.RTL ? -1 : 1) * foldoutSize.getWidth()}`, + ); + this.textPreview.setAttribute( + 'y', + `${textPreviewMargin + textPreviewSize.height / 2}`, + ); + this.textPreview.setAttribute('width', `${textPreviewWidth}`); + } + + /** Updates the position of the resize handle to reflect the new size. */ + private updateResizeHandlePosition(size: Size, resizeSize: Size) { + this.resizeHandle.setAttribute('y', `${size.height - resizeSize.height}`); + this.resizeHandle.setAttribute('x', `${size.width - resizeSize.width}`); + } + + /** + * Triggers listeners when the size of the comment changes, either + * programmatically or manually by the user. + */ + private onSizeChange(oldSize: Size, newSize: Size) { + // Loop through listeners backwards in case they remove themselves. + for (let i = this.sizeChangeListeners.length - 1; i >= 0; i--) { + this.sizeChangeListeners[i](oldSize, newSize); + } + } + + /** + * Registers a callback that listens for size changes. + * + * @param listener Receives callbacks when the size of the comment changes. + * The new and old size are in workspace units. + */ + addSizeChangeListener(listener: (oldSize: Size, newSize: Size) => void) { + this.sizeChangeListeners.push(listener); + } + + /** Removes the given listener from the list of size change listeners. */ + removeSizeChangeListener(listener: () => void) { + this.sizeChangeListeners.splice( + this.sizeChangeListeners.indexOf(listener), + 1, + ); + } + + /** + * Handles starting an interaction with the resize handle to resize the + * comment. + */ + private onResizePointerDown(e: PointerEvent) { + if (!this.isEditable()) return; + + this.bringToFront(); + if (browserEvents.isRightButton(e)) { + e.stopPropagation(); + return; + } + + this.preResizeSize = this.getSize(); + + drag.start( + this.workspace, + e, + new Coordinate( + this.workspace.RTL ? -this.getSize().width : this.getSize().width, + this.getSize().height, + ), + ); + + this.resizePointerUpListener = browserEvents.conditionalBind( + document, + 'pointerup', + this, + this.onResizePointerUp, + ); + this.resizePointerMoveListener = browserEvents.conditionalBind( + document, + 'pointermove', + this, + this.onResizePointerMove, + ); + + this.workspace.hideChaff(); + + e.stopPropagation(); + } + + /** Ends an interaction with the resize handle. */ + private onResizePointerUp(_e: PointerEvent) { + touch.clearTouchIdentifier(); + if (this.resizePointerUpListener) { + browserEvents.unbind(this.resizePointerUpListener); + this.resizePointerUpListener = null; + } + if (this.resizePointerMoveListener) { + browserEvents.unbind(this.resizePointerMoveListener); + this.resizePointerMoveListener = null; + } + // When ending a resize drag, notify size change listeners to fire an event. + this.setSize(this.size); + this.preResizeSize = undefined; + } + + /** Resizes the comment in response to a drag on the resize handle. */ + private onResizePointerMove(e: PointerEvent) { + const size = drag.move(this.workspace, e); + this.setSizeWithoutFiringEvents( + new Size(this.workspace.RTL ? -size.x : size.x, size.y), + ); + } + + /** Returns true if the comment is currently collapsed. */ + isCollapsed(): boolean { + return this.collapsed; + } + + /** Sets whether the comment is currently collapsed or not. */ + setCollapsed(collapsed: boolean) { + this.collapsed = collapsed; + if (collapsed) { + dom.addClass(this.svgRoot, 'blocklyCollapsed'); + } else { + dom.removeClass(this.svgRoot, 'blocklyCollapsed'); + } + // Repositions resize handle and such. + this.setSizeWithoutFiringEvents(this.size); + this.onCollapse(); + } + + /** + * Triggers listeners when the collapsed-ness of the comment changes, either + * progrmatically or manually by the user. + */ + private onCollapse() { + // Loop through listeners backwards in case they remove themselves. + for (let i = this.collapseChangeListeners.length - 1; i >= 0; i--) { + this.collapseChangeListeners[i](this.collapsed); + } + } + + /** Registers a callback that listens for collapsed-ness changes. */ + addOnCollapseListener(listener: (newCollapse: boolean) => void) { + this.collapseChangeListeners.push(listener); + } + + /** Removes the given listener from the list of on collapse listeners. */ + removeOnCollapseListener(listener: () => void) { + this.collapseChangeListeners.splice( + this.collapseChangeListeners.indexOf(listener), + 1, + ); + } + + /** Returns true if the comment is currently editable. */ + isEditable(): boolean { + return this.editable; + } + + /** Sets the editability of the comment. */ + setEditable(editable: boolean) { + this.editable = editable; + if (this.editable) { + dom.addClass(this.svgRoot, 'blocklyEditable'); + dom.removeClass(this.svgRoot, 'blocklyReadonly'); + } else { + dom.removeClass(this.svgRoot, 'blocklyEditable'); + dom.addClass(this.svgRoot, 'blocklyReadonly'); + } + this.commentEditor.setEditable(editable); + } + + /** Returns the current location of the comment in workspace coordinates. */ + getRelativeToSurfaceXY(): Coordinate { + return this.location; + } + + /** + * Moves the comment view to the given location. + * + * @param location The location to move to in workspace coordinates. + */ + moveTo(location: Coordinate) { + this.location = location; + this.svgRoot.setAttribute( + 'transform', + `translate(${location.x}, ${location.y})`, + ); + } + + /** Returns the current text of the comment. */ + getText() { + return this.commentEditor.getText(); + } + + /** Sets the current text of the comment. */ + setText(text: string) { + this.commentEditor.setText(text); + } + + /** Sets the placeholder text displayed for an empty comment. */ + setPlaceholderText(text: string) { + this.commentEditor.setPlaceholderText(text); + } + + /** Registers a callback that listens for text changes on the comment editor. */ + addTextChangeListener(listener: (oldText: string, newText: string) => void) { + this.commentEditor.addTextChangeListener(listener); + } + + /** Removes the given listener from the comment editor. */ + removeTextChangeListener(listener: () => void) { + this.commentEditor.removeTextChangeListener(listener); + } + + /** Updates the preview text element to reflect the given text. */ + private updateTextPreview(text: string) { + this.textPreviewNode.textContent = this.truncateText(text); + } + + /** Truncates the text to fit within the top view. */ + private truncateText(text: string): string { + return text.length >= 12 ? `${text.substring(0, 9)}...` : text; + } + + /** Brings the workspace comment to the front of its layer. */ + bringToFront() { + const parent = this.svgRoot.parentNode; + const childNodes = parent!.childNodes; + // Avoid moving the comment if it's already at the bottom. + if (childNodes[childNodes.length - 1] !== this.svgRoot) { + parent!.appendChild(this.svgRoot); + } + } + + /** + * Handles disposing of the comment when we get a pointer down event on the + * delete icon. + */ + private onDeleteDown(e: PointerEvent) { + touch.clearTouchIdentifier(); + if (browserEvents.isRightButton(e)) { + e.stopPropagation(); + return; + } + + this.dispose(); + e.stopPropagation(); + } + + /** Disposes of this comment view. */ + dispose() { + this.disposing = true; + dom.removeNode(this.svgRoot); + // Loop through listeners backwards in case they remove themselves. + for (let i = this.disposeListeners.length - 1; i >= 0; i--) { + this.disposeListeners[i](); + } + this.disposeListeners.length = 0; + this.disposed = true; + } + + /** Returns whether this comment view has been disposed or not. */ + isDisposed(): boolean { + return this.disposed; + } + + /** + * Returns true if this comment view is currently being disposed or has + * already been disposed. + */ + isDeadOrDying(): boolean { + return this.disposing || this.disposed; + } + + /** Registers a callback that listens for disposal of this view. */ + addDisposeListener(listener: () => void) { + this.disposeListeners.push(listener); + } + + /** Removes the given listener from the list of disposal listeners. */ + removeDisposeListener(listener: () => void) { + this.disposeListeners.splice(this.disposeListeners.indexOf(listener), 1); + } + + /** + * @internal + */ + getCommentBarButtons(): CommentBarButton[] { + return [this.foldoutButton, this.deleteButton]; + } +} + +css.register(` +.injectionDiv { + --commentFillColour: #FFFCC7; + --commentBorderColour: #F2E49B; +} + +.blocklyComment .blocklyTextarea { + background-color: var(--commentFillColour); + border: 1px solid var(--commentBorderColour); + box-sizing: border-box; + display: block; + outline: 0; + padding: 5px; + resize: none; + width: 100%; + height: 100%; +} + +.blocklyReadonly.blocklyComment .blocklyTextarea { + cursor: inherit; +} + +.blocklyDeleteIcon { + width: 20px; + height: 20px; + display: none; + cursor: pointer; +} + +.blocklyFoldoutIcon { + width: 20px; + height: 20px; + transform-origin: 12px 12px; + cursor: pointer; +} +.blocklyResizeHandle { + width: 12px; + height: 12px; + cursor: se-resize; +} +.blocklyReadonly.blocklyComment .blocklyResizeHandle { + cursor: inherit; +} + +.blocklyCommentTopbarBackground { + fill: var(--commentBorderColour); + height: 24px; +} + +.blocklyComment .blocklyCommentPreview.blocklyText { + fill: #000; + dominant-baseline: middle; + visibility: hidden; +} + +.blocklyCollapsed.blocklyComment .blocklyCommentPreview { + visibility: visible; +} + +.blocklyCollapsed.blocklyComment .blocklyCommentForeignObject, +.blocklyCollapsed.blocklyComment .blocklyResizeHandle { + display: none; +} + +.blocklyCollapsed.blocklyComment .blocklyFoldoutIcon { + transform: rotate(-90deg); +} + +.blocklyRTL .blocklyCommentTopbar { + transform: scale(-1, 1); +} + +.blocklyRTL .blocklyCommentForeignObject { + direction: rtl; +} + +.blocklyRTL .blocklyCommentPreview { + /* Revert the scale and control RTL using direction instead. */ + transform: scale(-1, 1); + direction: rtl; +} + +.blocklyRTL .blocklyResizeHandle { + transform: scale(-1, 1); + cursor: sw-resize; +} + +.blocklyCommentHighlight { + fill: none; +} + +.blocklyCommentText.blocklyActiveFocus { + border-color: #fc3; + border-width: 2px; +} + +.blocklySelected .blocklyCommentHighlight { + stroke: #fc3; + stroke-width: 3px; +} + +.blocklyCollapsed.blocklySelected .blocklyCommentHighlight { + stroke: none; +} + +.blocklyCollapsed.blocklySelected .blocklyCommentTopbarBackground { + stroke: #fc3; + stroke-width: 3px; +} +`); diff --git a/core/comments/delete_comment_bar_button.ts b/core/comments/delete_comment_bar_button.ts new file mode 100644 index 00000000000..c61db9b9cd2 --- /dev/null +++ b/core/comments/delete_comment_bar_button.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import {getFocusManager} from '../focus_manager.js'; +import * as touch from '../touch.js'; +import * as dom from '../utils/dom.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {CommentBarButton} from './comment_bar_button.js'; +import type {CommentView} from './comment_view.js'; + +/** + * Magic string appended to the comment ID to create a unique ID for this button. + */ +export const COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER = '_delete_bar_button'; + +/** + * Button that deletes a comment. + */ +export class DeleteCommentBarButton extends CommentBarButton { + /** + * Opaque ID used to unbind event handlers during disposal. + */ + private readonly bindId: browserEvents.Data; + + /** + * SVG image displayed on this button. + */ + protected override readonly icon: SVGImageElement; + + /** + * Creates a new DeleteCommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is shown on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + protected readonly commentView: CommentView, + ) { + super(id, workspace, container, commentView); + + this.icon = dom.createSvgElement( + Svg.IMAGE, + { + 'class': 'blocklyDeleteIcon', + 'href': `${this.workspace.options.pathToMedia}delete-icon.svg`, + 'id': `${this.id}${COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER}`, + }, + container, + ); + this.bindId = browserEvents.conditionalBind( + this.icon, + 'pointerdown', + this, + this.performAction.bind(this), + ); + } + + /** + * Disposes of this button. + */ + dispose() { + browserEvents.unbind(this.bindId); + } + + /** + * Adjusts the positioning of this button within its container. + */ + override reposition() { + const margin = this.getMargin(); + // Reset to 0 so that our position doesn't force the parent container to + // grow. + this.icon.setAttribute('x', `0`); + const containerSize = this.container.getBBox(); + this.icon.setAttribute('y', `${margin}`); + this.icon.setAttribute( + 'x', + `${containerSize.width - this.getSize(true).getWidth()}`, + ); + } + + /** + * Deletes parent comment. + * + * @param e The event that triggered this action. + */ + override performAction(e?: Event) { + touch.clearTouchIdentifier(); + if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) { + e.stopPropagation(); + return; + } + + this.getCommentView().dispose(); + e?.stopPropagation(); + getFocusManager().focusNode(this.workspace); + } +} diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts new file mode 100644 index 00000000000..2903bff4bce --- /dev/null +++ b/core/comments/rendered_workspace_comment.ts @@ -0,0 +1,362 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import { + WorkspaceCommentCopyData, + WorkspaceCommentPaster, +} from '../clipboard/workspace_comment_paster.js'; +import * as common from '../common.js'; +import * as contextMenu from '../contextmenu.js'; +import {ContextMenuRegistry} from '../contextmenu_registry.js'; +import {CommentDragStrategy} from '../dragging/comment_drag_strategy.js'; +import {getFocusManager} from '../focus_manager.js'; +import {IBoundedElement} from '../interfaces/i_bounded_element.js'; +import {IContextMenu} from '../interfaces/i_contextmenu.js'; +import {ICopyable} from '../interfaces/i_copyable.js'; +import {IDeletable} from '../interfaces/i_deletable.js'; +import {IDraggable} from '../interfaces/i_draggable.js'; +import {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; +import {IRenderedElement} from '../interfaces/i_rendered_element.js'; +import {ISelectable} from '../interfaces/i_selectable.js'; +import * as layers from '../layers.js'; +import * as commentSerialization from '../serialization/workspace_comments.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as dom from '../utils/dom.js'; +import {Rect} from '../utils/rect.js'; +import {Size} from '../utils/size.js'; +import * as svgMath from '../utils/svg_math.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; +import {CommentView} from './comment_view.js'; +import {WorkspaceComment} from './workspace_comment.js'; + +export class RenderedWorkspaceComment + extends WorkspaceComment + implements + IBoundedElement, + IRenderedElement, + IDraggable, + ISelectable, + IDeletable, + ICopyable, + IContextMenu, + IFocusableNode +{ + /** The class encompassing the svg elements making up the workspace comment. */ + view: CommentView; + + public readonly workspace: WorkspaceSvg; + + private dragStrategy = new CommentDragStrategy(this); + + /** Constructs the workspace comment, including the view. */ + constructor(workspace: WorkspaceSvg, id?: string) { + super(workspace, id); + + this.workspace = workspace; + + this.view = new CommentView(workspace, this.id); + // Set the size to the default size as defined in the superclass. + this.view.setSize(this.getSize()); + this.view.setEditable(this.isEditable()); + this.view.getSvgRoot().setAttribute('data-id', this.id); + this.view.getSvgRoot().setAttribute('id', this.id); + + this.addModelUpdateBindings(); + + browserEvents.conditionalBind( + this.view.getSvgRoot(), + 'pointerdown', + this, + this.startGesture, + ); + } + + /** + * Adds listeners to the view that updates the model (i.e. the superclass) + * when changes are made to the view. + */ + private addModelUpdateBindings() { + this.view.addTextChangeListener( + (_, newText: string) => void super.setText(newText), + ); + this.view.addSizeChangeListener( + (_, newSize: Size) => void super.setSize(newSize), + ); + this.view.addOnCollapseListener( + () => void super.setCollapsed(this.view.isCollapsed()), + ); + this.view.addDisposeListener(() => { + if (!this.isDeadOrDying()) this.dispose(); + }); + } + + /** Sets the text of the comment. */ + override setText(text: string): void { + // setText will trigger the change listener that updates + // the model aka superclass. + this.view.setText(text); + } + + /** Sets the placeholder text displayed if the comment is empty. */ + setPlaceholderText(text: string): void { + this.view.setPlaceholderText(text); + } + + /** Sets the size of the comment. */ + override setSize(size: Size) { + // setSize will trigger the change listener that updates + // the model aka superclass. + this.view.setSize(size); + } + + /** Sets whether the comment is collapsed or not. */ + override setCollapsed(collapsed: boolean) { + // setCollapsed will trigger the change listener that updates + // the model aka superclass. + this.view.setCollapsed(collapsed); + } + + /** Sets whether the comment is editable or not. */ + override setEditable(editable: boolean): void { + super.setEditable(editable); + // Use isEditable rather than isOwnEditable to account for workspace state. + this.view.setEditable(this.isEditable()); + } + + /** Returns the root SVG element of this comment. */ + getSvgRoot(): SVGElement { + return this.view.getSvgRoot(); + } + + /** + * Returns the comment's size in workspace units. + * Does not respect collapsing. + */ + getSize(): Size { + return super.getSize(); + } + + /** + * Returns the bounding rectangle of this comment in workspace coordinates. + * Respects collapsing. + */ + getBoundingRectangle(): Rect { + const loc = this.getRelativeToSurfaceXY(); + const size = this.view?.getSize() ?? this.getSize(); + let left; + let right; + if (this.workspace.RTL) { + left = loc.x - size.width; + right = loc.x; + } else { + left = loc.x; + right = loc.x + size.width; + } + return new Rect(loc.y, loc.y + size.height, left, right); + } + + /** Move the comment by the given amounts in workspace coordinates. */ + moveBy(dx: number, dy: number, reason?: string[] | undefined): void { + const loc = this.getRelativeToSurfaceXY(); + const newLoc = new Coordinate(loc.x + dx, loc.y + dy); + this.moveTo(newLoc, reason); + } + + /** Moves the comment to the given location in workspace coordinates. */ + override moveTo(location: Coordinate, reason?: string[] | undefined): void { + super.moveTo(location, reason); + this.view.moveTo(location); + } + + /** + * Moves the comment during a drag. Doesn't fire move events. + * + * @internal + */ + moveDuringDrag(location: Coordinate): void { + this.location = location; + this.view.moveTo(location); + } + + /** + * Adds the dragging CSS class to this comment. + * + * @internal + */ + setDragging(dragging: boolean): void { + if (dragging) { + dom.addClass(this.getSvgRoot(), 'blocklyDragging'); + } else { + dom.removeClass(this.getSvgRoot(), 'blocklyDragging'); + } + } + + /** Disposes of the view. */ + override dispose() { + this.disposing = true; + const focusManager = getFocusManager(); + if (focusManager.getFocusedNode() === this) { + setTimeout(() => focusManager.focusTree(this.workspace), 0); + } + if (!this.view.isDeadOrDying()) this.view.dispose(); + + super.dispose(); + } + + /** + * Starts a gesture because we detected a pointer down on the comment + * (that wasn't otherwise gobbled up, e.g. by resizing). + */ + private startGesture(e: PointerEvent) { + const gesture = this.workspace.getGesture(e); + if (gesture) { + gesture.handleCommentStart(e, this); + getFocusManager().focusNode(this); + } + } + + /** Visually indicates that this comment would be deleted if dropped. */ + setDeleteStyle(wouldDelete: boolean): void { + if (wouldDelete) { + dom.addClass(this.getSvgRoot(), 'blocklyDraggingDelete'); + } else { + dom.removeClass(this.getSvgRoot(), 'blocklyDraggingDelete'); + } + } + + /** Returns whether this comment is copyable or not */ + isCopyable(): boolean { + return this.isOwnMovable() && this.isOwnDeletable(); + } + + /** Returns whether this comment is movable or not. */ + isMovable(): boolean { + return this.dragStrategy.isMovable(); + } + + /** Starts a drag on the comment. */ + startDrag(): void { + this.dragStrategy.startDrag(); + } + + /** Drags the comment to the given location. */ + drag(newLoc: Coordinate): void { + this.dragStrategy.drag(newLoc); + } + + /** Ends the drag on the comment. */ + endDrag(): void { + this.dragStrategy.endDrag(); + } + + /** Moves the comment back to where it was at the start of a drag. */ + revertDrag(): void { + this.dragStrategy.revertDrag(); + } + + /** Visually highlights the comment. */ + select(): void { + dom.addClass(this.getSvgRoot(), 'blocklySelected'); + common.fireSelectedEvent(this); + } + + /** Visually unhighlights the comment. */ + unselect(): void { + dom.removeClass(this.getSvgRoot(), 'blocklySelected'); + common.fireSelectedEvent(null); + } + + /** + * Returns a JSON serializable representation of this comment's state that + * can be used for pasting. + */ + toCopyData(): WorkspaceCommentCopyData | null { + return { + paster: WorkspaceCommentPaster.TYPE, + commentState: commentSerialization.save(this, { + addCoordinates: true, + saveIds: false, + }), + }; + } + + /** Show a context menu for this comment. */ + showContextMenu(e: Event): void { + const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions( + {comment: this, focusedNode: this}, + e, + ); + + let location: Coordinate; + if (e instanceof PointerEvent) { + location = new Coordinate(e.clientX, e.clientY); + } else { + // Show the menu based on the location of the comment + const xy = svgMath.wsToScreenCoordinates( + this.workspace, + this.getRelativeToSurfaceXY(), + ); + location = xy.translate(10, 10); + } + + contextMenu.show( + e, + menuOptions, + this.workspace.RTL, + this.workspace, + location, + ); + } + + /** Snap this comment to the nearest grid point. */ + snapToGrid(): void { + if (this.isDeadOrDying()) return; + const grid = this.workspace.getGrid(); + if (!grid?.shouldSnap()) return; + const currentXY = this.getRelativeToSurfaceXY(); + const alignedXY = grid.alignXY(currentXY); + if (alignedXY !== currentXY) { + this.moveTo(alignedXY, ['snap']); + } + } + + /** + * @returns The FocusableNode representing the editor portion of this comment. + */ + getEditorFocusableNode(): IFocusableNode { + return this.view.getEditorFocusableNode(); + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + return this.getSvgRoot(); + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.workspace; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + this.select(); + // Ensure that the comment is always at the top when focused. + this.workspace.getLayerManager()?.append(this, layers.BLOCK); + this.workspace.scrollBoundsIntoView(this.getBoundingRectangle()); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void { + this.unselect(); + } + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } +} diff --git a/core/comments/workspace_comment.ts b/core/comments/workspace_comment.ts new file mode 100644 index 00000000000..b5dc3023cfe --- /dev/null +++ b/core/comments/workspace_comment.ts @@ -0,0 +1,247 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CommentMove} from '../events/events_comment_move.js'; +import {CommentResize} from '../events/events_comment_resize.js'; +import {EventType} from '../events/type.js'; +import * as eventUtils from '../events/utils.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as idGenerator from '../utils/idgenerator.js'; +import {Size} from '../utils/size.js'; +import {Workspace} from '../workspace.js'; +import {CommentView} from './comment_view.js'; + +export class WorkspaceComment { + /** The unique identifier for this comment. */ + public readonly id: string; + + /** The text of the comment. */ + private text = ''; + + /** The size of the comment in workspace units. */ + private size: Size; + + /** Whether the comment is collapsed or not. */ + private collapsed = false; + + /** Whether the comment is editable or not. */ + private editable = true; + + /** Whether the comment is movable or not. */ + private movable = true; + + /** Whether the comment is deletable or not. */ + private deletable = true; + + /** The location of the comment in workspace coordinates. */ + protected location = new Coordinate(0, 0); + + /** Whether this comment has been disposed or not. */ + protected disposed = false; + + /** Whether this comment is being disposed or not. */ + protected disposing = false; + + /** + * Constructs the comment. + * + * @param workspace The workspace to construct the comment in. + * @param id An optional ID to give to the comment. If not provided, one will + * be generated. + */ + constructor( + public readonly workspace: Workspace, + id?: string, + ) { + this.id = id && !workspace.getCommentById(id) ? id : idGenerator.genUid(); + this.size = CommentView.defaultCommentSize; + + workspace.addTopComment(this); + + this.fireCreateEvent(); + } + + private fireCreateEvent() { + if (eventUtils.isEnabled()) { + eventUtils.fire(new (eventUtils.get(EventType.COMMENT_CREATE))(this)); + } + } + + private fireDeleteEvent() { + if (eventUtils.isEnabled()) { + eventUtils.fire(new (eventUtils.get(EventType.COMMENT_DELETE))(this)); + } + } + + /** Fires a comment change event. */ + private fireChangeEvent(oldText: string, newText: string) { + if (eventUtils.isEnabled()) { + eventUtils.fire( + new (eventUtils.get(EventType.COMMENT_CHANGE))(this, oldText, newText), + ); + } + } + + /** Fires a comment collapse event. */ + private fireCollapseEvent(newCollapsed: boolean) { + if (eventUtils.isEnabled()) { + eventUtils.fire( + new (eventUtils.get(EventType.COMMENT_COLLAPSE))(this, newCollapsed), + ); + } + } + + /** Sets the text of the comment. */ + setText(text: string) { + const oldText = this.text; + this.text = text; + this.fireChangeEvent(oldText, text); + } + + /** Returns the text of the comment. */ + getText(): string { + return this.text; + } + + /** Sets the comment's size in workspace units. */ + setSize(size: Size) { + const event = new (eventUtils.get(EventType.COMMENT_RESIZE))( + this, + ) as CommentResize; + + this.size = size; + + event.recordCurrentSizeAsNewSize(); + eventUtils.fire(event); + } + + /** Returns the comment's size in workspace units. */ + getSize(): Size { + return this.size; + } + + /** Sets whether the comment is collapsed or not. */ + setCollapsed(collapsed: boolean) { + this.collapsed = collapsed; + this.fireCollapseEvent(collapsed); + } + + /** Returns whether the comment is collapsed or not. */ + isCollapsed(): boolean { + return this.collapsed; + } + + /** Sets whether the comment is editable or not. */ + setEditable(editable: boolean) { + this.editable = editable; + } + + /** + * Returns whether the comment is editable or not, respecting whether the + * workspace is read-only. + */ + isEditable(): boolean { + return this.isOwnEditable() && !this.workspace.isReadOnly(); + } + + /** + * Returns whether the comment is editable or not, only examining its own + * state and ignoring the state of the workspace. + */ + isOwnEditable(): boolean { + return this.editable; + } + + /** Sets whether the comment is movable or not. */ + setMovable(movable: boolean) { + this.movable = movable; + } + + /** + * Returns whether the comment is movable or not, respecting whether the + * workspace is read-only. + */ + isMovable() { + return ( + this.isOwnMovable() && + !this.workspace.isReadOnly() && + !this.workspace.isFlyout + ); + } + + /** + * Returns whether the comment is movable or not, only examining its own + * state and ignoring the state of the workspace. + */ + isOwnMovable() { + return this.movable; + } + + /** Sets whether the comment is deletable or not. */ + setDeletable(deletable: boolean) { + this.deletable = deletable; + } + + /** + * Returns whether the comment is deletable or not, respecting whether the + * workspace is read-only. + */ + isDeletable(): boolean { + return ( + this.isOwnDeletable() && + !this.isDeadOrDying() && + !this.workspace.isReadOnly() && + !this.workspace.isFlyout + ); + } + + /** + * Returns whether the comment is deletable or not, only examining its own + * state and ignoring the state of the workspace. + */ + isOwnDeletable(): boolean { + return this.deletable; + } + + /** Moves the comment to the given location in workspace coordinates. */ + moveTo(location: Coordinate, reason?: string[] | undefined) { + const event = new (eventUtils.get(EventType.COMMENT_MOVE))( + this, + ) as CommentMove; + if (reason) event.setReason(reason); + + this.location = location; + + event.recordNew(); + eventUtils.fire(event); + } + + /** Returns the position of the comment in workspace coordinates. */ + getRelativeToSurfaceXY(): Coordinate { + return this.location; + } + + /** Disposes of this comment. */ + dispose() { + this.disposing = true; + this.fireDeleteEvent(); + this.workspace.removeTopComment(this); + this.disposed = true; + } + + /** Returns whether the comment has been disposed or not. */ + isDisposed() { + return this.disposed; + } + + /** + * Returns true if this comment view is currently being disposed or has + * already been disposed. + */ + isDeadOrDying(): boolean { + return this.disposing || this.disposed; + } +} diff --git a/core/common.ts b/core/common.ts new file mode 100644 index 00000000000..7f23779ec93 --- /dev/null +++ b/core/common.ts @@ -0,0 +1,347 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.common + +import type {Block} from './block.js'; +import {BlockDefinition, Blocks} from './blocks.js'; +import * as browserEvents from './browser_events.js'; +import type {Connection} from './connection.js'; +import {EventType} from './events/type.js'; +import * as eventUtils from './events/utils.js'; +import {getFocusManager} from './focus_manager.js'; +import {ISelectable, isSelectable} from './interfaces/i_selectable.js'; +import {ShortcutRegistry} from './shortcut_registry.js'; +import type {Workspace} from './workspace.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +/** Database of all workspaces. */ +const WorkspaceDB_ = Object.create(null); + +/** + * Find the workspace with the specified ID. + * + * @param id ID of workspace to find. + * @returns The sought after workspace or null if not found. + */ +export function getWorkspaceById(id: string): Workspace | null { + return WorkspaceDB_[id] || null; +} + +/** + * Find all workspaces. + * + * @returns Array of workspaces. + */ +export function getAllWorkspaces(): Workspace[] { + const workspaces = []; + for (const workspaceId in WorkspaceDB_) { + workspaces.push(WorkspaceDB_[workspaceId]); + } + return workspaces; +} + +/** + * Register a workspace in the workspace db. + * + * @param workspace + */ +export function registerWorkspace(workspace: Workspace) { + WorkspaceDB_[workspace.id] = workspace; +} + +/** + * Unregister a workspace from the workspace db. + * + * @param workspace + */ +export function unregisterWorkpace(workspace: Workspace) { + delete WorkspaceDB_[workspace.id]; +} + +/** + * The main workspace most recently used. + * Set by Blockly.WorkspaceSvg.prototype.markFocused + */ +let mainWorkspace: Workspace; + +/** + * Returns the last used top level workspace (based on focus). Try not to use + * this function, particularly if there are multiple Blockly instances on a + * page. + * + * @returns The main workspace. + */ +export function getMainWorkspace(): Workspace { + return mainWorkspace; +} + +/** + * Sets last used main workspace. + * + * @param workspace The most recently used top level workspace. + */ +export function setMainWorkspace(workspace: Workspace) { + mainWorkspace = workspace; +} + +/** + * Returns the current selection. + */ +export function getSelected(): ISelectable | null { + const focused = getFocusManager().getFocusedNode(); + if (focused && isSelectable(focused)) return focused; + return null; +} + +/** + * Sets the current selection. + * + * To clear the current selection, select another ISelectable or focus a + * non-selectable (like the workspace root node). + * + * @param newSelection The new selection to make. + * @internal + */ +export function setSelected(newSelection: ISelectable) { + getFocusManager().focusNode(newSelection); +} + +/** + * Fires a selection change event based on the new selection. + * + * This is only expected to be called by ISelectable implementations and should + * always be called before updating the current selection state. It does not + * change focus or selection state. + * + * @param newSelection The new selection. + * @internal + */ +export function fireSelectedEvent(newSelection: ISelectable | null) { + const selected = getSelected(); + const event = new (eventUtils.get(EventType.SELECTED))( + selected?.id ?? null, + newSelection?.id ?? null, + newSelection?.workspace.id ?? selected?.workspace.id ?? '', + ); + eventUtils.fire(event); +} + +/** + * Container element in which to render the WidgetDiv, DropDownDiv and Tooltip. + */ +let parentContainer: Element | null; + +/** + * Get the container element in which to render the WidgetDiv, DropDownDiv and + * Tooltip. + * + * @returns The parent container. + */ +export function getParentContainer(): Element | null { + return parentContainer; +} + +/** + * Set the parent container. This is the container element that the WidgetDiv, + * DropDownDiv, and Tooltip are rendered into the first time `Blockly.inject` + * is called. + * This method is a NOP if called after the first `Blockly.inject`. + * + * @param newParent The container element. + */ +export function setParentContainer(newParent: Element) { + parentContainer = newParent; +} + +/** + * Size the SVG image to completely fill its container. Call this when the view + * actually changes sizes (e.g. on a window resize/device orientation change). + * See workspace.resizeContents to resize the workspace when the contents + * change (e.g. when a block is added or removed). + * Record the height/width of the SVG image. + * + * @param workspace Any workspace in the SVG. + */ +export function svgResize(workspace: WorkspaceSvg) { + let mainWorkspace = workspace; + while (mainWorkspace.options.parentWorkspace) { + mainWorkspace = mainWorkspace.options.parentWorkspace; + } + const svg = mainWorkspace.getParentSvg(); + const cachedSize = mainWorkspace.getCachedParentSvgSize(); + const div = svg.parentElement; + if (!(div instanceof HTMLElement)) { + // Workspace deleted, or something. + return; + } + + const width = div.offsetWidth; + const height = div.offsetHeight; + if (cachedSize.width !== width) { + svg.setAttribute('width', width + 'px'); + mainWorkspace.setCachedParentSvgSize(width, null); + } + if (cachedSize.height !== height) { + svg.setAttribute('height', height + 'px'); + mainWorkspace.setCachedParentSvgSize(null, height); + } + mainWorkspace.resize(); +} + +/** + * All of the connections on blocks that are currently being dragged. + */ +export const draggingConnections: Connection[] = []; + +/** + * Get a map of all the block's descendants mapping their type to the number of + * children with that type. + * + * @param block The block to map. + * @param opt_stripFollowing Optionally ignore all following + * statements (blocks that are not inside a value or statement input + * of the block). + * @returns Map of types to type counts for descendants of the bock. + */ +export function getBlockTypeCounts( + block: Block, + opt_stripFollowing?: boolean, +): {[key: string]: number} { + const typeCountsMap = Object.create(null); + const descendants = block.getDescendants(true); + if (opt_stripFollowing) { + const nextBlock = block.getNextBlock(); + if (nextBlock) { + const index = descendants.indexOf(nextBlock); + descendants.splice(index, descendants.length - index); + } + } + for (let i = 0, checkBlock; (checkBlock = descendants[i]); i++) { + if (typeCountsMap[checkBlock.type]) { + typeCountsMap[checkBlock.type]++; + } else { + typeCountsMap[checkBlock.type] = 1; + } + } + return typeCountsMap; +} + +/** + * Helper function for defining a block from JSON. The resulting function has + * the correct value of jsonDef at the point in code where jsonInit is called. + * + * @param jsonDef The JSON definition of a block. + * @returns A function that calls jsonInit with the correct value + * of jsonDef. + */ +function jsonInitFactory(jsonDef: AnyDuringMigration): () => void { + return function (this: Block) { + this.jsonInit(jsonDef); + }; +} + +/** + * Define blocks from an array of JSON block definitions, as might be generated + * by the Blockly Developer Tools. + * + * @param jsonArray An array of JSON block definitions. + */ +export function defineBlocksWithJsonArray(jsonArray: AnyDuringMigration[]) { + TEST_ONLY.defineBlocksWithJsonArrayInternal(jsonArray); +} + +/** + * Private version of defineBlocksWithJsonArray for stubbing in tests. + */ +function defineBlocksWithJsonArrayInternal(jsonArray: AnyDuringMigration[]) { + defineBlocks(createBlockDefinitionsFromJsonArray(jsonArray)); +} + +/** + * Define blocks from an array of JSON block definitions, as might be generated + * by the Blockly Developer Tools. + * + * @param jsonArray An array of JSON block definitions. + * @returns A map of the block + * definitions created. + */ +export function createBlockDefinitionsFromJsonArray( + jsonArray: AnyDuringMigration[], +): {[key: string]: BlockDefinition} { + const blocks: {[key: string]: BlockDefinition} = {}; + for (let i = 0; i < jsonArray.length; i++) { + const elem = jsonArray[i]; + if (!elem) { + console.warn(`Block definition #${i} in JSON array is ${elem}. Skipping`); + continue; + } + const type = elem['type']; + if (!type) { + console.warn( + `Block definition #${i} in JSON array is missing a type attribute. ` + + 'Skipping.', + ); + continue; + } + blocks[type] = {init: jsonInitFactory(elem)}; + } + return blocks; +} + +/** + * Add the specified block definitions to the block definitions + * dictionary (Blockly.Blocks). + * + * @param blocks A map of block + * type names to block definitions. + */ +export function defineBlocks(blocks: {[key: string]: BlockDefinition}) { + // Iterate over own enumerable properties. + for (const type of Object.keys(blocks)) { + const definition = blocks[type]; + if (type in Blocks) { + console.warn( + `Block definition "${type}" overwrites previous definition.`, + ); + } + Blocks[type] = definition; + } +} + +/** + * Handle a key-down on SVG drawing surface. Does nothing if the main workspace + * is not visible. + * + * @internal + * @param e Key down event. + */ +export function globalShortcutHandler(e: KeyboardEvent) { + // This would ideally just be a `focusedTree instanceof WorkspaceSvg`, but + // importing `WorkspaceSvg` (as opposed to just its type) causes cycles. + let workspace: WorkspaceSvg = getMainWorkspace() as WorkspaceSvg; + const focusedTree = getFocusManager().getFocusedTree(); + for (const ws of getAllWorkspaces()) { + if (focusedTree === (ws as WorkspaceSvg)) { + workspace = ws as WorkspaceSvg; + break; + } + } + + if ( + browserEvents.isTargetInput(e) || + !workspace || + (workspace.rendered && !workspace.isFlyout && !workspace.isVisible()) + ) { + // When focused on an HTML text input widget, don't trap any keys. + // Ignore keypresses on rendered workspaces that have been explicitly + // hidden. + return; + } + ShortcutRegistry.registry.onKeyDown(workspace, e); +} + +export const TEST_ONLY = {defineBlocksWithJsonArrayInternal}; diff --git a/core/component_manager.ts b/core/component_manager.ts new file mode 100644 index 00000000000..8363d6fb4a0 --- /dev/null +++ b/core/component_manager.ts @@ -0,0 +1,247 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Manager for all items registered with the workspace. + * + * @class + */ +// Former goog.module ID: Blockly.ComponentManager + +import type {IAutoHideable} from './interfaces/i_autohideable.js'; +import type {IComponent} from './interfaces/i_component.js'; +import type {IDeleteArea} from './interfaces/i_delete_area.js'; +import type {IDragTarget} from './interfaces/i_drag_target.js'; +import type {IPositionable} from './interfaces/i_positionable.js'; +import * as arrayUtils from './utils/array.js'; + +class Capability<_T> { + static POSITIONABLE = new Capability('positionable'); + static DRAG_TARGET = new Capability('drag_target'); + static DELETE_AREA = new Capability('delete_area'); + static AUTOHIDEABLE = new Capability('autohideable'); + private readonly name: string; + /** @param name The name of the component capability. */ + constructor(name: string) { + this.name = name; + } + + /** + * Returns the name of the capability. + * + * @returns The name. + */ + toString(): string { + return this.name; + } +} + +/** + * Manager for all items registered with the workspace. + */ +export class ComponentManager { + static Capability = Capability; + + /** + * A map of the components registered with the workspace, mapped to id. + */ + private readonly componentData = new Map(); + + /** A map of capabilities to component IDs. */ + private readonly capabilityToComponentIds = new Map(); + + /** + * Adds a component. + * + * @param componentInfo The data for the component to register. + * @param opt_allowOverrides True to prevent an error when overriding an + * already registered item. + */ + addComponent(componentInfo: ComponentDatum, opt_allowOverrides?: boolean) { + // Don't throw an error if opt_allowOverrides is true. + const id = componentInfo.component.id; + if (!opt_allowOverrides && this.componentData.has(id)) { + throw Error( + 'Plugin "' + + id + + '" with capabilities "' + + this.componentData.get(id)?.capabilities + + '" already added.', + ); + } + this.componentData.set(id, componentInfo); + const stringCapabilities = []; + for (let i = 0; i < componentInfo.capabilities.length; i++) { + const capability = String(componentInfo.capabilities[i]).toLowerCase(); + stringCapabilities.push(capability); + if (!this.capabilityToComponentIds.has(capability)) { + this.capabilityToComponentIds.set(capability, [id]); + } else { + this.capabilityToComponentIds.get(capability)?.push(id); + } + } + this.componentData.get(id)!.capabilities = stringCapabilities; + } + + /** + * Removes a component. + * + * @param id The ID of the component to remove. + */ + removeComponent(id: string) { + const componentInfo = this.componentData.get(id); + if (!componentInfo) { + return; + } + for (let i = 0; i < componentInfo.capabilities.length; i++) { + const capability = String(componentInfo.capabilities[i]).toLowerCase(); + arrayUtils.removeElem(this.capabilityToComponentIds.get(capability)!, id); + } + this.componentData.delete(id); + } + + /** + * Adds a capability to a existing registered component. + * + * @param id The ID of the component to add the capability to. + * @param capability The capability to add. + */ + addCapability(id: string, capability: string | Capability) { + if (!this.getComponent(id)) { + throw Error( + 'Cannot add capability, "' + + capability + + '". Plugin "' + + id + + '" has not been added to the ComponentManager', + ); + } + if (this.hasCapability(id, capability)) { + console.warn( + 'Plugin "' + id + 'already has capability "' + capability + '"', + ); + return; + } + capability = `${capability}`.toLowerCase(); + this.componentData.get(id)?.capabilities.push(capability); + this.capabilityToComponentIds.get(capability)?.push(id); + } + + /** + * Removes a capability from an existing registered component. + * + * @param id The ID of the component to remove the capability from. + * @param capability The capability to remove. + */ + removeCapability(id: string, capability: string | Capability) { + if (!this.getComponent(id)) { + throw Error( + 'Cannot remove capability, "' + + capability + + '". Plugin "' + + id + + '" has not been added to the ComponentManager', + ); + } + if (!this.hasCapability(id, capability)) { + console.warn( + 'Plugin "' + + id + + 'doesn\'t have capability "' + + capability + + '" to remove', + ); + return; + } + capability = `${capability}`.toLowerCase(); + arrayUtils.removeElem(this.componentData.get(id)!.capabilities, capability); + arrayUtils.removeElem(this.capabilityToComponentIds.get(capability)!, id); + } + + /** + * Returns whether the component with this id has the specified capability. + * + * @param id The ID of the component to check. + * @param capability The capability to check for. + * @returns Whether the component has the capability. + */ + hasCapability(id: string, capability: string | Capability): boolean { + capability = `${capability}`.toLowerCase(); + return ( + this.componentData.has(id) && + this.componentData.get(id)!.capabilities.includes(capability) + ); + } + + /** + * Gets the component with the given ID. + * + * @param id The ID of the component to get. + * @returns The component with the given name or undefined if not found. + */ + getComponent(id: string): IComponent | undefined { + return this.componentData.get(id)?.component; + } + + /** + * Gets all the components with the specified capability. + * + * @param capability The capability of the component. + * @param sorted Whether to return list ordered by weights. + * @returns The components that match the specified capability. + */ + getComponents( + capability: string | Capability, + sorted: boolean, + ): T[] { + capability = `${capability}`.toLowerCase(); + const componentIds = this.capabilityToComponentIds.get(capability); + if (!componentIds) { + return []; + } + const components: T[] = []; + if (sorted) { + const componentDataList: ComponentDatum[] = []; + componentIds.forEach((id) => { + componentDataList.push(this.componentData.get(id)!); + }); + componentDataList.sort(function (a, b) { + return a.weight - b.weight; + }); + componentDataList.forEach(function (componentDatum) { + components.push(componentDatum.component as T); + }); + } else { + componentIds.forEach((id) => { + components.push(this.componentData.get(id)!.component as T); + }); + } + return components; + } +} + +export namespace ComponentManager { + export enum ComponentWeight { + // The toolbox weight is lower (higher precedence) than the flyout, so that + // if both are under the pointer, the toolbox takes precedence even though + // the flyout's drag target area is large enough to include the toolbox. + TOOLBOX_WEIGHT = 0, + FLYOUT_WEIGHT = 1, + TRASHCAN_WEIGHT = 2, + ZOOM_CONTROLS_WEIGHT = 3, + } + + /** An object storing component information. */ + export interface ComponentDatum { + component: IComponent; + capabilities: Array>; + weight: number; + } +} + +export type ComponentWeight = ComponentManager.ComponentWeight; +export const ComponentWeight = ComponentManager.ComponentWeight; +export type ComponentDatum = ComponentManager.ComponentDatum; diff --git a/core/config.ts b/core/config.ts new file mode 100644 index 00000000000..9def1dca4e9 --- /dev/null +++ b/core/config.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.config + +/** + * All the values that we expect developers to be able to change + * before injecting Blockly. + */ +interface Config { + dragRadius: number; + flyoutDragRadius: number; + snapRadius: number; + currentConnectionPreference: number; + bumpDelay: number; + connectingSnapRadius: number; +} + +/** Default snap radius. */ +const DEFAULT_SNAP_RADIUS = 28; + +/** + * Object holding all the values on Blockly that we expect developers to be + * able to change. + */ +export const config: Config = { + /** + * Number of pixels the mouse must move before a drag starts. + * + */ + dragRadius: 5, + /** + * Number of pixels the mouse must move before a drag/scroll starts from the + * flyout. Because the drag-intention is determined when this is reached, it + * is larger than dragRadius so that the drag-direction is clearer. + * + */ + flyoutDragRadius: 10, + /** + * Maximum misalignment between connections for them to snap together. + * + */ + snapRadius: DEFAULT_SNAP_RADIUS, + /** + * Maximum misalignment between connections for them to snap together. + * This should be the same as the snap radius. + */ + connectingSnapRadius: DEFAULT_SNAP_RADIUS, + /** + * How much to prefer staying connected to the current connection over moving + * to a new connection. The current previewed connection is considered to be + * this much closer to the matching connection on the block than it actually + * is. + * + */ + currentConnectionPreference: 8, + /** + * Delay in ms between trigger and bumping unconnected block out of alignment. + * + */ + bumpDelay: 250, +}; diff --git a/core/connection.js b/core/connection.js deleted file mode 100644 index fc13cfaf08a..00000000000 --- a/core/connection.js +++ /dev/null @@ -1,663 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2011 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Components for creating connections between blocks. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Connection'); - -goog.require('goog.asserts'); -goog.require('goog.dom'); - - -/** - * Class for a connection between blocks. - * @param {!Blockly.Block} source The block establishing this connection. - * @param {number} type The type of the connection. - * @constructor - */ -Blockly.Connection = function(source, type) { - /** - * @type {!Blockly.Block} - * @private - */ - this.sourceBlock_ = source; - /** @type {number} */ - this.type = type; - // Shortcut for the databases for this connection's workspace. - if (source.workspace.connectionDBList) { - this.db_ = source.workspace.connectionDBList[type]; - this.dbOpposite_ = - source.workspace.connectionDBList[Blockly.OPPOSITE_TYPE[type]]; - this.hidden_ = !this.db_; - } -}; - -/** - * Constants for checking whether two connections are compatible. - */ -Blockly.Connection.CAN_CONNECT = 0; -Blockly.Connection.REASON_SELF_CONNECTION = 1; -Blockly.Connection.REASON_WRONG_TYPE = 2; -Blockly.Connection.REASON_TARGET_NULL = 3; -Blockly.Connection.REASON_CHECKS_FAILED = 4; -Blockly.Connection.REASON_DIFFERENT_WORKSPACES = 5; -Blockly.Connection.REASON_SHADOW_PARENT = 6; - -/** - * Connection this connection connects to. Null if not connected. - * @type {Blockly.Connection} - */ -Blockly.Connection.prototype.targetConnection = null; - -/** - * List of compatible value types. Null if all types are compatible. - * @type {Array} - * @private - */ -Blockly.Connection.prototype.check_ = null; - -/** - * DOM representation of a shadow block, or null if none. - * @type {Element} - * @private - */ -Blockly.Connection.prototype.shadowDom_ = null; - -/** - * Horizontal location of this connection. - * @type {number} - * @private - */ -Blockly.Connection.prototype.x_ = 0; - -/** - * Vertical location of this connection. - * @type {number} - * @private - */ -Blockly.Connection.prototype.y_ = 0; - -/** - * Has this connection been added to the connection database? - * @type {boolean} - * @private - */ -Blockly.Connection.prototype.inDB_ = false; - -/** - * Connection database for connections of this type on the current workspace. - * @type {Blockly.ConnectionDB} - * @private - */ -Blockly.Connection.prototype.db_ = null; - -/** - * Connection database for connections compatible with this type on the - * current workspace. - * @type {Blockly.ConnectionDB} - * @private - */ -Blockly.Connection.prototype.dbOpposite_ = null; - -/** - * Whether this connections is hidden (not tracked in a database) or not. - * @type {boolean} - * @private - */ -Blockly.Connection.prototype.hidden_ = null; - -/** - * Connect two connections together. This is the connection on the superior - * block. - * @param {!Blockly.Connection} childConnection Connection on inferior block. - * @private - */ -Blockly.Connection.prototype.connect_ = function(childConnection) { - var parentConnection = this; - var parentBlock = parentConnection.getSourceBlock(); - var childBlock = childConnection.getSourceBlock(); - // Disconnect any existing parent on the child connection. - if (childConnection.isConnected()) { - childConnection.disconnect(); - } - if (parentConnection.isConnected()) { - // Other connection is already connected to something. - // Disconnect it and reattach it or bump it as needed. - var orphanBlock = parentConnection.targetBlock(); - var shadowDom = parentConnection.getShadowDom(); - // Temporarily set the shadow DOM to null so it does not respawn. - parentConnection.setShadowDom(null); - // Displaced shadow blocks dissolve rather than reattaching or bumping. - if (orphanBlock.isShadow()) { - // Save the shadow block so that field values are preserved. - shadowDom = Blockly.Xml.blockToDom(orphanBlock); - orphanBlock.dispose(); - orphanBlock = null; - } else if (parentConnection.type == Blockly.INPUT_VALUE) { - // Value connections. - // If female block is already connected, disconnect and bump the male. - if (!orphanBlock.outputConnection) { - throw 'Orphan block does not have an output connection.'; - } - // Attempt to reattach the orphan at the end of the newly inserted - // block. Since this block may be a row, walk down to the end - // or to the first (and only) shadow block. - var connection = Blockly.Connection.lastConnectionInRow_( - childBlock, orphanBlock); - if (connection) { - orphanBlock.outputConnection.connect(connection); - orphanBlock = null; - } - } else if (parentConnection.type == Blockly.NEXT_STATEMENT) { - // Statement connections. - // Statement blocks may be inserted into the middle of a stack. - // Split the stack. - if (!orphanBlock.previousConnection) { - throw 'Orphan block does not have a previous connection.'; - } - // Attempt to reattach the orphan at the bottom of the newly inserted - // block. Since this block may be a stack, walk down to the end. - var newBlock = childBlock; - while (newBlock.nextConnection) { - var nextBlock = newBlock.getNextBlock(); - if (nextBlock && !nextBlock.isShadow()) { - newBlock = nextBlock; - } else { - if (orphanBlock.previousConnection.checkType_( - newBlock.nextConnection)) { - newBlock.nextConnection.connect(orphanBlock.previousConnection); - orphanBlock = null; - } - break; - } - } - } - if (orphanBlock) { - // Unable to reattach orphan. - parentConnection.disconnect(); - if (Blockly.Events.recordUndo) { - // Bump it off to the side after a moment. - var group = Blockly.Events.getGroup(); - setTimeout(function() { - // Verify orphan hasn't been deleted or reconnected (user on meth). - if (orphanBlock.workspace && !orphanBlock.getParent()) { - Blockly.Events.setGroup(group); - if (orphanBlock.outputConnection) { - orphanBlock.outputConnection.bumpAwayFrom_(parentConnection); - } else if (orphanBlock.previousConnection) { - orphanBlock.previousConnection.bumpAwayFrom_(parentConnection); - } - Blockly.Events.setGroup(false); - } - }, Blockly.BUMP_DELAY); - } - } - // Restore the shadow DOM. - parentConnection.setShadowDom(shadowDom); - } - - var event; - if (Blockly.Events.isEnabled()) { - event = new Blockly.Events.BlockMove(childBlock); - } - // Establish the connections. - Blockly.Connection.connectReciprocally_(parentConnection, childConnection); - // Demote the inferior block so that one is a child of the superior one. - childBlock.setParent(parentBlock); - if (event) { - event.recordNew(); - Blockly.Events.fire(event); - } -}; - -/** - * Sever all links to this connection (not including from the source object). - */ -Blockly.Connection.prototype.dispose = function() { - if (this.isConnected()) { - throw 'Disconnect connection before disposing of it.'; - } - if (this.inDB_) { - this.db_.removeConnection_(this); - } - this.db_ = null; - this.dbOpposite_ = null; -}; - -/** - * Get the source block for this connection. - * @return {Blockly.Block} The source block, or null if there is none. - */ -Blockly.Connection.prototype.getSourceBlock = function() { - return this.sourceBlock_; -}; - -/** - * Does the connection belong to a superior block (higher in the source stack)? - * @return {boolean} True if connection faces down or right. - */ -Blockly.Connection.prototype.isSuperior = function() { - return this.type == Blockly.INPUT_VALUE || - this.type == Blockly.NEXT_STATEMENT; -}; - -/** - * Is the connection connected? - * @return {boolean} True if connection is connected to another connection. - */ -Blockly.Connection.prototype.isConnected = function() { - return !!this.targetConnection; -}; - -/** - * Checks whether the current connection can connect with the target - * connection. - * @param {Blockly.Connection} target Connection to check compatibility with. - * @return {number} Blockly.Connection.CAN_CONNECT if the connection is legal, - * an error code otherwise. - * @private - */ -Blockly.Connection.prototype.canConnectWithReason_ = function(target) { - if (!target) { - return Blockly.Connection.REASON_TARGET_NULL; - } - if (this.isSuperior()) { - var blockA = this.sourceBlock_; - var blockB = target.getSourceBlock(); - } else { - var blockB = this.sourceBlock_; - var blockA = target.getSourceBlock(); - } - if (blockA && blockA == blockB) { - return Blockly.Connection.REASON_SELF_CONNECTION; - } else if (target.type != Blockly.OPPOSITE_TYPE[this.type]) { - return Blockly.Connection.REASON_WRONG_TYPE; - } else if (blockA && blockB && blockA.workspace !== blockB.workspace) { - return Blockly.Connection.REASON_DIFFERENT_WORKSPACES; - } else if (!this.checkType_(target)) { - return Blockly.Connection.REASON_CHECKS_FAILED; - } else if (blockA.isShadow() && !blockB.isShadow()) { - return Blockly.Connection.REASON_SHADOW_PARENT; - } - return Blockly.Connection.CAN_CONNECT; -}; - -/** - * Checks whether the current connection and target connection are compatible - * and throws an exception if they are not. - * @param {Blockly.Connection} target The connection to check compatibility - * with. - * @private - */ -Blockly.Connection.prototype.checkConnection_ = function(target) { - switch (this.canConnectWithReason_(target)) { - case Blockly.Connection.CAN_CONNECT: - break; - case Blockly.Connection.REASON_SELF_CONNECTION: - throw 'Attempted to connect a block to itself.'; - case Blockly.Connection.REASON_DIFFERENT_WORKSPACES: - // Usually this means one block has been deleted. - throw 'Blocks not on same workspace.'; - case Blockly.Connection.REASON_WRONG_TYPE: - throw 'Attempt to connect incompatible types.'; - case Blockly.Connection.REASON_TARGET_NULL: - throw 'Target connection is null.'; - case Blockly.Connection.REASON_CHECKS_FAILED: - var msg = 'Connection checks failed. '; - msg += this + ' expected ' + this.check_ + ', found ' + target.check_; - throw msg; - case Blockly.Connection.REASON_SHADOW_PARENT: - throw 'Connecting non-shadow to shadow block.'; - default: - throw 'Unknown connection failure: this should never happen!'; - } -}; - -/** - * Check if the two connections can be dragged to connect to each other. - * @param {!Blockly.Connection} candidate A nearby connection to check. - * @return {boolean} True if the connection is allowed, false otherwise. - */ -Blockly.Connection.prototype.isConnectionAllowed = function(candidate) { - // Type checking. - var canConnect = this.canConnectWithReason_(candidate); - if (canConnect != Blockly.Connection.CAN_CONNECT) { - return false; - } - - // Don't offer to connect an already connected left (male) value plug to - // an available right (female) value plug. Don't offer to connect the - // bottom of a statement block to one that's already connected. - if (candidate.type == Blockly.OUTPUT_VALUE || - candidate.type == Blockly.PREVIOUS_STATEMENT) { - if (candidate.isConnected() || this.isConnected()) { - return false; - } - } - - // Offering to connect the left (male) of a value block to an already - // connected value pair is ok, we'll splice it in. - // However, don't offer to splice into an immovable block. - if (candidate.type == Blockly.INPUT_VALUE && candidate.isConnected() && - !candidate.targetBlock().isMovable() && - !candidate.targetBlock().isShadow()) { - return false; - } - - // Don't let a block with no next connection bump other blocks out of the - // stack. But covering up a shadow block or stack of shadow blocks is fine. - // Similarly, replacing a terminal statement with another terminal statement - // is allowed. - if (this.type == Blockly.PREVIOUS_STATEMENT && - candidate.isConnected() && - !this.sourceBlock_.nextConnection && - !candidate.targetBlock().isShadow() && - candidate.targetBlock().nextConnection) { - return false; - } - - // Don't let blocks try to connect to themselves or ones they nest. - if (Blockly.draggingConnections_.indexOf(candidate) != -1) { - return false; - } - - return true; -}; - -/** - * Connect this connection to another connection. - * @param {!Blockly.Connection} otherConnection Connection to connect to. - */ -Blockly.Connection.prototype.connect = function(otherConnection) { - if (this.targetConnection == otherConnection) { - // Already connected together. NOP. - return; - } - this.checkConnection_(otherConnection); - // Determine which block is superior (higher in the source stack). - if (this.isSuperior()) { - // Superior block. - this.connect_(otherConnection); - } else { - // Inferior block. - otherConnection.connect_(this); - } -}; - -/** - * Update two connections to target each other. - * @param {Blockly.Connection} first The first connection to update. - * @param {Blockly.Connection} second The second connection to update. - * @private - */ -Blockly.Connection.connectReciprocally_ = function(first, second) { - goog.asserts.assert(first && second, 'Cannot connect null connections.'); - first.targetConnection = second; - second.targetConnection = first; -}; - -/** - * Does the given block have one and only one connection point that will accept - * an orphaned block? - * @param {!Blockly.Block} block The superior block. - * @param {!Blockly.Block} orphanBlock The inferior block. - * @return {Blockly.Connection} The suitable connection point on 'block', - * or null. - * @private - */ -Blockly.Connection.singleConnection_ = function(block, orphanBlock) { - var connection = false; - for (var i = 0; i < block.inputList.length; i++) { - var thisConnection = block.inputList[i].connection; - if (thisConnection && thisConnection.type == Blockly.INPUT_VALUE && - orphanBlock.outputConnection.checkType_(thisConnection)) { - if (connection) { - return null; // More than one connection. - } - connection = thisConnection; - } - } - return connection; -}; - -/** - * Walks down a row a blocks, at each stage checking if there are any - * connections that will accept the orphaned block. If at any point there - * are zero or multiple eligible connections, returns null. Otherwise - * returns the only input on the last block in the chain. - * Terminates early for shadow blocks. - * @param {!Blockly.Block} startBlock The block on which to start the search. - * @param {!Blockly.Block} orphanBlock The block that is looking for a home. - * @return {Blockly.Connection} The suitable connection point on the chain - * of blocks, or null. - * @private - */ -Blockly.Connection.lastConnectionInRow_ = function(startBlock, orphanBlock) { - var newBlock = startBlock; - var connection; - while (connection = Blockly.Connection.singleConnection_( - /** @type {!Blockly.Block} */ (newBlock), orphanBlock)) { - // '=' is intentional in line above. - newBlock = connection.targetBlock(); - if (!newBlock || newBlock.isShadow()) { - return connection; - } - } - return null; -}; - -/** - * Disconnect this connection. - */ -Blockly.Connection.prototype.disconnect = function() { - var otherConnection = this.targetConnection; - goog.asserts.assert(otherConnection, 'Source connection not connected.'); - goog.asserts.assert(otherConnection.targetConnection == this, - 'Target connection not connected to source connection.'); - - var parentBlock, childBlock, parentConnection; - if (this.isSuperior()) { - // Superior block. - parentBlock = this.sourceBlock_; - childBlock = otherConnection.getSourceBlock(); - parentConnection = this; - } else { - // Inferior block. - parentBlock = otherConnection.getSourceBlock(); - childBlock = this.sourceBlock_; - parentConnection = otherConnection; - } - this.disconnectInternal_(parentBlock, childBlock); - parentConnection.respawnShadow_(); -}; - -/** - * Disconnect two blocks that are connected by this connection. - * @param {!Blockly.Block} parentBlock The superior block. - * @param {!Blockly.Block} childBlock The inferior block. - * @private - */ -Blockly.Connection.prototype.disconnectInternal_ = function(parentBlock, - childBlock) { - var event; - if (Blockly.Events.isEnabled()) { - event = new Blockly.Events.BlockMove(childBlock); - } - var otherConnection = this.targetConnection; - otherConnection.targetConnection = null; - this.targetConnection = null; - childBlock.setParent(null); - if (event) { - event.recordNew(); - Blockly.Events.fire(event); - } -}; - -/** - * Respawn the shadow block if there was one connected to the this connection. - * @private - */ -Blockly.Connection.prototype.respawnShadow_ = function() { - var parentBlock = this.getSourceBlock(); - var shadow = this.getShadowDom(); - if (parentBlock.workspace && shadow && Blockly.Events.recordUndo) { - var blockShadow = - Blockly.Xml.domToBlock(shadow, parentBlock.workspace); - if (blockShadow.outputConnection) { - this.connect(blockShadow.outputConnection); - } else if (blockShadow.previousConnection) { - this.connect(blockShadow.previousConnection); - } else { - throw 'Child block does not have output or previous statement.'; - } - } -}; - -/** - * Returns the block that this connection connects to. - * @return {Blockly.Block} The connected block or null if none is connected. - */ -Blockly.Connection.prototype.targetBlock = function() { - if (this.isConnected()) { - return this.targetConnection.getSourceBlock(); - } - return null; -}; - -/** - * Is this connection compatible with another connection with respect to the - * value type system. E.g. square_root("Hello") is not compatible. - * @param {!Blockly.Connection} otherConnection Connection to compare against. - * @return {boolean} True if the connections share a type. - * @private - */ -Blockly.Connection.prototype.checkType_ = function(otherConnection) { - if (!this.check_ || !otherConnection.check_) { - // One or both sides are promiscuous enough that anything will fit. - return true; - } - // Find any intersection in the check lists. - for (var i = 0; i < this.check_.length; i++) { - if (otherConnection.check_.indexOf(this.check_[i]) != -1) { - return true; - } - } - // No intersection. - return false; -}; - -/** - * Function to be called when this connection's compatible types have changed. - * @private - */ -Blockly.Connection.prototype.onCheckChanged_ = function() { - // The new value type may not be compatible with the existing connection. - if (this.isConnected() && !this.checkType_(this.targetConnection)) { - var child = this.isSuperior() ? this.targetBlock() : this.sourceBlock_; - child.unplug(); - } -}; - -/** - * Change a connection's compatibility. - * @param {*} check Compatible value type or list of value types. - * Null if all types are compatible. - * @return {!Blockly.Connection} The connection being modified - * (to allow chaining). - */ -Blockly.Connection.prototype.setCheck = function(check) { - if (check) { - // Ensure that check is in an array. - if (!goog.isArray(check)) { - check = [check]; - } - this.check_ = check; - this.onCheckChanged_(); - } else { - this.check_ = null; - } - return this; -}; - -/** - * Change a connection's shadow block. - * @param {Element} shadow DOM representation of a block or null. - */ -Blockly.Connection.prototype.setShadowDom = function(shadow) { - this.shadowDom_ = shadow; -}; - -/** - * Return a connection's shadow block. - * @return {Element} shadow DOM representation of a block or null. - */ -Blockly.Connection.prototype.getShadowDom = function() { - return this.shadowDom_; -}; - -/** - * Find all nearby compatible connections to this connection. - * Type checking does not apply, since this function is used for bumping. - * - * Headless configurations (the default) do not have neighboring connection, - * and always return an empty list (the default). - * {@link Blockly.RenderedConnection} overrides this behavior with a list - * computed from the rendered positioning. - * @param {number} maxLimit The maximum radius to another connection. - * @return {!Array.} List of connections. - * @private - */ -Blockly.Connection.prototype.neighbours_ = function(/* maxLimit */) { - return []; -}; - -/** - * This method returns a string describing this Connection in developer terms - * (English only). Intended to on be used in console logs and errors. - * @return {string} The description. - */ -Blockly.Connection.prototype.toString = function() { - var msg; - var block = this.sourceBlock_; - if (!block) { - return 'Orphan Connection'; - } else if (block.outputConnection == this) { - msg = 'Output Connection of '; - } else if (block.previousConnection == this) { - msg = 'Previous Connection of '; - } else if (block.nextConnection == this) { - msg = 'Next Connection of '; - } else { - var parentInput = goog.array.find(block.inputList, function(input) { - return input.connection == this; - }, this); - if (parentInput) { - msg = 'Input "' + parentInput.name + '" connection on '; - } else { - console.warn('Connection not actually connected to sourceBlock_'); - return 'Orphan Connection'; - } - } - return msg + block.toDevString(); -}; diff --git a/core/connection.ts b/core/connection.ts new file mode 100644 index 00000000000..a79b7b9b143 --- /dev/null +++ b/core/connection.ts @@ -0,0 +1,804 @@ +/** + * @license + * Copyright 2011 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Components for creating connections between blocks. + * + * @class + */ +// Former goog.module ID: Blockly.Connection + +import type {Block} from './block.js'; +import {ConnectionType} from './connection_type.js'; +import type {BlockMove} from './events/events_block_move.js'; +import {EventType} from './events/type.js'; +import * as eventUtils from './events/utils.js'; +import type {Input} from './inputs/input.js'; +import type {IConnectionChecker} from './interfaces/i_connection_checker.js'; +import * as blocks from './serialization/blocks.js'; +import {idGenerator} from './utils.js'; +import * as Xml from './xml.js'; + +/** + * Class for a connection between blocks. + */ +export class Connection { + /** Constants for checking whether two connections are compatible. */ + static CAN_CONNECT = 0; + static REASON_SELF_CONNECTION = 1; + static REASON_WRONG_TYPE = 2; + static REASON_TARGET_NULL = 3; + static REASON_CHECKS_FAILED = 4; + static REASON_DIFFERENT_WORKSPACES = 5; + static REASON_SHADOW_PARENT = 6; + static REASON_DRAG_CHECKS_FAILED = 7; + static REASON_PREVIOUS_AND_OUTPUT = 8; + + protected sourceBlock_: Block; + + /** Connection this connection connects to. Null if not connected. */ + targetConnection: Connection | null = null; + + /** + * Has this connection been disposed of? + * + * @internal + */ + disposed = false; + + /** List of compatible value types. Null if all types are compatible. */ + private check: string[] | null = null; + + /** DOM representation of a shadow block, or null if none. */ + private shadowDom: Element | null = null; + + /** The unique ID of this connection. */ + id: string; + + /** + * Horizontal location of this connection. + * + * @internal + */ + x = 0; + + /** + * Vertical location of this connection. + * + * @internal + */ + y = 0; + + private shadowState: blocks.State | null = null; + + /** + * @param source The block establishing this connection. + * @param type The type of the connection. + */ + constructor( + source: Block, + public type: number, + ) { + this.sourceBlock_ = source; + if (source.id.includes('_connection')) { + throw new Error( + `Connection ID indicator is contained in block ID. This will cause ` + + `problems with focus: ${source.id}.`, + ); + } + this.id = `${source.id}_connection_${idGenerator.getNextUniqueId()}`; + } + + /** + * Connect two connections together. This is the connection on the superior + * block. + * + * @param childConnection Connection on inferior block. + */ + protected connect_(childConnection: Connection) { + const INPUT = ConnectionType.INPUT_VALUE; + const parentBlock = this.getSourceBlock(); + const childBlock = childConnection.getSourceBlock(); + + // Make sure the childConnection is available. + if (childConnection.isConnected()) { + childConnection.disconnectInternal(false); + } + + // Make sure the parentConnection is available. + let orphan; + if (this.isConnected()) { + const shadowState = this.stashShadowState(); + const target = this.targetBlock(); + if (target!.isShadow()) { + target!.dispose(false); + } else { + this.disconnectInternal(); + orphan = target; + } + this.applyShadowState(shadowState); + } + + // Connect the new connection to the parent. + let event; + if (eventUtils.isEnabled()) { + event = new (eventUtils.get(EventType.BLOCK_MOVE))( + childBlock, + ) as BlockMove; + event.setReason(['connect']); + } + connectReciprocally(this, childConnection); + childBlock.setParent(parentBlock); + if (event) { + event.recordNew(); + eventUtils.fire(event); + } + + // Deal with the orphan if it exists. + if (orphan) { + const orphanConnection = + this.type === INPUT + ? orphan.outputConnection + : orphan.previousConnection; + if (!orphanConnection) return; + const connection = Connection.getConnectionForOrphanedConnection( + childBlock, + orphanConnection, + ); + if (connection) { + orphanConnection.connect(connection); + } else { + orphanConnection.onFailedConnect(this); + } + } + } + + /** + * Dispose of this connection and deal with connected blocks. + * + * @internal + */ + dispose() { + // isConnected returns true for shadows and non-shadows. + if (this.isConnected()) { + if (this.isSuperior()) { + // Destroy the attached shadow block & its children (if it exists). + this.setShadowStateInternal(); + } + + const targetBlock = this.targetBlock(); + if (targetBlock && !targetBlock.isDeadOrDying()) { + // Disconnect the attached normal block. + targetBlock.unplug(); + } + } + + this.disposed = true; + } + + /** + * Get the source block for this connection. + * + * @returns The source block. + */ + getSourceBlock(): Block { + return this.sourceBlock_; + } + + /** + * Does the connection belong to a superior block (higher in the source + * stack)? + * + * @returns True if connection faces down or right. + */ + isSuperior(): boolean { + return ( + this.type === ConnectionType.INPUT_VALUE || + this.type === ConnectionType.NEXT_STATEMENT + ); + } + + /** + * Is the connection connected? + * + * @returns True if connection is connected to another connection. + */ + isConnected(): boolean { + return !!this.targetConnection; + } + + /** + * Get the workspace's connection type checker object. + * + * @returns The connection type checker for the source block's workspace. + * @internal + */ + getConnectionChecker(): IConnectionChecker { + return this.sourceBlock_.workspace.connectionChecker; + } + + /** + * Called when an attempted connection fails. NOP by default (i.e. for + * headless workspaces). + * + * @param _superiorConnection Connection that this connection failed to connect + * to. The provided connection should be the superior connection. + * @internal + */ + onFailedConnect(_superiorConnection: Connection) {} + // NOP + + /** + * Connect this connection to another connection. + * + * @param otherConnection Connection to connect to. + * @returns Whether the blocks are now connected or not. + */ + connect(otherConnection: Connection): boolean { + if (this.targetConnection === otherConnection) { + // Already connected together. NOP. + return true; + } + + const checker = this.getConnectionChecker(); + if (checker.canConnect(this, otherConnection, false)) { + const existingGroup = eventUtils.getGroup(); + if (!existingGroup) { + eventUtils.setGroup(true); + } + // Determine which block is superior (higher in the source stack). + if (this.isSuperior()) { + // Superior block. + this.connect_(otherConnection); + } else { + // Inferior block. + otherConnection.connect_(this); + } + eventUtils.setGroup(existingGroup); + } + + return this.isConnected(); + } + + /** + * Disconnect this connection. + */ + disconnect() { + this.disconnectInternal(); + } + + /** + * Disconnect two blocks that are connected by this connection. + * + * @param setParent Whether to set the parent of the disconnected block or + * not, defaults to true. + * If you do not set the parent, ensure that a subsequent action does, + * otherwise the view and model will be out of sync. + */ + protected disconnectInternal(setParent = true) { + const {parentConnection, childConnection} = + this.getParentAndChildConnections(); + if (!parentConnection || !childConnection) { + throw Error('Source connection not connected.'); + } + + const existingGroup = eventUtils.getGroup(); + if (!existingGroup) { + eventUtils.setGroup(true); + } + + let event; + if (eventUtils.isEnabled()) { + event = new (eventUtils.get(EventType.BLOCK_MOVE))( + childConnection.getSourceBlock(), + ) as BlockMove; + event.setReason(['disconnect']); + } + const otherConnection = this.targetConnection; + if (otherConnection) { + otherConnection.targetConnection = null; + } + this.targetConnection = null; + if (setParent) childConnection.getSourceBlock().setParent(null); + if (event) { + event.recordNew(); + eventUtils.fire(event); + } + + if (!childConnection.getSourceBlock().isShadow()) { + // If we were disconnecting a shadow, no need to spawn a new one. + parentConnection.respawnShadow_(); + } + + eventUtils.setGroup(existingGroup); + } + + /** + * Returns the parent connection (superior) and child connection (inferior) + * given this connection and the connection it is connected to. + * + * @returns The parent connection and child connection, given this connection + * and the connection it is connected to. + */ + protected getParentAndChildConnections(): { + parentConnection?: Connection; + childConnection?: Connection; + } { + if (!this.targetConnection) return {}; + if (this.isSuperior()) { + return { + parentConnection: this, + childConnection: this.targetConnection, + }; + } + return { + parentConnection: this.targetConnection, + childConnection: this, + }; + } + + /** + * Respawn the shadow block if there was one connected to the this connection. + */ + protected respawnShadow_() { + // Have to keep respawnShadow_ for backwards compatibility. + this.createShadowBlock(true); + } + + /** + * Reconnects this connection to the input with the given name on the given + * block. If there is already a connection connected to that input, that + * connection is disconnected. + * + * @param block The block to connect this connection to. + * @param inputName The name of the input to connect this connection to. + * @returns True if this connection was able to connect, false otherwise. + */ + reconnect(block: Block, inputName: string): boolean { + // No need to reconnect if this connection's block is deleted. + if (this.getSourceBlock().isDeadOrDying()) return false; + + const connectionParent = block.getInput(inputName)?.connection; + const currentParent = this.targetBlock(); + if ( + (!currentParent || currentParent === block) && + connectionParent && + connectionParent.targetConnection !== this + ) { + if (connectionParent.isConnected()) { + // There's already something connected here. Get rid of it. + connectionParent.disconnect(); + } + connectionParent.connect(this); + return true; + } + return false; + } + + /** + * Returns the block that this connection connects to. + * + * @returns The connected block or null if none is connected. + */ + targetBlock(): Block | null { + if (this.isConnected()) { + return this.targetConnection?.getSourceBlock() ?? null; + } + return null; + } + + /** + * Function to be called when this connection's compatible types have changed. + */ + protected onCheckChanged_() { + // The new value type may not be compatible with the existing connection. + if ( + this.isConnected() && + (!this.targetConnection || + !this.getConnectionChecker().canConnect( + this, + this.targetConnection, + false, + )) + ) { + const child = this.isSuperior() ? this.targetBlock() : this.sourceBlock_; + child!.unplug(); + } + } + + /** + * Change a connection's compatibility. + * + * @param check Compatible value type or list of value types. Null if all + * types are compatible. + * @returns The connection being modified (to allow chaining). + */ + setCheck(check: string | string[] | null): Connection { + if (check) { + if (!Array.isArray(check)) { + check = [check]; + } + this.check = check; + this.onCheckChanged_(); + } else { + this.check = null; + } + return this; + } + + /** + * Get a connection's compatibility. + * + * @returns List of compatible value types. + * Null if all types are compatible. + */ + getCheck(): string[] | null { + return this.check; + } + + /** + * Changes the connection's shadow block. + * + * @param shadowDom DOM representation of a block or null. + */ + setShadowDom(shadowDom: Element | null) { + this.setShadowStateInternal({shadowDom}); + } + + /** + * Returns the xml representation of the connection's shadow block. + * + * @param returnCurrent If true, and the shadow block is currently attached to + * this connection, this serializes the state of that block and returns it + * (so that field values are correct). Otherwise the saved shadowDom is + * just returned. + * @returns Shadow DOM representation of a block or null. + */ + getShadowDom(returnCurrent?: boolean): Element | null { + return returnCurrent && this.targetBlock()!.isShadow() + ? (Xml.blockToDom(this.targetBlock() as Block) as Element) + : this.shadowDom; + } + + /** + * Changes the connection's shadow block. + * + * @param shadowState An state represetation of the block or null. + */ + setShadowState(shadowState: blocks.State | null) { + this.setShadowStateInternal({shadowState}); + } + + /** + * Returns the serialized object representation of the connection's shadow + * block. + * + * @param returnCurrent If true, and the shadow block is currently attached to + * this connection, this serializes the state of that block and returns it + * (so that field values are correct). Otherwise the saved state is just + * returned. + * @returns Serialized object representation of the block, or null. + */ + getShadowState(returnCurrent?: boolean): blocks.State | null { + if (returnCurrent && this.targetBlock() && this.targetBlock()!.isShadow()) { + return blocks.save(this.targetBlock() as Block); + } + return this.shadowState; + } + + /** + * Find all nearby compatible connections to this connection. + * Type checking does not apply, since this function is used for bumping. + * + * Headless configurations (the default) do not have neighboring connection, + * and always return an empty list (the default). + * {@link (RenderedConnection:class).neighbours} overrides this behavior with a list + * computed from the rendered positioning. + * + * @param _maxLimit The maximum radius to another connection. + * @returns List of connections. + * @internal + */ + neighbours(_maxLimit: number): Connection[] { + return []; + } + + /** + * Get the parent input of a connection. + * + * @returns The input that the connection belongs to or null if no parent + * exists. + * @internal + */ + getParentInput(): Input | null { + let parentInput = null; + const inputs = this.sourceBlock_.inputList; + for (let i = 0; i < inputs.length; i++) { + if (inputs[i].connection === this) { + parentInput = inputs[i]; + break; + } + } + return parentInput; + } + + /** + * This method returns a string describing this Connection in developer terms + * (English only). Intended to on be used in console logs and errors. + * + * @returns The description. + */ + toString(): string { + const block = this.sourceBlock_; + if (!block) { + return 'Orphan Connection'; + } + let msg; + if (block.outputConnection === this) { + msg = 'Output Connection of '; + } else if (block.previousConnection === this) { + msg = 'Previous Connection of '; + } else if (block.nextConnection === this) { + msg = 'Next Connection of '; + } else { + let parentInput = null; + for (let i = 0, input; (input = block.inputList[i]); i++) { + if (input.connection === this) { + parentInput = input; + break; + } + } + if (parentInput) { + msg = 'Input "' + parentInput.name + '" connection on '; + } else { + console.warn('Connection not actually connected to sourceBlock_'); + return 'Orphan Connection'; + } + } + return msg + block.toDevString(); + } + + /** + * Returns the state of the shadowDom_ and shadowState_ properties, then + * temporarily sets those properties to null so no shadow respawns. + * + * @returns The state of both the shadowDom_ and shadowState_ properties. + */ + private stashShadowState(): { + shadowDom: Element | null; + shadowState: blocks.State | null; + } { + const shadowDom = this.getShadowDom(true); + const shadowState = this.getShadowState(true); + // Set to null so it doesn't respawn. + this.shadowDom = null; + this.shadowState = null; + return {shadowDom, shadowState}; + } + + /** + * Reapplies the stashed state of the shadowDom_ and shadowState_ properties. + * + * @param param0 The state to reapply to the shadowDom_ and shadowState_ + * properties. + */ + private applyShadowState({ + shadowDom, + shadowState, + }: { + shadowDom: Element | null; + shadowState: blocks.State | null; + }) { + this.shadowDom = shadowDom; + this.shadowState = shadowState; + } + + /** + * Sets the state of the shadow of this connection. + * + * @param param0 The state to set the shadow of this connection to. + */ + private setShadowStateInternal({ + shadowDom = null, + shadowState = null, + }: { + shadowDom?: Element | null; + shadowState?: blocks.State | null; + } = {}) { + // One or both of these should always be null. + // If neither is null, the shadowState will get priority. + this.shadowDom = shadowDom; + this.shadowState = shadowState; + + if (this.getSourceBlock().isDeadOrDying()) return; + + const target = this.targetBlock(); + if (!target) { + this.respawnShadow_(); + if (this.targetBlock() && this.targetBlock()!.isShadow()) { + this.serializeShadow(this.targetBlock()); + } + } else if (target.isShadow()) { + target.dispose(false); + this.respawnShadow_(); + if (this.targetBlock() && this.targetBlock()!.isShadow()) { + this.serializeShadow(this.targetBlock()); + } + } else { + const shadow = this.createShadowBlock(false); + this.serializeShadow(shadow); + if (shadow) { + shadow.dispose(false); + } + } + } + + /** + * Creates a shadow block based on the current shadowState_ or shadowDom_. + * shadowState_ gets priority. + * + * @param attemptToConnect Whether to try to connect the shadow block to this + * connection or not. + * @returns The shadow block that was created, or null if both the + * shadowState_ and shadowDom_ are null. + */ + private createShadowBlock(attemptToConnect: boolean): Block | null { + const parentBlock = this.getSourceBlock(); + const shadowState = this.getShadowState(); + const shadowDom = this.getShadowDom(); + if (parentBlock.isDeadOrDying() || (!shadowState && !shadowDom)) { + return null; + } + + let blockShadow; + if (shadowState) { + blockShadow = blocks.appendInternal(shadowState, parentBlock.workspace, { + parentConnection: attemptToConnect ? this : undefined, + isShadow: true, + recordUndo: false, + }); + return blockShadow; + } + + if (shadowDom) { + blockShadow = Xml.domToBlockInternal(shadowDom, parentBlock.workspace); + if (attemptToConnect) { + if (this.type === ConnectionType.INPUT_VALUE) { + if (!blockShadow.outputConnection) { + throw new Error('Shadow block is missing an output connection'); + } + if (!this.connect(blockShadow.outputConnection)) { + throw new Error('Could not connect shadow block to connection'); + } + } else if (this.type === ConnectionType.NEXT_STATEMENT) { + if (!blockShadow.previousConnection) { + throw new Error('Shadow block is missing previous connection'); + } + if (!this.connect(blockShadow.previousConnection)) { + throw new Error('Could not connect shadow block to connection'); + } + } else { + throw new Error( + 'Cannot connect a shadow block to a previous/output connection', + ); + } + } + return blockShadow; + } + return null; + } + + /** + * Saves the given shadow block to both the shadowDom_ and shadowState_ + * properties, in their respective serialized forms. + * + * @param shadow The shadow to serialize, or null. + */ + private serializeShadow(shadow: Block | null) { + if (!shadow) { + return; + } + this.shadowDom = Xml.blockToDom(shadow) as Element; + this.shadowState = blocks.save(shadow); + } + + /** + * Returns the connection (starting at the startBlock) which will accept + * the given connection. This includes compatible connection types and + * connection checks. + * + * @param startBlock The block on which to start the search. + * @param orphanConnection The connection that is looking for a home. + * @returns The suitable connection point on the chain of blocks, or null. + */ + static getConnectionForOrphanedConnection( + startBlock: Block, + orphanConnection: Connection, + ): Connection | null { + if (orphanConnection.type === ConnectionType.OUTPUT_VALUE) { + return getConnectionForOrphanedOutput( + startBlock, + orphanConnection.getSourceBlock(), + ); + } + // Otherwise we're dealing with a stack. + const connection = startBlock.lastConnectionInStack(true); + const checker = orphanConnection.getConnectionChecker(); + if (connection && checker.canConnect(orphanConnection, connection, false)) { + return connection; + } + return null; + } +} + +/** + * Update two connections to target each other. + * + * @param first The first connection to update. + * @param second The second connection to update. + */ +function connectReciprocally(first: Connection, second: Connection) { + if (!first || !second) { + throw Error('Cannot connect null connections.'); + } + first.targetConnection = second; + second.targetConnection = first; +} +/** + * Returns the single connection on the block that will accept the orphaned + * block, if one can be found. If the block has multiple compatible connections + * (even if they are filled) this returns null. If the block has no compatible + * connections, this returns null. + * + * @param block The superior block. + * @param orphanBlock The inferior block. + * @returns The suitable connection point on 'block', or null. + */ +function getSingleConnection( + block: Block, + orphanBlock: Block, +): Connection | null { + let foundConnection = null; + const output = orphanBlock.outputConnection; + const typeChecker = output?.getConnectionChecker(); + + for (let i = 0, input; (input = block.inputList[i]); i++) { + const connection = input.connection; + if (connection && typeChecker?.canConnect(output, connection, false)) { + if (foundConnection) { + return null; // More than one connection. + } + foundConnection = connection; + } + } + return foundConnection; +} + +/** + * Walks down a row a blocks, at each stage checking if there are any + * connections that will accept the orphaned block. If at any point there + * are zero or multiple eligible connections, returns null. Otherwise + * returns the only input on the last block in the chain. + * Terminates early for shadow blocks. + * + * @param startBlock The block on which to start the search. + * @param orphanBlock The block that is looking for a home. + * @returns The suitable connection point on the chain of blocks, or null. + */ +function getConnectionForOrphanedOutput( + startBlock: Block, + orphanBlock: Block, +): Connection | null { + let newBlock: Block | null = startBlock; + let connection; + while ((connection = getSingleConnection(newBlock, orphanBlock))) { + newBlock = connection.targetBlock(); + if (!newBlock || newBlock.isShadow()) { + return connection; + } + } + return null; +} diff --git a/core/connection_checker.ts b/core/connection_checker.ts new file mode 100644 index 00000000000..6f5ecd5d5c1 --- /dev/null +++ b/core/connection_checker.ts @@ -0,0 +1,348 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * An object that encapsulates logic for checking whether a + * potential connection is safe and valid. + * + * @class + */ +// Former goog.module ID: Blockly.ConnectionChecker + +import * as common from './common.js'; +import {Connection} from './connection.js'; +import {ConnectionType} from './connection_type.js'; +import type {IConnectionChecker} from './interfaces/i_connection_checker.js'; +import * as internalConstants from './internal_constants.js'; +import * as registry from './registry.js'; +import type {RenderedConnection} from './rendered_connection.js'; + +/** + * Class for connection type checking logic. + */ +export class ConnectionChecker implements IConnectionChecker { + /** + * Check whether the current connection can connect with the target + * connection. + * + * @param a Connection to check compatibility with. + * @param b Connection to check compatibility with. + * @param isDragging True if the connection is being made by dragging a block. + * @param opt_distance The max allowable distance between the connections for + * drag checks. + * @returns Whether the connection is legal. + */ + canConnect( + a: Connection | null, + b: Connection | null, + isDragging: boolean, + opt_distance?: number, + ): boolean { + return ( + this.canConnectWithReason(a, b, isDragging, opt_distance) === + Connection.CAN_CONNECT + ); + } + + /** + * Checks whether the current connection can connect with the target + * connection, and return an error code if there are problems. + * + * @param a Connection to check compatibility with. + * @param b Connection to check compatibility with. + * @param isDragging True if the connection is being made by dragging a block. + * @param opt_distance The max allowable distance between the connections for + * drag checks. + * @returns Connection.CAN_CONNECT if the connection is legal, an error code + * otherwise. + */ + canConnectWithReason( + a: Connection | null, + b: Connection | null, + isDragging: boolean, + opt_distance?: number, + ): number { + const safety = this.doSafetyChecks(a, b); + if (safety !== Connection.CAN_CONNECT) { + return safety; + } + + // If the safety checks passed, both connections are non-null. + const connOne = a!; + const connTwo = b!; + if (!this.doTypeChecks(connOne, connTwo)) { + return Connection.REASON_CHECKS_FAILED; + } + + if ( + isDragging && + !this.doDragChecks( + a as RenderedConnection, + b as RenderedConnection, + opt_distance || 0, + ) + ) { + return Connection.REASON_DRAG_CHECKS_FAILED; + } + + return Connection.CAN_CONNECT; + } + + /** + * Helper method that translates a connection error code into a string. + * + * @param errorCode The error code. + * @param a One of the two connections being checked. + * @param b The second of the two connections being checked. + * @returns A developer-readable error string. + */ + getErrorMessage( + errorCode: number, + a: Connection | null, + b: Connection | null, + ): string { + switch (errorCode) { + case Connection.REASON_SELF_CONNECTION: + return 'Attempted to connect a block to itself.'; + case Connection.REASON_DIFFERENT_WORKSPACES: + // Usually this means one block has been deleted. + return 'Blocks not on same workspace.'; + case Connection.REASON_WRONG_TYPE: + return 'Attempt to connect incompatible types.'; + case Connection.REASON_TARGET_NULL: + return 'Target connection is null.'; + case Connection.REASON_CHECKS_FAILED: { + const connOne = a!; + const connTwo = b!; + let msg = 'Connection checks failed. '; + msg += + connOne + + ' expected ' + + connOne.getCheck() + + ', found ' + + connTwo.getCheck(); + return msg; + } + case Connection.REASON_SHADOW_PARENT: + return 'Connecting non-shadow to shadow block.'; + case Connection.REASON_DRAG_CHECKS_FAILED: + return 'Drag checks failed.'; + case Connection.REASON_PREVIOUS_AND_OUTPUT: + return 'Block would have an output and a previous connection.'; + default: + return 'Unknown connection failure: this should never happen!'; + } + } + + /** + * Check that connecting the given connections is safe, meaning that it would + * not break any of Blockly's basic assumptions (e.g. no self connections). + * + * @param a The first of the connections to check. + * @param b The second of the connections to check. + * @returns An enum with the reason this connection is safe or unsafe. + */ + doSafetyChecks(a: Connection | null, b: Connection | null): number { + if (!a || !b) { + return Connection.REASON_TARGET_NULL; + } + let superiorBlock; + let inferiorBlock; + let superiorConnection; + let inferiorConnection; + if (a.isSuperior()) { + superiorBlock = a.getSourceBlock(); + inferiorBlock = b.getSourceBlock(); + superiorConnection = a; + inferiorConnection = b; + } else { + inferiorBlock = a.getSourceBlock(); + superiorBlock = b.getSourceBlock(); + inferiorConnection = a; + superiorConnection = b; + } + if (superiorBlock === inferiorBlock) { + return Connection.REASON_SELF_CONNECTION; + } else if ( + inferiorConnection.type !== + internalConstants.OPPOSITE_TYPE[superiorConnection.type] + ) { + return Connection.REASON_WRONG_TYPE; + } else if (superiorBlock.workspace !== inferiorBlock.workspace) { + return Connection.REASON_DIFFERENT_WORKSPACES; + } else if (superiorBlock.isShadow() && !inferiorBlock.isShadow()) { + return Connection.REASON_SHADOW_PARENT; + } else if ( + inferiorConnection.type === ConnectionType.OUTPUT_VALUE && + inferiorBlock.previousConnection && + inferiorBlock.previousConnection.isConnected() + ) { + return Connection.REASON_PREVIOUS_AND_OUTPUT; + } else if ( + inferiorConnection.type === ConnectionType.PREVIOUS_STATEMENT && + inferiorBlock.outputConnection && + inferiorBlock.outputConnection.isConnected() + ) { + return Connection.REASON_PREVIOUS_AND_OUTPUT; + } + return Connection.CAN_CONNECT; + } + + /** + * Check whether this connection is compatible with another connection with + * respect to the value type system. E.g. square_root("Hello") is not + * compatible. + * + * @param a Connection to compare. + * @param b Connection to compare against. + * @returns True if the connections share a type. + */ + doTypeChecks(a: Connection, b: Connection): boolean { + const checkArrayOne = a.getCheck(); + const checkArrayTwo = b.getCheck(); + + if (!checkArrayOne || !checkArrayTwo) { + // One or both sides are promiscuous enough that anything will fit. + return true; + } + // Find any intersection in the check lists. + for (let i = 0; i < checkArrayOne.length; i++) { + if (checkArrayTwo.includes(checkArrayOne[i])) { + return true; + } + } + // No intersection. + return false; + } + + /** + * Check whether this connection can be made by dragging. + * + * @param a Connection to compare (on the block that's being dragged). + * @param b Connection to compare against. + * @param distance The maximum allowable distance between connections. + * @returns True if the connection is allowed during a drag. + */ + doDragChecks( + a: RenderedConnection, + b: RenderedConnection, + distance: number, + ): boolean { + if (a.distanceFrom(b) > distance) { + return false; + } + + // Don't consider insertion markers. + if (b.getSourceBlock().isInsertionMarker()) { + return false; + } + + switch (b.type) { + case ConnectionType.PREVIOUS_STATEMENT: + return this.canConnectToPrevious_(a, b); + case ConnectionType.OUTPUT_VALUE: { + // Don't offer to connect an already connected left (male) value plug to + // an available right (female) value plug. + if ( + (b.isConnected() && !b.targetBlock()!.isInsertionMarker()) || + a.isConnected() + ) { + return false; + } + break; + } + case ConnectionType.INPUT_VALUE: { + // Offering to connect the left (male) of a value block to an already + // connected value pair is ok, we'll splice it in. + // However, don't offer to splice into an immovable block. + if ( + b.isConnected() && + !b.targetBlock()!.isMovable() && + !b.targetBlock()!.isShadow() + ) { + return false; + } + break; + } + case ConnectionType.NEXT_STATEMENT: { + // Don't let a block with no next connection bump other blocks out of + // the stack. But covering up a shadow block or stack of shadow blocks + // is fine. Similarly, replacing a terminal statement with another + // terminal statement is allowed. + if ( + b.isConnected() && + !a.getSourceBlock().nextConnection && + !b.targetBlock()!.isShadow() && + b.targetBlock()!.nextConnection + ) { + return false; + } + + // Don't offer to splice into a stack where the connected block is + // immovable, unless the block is a shadow block. + if ( + b.targetBlock() && + !b.targetBlock()!.isMovable() && + !b.targetBlock()!.isShadow() + ) { + return false; + } + break; + } + default: + // Unexpected connection type. + return false; + } + + // Don't let blocks try to connect to themselves or ones they nest. + if (common.draggingConnections.includes(b)) { + return false; + } + + return true; + } + + /** + * Helper function for drag checking. + * + * @param a The connection to check, which must be a statement input or next + * connection. + * @param b A nearby connection to check, which must be a previous connection. + * @returns True if the connection is allowed, false otherwise. + */ + protected canConnectToPrevious_(a: Connection, b: Connection): boolean { + if (a.targetConnection) { + // This connection is already occupied. + // A next connection will never disconnect itself mid-drag. + return false; + } + + // Don't let blocks try to connect to themselves or ones they nest. + if (common.draggingConnections.includes(b)) { + return false; + } + + if (!b.targetConnection) { + return true; + } + + const targetBlock = b.targetBlock(); + // If it is connected to a real block, game over. + if (!targetBlock!.isInsertionMarker()) { + return false; + } + // If it's connected to an insertion marker but that insertion marker + // is the first block in a stack, it's still fine. If that insertion + // marker is in the middle of a stack, it won't work. + return !targetBlock!.getPreviousBlock(); + } +} + +registry.register( + registry.Type.CONNECTION_CHECKER, + registry.DEFAULT, + ConnectionChecker, +); diff --git a/core/connection_db.js b/core/connection_db.js deleted file mode 100644 index 8b3c3008e3c..00000000000 --- a/core/connection_db.js +++ /dev/null @@ -1,301 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2011 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Components for managing connections between blocks. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.ConnectionDB'); - -goog.require('Blockly.Connection'); - - -/** - * Database of connections. - * Connections are stored in order of their vertical component. This way - * connections in an area may be looked up quickly using a binary search. - * @constructor - */ -Blockly.ConnectionDB = function() { -}; - -Blockly.ConnectionDB.prototype = new Array(); -/** - * Don't inherit the constructor from Array. - * @type {!Function} - */ -Blockly.ConnectionDB.constructor = Blockly.ConnectionDB; - -/** - * Add a connection to the database. Must not already exist in DB. - * @param {!Blockly.Connection} connection The connection to be added. - */ -Blockly.ConnectionDB.prototype.addConnection = function(connection) { - if (connection.inDB_) { - throw 'Connection already in database.'; - } - if (connection.getSourceBlock().isInFlyout) { - // Don't bother maintaining a database of connections in a flyout. - return; - } - var position = this.findPositionForConnection_(connection); - this.splice(position, 0, connection); - connection.inDB_ = true; -}; - -/** - * Find the given connection. - * Starts by doing a binary search to find the approximate location, then - * linearly searches nearby for the exact connection. - * @param {!Blockly.Connection} conn The connection to find. - * @return {number} The index of the connection, or -1 if the connection was - * not found. - */ -Blockly.ConnectionDB.prototype.findConnection = function(conn) { - if (!this.length) { - return -1; - } - - var bestGuess = this.findPositionForConnection_(conn); - if (bestGuess >= this.length) { - // Not in list - return -1; - } - - var yPos = conn.y_; - // Walk forward and back on the y axis looking for the connection. - var pointerMin = bestGuess; - var pointerMax = bestGuess; - while (pointerMin >= 0 && this[pointerMin].y_ == yPos) { - if (this[pointerMin] == conn) { - return pointerMin; - } - pointerMin--; - } - - while (pointerMax < this.length && this[pointerMax].y_ == yPos) { - if (this[pointerMax] == conn) { - return pointerMax; - } - pointerMax++; - } - return -1; -}; - -/** - * Finds a candidate position for inserting this connection into the list. - * This will be in the correct y order but makes no guarantees about ordering in - * the x axis. - * @param {!Blockly.Connection} connection The connection to insert. - * @return {number} The candidate index. - * @private - */ -Blockly.ConnectionDB.prototype.findPositionForConnection_ = - function(connection) { - /* eslint-disable indent */ - if (!this.length) { - return 0; - } - var pointerMin = 0; - var pointerMax = this.length; - while (pointerMin < pointerMax) { - var pointerMid = Math.floor((pointerMin + pointerMax) / 2); - if (this[pointerMid].y_ < connection.y_) { - pointerMin = pointerMid + 1; - } else if (this[pointerMid].y_ > connection.y_) { - pointerMax = pointerMid; - } else { - pointerMin = pointerMid; - break; - } - } - return pointerMin; -}; /* eslint-enable indent */ - -/** - * Remove a connection from the database. Must already exist in DB. - * @param {!Blockly.Connection} connection The connection to be removed. - * @private - */ -Blockly.ConnectionDB.prototype.removeConnection_ = function(connection) { - if (!connection.inDB_) { - throw 'Connection not in database.'; - } - var removalIndex = this.findConnection(connection); - if (removalIndex == -1) { - throw 'Unable to find connection in connectionDB.'; - } - connection.inDB_ = false; - this.splice(removalIndex, 1); -}; - -/** - * Find all nearby connections to the given connection. - * Type checking does not apply, since this function is used for bumping. - * @param {!Blockly.Connection} connection The connection whose neighbours - * should be returned. - * @param {number} maxRadius The maximum radius to another connection. - * @return {!Array.} List of connections. - */ -Blockly.ConnectionDB.prototype.getNeighbours = function(connection, maxRadius) { - var db = this; - var currentX = connection.x_; - var currentY = connection.y_; - - // Binary search to find the closest y location. - var pointerMin = 0; - var pointerMax = db.length - 2; - var pointerMid = pointerMax; - while (pointerMin < pointerMid) { - if (db[pointerMid].y_ < currentY) { - pointerMin = pointerMid; - } else { - pointerMax = pointerMid; - } - pointerMid = Math.floor((pointerMin + pointerMax) / 2); - } - - var neighbours = []; - /** - * Computes if the current connection is within the allowed radius of another - * connection. - * This function is a closure and has access to outside variables. - * @param {number} yIndex The other connection's index in the database. - * @return {boolean} True if the current connection's vertical distance from - * the other connection is less than the allowed radius. - */ - function checkConnection_(yIndex) { - var dx = currentX - db[yIndex].x_; - var dy = currentY - db[yIndex].y_; - var r = Math.sqrt(dx * dx + dy * dy); - if (r <= maxRadius) { - neighbours.push(db[yIndex]); - } - return dy < maxRadius; - } - - // Walk forward and back on the y axis looking for the closest x,y point. - pointerMin = pointerMid; - pointerMax = pointerMid; - if (db.length) { - while (pointerMin >= 0 && checkConnection_(pointerMin)) { - pointerMin--; - } - do { - pointerMax++; - } while (pointerMax < db.length && checkConnection_(pointerMax)); - } - - return neighbours; -}; - - -/** - * Is the candidate connection close to the reference connection. - * Extremely fast; only looks at Y distance. - * @param {number} index Index in database of candidate connection. - * @param {number} baseY Reference connection's Y value. - * @param {number} maxRadius The maximum radius to another connection. - * @return {boolean} True if connection is in range. - * @private - */ -Blockly.ConnectionDB.prototype.isInYRange_ = function(index, baseY, maxRadius) { - return (Math.abs(this[index].y_ - baseY) <= maxRadius); -}; - -/** - * Find the closest compatible connection to this connection. - * @param {!Blockly.Connection} conn The connection searching for a compatible - * mate. - * @param {number} maxRadius The maximum radius to another connection. - * @param {!goog.math.Coordinate} dxy Offset between this connection's location - * in the database and the current location (as a result of dragging). - * @return {!{connection: ?Blockly.Connection, radius: number}} Contains two - * properties:' connection' which is either another connection or null, - * and 'radius' which is the distance. - */ -Blockly.ConnectionDB.prototype.searchForClosest = function(conn, maxRadius, - dxy) { - // Don't bother. - if (!this.length) { - return {connection: null, radius: maxRadius}; - } - - // Stash the values of x and y from before the drag. - var baseY = conn.y_; - var baseX = conn.x_; - - conn.x_ = baseX + dxy.x; - conn.y_ = baseY + dxy.y; - - // findPositionForConnection finds an index for insertion, which is always - // after any block with the same y index. We want to search both forward - // and back, so search on both sides of the index. - var closestIndex = this.findPositionForConnection_(conn); - - var bestConnection = null; - var bestRadius = maxRadius; - var temp; - - // Walk forward and back on the y axis looking for the closest x,y point. - var pointerMin = closestIndex - 1; - while (pointerMin >= 0 && this.isInYRange_(pointerMin, conn.y_, maxRadius)) { - temp = this[pointerMin]; - if (conn.isConnectionAllowed(temp, bestRadius)) { - bestConnection = temp; - bestRadius = temp.distanceFrom(conn); - } - pointerMin--; - } - - var pointerMax = closestIndex; - while (pointerMax < this.length && this.isInYRange_(pointerMax, conn.y_, - maxRadius)) { - temp = this[pointerMax]; - if (conn.isConnectionAllowed(temp, bestRadius)) { - bestConnection = temp; - bestRadius = temp.distanceFrom(conn); - } - pointerMax++; - } - - // Reset the values of x and y. - conn.x_ = baseX; - conn.y_ = baseY; - - // If there were no valid connections, bestConnection will be null. - return {connection: bestConnection, radius: bestRadius}; -}; - -/** - * Initialize a set of connection DBs for a specified workspace. - * @param {!Blockly.Workspace} workspace The workspace this DB is for. - */ -Blockly.ConnectionDB.init = function(workspace) { - // Create four databases, one for each connection type. - var dbList = []; - dbList[Blockly.INPUT_VALUE] = new Blockly.ConnectionDB(); - dbList[Blockly.OUTPUT_VALUE] = new Blockly.ConnectionDB(); - dbList[Blockly.NEXT_STATEMENT] = new Blockly.ConnectionDB(); - dbList[Blockly.PREVIOUS_STATEMENT] = new Blockly.ConnectionDB(); - workspace.connectionDBList = dbList; -}; diff --git a/core/connection_db.ts b/core/connection_db.ts new file mode 100644 index 00000000000..8a83d154814 --- /dev/null +++ b/core/connection_db.ts @@ -0,0 +1,297 @@ +/** + * @license + * Copyright 2011 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A database of all the rendered connections that could + * possibly be connected to (i.e. not collapsed, etc). + * Sorted by y coordinate. + * + * @class + */ +// Former goog.module ID: Blockly.ConnectionDB + +import {ConnectionType} from './connection_type.js'; +import type {IConnectionChecker} from './interfaces/i_connection_checker.js'; +import type {RenderedConnection} from './rendered_connection.js'; +import type {Coordinate} from './utils/coordinate.js'; + +/** + * Database of connections. + * Connections are stored in order of their vertical component. This way + * connections in an area may be looked up quickly using a binary search. + */ +export class ConnectionDB { + /** Array of connections sorted by y position in workspace units. */ + private readonly connections: RenderedConnection[] = []; + + /** + * @param connectionChecker The workspace's connection type checker, used to + * decide if connections are valid during a drag. + */ + constructor(private readonly connectionChecker: IConnectionChecker) {} + + /** + * Add a connection to the database. Should not already exist in the database. + * + * @param connection The connection to be added. + * @param yPos The y position used to decide where to insert the connection. + * @internal + */ + addConnection(connection: RenderedConnection, yPos: number) { + const index = this.calculateIndexForYPos(yPos); + this.connections.splice(index, 0, connection); + } + + /** + * Finds the index of the given connection. + * + * Starts by doing a binary search to find the approximate location, then + * linearly searches nearby for the exact connection. + * + * @param conn The connection to find. + * @param yPos The y position used to find the index of the connection. + * @returns The index of the connection, or -1 if the connection was not + * found. + */ + private findIndexOfConnection( + conn: RenderedConnection, + yPos: number, + ): number { + if (!this.connections.length) { + return -1; + } + + const bestGuess = this.calculateIndexForYPos(yPos); + if (bestGuess >= this.connections.length) { + // Not in list + return -1; + } + + yPos = conn.y; + // Walk forward and back on the y axis looking for the connection. + let pointer = bestGuess; + while (pointer >= 0 && this.connections[pointer].y === yPos) { + if (this.connections[pointer] === conn) { + return pointer; + } + pointer--; + } + + pointer = bestGuess; + while ( + pointer < this.connections.length && + this.connections[pointer].y === yPos + ) { + if (this.connections[pointer] === conn) { + return pointer; + } + pointer++; + } + return -1; + } + + /** + * Finds the correct index for the given y position. + * + * @param yPos The y position used to decide where to insert the connection. + * @returns The candidate index. + */ + private calculateIndexForYPos(yPos: number): number { + if (!this.connections.length) { + return 0; + } + let pointerMin = 0; + let pointerMax = this.connections.length; + while (pointerMin < pointerMax) { + const pointerMid = Math.floor((pointerMin + pointerMax) / 2); + if (this.connections[pointerMid].y < yPos) { + pointerMin = pointerMid + 1; + } else if (this.connections[pointerMid].y > yPos) { + pointerMax = pointerMid; + } else { + pointerMin = pointerMid; + break; + } + } + return pointerMin; + } + + /** + * Remove a connection from the database. Must already exist in DB. + * + * @param connection The connection to be removed. + * @param yPos The y position used to find the index of the connection. + * @throws {Error} If the connection cannot be found in the database. + */ + removeConnection(connection: RenderedConnection, yPos: number) { + const index = this.findIndexOfConnection(connection, yPos); + if (index === -1) { + throw Error('Unable to find connection in connectionDB.'); + } + this.connections.splice(index, 1); + } + + /** + * Find all nearby connections to the given connection. + * Type checking does not apply, since this function is used for bumping. + * + * @param connection The connection whose neighbours should be returned. + * @param maxRadius The maximum radius to another connection. + * @returns List of connections. + */ + getNeighbours( + connection: RenderedConnection, + maxRadius: number, + ): RenderedConnection[] { + const db = this.connections; + const currentX = connection.x; + const currentY = connection.y; + + // Binary search to find the closest y location. + let pointerMin = 0; + let pointerMax = db.length - 2; + let pointerMid = pointerMax; + while (pointerMin < pointerMid) { + if (db[pointerMid].y < currentY) { + pointerMin = pointerMid; + } else { + pointerMax = pointerMid; + } + pointerMid = Math.floor((pointerMin + pointerMax) / 2); + } + + const neighbours: RenderedConnection[] = []; + /** + * Computes if the current connection is within the allowed radius of + * another connection. This function is a closure and has access to outside + * variables. + * + * @param yIndex The other connection's index in the database. + * @returns True if the current connection's vertical distance from the + * other connection is less than the allowed radius. + */ + function checkConnection(yIndex: number): boolean { + const dx = currentX - db[yIndex].x; + const dy = currentY - db[yIndex].y; + const r = Math.sqrt(dx * dx + dy * dy); + if (r <= maxRadius) { + neighbours.push(db[yIndex]); + } + return dy < maxRadius; + } + + // Walk forward and back on the y axis looking for the closest x,y point. + pointerMin = pointerMid; + pointerMax = pointerMid; + if (db.length) { + while (pointerMin >= 0 && checkConnection(pointerMin)) { + pointerMin--; + } + do { + pointerMax++; + } while (pointerMax < db.length && checkConnection(pointerMax)); + } + + return neighbours; + } + + /** + * Is the candidate connection close to the reference connection. + * Extremely fast; only looks at Y distance. + * + * @param index Index in database of candidate connection. + * @param baseY Reference connection's Y value. + * @param maxRadius The maximum radius to another connection. + * @returns True if connection is in range. + */ + private isInYRange(index: number, baseY: number, maxRadius: number): boolean { + return Math.abs(this.connections[index].y - baseY) <= maxRadius; + } + + /** + * Find the closest compatible connection to this connection. + * + * @param conn The connection searching for a compatible mate. + * @param maxRadius The maximum radius to another connection. + * @param dxy Offset between this connection's location in the database and + * the current location (as a result of dragging). + * @returns Contains two properties: 'connection' which is either another + * connection or null, and 'radius' which is the distance. + */ + searchForClosest( + conn: RenderedConnection, + maxRadius: number, + dxy: Coordinate, + ): {connection: RenderedConnection | null; radius: number} { + if (!this.connections.length) { + // Don't bother. + return {connection: null, radius: maxRadius}; + } + + // Stash the values of x and y from before the drag. + const baseY = conn.y; + const baseX = conn.x; + + conn.x = baseX + dxy.x; + conn.y = baseY + dxy.y; + + // calculateIndexForYPos_ finds an index for insertion, which is always + // after any block with the same y index. We want to search both forward + // and back, so search on both sides of the index. + const closestIndex = this.calculateIndexForYPos(conn.y); + + let bestConnection = null; + let bestRadius = maxRadius; + let temp; + + // Walk forward and back on the y axis looking for the closest x,y point. + let pointerMin = closestIndex - 1; + while (pointerMin >= 0 && this.isInYRange(pointerMin, conn.y, maxRadius)) { + temp = this.connections[pointerMin]; + if (this.connectionChecker.canConnect(conn, temp, true, bestRadius)) { + bestConnection = temp; + bestRadius = temp.distanceFrom(conn); + } + pointerMin--; + } + + let pointerMax = closestIndex; + while ( + pointerMax < this.connections.length && + this.isInYRange(pointerMax, conn.y, maxRadius) + ) { + temp = this.connections[pointerMax]; + if (this.connectionChecker.canConnect(conn, temp, true, bestRadius)) { + bestConnection = temp; + bestRadius = temp.distanceFrom(conn); + } + pointerMax++; + } + + // Reset the values of x and y. + conn.x = baseX; + conn.y = baseY; + // If there were no valid connections, bestConnection will be null. + return {connection: bestConnection, radius: bestRadius}; + } + + /** + * Initialize a set of connection DBs for a workspace. + * + * @param checker The workspace's connection checker, used to decide if + * connections are valid during a drag. + * @returns Array of databases. + */ + static init(checker: IConnectionChecker): ConnectionDB[] { + // Create four databases, one for each connection type. + const dbList = []; + dbList[ConnectionType.INPUT_VALUE] = new ConnectionDB(checker); + dbList[ConnectionType.OUTPUT_VALUE] = new ConnectionDB(checker); + dbList[ConnectionType.NEXT_STATEMENT] = new ConnectionDB(checker); + dbList[ConnectionType.PREVIOUS_STATEMENT] = new ConnectionDB(checker); + return dbList; + } +} diff --git a/core/connection_type.ts b/core/connection_type.ts new file mode 100644 index 00000000000..ca18d00cbd2 --- /dev/null +++ b/core/connection_type.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.ConnectionType + +/** + * Enum for the type of a connection or input. + */ +export enum ConnectionType { + // A right-facing value input. E.g. 'set item to' or 'return'. + INPUT_VALUE = 1, + // A left-facing value output. E.g. 'random fraction'. + OUTPUT_VALUE, + // A down-facing block stack. E.g. 'if-do' or 'else'. + NEXT_STATEMENT, + // An up-facing block stack. E.g. 'break out of loop'. + PREVIOUS_STATEMENT, +} diff --git a/core/constants.js b/core/constants.js deleted file mode 100644 index f5428ff741f..00000000000 --- a/core/constants.js +++ /dev/null @@ -1,267 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Blockly constants. - * @author fenichel@google.com (Rachel Fenichel) - */ -'use strict'; - -goog.provide('Blockly.constants'); - - -/** - * Number of pixels the mouse must move before a drag starts. - */ -Blockly.DRAG_RADIUS = 5; - -/** - * Number of pixels the mouse must move before a drag/scroll starts from the - * flyout. Because the drag-intention is determined when this is reached, it is - * larger than Blockly.DRAG_RADIUS so that the drag-direction is clearer. - */ -Blockly.FLYOUT_DRAG_RADIUS = 10; - -/** - * Maximum misalignment between connections for them to snap together. - */ -Blockly.SNAP_RADIUS = 20; - -/** - * Delay in ms between trigger and bumping unconnected block out of alignment. - */ -Blockly.BUMP_DELAY = 250; - -/** - * Number of characters to truncate a collapsed block to. - */ -Blockly.COLLAPSE_CHARS = 30; - -/** - * Length in ms for a touch to become a long press. - */ -Blockly.LONGPRESS = 750; - -/** - * Prevent a sound from playing if another sound preceded it within this many - * milliseconds. - */ -Blockly.SOUND_LIMIT = 100; - -/** - * When dragging a block out of a stack, split the stack in two (true), or drag - * out the block healing the stack (false). - */ -Blockly.DRAG_STACK = true; - -/** - * The richness of block colours, regardless of the hue. - * Must be in the range of 0 (inclusive) to 1 (exclusive). - */ -Blockly.HSV_SATURATION = 0.45; - -/** - * The intensity of block colours, regardless of the hue. - * Must be in the range of 0 (inclusive) to 1 (exclusive). - */ -Blockly.HSV_VALUE = 0.65; - -/** - * Sprited icons and images. - */ -Blockly.SPRITE = { - width: 96, - height: 124, - url: 'sprites.png' -}; - -// Constants below this point are not intended to be changed. - -/** - * Required name space for SVG elements. - * @const - */ -Blockly.SVG_NS = 'http://www.w3.org/2000/svg'; - -/** - * Required name space for HTML elements. - * @const - */ -Blockly.HTML_NS = 'http://www.w3.org/1999/xhtml'; - -/** - * ENUM for a right-facing value input. E.g. 'set item to' or 'return'. - * @const - */ -Blockly.INPUT_VALUE = 1; - -/** - * ENUM for a left-facing value output. E.g. 'random fraction'. - * @const - */ -Blockly.OUTPUT_VALUE = 2; - -/** - * ENUM for a down-facing block stack. E.g. 'if-do' or 'else'. - * @const - */ -Blockly.NEXT_STATEMENT = 3; - -/** - * ENUM for an up-facing block stack. E.g. 'break out of loop'. - * @const - */ -Blockly.PREVIOUS_STATEMENT = 4; - -/** - * ENUM for an dummy input. Used to add field(s) with no input. - * @const - */ -Blockly.DUMMY_INPUT = 5; - -/** - * ENUM for left alignment. - * @const - */ -Blockly.ALIGN_LEFT = -1; - -/** - * ENUM for centre alignment. - * @const - */ -Blockly.ALIGN_CENTRE = 0; - -/** - * ENUM for right alignment. - * @const - */ -Blockly.ALIGN_RIGHT = 1; - -/** - * ENUM for no drag operation. - * @const - */ -Blockly.DRAG_NONE = 0; - -/** - * ENUM for inside the sticky DRAG_RADIUS. - * @const - */ -Blockly.DRAG_STICKY = 1; - -/** - * ENUM for inside the non-sticky DRAG_RADIUS, for differentiating between - * clicks and drags. - * @const - */ -Blockly.DRAG_BEGIN = 1; - -/** - * ENUM for freely draggable (outside the DRAG_RADIUS, if one applies). - * @const - */ -Blockly.DRAG_FREE = 2; - -/** - * Lookup table for determining the opposite type of a connection. - * @const - */ -Blockly.OPPOSITE_TYPE = []; -Blockly.OPPOSITE_TYPE[Blockly.INPUT_VALUE] = Blockly.OUTPUT_VALUE; -Blockly.OPPOSITE_TYPE[Blockly.OUTPUT_VALUE] = Blockly.INPUT_VALUE; -Blockly.OPPOSITE_TYPE[Blockly.NEXT_STATEMENT] = Blockly.PREVIOUS_STATEMENT; -Blockly.OPPOSITE_TYPE[Blockly.PREVIOUS_STATEMENT] = Blockly.NEXT_STATEMENT; - - -/** - * ENUM for toolbox and flyout at top of screen. - * @const - */ -Blockly.TOOLBOX_AT_TOP = 0; - -/** - * ENUM for toolbox and flyout at bottom of screen. - * @const - */ -Blockly.TOOLBOX_AT_BOTTOM = 1; - -/** - * ENUM for toolbox and flyout at left of screen. - * @const - */ -Blockly.TOOLBOX_AT_LEFT = 2; - -/** - * ENUM for toolbox and flyout at right of screen. - * @const - */ -Blockly.TOOLBOX_AT_RIGHT = 3; - -/** - * ENUM representing that an event is not in any delete areas. - * Null for backwards compatibility reasons. - * @const - */ -Blockly.DELETE_AREA_NONE = null; - -/** - * ENUM representing that an event is in the delete area of the trash can. - * @const - */ -Blockly.DELETE_AREA_TRASH = 1; - -/** - * ENUM representing that an event is in the delete area of the toolbox or - * flyout. - * @const - */ -Blockly.DELETE_AREA_TOOLBOX = 2; - -/** - * String for use in the "custom" attribute of a category in toolbox xml. - * This string indicates that the category should be dynamically populated with - * variable blocks. - * @const {string} - */ -Blockly.VARIABLE_CATEGORY_NAME = 'VARIABLE'; - -/** - * String for use in the "custom" attribute of a category in toolbox xml. - * This string indicates that the category should be dynamically populated with - * procedure blocks. - * @const {string} - */ -Blockly.PROCEDURE_CATEGORY_NAME = 'PROCEDURE'; - -/** - * String for use in the dropdown created in field_variable. - * This string indicates that this option in the dropdown is 'Rename - * variable...' and if selected, should trigger the prompt to rename a variable. - * @const {string} - */ -Blockly.RENAME_VARIABLE_ID = 'RENAME_VARIABLE_ID'; - -/** - * String for use in the dropdown created in field_variable. - * This string indicates that this option in the dropdown is 'Delete the "%1" - * variable' and if selected, should trigger the prompt to delete a variable. - * @const {string} - */ -Blockly.DELETE_VARIABLE_ID = 'DELETE_VARIABLE_ID'; diff --git a/core/constants.ts b/core/constants.ts new file mode 100644 index 00000000000..538bd378300 --- /dev/null +++ b/core/constants.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.constants + +/** + * The language-neutral ID given to the collapsed input. + */ +export const COLLAPSED_INPUT_NAME = '_TEMP_COLLAPSED_INPUT'; + +/** + * The language-neutral ID given to the collapsed field. + */ +export const COLLAPSED_FIELD_NAME = '_TEMP_COLLAPSED_FIELD'; + +/** + * The language-neutral ID for when the reason why a block is disabled is + * because the user manually disabled it, such as via the context menu. + */ +export const MANUALLY_DISABLED = 'MANUALLY_DISABLED'; diff --git a/core/contextmenu.js b/core/contextmenu.js deleted file mode 100644 index ebb8caeb6e7..00000000000 --- a/core/contextmenu.js +++ /dev/null @@ -1,191 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2011 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Functionality for the right-click context menus. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -/** - * @name Blockly.ContextMenu - * @namespace - */ -goog.provide('Blockly.ContextMenu'); - -goog.require('goog.dom'); -goog.require('goog.events'); -goog.require('goog.style'); -goog.require('goog.ui.Menu'); -goog.require('goog.ui.MenuItem'); - - -/** - * Which block is the context menu attached to? - * @type {Blockly.Block} - */ -Blockly.ContextMenu.currentBlock = null; - -/** - * @type {Array.} Opaque data that can be passed to unbindEvent_. - * @private - */ -Blockly.ContextMenu.eventWrapper_ = null; - -/** - * Construct the menu based on the list of options and show the menu. - * @param {!Event} e Mouse event. - * @param {!Array.} options Array of menu options. - * @param {boolean} rtl True if RTL, false if LTR. - */ -Blockly.ContextMenu.show = function(e, options, rtl) { - Blockly.WidgetDiv.show(Blockly.ContextMenu, rtl, null); - if (!options.length) { - Blockly.ContextMenu.hide(); - return; - } - var menu = Blockly.ContextMenu.populate_(options, rtl); - - goog.events.listen(menu, goog.ui.Component.EventType.ACTION, - Blockly.ContextMenu.hide); - - Blockly.ContextMenu.position_(menu, e, rtl); - // 1ms delay is required for focusing on context menus because some other - // mouse event is still waiting in the queue and clears focus. - setTimeout(function() {menu.getElement().focus();}, 1); - Blockly.ContextMenu.currentBlock = null; // May be set by Blockly.Block. -}; - -/** - * Create the context menu object and populate it with the given options. - * @param {!Array.} options Array of menu options. - * @param {boolean} rtl True if RTL, false if LTR. - * @return {!goog.ui.Menu} The menu that will be shown on right click. - * @private - */ -Blockly.ContextMenu.populate_ = function(options, rtl) { - /* Here's what one option object looks like: - {text: 'Make It So', - enabled: true, - callback: Blockly.MakeItSo} - */ - var menu = new goog.ui.Menu(); - menu.setAllowAutoFocus(true); - menu.setRightToLeft(rtl); - for (var i = 0, option; option = options[i]; i++) { - var menuItem = new goog.ui.MenuItem(option.text); - menuItem.setRightToLeft(rtl); - menu.addChild(menuItem, true); - menuItem.setEnabled(option.enabled); - if (option.enabled) { - goog.events.listen(menuItem, goog.ui.Component.EventType.ACTION, - option.callback); - menuItem.handleContextMenu = function(/* e */) { - // Right-clicking on menu option should count as a click. - goog.events.dispatchEvent(this, goog.ui.Component.EventType.ACTION); - }; - } - } - return menu; -}; - -/** - * Add the menu to the page and position it correctly. - * @param {!goog.ui.Menu} menu The menu to add and position. - * @param {!Event} e Mouse event for the right click that is making the context - * menu appear. - * @param {boolean} rtl True if RTL, false if LTR. - * @private - */ -Blockly.ContextMenu.position_ = function(menu, e, rtl) { - // Record windowSize and scrollOffset before adding menu. - var windowSize = goog.dom.getViewportSize(); - var scrollOffset = goog.style.getViewportPageOffset(document); - var div = Blockly.WidgetDiv.DIV; - menu.render(div); - var menuDom = menu.getElement(); - Blockly.utils.addClass(menuDom, 'blocklyContextMenu'); - // Prevent system context menu when right-clicking a Blockly context menu. - Blockly.bindEventWithChecks_(menuDom, 'contextmenu', null, - Blockly.utils.noEvent); - // Record menuSize after adding menu. - var menuSize = goog.style.getSize(menuDom); - - // Position the menu. - var x = e.clientX + scrollOffset.x; - var y = e.clientY + scrollOffset.y; - // Flip menu vertically if off the bottom. - if (e.clientY + menuSize.height >= windowSize.height) { - y -= menuSize.height; - } - // Flip menu horizontally if off the edge. - if (rtl) { - if (menuSize.width >= e.clientX) { - x += menuSize.width; - } - } else { - if (e.clientX + menuSize.width >= windowSize.width) { - x -= menuSize.width; - } - } - Blockly.WidgetDiv.position(x, y, windowSize, scrollOffset, rtl); -}; - -/** - * Hide the context menu. - */ -Blockly.ContextMenu.hide = function() { - Blockly.WidgetDiv.hideIfOwner(Blockly.ContextMenu); - Blockly.ContextMenu.currentBlock = null; - if (Blockly.ContextMenu.eventWrapper_) { - Blockly.unbindEvent_(Blockly.ContextMenu.eventWrapper_); - } -}; - -/** - * Create a callback function that creates and configures a block, - * then places the new block next to the original. - * @param {!Blockly.Block} block Original block. - * @param {!Element} xml XML representation of new block. - * @return {!Function} Function that creates a block. - */ -Blockly.ContextMenu.callbackFactory = function(block, xml) { - return function() { - Blockly.Events.disable(); - try { - var newBlock = Blockly.Xml.domToBlock(xml, block.workspace); - // Move the new block next to the old block. - var xy = block.getRelativeToSurfaceXY(); - if (block.RTL) { - xy.x -= Blockly.SNAP_RADIUS; - } else { - xy.x += Blockly.SNAP_RADIUS; - } - xy.y += Blockly.SNAP_RADIUS * 2; - newBlock.moveBy(xy.x, xy.y); - } finally { - Blockly.Events.enable(); - } - if (Blockly.Events.isEnabled() && !newBlock.isShadow()) { - Blockly.Events.fire(new Blockly.Events.BlockCreate(newBlock)); - } - newBlock.select(); - }; -}; diff --git a/core/contextmenu.ts b/core/contextmenu.ts new file mode 100644 index 00000000000..f3ebbd1c681 --- /dev/null +++ b/core/contextmenu.ts @@ -0,0 +1,295 @@ +/** + * @license + * Copyright 2011 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.ContextMenu + +import type {Block} from './block.js'; +import type {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; +import {config} from './config.js'; +import type { + ContextMenuOption, + LegacyContextMenuOption, +} from './contextmenu_registry.js'; +import {EventType} from './events/type.js'; +import * as eventUtils from './events/utils.js'; +import {getFocusManager} from './focus_manager.js'; +import {Menu} from './menu.js'; +import {MenuSeparator} from './menu_separator.js'; +import {MenuItem} from './menuitem.js'; +import * as serializationBlocks from './serialization/blocks.js'; +import * as aria from './utils/aria.js'; +import {Coordinate} from './utils/coordinate.js'; +import * as dom from './utils/dom.js'; +import {Rect} from './utils/rect.js'; +import * as svgMath from './utils/svg_math.js'; +import * as WidgetDiv from './widgetdiv.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; +import * as Xml from './xml.js'; + +/** + * Which block is the context menu attached to? + */ +let currentBlock: Block | null = null; + +const dummyOwner = {}; + +/** + * Gets the block the context menu is currently attached to. + * It is not recommended that you use this function; instead, + * use the scope object passed to the context menu callback. + * + * @returns The block the context menu is attached to. + */ +export function getCurrentBlock(): Block | null { + return currentBlock; +} + +/** + * Sets the block the context menu is currently attached to. + * + * @param block The block the context menu is attached to. + */ +export function setCurrentBlock(block: Block | null) { + currentBlock = block; +} + +/** + * Menu object. + */ +let menu_: Menu | null = null; + +/** + * Construct the menu based on the list of options and show the menu. + * + * @param menuOpenEvent Event that caused the menu to open. + * @param options Array of menu options. + * @param rtl True if RTL, false if LTR. + * @param workspace The workspace associated with the context menu, if any. + * @param location The screen coordinates at which to show the menu. + */ +export function show( + menuOpenEvent: Event, + options: (ContextMenuOption | LegacyContextMenuOption)[], + rtl: boolean, + workspace?: WorkspaceSvg, + location?: Coordinate, +) { + WidgetDiv.show(dummyOwner, rtl, dispose, workspace); + if (!options.length) { + hide(); + return; + } + + if (!location) { + if (menuOpenEvent instanceof PointerEvent) { + location = new Coordinate(menuOpenEvent.clientX, menuOpenEvent.clientY); + } else { + // We got a keyboard event that didn't tell us where to open the menu, so just guess + console.warn('Context menu opened with keyboard but no location given'); + location = new Coordinate(0, 0); + } + } + const menu = populate_(options, rtl, menuOpenEvent, location); + menu_ = menu; + + position_(menu, rtl, location); + // 1ms delay is required for focusing on context menus because some other + // mouse event is still waiting in the queue and clears focus. + setTimeout(function () { + menu.focus(); + }, 1); + currentBlock = null; // May be set by Blockly.Block. +} + +/** + * Create the context menu object and populate it with the given options. + * + * @param options Array of menu options. + * @param rtl True if RTL, false if LTR. + * @param menuOpenEvent The event that triggered the context menu to open. + * @param location The screen coordinates at which to show the menu. + * @returns The menu that will be shown on right click. + */ +function populate_( + options: (ContextMenuOption | LegacyContextMenuOption)[], + rtl: boolean, + menuOpenEvent: Event, + location: Coordinate, +): Menu { + /* Here's what one option object looks like: + {text: 'Make It So', + enabled: true, + callback: Blockly.MakeItSo} + */ + const menu = new Menu(); + menu.setRole(aria.Role.MENU); + for (let i = 0; i < options.length; i++) { + const option = options[i]; + if (option.separator) { + menu.addChild(new MenuSeparator()); + continue; + } + + const menuItem = new MenuItem(option.text); + menuItem.setRightToLeft(rtl); + menuItem.setRole(aria.Role.MENUITEM); + menu.addChild(menuItem); + menuItem.setEnabled(option.enabled); + if (option.enabled) { + const actionHandler = function (p1: MenuItem, menuSelectEvent: Event) { + hide(); + requestAnimationFrame(() => { + setTimeout(() => { + // If .scope does not exist on the option, then the callback + // will not be expecting a scope parameter, so there should be + // no problems. Just assume it is a ContextMenuOption and we'll + // pass undefined if it's not. + option.callback( + (option as ContextMenuOption).scope, + menuOpenEvent, + menuSelectEvent, + location, + ); + }, 0); + }); + }; + menuItem.onAction(actionHandler, {}); + } + } + return menu; +} + +/** + * Add the menu to the page and position it correctly. + * + * @param menu The menu to add and position. + * @param rtl True if RTL, false if LTR. + * @param location The location at which to anchor the menu. + */ +function position_(menu: Menu, rtl: boolean, location: Coordinate) { + // Record windowSize and scrollOffset before adding menu. + const viewportBBox = svgMath.getViewportBBox(); + // This one is just a point, but we'll pretend that it's a rect so we can use + // some helper functions. + const anchorBBox = new Rect( + location.y + viewportBBox.top, + location.y + viewportBBox.top, + location.x + viewportBBox.left, + location.x + viewportBBox.left, + ); + + createWidget_(menu); + const menuSize = menu.getSize(); + + if (rtl) { + anchorBBox.left += menuSize.width; + anchorBBox.right += menuSize.width; + viewportBBox.left += menuSize.width; + viewportBBox.right += menuSize.width; + } + + WidgetDiv.positionWithAnchor(viewportBBox, anchorBBox, menuSize, rtl); + // Calling menuDom.focus() has to wait until after the menu has been placed + // correctly. Otherwise it will cause a page scroll to get the misplaced menu + // in view. See issue #1329. + menu.focus(); +} + +/** + * Create and render the menu widget inside Blockly's widget div. + * + * @param menu The menu to add to the widget div. + */ +function createWidget_(menu: Menu) { + const div = WidgetDiv.getDiv(); + if (!div) { + throw Error('Attempting to create a context menu when widget div is null'); + } + const menuDom = menu.render(div); + dom.addClass(menuDom, 'blocklyContextMenu'); + // Prevent system context menu when right-clicking a Blockly context menu. + browserEvents.conditionalBind( + menuDom as EventTarget, + 'contextmenu', + null, + haltPropagation, + ); + // Focus only after the initial render to avoid issue #1329. + menu.focus(); +} +/** + * Halts the propagation of the event without doing anything else. + * + * @param e An event. + */ +function haltPropagation(e: Event) { + // This event has been handled. No need to bubble up to the document. + e.preventDefault(); + e.stopPropagation(); +} + +/** + * Hide the context menu. + */ +export function hide() { + WidgetDiv.hideIfOwner(dummyOwner); + currentBlock = null; +} + +/** + * Dispose of the menu. + */ +export function dispose() { + if (menu_) { + menu_.dispose(); + menu_ = null; + } +} + +/** + * Create a callback function that creates and configures a block, + * then places the new block next to the original and returns it. + * + * @param block Original block. + * @param state XML or JSON object representation of the new block. + * @returns Function that creates a block. + */ +export function callbackFactory( + block: Block, + state: Element | serializationBlocks.State, +): () => BlockSvg { + return () => { + eventUtils.disable(); + let newBlock: BlockSvg; + try { + if (state instanceof Element) { + newBlock = Xml.domToBlockInternal(state, block.workspace!) as BlockSvg; + } else { + newBlock = serializationBlocks.appendInternal( + state, + block.workspace, + ) as BlockSvg; + } + // Move the new block next to the old block. + const xy = block.getRelativeToSurfaceXY(); + if (block.RTL) { + xy.x -= config.snapRadius; + } else { + xy.x += config.snapRadius; + } + xy.y += config.snapRadius * 2; + newBlock.moveBy(xy.x, xy.y); + } finally { + eventUtils.enable(); + } + if (eventUtils.isEnabled() && !newBlock.isShadow()) { + eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(newBlock)); + } + getFocusManager().focusNode(newBlock); + return newBlock; + }; +} diff --git a/core/contextmenu_items.ts b/core/contextmenu_items.ts new file mode 100644 index 00000000000..001a3c58e25 --- /dev/null +++ b/core/contextmenu_items.ts @@ -0,0 +1,685 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.ContextMenuItems + +import type {BlockSvg} from './block_svg.js'; +import * as clipboard from './clipboard.js'; +import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; +import {MANUALLY_DISABLED} from './constants.js'; +import { + ContextMenuRegistry, + RegistryItem, + Scope, +} from './contextmenu_registry.js'; +import * as dialog from './dialog.js'; +import * as Events from './events/events.js'; +import * as eventUtils from './events/utils.js'; +import {getFocusManager} from './focus_manager.js'; +import {CommentIcon} from './icons/comment_icon.js'; +import {Msg} from './msg.js'; +import {StatementInput} from './renderers/zelos/zelos.js'; +import {Coordinate} from './utils/coordinate.js'; +import * as svgMath from './utils/svg_math.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +function isFullBlockField(block?: BlockSvg) { + if (!block || !block.isSimpleReporter()) return false; + const firstField = block.getFields().next().value; + return firstField?.isFullBlockField(); +} + +/** + * Option to undo previous action. + */ +export function registerUndo() { + const undoOption: RegistryItem = { + displayText() { + return Msg['UNDO']; + }, + preconditionFn(scope: Scope) { + if (scope.workspace!.getUndoStack().length > 0) { + return 'enabled'; + } + return 'disabled'; + }, + callback(scope: Scope) { + scope.workspace!.undo(false); + }, + scopeType: ContextMenuRegistry.ScopeType.WORKSPACE, + id: 'undoWorkspace', + weight: 1, + }; + ContextMenuRegistry.registry.register(undoOption); +} + +/** + * Option to redo previous action. + */ +export function registerRedo() { + const redoOption: RegistryItem = { + displayText() { + return Msg['REDO']; + }, + preconditionFn(scope: Scope) { + if (scope.workspace!.getRedoStack().length > 0) { + return 'enabled'; + } + return 'disabled'; + }, + callback(scope: Scope) { + scope.workspace!.undo(true); + }, + scopeType: ContextMenuRegistry.ScopeType.WORKSPACE, + id: 'redoWorkspace', + weight: 2, + }; + ContextMenuRegistry.registry.register(redoOption); +} + +/** + * Option to clean up blocks. + */ +export function registerCleanup() { + const cleanOption: RegistryItem = { + displayText() { + return Msg['CLEAN_UP']; + }, + preconditionFn(scope: Scope) { + if (scope.workspace!.isMovable()) { + if (scope.workspace!.getTopBlocks(false).length > 1) { + return 'enabled'; + } + return 'disabled'; + } + return 'hidden'; + }, + callback(scope: Scope) { + scope.workspace!.cleanUp(); + }, + scopeType: ContextMenuRegistry.ScopeType.WORKSPACE, + id: 'cleanWorkspace', + weight: 3, + }; + ContextMenuRegistry.registry.register(cleanOption); +} +/** + * Creates a callback to collapse or expand top blocks. + * + * @param shouldCollapse Whether a block should collapse. + * @param topBlocks Top blocks in the workspace. + */ +function toggleOption_(shouldCollapse: boolean, topBlocks: BlockSvg[]) { + const DELAY = 10; + let ms = 0; + let timeoutCounter = 0; + function timeoutFn(block: BlockSvg) { + timeoutCounter--; + block.setCollapsed(shouldCollapse); + if (timeoutCounter === 0) { + Events.setGroup(false); + } + } + Events.setGroup(true); + for (let i = 0; i < topBlocks.length; i++) { + let block: BlockSvg | null = topBlocks[i]; + while (block) { + timeoutCounter++; + setTimeout(timeoutFn.bind(null, block), ms); + block = block.getNextBlock(); + ms += DELAY; + } + } +} + +/** + * Option to collapse all blocks. + */ +export function registerCollapse() { + const collapseOption: RegistryItem = { + displayText() { + return Msg['COLLAPSE_ALL']; + }, + preconditionFn(scope: Scope) { + if (scope.workspace!.options.collapse) { + const topBlocks = scope.workspace!.getTopBlocks(false); + for (let i = 0; i < topBlocks.length; i++) { + let block: BlockSvg | null = topBlocks[i]; + while (block) { + if (!block.isCollapsed()) { + return 'enabled'; + } + block = block.getNextBlock(); + } + } + return 'disabled'; + } + return 'hidden'; + }, + callback(scope: Scope) { + toggleOption_(true, scope.workspace!.getTopBlocks(true)); + }, + scopeType: ContextMenuRegistry.ScopeType.WORKSPACE, + id: 'collapseWorkspace', + weight: 4, + }; + ContextMenuRegistry.registry.register(collapseOption); +} + +/** + * Option to expand all blocks. + */ +export function registerExpand() { + const expandOption: RegistryItem = { + displayText() { + return Msg['EXPAND_ALL']; + }, + preconditionFn(scope: Scope) { + if (scope.workspace!.options.collapse) { + const topBlocks = scope.workspace!.getTopBlocks(false); + for (let i = 0; i < topBlocks.length; i++) { + let block: BlockSvg | null = topBlocks[i]; + while (block) { + if (block.isCollapsed()) { + return 'enabled'; + } + block = block.getNextBlock(); + } + } + return 'disabled'; + } + return 'hidden'; + }, + callback(scope: Scope) { + toggleOption_(false, scope.workspace!.getTopBlocks(true)); + }, + scopeType: ContextMenuRegistry.ScopeType.WORKSPACE, + id: 'expandWorkspace', + weight: 5, + }; + ContextMenuRegistry.registry.register(expandOption); +} +/** + * Adds a block and its children to a list of deletable blocks. + * + * @param block to delete. + * @param deleteList list of blocks that can be deleted. + * This will be modified in place with the given block and its descendants. + */ +function addDeletableBlocks_(block: BlockSvg, deleteList: BlockSvg[]) { + if (block.isDeletable()) { + Array.prototype.push.apply(deleteList, block.getDescendants(false)); + } else { + const children = block.getChildren(false); + for (let i = 0; i < children.length; i++) { + addDeletableBlocks_(children[i], deleteList); + } + } +} + +/** + * Constructs a list of blocks that can be deleted in the given workspace. + * + * @param workspace to delete all blocks from. + * @returns list of blocks to delete. + */ +function getDeletableBlocks_(workspace: WorkspaceSvg): BlockSvg[] { + const deleteList: BlockSvg[] = []; + const topBlocks = workspace.getTopBlocks(true); + for (let i = 0; i < topBlocks.length; i++) { + addDeletableBlocks_(topBlocks[i], deleteList); + } + return deleteList; +} + +/** + * Deletes the given blocks. Used to delete all blocks in the workspace. + * + * @param deleteList List of blocks to delete. + * @param eventGroup Event group ID with which all delete events should be + * associated. If not specified, create a new group. + */ +function deleteNext_(deleteList: BlockSvg[], eventGroup?: string) { + const DELAY = 10; + if (eventGroup) { + eventUtils.setGroup(eventGroup); + } else { + eventUtils.setGroup(true); + eventGroup = eventUtils.getGroup(); + } + const block = deleteList.shift(); + if (block) { + if (!block.isDeadOrDying()) { + block.dispose(false, true); + setTimeout(deleteNext_, DELAY, deleteList, eventGroup); + } else { + deleteNext_(deleteList, eventGroup); + } + } + eventUtils.setGroup(false); +} + +/** + * Option to delete all blocks. + */ +export function registerDeleteAll() { + const deleteOption: RegistryItem = { + displayText(scope: Scope) { + if (!scope.workspace) { + return ''; + } + const deletableBlocksLength = getDeletableBlocks_(scope.workspace).length; + if (deletableBlocksLength === 1) { + return Msg['DELETE_BLOCK']; + } + return Msg['DELETE_X_BLOCKS'].replace('%1', `${deletableBlocksLength}`); + }, + preconditionFn(scope: Scope) { + if (!scope.workspace) { + return 'disabled'; + } + const deletableBlocksLength = getDeletableBlocks_(scope.workspace).length; + return deletableBlocksLength > 0 ? 'enabled' : 'disabled'; + }, + callback(scope: Scope) { + if (!scope.workspace) { + return; + } + scope.workspace.cancelCurrentGesture(); + const deletableBlocks = getDeletableBlocks_(scope.workspace); + if (deletableBlocks.length < 2) { + deleteNext_(deletableBlocks); + } else { + dialog.confirm( + Msg['DELETE_ALL_BLOCKS'].replace( + '%1', + String(deletableBlocks.length), + ), + function (ok) { + if (ok) { + deleteNext_(deletableBlocks); + } + }, + ); + } + }, + scopeType: ContextMenuRegistry.ScopeType.WORKSPACE, + id: 'workspaceDelete', + weight: 6, + }; + ContextMenuRegistry.registry.register(deleteOption); +} +/** Registers all workspace-scoped context menu items. */ +function registerWorkspaceOptions_() { + registerUndo(); + registerRedo(); + registerCleanup(); + registerCollapse(); + registerExpand(); + registerDeleteAll(); +} + +/** + * Option to duplicate a block. + */ +export function registerDuplicate() { + const duplicateOption: RegistryItem = { + displayText() { + return Msg['DUPLICATE_BLOCK']; + }, + preconditionFn(scope: Scope) { + const block = scope.block; + if (!block!.isInFlyout && block!.isDeletable() && block!.isMovable()) { + if (block!.isDuplicatable()) { + return 'enabled'; + } + return 'disabled'; + } + return 'hidden'; + }, + callback(scope: Scope) { + if (!scope.block) return; + const data = scope.block.toCopyData(); + if (!data) return; + clipboard.paste(data, scope.block.workspace); + }, + scopeType: ContextMenuRegistry.ScopeType.BLOCK, + id: 'blockDuplicate', + weight: 1, + }; + ContextMenuRegistry.registry.register(duplicateOption); +} + +/** + * Option to add or remove block-level comment. + */ +export function registerComment() { + const commentOption: RegistryItem = { + displayText(scope: Scope) { + if (scope.block!.hasIcon(CommentIcon.TYPE)) { + // If there's already a comment, option is to remove. + return Msg['REMOVE_COMMENT']; + } + // If there's no comment yet, option is to add. + return Msg['ADD_COMMENT']; + }, + preconditionFn(scope: Scope) { + const block = scope.block; + if ( + block && + !block.isInFlyout && + block.workspace.options.comments && + !block.isCollapsed() && + block.isEditable() && + // Either block already has a comment so let us remove it, + // or the block isn't just one full-block field block, which + // shouldn't be allowed to have comments as there's no way to read them. + (block.hasIcon(CommentIcon.TYPE) || !isFullBlockField(block)) + ) { + return 'enabled'; + } + return 'hidden'; + }, + callback(scope: Scope) { + const block = scope.block; + if (block && block.hasIcon(CommentIcon.TYPE)) { + block.setCommentText(null); + } else { + block!.setCommentText(''); + } + }, + scopeType: ContextMenuRegistry.ScopeType.BLOCK, + id: 'blockComment', + weight: 2, + }; + ContextMenuRegistry.registry.register(commentOption); +} + +/** + * Option to inline variables. + */ +export function registerInline() { + const inlineOption: RegistryItem = { + displayText(scope: Scope) { + return scope.block!.getInputsInline() + ? Msg['EXTERNAL_INPUTS'] + : Msg['INLINE_INPUTS']; + }, + preconditionFn(scope: Scope) { + const block = scope.block; + if (!block!.isInFlyout && block!.isMovable() && !block!.isCollapsed()) { + for (let i = 1; i < block!.inputList.length; i++) { + // Only display this option if there are two value or dummy inputs + // next to each other. + if ( + !(block!.inputList[i - 1] instanceof StatementInput) && + !(block!.inputList[i] instanceof StatementInput) + ) { + return 'enabled'; + } + } + } + return 'hidden'; + }, + callback(scope: Scope) { + scope.block!.setInputsInline(!scope.block!.getInputsInline()); + }, + scopeType: ContextMenuRegistry.ScopeType.BLOCK, + id: 'blockInline', + weight: 3, + }; + ContextMenuRegistry.registry.register(inlineOption); +} + +/** + * Option to collapse or expand a block. + */ +export function registerCollapseExpandBlock() { + const collapseExpandOption: RegistryItem = { + displayText(scope: Scope) { + return scope.block!.isCollapsed() + ? Msg['EXPAND_BLOCK'] + : Msg['COLLAPSE_BLOCK']; + }, + preconditionFn(scope: Scope) { + const block = scope.block; + if ( + !block!.isInFlyout && + block!.isMovable() && + block!.workspace.options.collapse + ) { + return 'enabled'; + } + return 'hidden'; + }, + callback(scope: Scope) { + scope.block!.setCollapsed(!scope.block!.isCollapsed()); + }, + scopeType: ContextMenuRegistry.ScopeType.BLOCK, + id: 'blockCollapseExpand', + weight: 4, + }; + ContextMenuRegistry.registry.register(collapseExpandOption); +} + +/** + * Option to disable or enable a block. + */ +export function registerDisable() { + const disableOption: RegistryItem = { + displayText(scope: Scope) { + return scope.block!.hasDisabledReason(MANUALLY_DISABLED) + ? Msg['ENABLE_BLOCK'] + : Msg['DISABLE_BLOCK']; + }, + preconditionFn(scope: Scope) { + const block = scope.block; + if ( + !block!.isInFlyout && + block!.workspace.options.disable && + block!.isEditable() + ) { + // Determine whether this block is currently disabled for any reason + // other than the manual reason that this context menu item controls. + const disabledReasons = block!.getDisabledReasons(); + const isDisabledForOtherReason = + disabledReasons.size > + (disabledReasons.has(MANUALLY_DISABLED) ? 1 : 0); + + if (block!.getInheritedDisabled() || isDisabledForOtherReason) { + return 'disabled'; + } + return 'enabled'; + } + return 'hidden'; + }, + callback(scope: Scope) { + const block = scope.block; + const existingGroup = eventUtils.getGroup(); + if (!existingGroup) { + eventUtils.setGroup(true); + } + block!.setDisabledReason( + !block!.hasDisabledReason(MANUALLY_DISABLED), + MANUALLY_DISABLED, + ); + eventUtils.setGroup(existingGroup); + }, + scopeType: ContextMenuRegistry.ScopeType.BLOCK, + id: 'blockDisable', + weight: 5, + }; + ContextMenuRegistry.registry.register(disableOption); +} + +/** + * Option to delete a block. + */ +export function registerDelete() { + const deleteOption: RegistryItem = { + displayText(scope: Scope) { + const block = scope.block; + // Count the number of blocks that are nested in this block. + let descendantCount = block!.getDescendants(false).length; + const nextBlock = block!.getNextBlock(); + if (nextBlock) { + // Blocks in the current stack would survive this block's deletion. + descendantCount -= nextBlock.getDescendants(false).length; + } + return descendantCount === 1 + ? Msg['DELETE_BLOCK'] + : Msg['DELETE_X_BLOCKS'].replace('%1', `${descendantCount}`); + }, + preconditionFn(scope: Scope) { + if (!scope.block!.isInFlyout && scope.block!.isDeletable()) { + return 'enabled'; + } + return 'hidden'; + }, + callback(scope: Scope) { + if (scope.block) { + scope.block.checkAndDelete(); + } + }, + scopeType: ContextMenuRegistry.ScopeType.BLOCK, + id: 'blockDelete', + weight: 6, + }; + ContextMenuRegistry.registry.register(deleteOption); +} + +/** + * Option to open help for a block. + */ +export function registerHelp() { + const helpOption: RegistryItem = { + displayText() { + return Msg['HELP']; + }, + preconditionFn(scope: Scope) { + const block = scope.block; + const url = + typeof block!.helpUrl === 'function' + ? block!.helpUrl() + : block!.helpUrl; + if (url) { + return 'enabled'; + } + return 'hidden'; + }, + callback(scope: Scope) { + scope.block!.showHelp(); + }, + scopeType: ContextMenuRegistry.ScopeType.BLOCK, + id: 'blockHelp', + weight: 7, + }; + ContextMenuRegistry.registry.register(helpOption); +} + +/** Registers an option for deleting a workspace comment. */ +export function registerCommentDelete() { + const deleteOption: RegistryItem = { + displayText: () => Msg['REMOVE_COMMENT'], + preconditionFn(scope: Scope) { + return scope.comment?.isDeletable() ? 'enabled' : 'hidden'; + }, + callback(scope: Scope) { + eventUtils.setGroup(true); + scope.comment?.dispose(); + eventUtils.setGroup(false); + }, + scopeType: ContextMenuRegistry.ScopeType.COMMENT, + id: 'commentDelete', + weight: 6, + }; + ContextMenuRegistry.registry.register(deleteOption); +} + +/** Registers an option for duplicating a workspace comment. */ +export function registerCommentDuplicate() { + const duplicateOption: RegistryItem = { + displayText: () => Msg['DUPLICATE_COMMENT'], + preconditionFn(scope: Scope) { + return scope.comment?.isMovable() ? 'enabled' : 'hidden'; + }, + callback(scope: Scope) { + if (!scope.comment) return; + const data = scope.comment.toCopyData(); + if (!data) return; + clipboard.paste(data, scope.comment.workspace); + }, + scopeType: ContextMenuRegistry.ScopeType.COMMENT, + id: 'commentDuplicate', + weight: 1, + }; + ContextMenuRegistry.registry.register(duplicateOption); +} + +/** Registers an option for adding a workspace comment to the workspace. */ +export function registerCommentCreate() { + const createOption: RegistryItem = { + displayText: () => Msg['ADD_COMMENT'], + preconditionFn: (scope: Scope) => { + return scope.workspace?.isMutator ? 'hidden' : 'enabled'; + }, + callback: ( + scope: Scope, + menuOpenEvent: Event, + menuSelectEvent: Event, + location: Coordinate, + ) => { + const workspace = scope.workspace; + if (!workspace) return; + eventUtils.setGroup(true); + const comment = new RenderedWorkspaceComment(workspace); + comment.setPlaceholderText(Msg['WORKSPACE_COMMENT_DEFAULT_TEXT']); + comment.moveTo( + svgMath.screenToWsCoordinates( + workspace, + new Coordinate(location.x, location.y), + ), + ); + getFocusManager().focusNode(comment); + eventUtils.setGroup(false); + }, + scopeType: ContextMenuRegistry.ScopeType.WORKSPACE, + id: 'commentCreate', + weight: 8, + }; + ContextMenuRegistry.registry.register(createOption); +} + +/** Registers all block-scoped context menu items. */ +function registerBlockOptions_() { + registerDuplicate(); + registerComment(); + registerInline(); + registerCollapseExpandBlock(); + registerDisable(); + registerDelete(); + registerHelp(); +} + +/** Registers all workspace comment related menu items. */ +export function registerCommentOptions() { + registerCommentDuplicate(); + registerCommentDelete(); + registerCommentCreate(); +} + +/** + * Registers all default context menu items. This should be called once per + * instance of ContextMenuRegistry. + * + * @internal + */ +export function registerDefaultOptions() { + registerWorkspaceOptions_(); + registerBlockOptions_(); +} + +registerDefaultOptions(); diff --git a/core/contextmenu_registry.ts b/core/contextmenu_registry.ts new file mode 100644 index 00000000000..61fac7a719f --- /dev/null +++ b/core/contextmenu_registry.ts @@ -0,0 +1,278 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Registry for context menu option items. + * + * @class + */ +// Former goog.module ID: Blockly.ContextMenuRegistry + +import type {BlockSvg} from './block_svg.js'; +import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import {Coordinate} from './utils/coordinate.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +/** + * Class for the registry of context menu items. This is intended to be a + * singleton. You should not create a new instance, and only access this class + * from ContextMenuRegistry.registry. + */ +export class ContextMenuRegistry { + static registry: ContextMenuRegistry; + /** Registry of all registered RegistryItems, keyed by ID. */ + private registeredItems = new Map(); + + /** Resets the existing singleton instance of ContextMenuRegistry. */ + constructor() { + this.reset(); + } + + /** Clear and recreate the registry. */ + reset() { + this.registeredItems.clear(); + } + + /** + * Registers a RegistryItem. + * + * @param item Context menu item to register. + * @throws {Error} if an item with the given ID already exists. + */ + register(item: RegistryItem) { + if (this.registeredItems.has(item.id)) { + throw Error('Menu item with ID "' + item.id + '" is already registered.'); + } + this.registeredItems.set(item.id, item); + } + + /** + * Unregisters a RegistryItem with the given ID. + * + * @param id The ID of the RegistryItem to remove. + * @throws {Error} if an item with the given ID does not exist. + */ + unregister(id: string) { + if (!this.registeredItems.has(id)) { + throw new Error('Menu item with ID "' + id + '" not found.'); + } + this.registeredItems.delete(id); + } + + /** + * @param id The ID of the RegistryItem to get. + * @returns RegistryItem or null if not found + */ + getItem(id: string): RegistryItem | null { + return this.registeredItems.get(id) ?? null; + } + + /** + * Gets the valid context menu options for the given scope. + * Options are only included if the preconditionFn shows + * they should not be hidden. + * + * @param scope Current scope of context menu (i.e., the exact workspace or + * block being clicked on). + * @param menuOpenEvent Event that caused the menu to open. + * @returns the list of ContextMenuOptions + */ + getContextMenuOptions( + scope: Scope, + menuOpenEvent: Event, + ): ContextMenuOption[] { + const menuOptions: ContextMenuOption[] = []; + for (const item of this.registeredItems.values()) { + if (item.scopeType) { + // If the scopeType is present, check to make sure + // that the option is compatible with the current scope + if (item.scopeType === ScopeType.BLOCK && !scope.block) continue; + if (item.scopeType === ScopeType.COMMENT && !scope.comment) continue; + if (item.scopeType === ScopeType.WORKSPACE && !scope.workspace) + continue; + } + let menuOption: + | ContextMenuRegistry.CoreContextMenuOption + | ContextMenuRegistry.SeparatorContextMenuOption + | ContextMenuRegistry.ActionContextMenuOption; + menuOption = { + scope, + weight: item.weight, + }; + + if (item.separator) { + menuOption = { + ...menuOption, + separator: true, + }; + } else { + const precondition = item.preconditionFn(scope, menuOpenEvent); + if (precondition === 'hidden') continue; + + const displayText = + typeof item.displayText === 'function' + ? item.displayText(scope) + : item.displayText; + menuOption = { + ...menuOption, + text: displayText, + callback: item.callback, + enabled: precondition === 'enabled', + }; + } + + menuOptions.push(menuOption); + } + menuOptions.sort(function (a, b) { + return a.weight - b.weight; + }); + return menuOptions; + } +} + +export namespace ContextMenuRegistry { + /** + * Where this menu item should be rendered. If the menu item should be + * rendered in multiple scopes, e.g. on both a block and a workspace, it + * should be registered for each scope. + */ + export enum ScopeType { + BLOCK = 'block', + WORKSPACE = 'workspace', + COMMENT = 'comment', + } + + /** + * The actual workspace/block/focused object where the menu is being + * rendered. This is passed to callback and displayText functions + * that depend on this information. + */ + export interface Scope { + block?: BlockSvg; + workspace?: WorkspaceSvg; + comment?: RenderedWorkspaceComment; + focusedNode?: IFocusableNode; + } + + /** + * Fields common to all context menu registry items. + */ + interface CoreRegistryItem { + scopeType?: ScopeType; + weight: number; + id: string; + } + + /** + * A representation of a normal, clickable menu item in the registry. + */ + interface ActionRegistryItem extends CoreRegistryItem { + /** + * @param scope Object that provides a reference to the thing that had its + * context menu opened. + * @param menuOpenEvent The original event that triggered the context menu to open. + * @param menuSelectEvent The event that triggered the option being selected. + * @param location The location in screen coordinates where the menu was opened. + */ + callback: ( + scope: Scope, + menuOpenEvent: Event, + menuSelectEvent: Event, + location: Coordinate, + ) => void; + displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement; + preconditionFn: (p1: Scope, menuOpenEvent: Event) => string; + separator?: never; + } + + /** + * A representation of a menu separator item in the registry. + */ + interface SeparatorRegistryItem extends CoreRegistryItem { + separator: true; + callback?: never; + displayText?: never; + preconditionFn?: never; + } + + /** + * A menu item as entered in the registry. + */ + export type RegistryItem = ActionRegistryItem | SeparatorRegistryItem; + + /** + * Fields common to all context menu items as used by contextmenu.ts. + */ + export interface CoreContextMenuOption { + scope: Scope; + weight: number; + } + + /** + * A representation of a normal, clickable menu item in contextmenu.ts. + */ + export interface ActionContextMenuOption extends CoreContextMenuOption { + text: string | HTMLElement; + enabled: boolean; + /** + * @param scope Object that provides a reference to the thing that had its + * context menu opened. + * @param menuOpenEvent The original event that triggered the context menu to open. + * @param menuSelectEvent The event that triggered the option being selected. + * @param location The location in screen coordinates where the menu was opened. + */ + callback: ( + scope: Scope, + menuOpenEvent: Event, + menuSelectEvent: Event, + location: Coordinate, + ) => void; + separator?: never; + } + + /** + * A representation of a menu separator item in contextmenu.ts. + */ + export interface SeparatorContextMenuOption extends CoreContextMenuOption { + separator: true; + text?: never; + enabled?: never; + callback?: never; + } + + /** + * A menu item as presented to contextmenu.ts. + */ + export type ContextMenuOption = + | ActionContextMenuOption + | SeparatorContextMenuOption; + + /** + * A subset of ContextMenuOption corresponding to what was publicly + * documented. ContextMenuOption should be preferred for new code. + */ + export interface LegacyContextMenuOption { + text: string; + enabled: boolean; + callback: (p1: Scope) => void; + separator?: never; + } + + /** + * Singleton instance of this class. All interactions with this class should + * be done on this object. + */ + ContextMenuRegistry.registry = new ContextMenuRegistry(); +} + +export type ScopeType = ContextMenuRegistry.ScopeType; +export const ScopeType = ContextMenuRegistry.ScopeType; +export type Scope = ContextMenuRegistry.Scope; +export type RegistryItem = ContextMenuRegistry.RegistryItem; +export type ContextMenuOption = ContextMenuRegistry.ContextMenuOption; +export type LegacyContextMenuOption = + ContextMenuRegistry.LegacyContextMenuOption; diff --git a/core/css.js b/core/css.js deleted file mode 100644 index a4d766c36f2..00000000000 --- a/core/css.js +++ /dev/null @@ -1,871 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2013 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Inject Blockly's CSS synchronously. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -/** - * @name Blockly.Css - * @namespace - */ -goog.provide('Blockly.Css'); - - -/** - * List of cursors. - * @enum {string} - */ -Blockly.Css.Cursor = { - OPEN: 'handopen', - CLOSED: 'handclosed', - DELETE: 'handdelete' -}; - -/** - * Current cursor (cached value). - * @type {string} - * @private - */ -Blockly.Css.currentCursor_ = ''; - -/** - * Large stylesheet added by Blockly.Css.inject. - * @type {Element} - * @private - */ -Blockly.Css.styleSheet_ = null; - -/** - * Path to media directory, with any trailing slash removed. - * @type {string} - * @private - */ -Blockly.Css.mediaPath_ = ''; - -/** - * Inject the CSS into the DOM. This is preferable over using a regular CSS - * file since: - * a) It loads synchronously and doesn't force a redraw later. - * b) It speeds up loading by not blocking on a separate HTTP transfer. - * c) The CSS content may be made dynamic depending on init options. - * @param {boolean} hasCss If false, don't inject CSS - * (providing CSS becomes the document's responsibility). - * @param {string} pathToMedia Path from page to the Blockly media directory. - */ -Blockly.Css.inject = function(hasCss, pathToMedia) { - // Only inject the CSS once. - if (Blockly.Css.styleSheet_) { - return; - } - // Placeholder for cursor rule. Must be first rule (index 0). - var text = '.blocklyDraggable {}\n'; - if (hasCss) { - text += Blockly.Css.CONTENT.join('\n'); - if (Blockly.FieldDate) { - text += Blockly.FieldDate.CSS.join('\n'); - } - } - // Strip off any trailing slash (either Unix or Windows). - Blockly.Css.mediaPath_ = pathToMedia.replace(/[\\\/]$/, ''); - text = text.replace(/<<>>/g, Blockly.Css.mediaPath_); - // Inject CSS tag at start of head. - var cssNode = document.createElement('style'); - document.head.insertBefore(cssNode, document.head.firstChild); - - var cssTextNode = document.createTextNode(text); - cssNode.appendChild(cssTextNode); - Blockly.Css.styleSheet_ = cssNode.sheet; -}; - -/** - * Set the cursor to be displayed when over something draggable. - * See See https://github.com/google/blockly/issues/981 for context. - * @param {Blockly.Css.Cursor} cursor Enum. - * @deprecated April 2017. - */ -Blockly.Css.setCursor = function(cursor) { - console.warn('Deprecated call to Blockly.Css.setCursor.' + - 'See https://github.com/google/blockly/issues/981 for context'); -}; - -/** - * Array making up the CSS content for Blockly. - */ -Blockly.Css.CONTENT = [ - '.blocklySvg {', - 'background-color: #fff;', - 'outline: none;', - 'overflow: hidden;', /* IE overflows by default. */ - 'position: absolute;', - 'display: block;', - '}', - - '.blocklyWidgetDiv {', - 'display: none;', - 'position: absolute;', - 'z-index: 99999;', /* big value for bootstrap3 compatibility */ - '}', - - '.injectionDiv {', - 'height: 100%;', - 'position: relative;', - 'overflow: hidden;', /* So blocks in drag surface disappear at edges */ - 'touch-action: none', - '}', - - '.blocklyNonSelectable {', - 'user-select: none;', - '-moz-user-select: none;', - '-webkit-user-select: none;', - '-ms-user-select: none;', - '}', - - '.blocklyWsDragSurface {', - 'display: none;', - 'position: absolute;', - 'overflow: visible;', - 'top: 0;', - 'left: 0;', - '}', - - '.blocklyBlockDragSurface {', - 'display: none;', - 'position: absolute;', - 'top: 0;', - 'left: 0;', - 'right: 0;', - 'bottom: 0;', - 'overflow: visible !important;', - 'z-index: 50;', /* Display below toolbox, but above everything else. */ - '}', - - '.blocklyTooltipDiv {', - 'background-color: #ffffc7;', - 'border: 1px solid #ddc;', - 'box-shadow: 4px 4px 20px 1px rgba(0,0,0,.15);', - 'color: #000;', - 'display: none;', - 'font-family: sans-serif;', - 'font-size: 9pt;', - 'opacity: 0.9;', - 'padding: 2px;', - 'position: absolute;', - 'z-index: 100000;', /* big value for bootstrap3 compatibility */ - '}', - - '.blocklyResizeSE {', - 'cursor: se-resize;', - 'fill: #aaa;', - '}', - - '.blocklyResizeSW {', - 'cursor: sw-resize;', - 'fill: #aaa;', - '}', - - '.blocklyResizeLine {', - 'stroke: #888;', - 'stroke-width: 1;', - '}', - - '.blocklyHighlightedConnectionPath {', - 'fill: none;', - 'stroke: #fc3;', - 'stroke-width: 4px;', - '}', - - '.blocklyPathLight {', - 'fill: none;', - 'stroke-linecap: round;', - 'stroke-width: 1;', - '}', - - '.blocklySelected>.blocklyPath {', - 'stroke: #fc3;', - 'stroke-width: 3px;', - '}', - - '.blocklySelected>.blocklyPathLight {', - 'display: none;', - '}', - - '.blocklyDraggable {', - /* backup for browsers (e.g. IE11) that don't support grab */ - 'cursor: url("<<>>/handopen.cur"), auto;', - 'cursor: grab;', - 'cursor: -webkit-grab;', - 'cursor: -moz-grab;', - '}', - - '.blocklyDragging {', - /* backup for browsers (e.g. IE11) that don't support grabbing */ - 'cursor: url("<<>>/handclosed.cur"), auto;', - 'cursor: grabbing;', - 'cursor: -webkit-grabbing;', - 'cursor: -moz-grabbing;', - '}', - /* Changes cursor on mouse down. Not effective in Firefox because of - https://bugzilla.mozilla.org/show_bug.cgi?id=771241 */ - '.blocklyDraggable:active {', - /* backup for browsers (e.g. IE11) that don't support grabbing */ - 'cursor: url("<<>>/handclosed.cur"), auto;', - 'cursor: grabbing;', - 'cursor: -webkit-grabbing;', - 'cursor: -moz-grabbing;', - '}', - /* Change the cursor on the whole drag surface in case the mouse gets - ahead of block during a drag. This way the cursor is still a closed hand. - */ - '.blocklyBlockDragSurface .blocklyDraggable {', - /* backup for browsers (e.g. IE11) that don't support grabbing */ - 'cursor: url("<<>>/handclosed.cur"), auto;', - 'cursor: grabbing;', - 'cursor: -webkit-grabbing;', - 'cursor: -moz-grabbing;', - '}', - - '.blocklyDragging.blocklyDraggingDelete {', - 'cursor: url("<<>>/handdelete.cur"), auto;', - '}', - - '.blocklyToolboxDelete {', - 'cursor: url("<<>>/handdelete.cur"), auto;', - '}', - - '.blocklyDragging>.blocklyPath,', - '.blocklyDragging>.blocklyPathLight {', - 'fill-opacity: .8;', - 'stroke-opacity: .8;', - '}', - - '.blocklyDragging>.blocklyPathDark {', - 'display: none;', - '}', - - '.blocklyDisabled>.blocklyPath {', - 'fill-opacity: .5;', - 'stroke-opacity: .5;', - '}', - - '.blocklyDisabled>.blocklyPathLight,', - '.blocklyDisabled>.blocklyPathDark {', - 'display: none;', - '}', - - '.blocklyText {', - 'cursor: default;', - 'fill: #fff;', - 'font-family: sans-serif;', - 'font-size: 11pt;', - '}', - - '.blocklyNonEditableText>text {', - 'pointer-events: none;', - '}', - - '.blocklyNonEditableText>rect,', - '.blocklyEditableText>rect {', - 'fill: #fff;', - 'fill-opacity: .6;', - '}', - - '.blocklyNonEditableText>text,', - '.blocklyEditableText>text {', - 'fill: #000;', - '}', - - '.blocklyEditableText:hover>rect {', - 'stroke: #fff;', - 'stroke-width: 2;', - '}', - - '.blocklyBubbleText {', - 'fill: #000;', - '}', - - '.blocklyFlyout {', - 'position: absolute;', - 'z-index: 20;', - '}', - '.blocklyFlyoutButton {', - 'fill: #888;', - 'cursor: default;', - '}', - - '.blocklyFlyoutButtonShadow {', - 'fill: #666;', - '}', - - '.blocklyFlyoutButton:hover {', - 'fill: #aaa;', - '}', - - '.blocklyFlyoutLabel {', - 'cursor: default;', - '}', - - '.blocklyFlyoutLabelBackground {', - 'opacity: 0;', - '}', - - '.blocklyFlyoutLabelText {', - 'fill: #000;', - '}', - - /* - Don't allow users to select text. It gets annoying when trying to - drag a block and selected text moves instead. - */ - '.blocklySvg text, .blocklyBlockDragSurface text {', - 'user-select: none;', - '-moz-user-select: none;', - '-webkit-user-select: none;', - 'cursor: inherit;', - '}', - - '.blocklyHidden {', - 'display: none;', - '}', - - '.blocklyFieldDropdown:not(.blocklyHidden) {', - 'display: block;', - '}', - - '.blocklyIconGroup {', - 'cursor: default;', - '}', - - '.blocklyIconGroup:not(:hover),', - '.blocklyIconGroupReadonly {', - 'opacity: .6;', - '}', - - '.blocklyIconShape {', - 'fill: #00f;', - 'stroke: #fff;', - 'stroke-width: 1px;', - '}', - - '.blocklyIconSymbol {', - 'fill: #fff;', - '}', - - '.blocklyMinimalBody {', - 'margin: 0;', - 'padding: 0;', - '}', - - '.blocklyCommentTextarea {', - 'background-color: #ffc;', - 'border: 0;', - 'margin: 0;', - 'padding: 2px;', - 'resize: none;', - '}', - - '.blocklyHtmlInput {', - 'border: none;', - 'border-radius: 4px;', - 'font-family: sans-serif;', - 'height: 100%;', - 'margin: 0;', - 'outline: none;', - 'padding: 0 1px;', - 'width: 100%', - '}', - - '.blocklyMainBackground {', - 'stroke-width: 1;', - 'stroke: #c6c6c6;', /* Equates to #ddd due to border being off-pixel. */ - '}', - - '.blocklyMutatorBackground {', - 'fill: #fff;', - 'stroke: #ddd;', - 'stroke-width: 1;', - '}', - - '.blocklyFlyoutBackground {', - 'fill: #ddd;', - 'fill-opacity: .8;', - '}', - - '.blocklyTransparentBackground {', - 'opacity: 0;', - '}', - - '.blocklyMainWorkspaceScrollbar {', - 'z-index: 20;', - '}', - - '.blocklyFlyoutScrollbar {', - 'z-index: 30;', - '}', - - '.blocklyScrollbarHorizontal, .blocklyScrollbarVertical {', - 'position: absolute;', - 'outline: none;', - '}', - - '.blocklyScrollbarBackground {', - 'opacity: 0;', - '}', - - '.blocklyScrollbarHandle {', - 'fill: #ccc;', - '}', - - '.blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,', - '.blocklyScrollbarHandle:hover {', - 'fill: #bbb;', - '}', - - '.blocklyZoom>image {', - 'opacity: .4;', - '}', - - '.blocklyZoom>image:hover {', - 'opacity: .6;', - '}', - - '.blocklyZoom>image:active {', - 'opacity: .8;', - '}', - - /* Darken flyout scrollbars due to being on a grey background. */ - /* By contrast, workspace scrollbars are on a white background. */ - '.blocklyFlyout .blocklyScrollbarHandle {', - 'fill: #bbb;', - '}', - - '.blocklyFlyout .blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,', - '.blocklyFlyout .blocklyScrollbarHandle:hover {', - 'fill: #aaa;', - '}', - - '.blocklyInvalidInput {', - 'background: #faa;', - '}', - - '.blocklyAngleCircle {', - 'stroke: #444;', - 'stroke-width: 1;', - 'fill: #ddd;', - 'fill-opacity: .8;', - '}', - - '.blocklyAngleMarks {', - 'stroke: #444;', - 'stroke-width: 1;', - '}', - - '.blocklyAngleGauge {', - 'fill: #f88;', - 'fill-opacity: .8;', - '}', - - '.blocklyAngleLine {', - 'stroke: #f00;', - 'stroke-width: 2;', - 'stroke-linecap: round;', - 'pointer-events: none;', - '}', - - '.blocklyContextMenu {', - 'border-radius: 4px;', - '}', - - '.blocklyDropdownMenu {', - 'padding: 0 !important;', - '}', - - /* Override the default Closure URL. */ - '.blocklyWidgetDiv .goog-option-selected .goog-menuitem-checkbox,', - '.blocklyWidgetDiv .goog-option-selected .goog-menuitem-icon {', - 'background: url(<<>>/sprites.png) no-repeat -48px -16px !important;', - '}', - - /* Category tree in Toolbox. */ - '.blocklyToolboxDiv {', - 'background-color: #ddd;', - 'overflow-x: visible;', - 'overflow-y: auto;', - 'position: absolute;', - 'z-index: 70;', /* so blocks go under toolbox when dragging */ - '}', - - '.blocklyTreeRoot {', - 'padding: 4px 0;', - '}', - - '.blocklyTreeRoot:focus {', - 'outline: none;', - '}', - - '.blocklyTreeRow {', - 'height: 22px;', - 'line-height: 22px;', - 'margin-bottom: 3px;', - 'padding-right: 8px;', - 'white-space: nowrap;', - '}', - - '.blocklyHorizontalTree {', - 'float: left;', - 'margin: 1px 5px 8px 0;', - '}', - - '.blocklyHorizontalTreeRtl {', - 'float: right;', - 'margin: 1px 0 8px 5px;', - '}', - - '.blocklyToolboxDiv[dir="RTL"] .blocklyTreeRow {', - 'margin-left: 8px;', - '}', - - '.blocklyTreeRow:not(.blocklyTreeSelected):hover {', - 'background-color: #e4e4e4;', - '}', - - '.blocklyTreeSeparator {', - 'border-bottom: solid #e5e5e5 1px;', - 'height: 0;', - 'margin: 5px 0;', - '}', - - '.blocklyTreeSeparatorHorizontal {', - 'border-right: solid #e5e5e5 1px;', - 'width: 0;', - 'padding: 5px 0;', - 'margin: 0 5px;', - '}', - - - '.blocklyTreeIcon {', - 'background-image: url(<<>>/sprites.png);', - 'height: 16px;', - 'vertical-align: middle;', - 'width: 16px;', - '}', - - '.blocklyTreeIconClosedLtr {', - 'background-position: -32px -1px;', - '}', - - '.blocklyTreeIconClosedRtl {', - 'background-position: 0px -1px;', - '}', - - '.blocklyTreeIconOpen {', - 'background-position: -16px -1px;', - '}', - - '.blocklyTreeSelected>.blocklyTreeIconClosedLtr {', - 'background-position: -32px -17px;', - '}', - - '.blocklyTreeSelected>.blocklyTreeIconClosedRtl {', - 'background-position: 0px -17px;', - '}', - - '.blocklyTreeSelected>.blocklyTreeIconOpen {', - 'background-position: -16px -17px;', - '}', - - '.blocklyTreeIconNone,', - '.blocklyTreeSelected>.blocklyTreeIconNone {', - 'background-position: -48px -1px;', - '}', - - '.blocklyTreeLabel {', - 'cursor: default;', - 'font-family: sans-serif;', - 'font-size: 16px;', - 'padding: 0 3px;', - 'vertical-align: middle;', - '}', - - '.blocklyToolboxDelete .blocklyTreeLabel {', - 'cursor: url("<<>>/handdelete.cur"), auto;', - '}', - - '.blocklyTreeSelected .blocklyTreeLabel {', - 'color: #fff;', - '}', - - /* Copied from: goog/css/colorpicker-simplegrid.css */ - /* - * 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 - */ - '.blocklyWidgetDiv .goog-palette {', - 'outline: none;', - 'cursor: default;', - '}', - - '.blocklyWidgetDiv .goog-palette-table {', - 'border: 1px solid #666;', - 'border-collapse: collapse;', - '}', - - '.blocklyWidgetDiv .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;', - '}', - - '.blocklyWidgetDiv .goog-palette-colorswatch {', - 'position: relative;', - 'height: 13px;', - 'width: 15px;', - 'border: 1px solid #666;', - '}', - - '.blocklyWidgetDiv .goog-palette-cell-hover .goog-palette-colorswatch {', - 'border: 1px solid #FFF;', - '}', - - '.blocklyWidgetDiv .goog-palette-cell-selected .goog-palette-colorswatch {', - 'border: 1px solid #000;', - 'color: #fff;', - '}', - - /* Copied from: goog/css/menu.css */ - /* - * 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. - * - * @author attila@google.com (Attila Bodis) - */ - - '.blocklyWidgetDiv .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;', - 'overflow-y: auto;', - 'overflow-x: hidden;', - 'max-height: 100%;', - 'z-index: 20000;', /* Arbitrary, but some apps depend on it... */ - '}', - - /* Copied from: goog/css/menuitem.css */ - /* - * 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. - * - * @author attila@google.com (Attila Bodis) - */ - - /** - * 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. - */ - '.blocklyWidgetDiv .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. */ - /* #noflip */ - '.blocklyWidgetDiv .goog-menuitem.goog-menuitem-rtl {', - /* Flip left/right padding for BiDi. */ - 'padding-left: 7em;', - 'padding-right: 28px;', - '}', - - /* If a menu doesn't have checkable items or items with icons, remove padding. */ - '.blocklyWidgetDiv .goog-menu-nocheckbox .goog-menuitem,', - '.blocklyWidgetDiv .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. - */ - '.blocklyWidgetDiv .goog-menu-noaccel .goog-menuitem {', - 'padding-right: 20px;', - '}', - - '.blocklyWidgetDiv .goog-menuitem-content {', - 'color: #000;', - 'font: normal 13px Arial, sans-serif;', - '}', - - /* State: disabled. */ - '.blocklyWidgetDiv .goog-menuitem-disabled .goog-menuitem-accel,', - '.blocklyWidgetDiv .goog-menuitem-disabled .goog-menuitem-content {', - 'color: #ccc !important;', - '}', - - '.blocklyWidgetDiv .goog-menuitem-disabled .goog-menuitem-icon {', - 'opacity: 0.3;', - '-moz-opacity: 0.3;', - 'filter: alpha(opacity=30);', - '}', - - /* State: hover. */ - '.blocklyWidgetDiv .goog-menuitem-highlight,', - '.blocklyWidgetDiv .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. */ - '.blocklyWidgetDiv .goog-menuitem-checkbox,', - '.blocklyWidgetDiv .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. */ - /* #noflip */ - '.blocklyWidgetDiv .goog-menuitem-rtl .goog-menuitem-checkbox,', - '.blocklyWidgetDiv .goog-menuitem-rtl .goog-menuitem-icon {', - /* Flip left/right positioning. */ - 'left: auto;', - 'right: 6px;', - '}', - - '.blocklyWidgetDiv .goog-option-selected .goog-menuitem-checkbox,', - '.blocklyWidgetDiv .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. */ - '.blocklyWidgetDiv .goog-menuitem-accel {', - 'color: #999;', - /* Keyboard shortcuts are untranslated; always left-to-right. */ - /* #noflip */ - 'direction: ltr;', - 'left: auto;', - 'padding: 0 6px;', - 'position: absolute;', - 'right: 0;', - 'text-align: right;', - '}', - - /* BiDi override for shortcut style. */ - /* #noflip */ - '.blocklyWidgetDiv .goog-menuitem-rtl .goog-menuitem-accel {', - /* Flip left/right positioning and text alignment. */ - 'left: 0;', - 'right: auto;', - 'text-align: left;', - '}', - - /* Mnemonic styles. */ - '.blocklyWidgetDiv .goog-menuitem-mnemonic-hint {', - 'text-decoration: underline;', - '}', - - '.blocklyWidgetDiv .goog-menuitem-mnemonic-separator {', - 'color: #999;', - 'font-size: 12px;', - 'padding-left: 4px;', - '}', - - /* Copied from: goog/css/menuseparator.css */ - /* - * 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. - * - * @author attila@google.com (Attila Bodis) - */ - - '.blocklyWidgetDiv .goog-menuseparator {', - 'border-top: 1px solid #ccc;', - 'margin: 4px 0;', - 'padding: 0;', - '}', - - '' -]; diff --git a/core/css.ts b/core/css.ts new file mode 100644 index 00000000000..503b6362ba2 --- /dev/null +++ b/core/css.ts @@ -0,0 +1,511 @@ +/** + * @license + * Copyright 2013 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.Css +/** Has CSS already been injected? */ +let injected = false; + +/** + * Add some CSS to the blob that will be injected later. Allows optional + * components such as fields and the toolbox to store separate CSS. + * + * @param cssContent Multiline CSS string or an array of single lines of CSS. + */ +export function register(cssContent: string) { + if (injected) { + throw Error('CSS already injected'); + } + content += '\n' + cssContent; +} + +/** + * Inject the CSS into the DOM. This is preferable over using a regular CSS + * file since: + * a) It loads synchronously and doesn't force a redraw later. + * b) It speeds up loading by not blocking on a separate HTTP transfer. + * c) The CSS content may be made dynamic depending on init options. + * + * @param hasCss If false, don't inject CSS (providing CSS becomes the + * document's responsibility). + * @param pathToMedia Path from page to the Blockly media directory. + */ +export function inject(hasCss: boolean, pathToMedia: string) { + // Only inject the CSS once. + if (injected) { + return; + } + injected = true; + if (!hasCss) { + return; + } + // Strip off any trailing slash (either Unix or Windows). + const mediaPath = pathToMedia.replace(/[\\/]$/, ''); + const cssContent = content.replace(/<<>>/g, mediaPath); + // Cleanup the collected css content after injecting it to the DOM. + content = ''; + + // Inject CSS tag at start of head. + const cssNode = document.createElement('style'); + cssNode.id = 'blockly-common-style'; + const cssTextNode = document.createTextNode(cssContent); + cssNode.appendChild(cssTextNode); + document.head.insertBefore(cssNode, document.head.firstChild); +} + +/** + * The CSS content for Blockly. + */ +let content = ` +.blocklySvg { + background-color: #fff; + outline: none; + overflow: hidden; /* IE overflows by default. */ + position: absolute; + display: block; +} + +.blocklyWidgetDiv { + display: none; + position: absolute; + z-index: 99999; /* big value for bootstrap3 compatibility */ +} + +.injectionDiv { + height: 100%; + position: relative; + overflow: hidden; /* So blocks in drag surface disappear at edges */ + touch-action: none; + user-select: none; + -webkit-user-select: none; +} + +.blocklyBlockCanvas.blocklyCanvasTransitioning, +.blocklyBubbleCanvas.blocklyCanvasTransitioning { + transition: transform .5s; +} + +.blocklyEmboss { + filter: var(--blocklyEmbossFilter); +} + +.blocklyTooltipDiv { + background-color: #ffffc7; + border: 1px solid #ddc; + box-shadow: 4px 4px 20px 1px rgba(0,0,0,.15); + color: #000; + display: none; + font: 9pt sans-serif; + opacity: .9; + padding: 2px; + position: absolute; + z-index: 100000; /* big value for bootstrap3 compatibility */ +} + +.blocklyDropDownDiv { + position: absolute; + left: 0; + top: 0; + z-index: 1000; + display: none; + border: 1px solid; + border-color: #dadce0; + background-color: #fff; + border-radius: 2px; + padding: 4px; + box-shadow: 0 0 3px 1px rgba(0,0,0,.3); +} + +.blocklyDropDownDiv:focus { + box-shadow: 0 0 6px 1px rgba(0,0,0,.3); +} + +.blocklyDropDownContent { + max-height: 300px; /* @todo: spec for maximum height. */ +} + +.blocklyDropDownArrow { + position: absolute; + left: 0; + top: 0; + width: 16px; + height: 16px; + z-index: -1; + background-color: inherit; + border-color: inherit; + border-top: 1px solid; + border-left: 1px solid; + border-top-left-radius: 4px; + border-color: inherit; +} + +.blocklyHighlighted>.blocklyPath { + filter: var(--blocklyEmbossFilter); +} + +.blocklyHighlightedConnectionPath { + fill: none; + stroke: #fc3; + stroke-width: 4px; +} + +.blocklyPathLight { + fill: none; + stroke-linecap: round; + stroke-width: 1; +} + +.blocklySelected>.blocklyPathLight { + display: none; +} + +.blocklyDraggable { + cursor: grab; + cursor: -webkit-grab; +} + +.blocklyDragging { + cursor: grabbing; + cursor: -webkit-grabbing; + /* Drag surface disables events to not block the toolbox, so we have to + * reenable them here for the cursor values to work. */ + pointer-events: auto; +} + + /* Changes cursor on mouse down. Not effective in Firefox because of + https://bugzilla.mozilla.org/show_bug.cgi?id=771241 */ +.blocklyDraggable:active { + cursor: grabbing; + cursor: -webkit-grabbing; +} + +.blocklyDragging.blocklyDraggingDelete, +.blocklyDragging.blocklyDraggingDelete .blocklyField { + cursor: url("<<>>/handdelete.cur"), auto; +} + +.blocklyDragging>.blocklyPath, +.blocklyDragging>.blocklyPathLight { + fill-opacity: .8; + stroke-opacity: .8; +} + +.blocklyDragging>.blocklyPathDark { + display: none; +} + +.blocklyDisabledPattern>.blocklyPath { + fill: var(--blocklyDisabledPattern); + fill-opacity: .5; + stroke-opacity: .5; +} + +.blocklyDisabled>.blocklyPathLight, +.blocklyDisabled>.blocklyPathDark { + display: none; +} + +.blocklyInsertionMarker>.blocklyPath, +.blocklyInsertionMarker>.blocklyPathLight, +.blocklyInsertionMarker>.blocklyPathDark { + fill-opacity: .2; + stroke: none; +} + +.blocklyNonEditableField>text { + pointer-events: none; +} + +.blocklyFlyout { + position: absolute; + z-index: 20; +} + +.blocklyText text { + cursor: default; +} + +/* + Don't allow users to select text. It gets annoying when trying to + drag a block and selected text moves instead. +*/ +.blocklySvg text { + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; + cursor: inherit; +} + +.blocklyIconGroup { + cursor: default; +} + +.blocklyIconGroup:not(:hover):not(:focus), +.blocklyIconGroupReadonly { + opacity: .6; +} + +.blocklyIconShape { + fill: #00f; + stroke: #fff; + stroke-width: 1px; +} + +.blocklyIconSymbol { + fill: #fff; +} + +.blocklyMinimalBody { + margin: 0; + padding: 0; + height: 100%; +} + +.blocklyHtmlInput { + border: none; + border-radius: 4px; + height: 100%; + margin: 0; + outline: none; + padding: 0; + width: 100%; + text-align: center; + display: block; + box-sizing: border-box; +} + +/* Remove the increase and decrease arrows on the field number editor */ +input.blocklyHtmlInput[type=number]::-webkit-inner-spin-button, +input.blocklyHtmlInput[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type=number] { + -moz-appearance: textfield; +} + +.blocklyMainBackground { + stroke-width: 1; + stroke: #c6c6c6; /* Equates to #ddd due to border being off-pixel. */ +} + +.blocklyMutatorBackground { + fill: #fff; + stroke: #ddd; + stroke-width: 1; +} + +.blocklyFlyoutBackground { + fill: #ddd; + fill-opacity: .8; +} + +.blocklyMainWorkspaceScrollbar { + z-index: 20; +} + +.blocklyFlyoutScrollbar { + z-index: 30; +} + +.blocklyScrollbarHorizontal, +.blocklyScrollbarVertical { + position: absolute; + outline: none; +} + +.blocklyScrollbarBackground { + opacity: 0; + pointer-events: none; +} + +.blocklyScrollbarHandle { + fill: #ccc; +} + +.blocklyScrollbarBackground:hover+.blocklyScrollbarHandle, +.blocklyScrollbarHandle:hover { + fill: #bbb; +} + +/* Darken flyout scrollbars due to being on a grey background. */ +/* By contrast, workspace scrollbars are on a white background. */ +.blocklyFlyout .blocklyScrollbarHandle { + fill: #bbb; +} + +.blocklyFlyout .blocklyScrollbarBackground:hover+.blocklyScrollbarHandle, +.blocklyFlyout .blocklyScrollbarHandle:hover { + fill: #aaa; +} + +.blocklyInvalidInput { + background: #faa; +} + +.blocklyVerticalMarker { + stroke-width: 3px; + fill: rgba(255,255,255,.5); + pointer-events: none; +} + +.blocklyComputeCanvas { + position: absolute; + width: 0; + height: 0; +} + +.blocklyNoPointerEvents { + pointer-events: none; +} + +.blocklyContextMenu { + border-radius: 4px; + max-height: 100%; +} + +.blocklyDropdownMenu { + border-radius: 2px; + padding: 0 !important; +} + +.blocklyDropdownMenu .blocklyMenuItem { + /* 28px on the left for icon or checkbox. */ + padding-left: 28px; +} + +/* BiDi override for the resting state. */ +.blocklyDropdownMenu .blocklyMenuItemRtl { + /* Flip left/right padding for BiDi. */ + padding-left: 5px; + padding-right: 28px; +} + +.blocklyWidgetDiv .blocklyMenu { + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; + background: #fff; + border: 1px solid transparent; + box-shadow: 0 0 3px 1px rgba(0,0,0,.3); + font: normal 13px Arial, sans-serif; + margin: 0; + outline: none; + padding: 4px 0; + position: absolute; + overflow-y: auto; + overflow-x: hidden; + max-height: 100%; + z-index: 20000; /* Arbitrary, but some apps depend on it... */ +} + +.blocklyWidgetDiv .blocklyMenu:focus { + box-shadow: 0 0 6px 1px rgba(0,0,0,.3); +} + +.blocklyDropDownDiv .blocklyMenu { + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; + background: inherit; /* Compatibility with gapi, reset from goog-menu */ + border: inherit; /* Compatibility with gapi, reset from goog-menu */ + font: normal 13px "Helvetica Neue", Helvetica, sans-serif; + outline: none; + overflow-y: auto; + overflow-x: hidden; + max-height: 100%; + z-index: 20000; /* Arbitrary, but some apps depend on it... */ +} + +/* State: resting. */ +.blocklyMenuItem { + border: none; + color: #000; + cursor: pointer; + list-style: none; + margin: 0; + /* 7em on the right for shortcut. */ + min-width: 7em; + padding: 6px 15px; + white-space: nowrap; +} + +/* State: disabled. */ +.blocklyMenuItemDisabled { + color: #ccc; + cursor: inherit; +} + +/* State: hover. */ +.blocklyMenuItemHighlight { + background-color: rgba(0,0,0,.1); +} + +/* State: selected/checked. */ +.blocklyMenuItemCheckbox { + height: 16px; + position: absolute; + width: 16px; +} + +.blocklyMenuItemSelected .blocklyMenuItemCheckbox { + background: url(<<>>/sprites.png) no-repeat -48px -16px; + float: left; + margin-left: -24px; + position: static; /* Scroll with the menu. */ +} + +.blocklyMenuItemRtl .blocklyMenuItemCheckbox { + float: right; + margin-right: -24px; +} + +.blocklyMenuSeparator { + background-color: #ccc; + height: 1px; + border: 0; + margin-left: 4px; + margin-right: 4px; +} + +.blocklyBlockDragSurface, .blocklyAnimationLayer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: visible !important; + z-index: 80; + pointer-events: none; +} + +.blocklyField { + cursor: default; +} + +.blocklyInputField { + cursor: text; +} + +.blocklyDragging .blocklyField, +.blocklyDragging .blocklyIconGroup { + cursor: grabbing; +} + +.blocklyActiveFocus:is( + .blocklyFlyout, + .blocklyWorkspace, + .blocklyField, + .blocklyPath, + .blocklyHighlightedConnectionPath, + .blocklyComment, + .blocklyBubble, + .blocklyIconGroup, + .blocklyTextarea +) { + outline: none; +} +`; diff --git a/core/delete_area.ts b/core/delete_area.ts new file mode 100644 index 00000000000..405084db9b1 --- /dev/null +++ b/core/delete_area.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The abstract class for a component that can delete a block or + * bubble that is dropped on top of it. + * + * @class + */ +// Former goog.module ID: Blockly.DeleteArea + +import {BlockSvg} from './block_svg.js'; +import {DragTarget} from './drag_target.js'; +import {isDeletable} from './interfaces/i_deletable.js'; +import type {IDeleteArea} from './interfaces/i_delete_area.js'; +import type {IDraggable} from './interfaces/i_draggable.js'; + +/** + * Abstract class for a component that can delete a block or bubble that is + * dropped on top of it. + */ +export class DeleteArea extends DragTarget implements IDeleteArea { + /** + * Whether the last block or bubble dragged over this delete area would be + * deleted if dropped on this component. + * This property is not updated after the block or bubble is deleted. + */ + protected wouldDelete_ = false; + + /** + * The unique id for this component that is used to register with the + * ComponentManager. + */ + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + override id!: string; + + /** + * Constructor for DeleteArea. Should not be called directly, only by a + * subclass. + */ + constructor() { + super(); + } + + /** + * Returns whether the provided block or bubble would be deleted if dropped on + * this area. + * This method should check if the element is deletable and is always called + * before onDragEnter/onDragOver/onDragExit. + * + * @param element The block or bubble currently being dragged. + * @returns Whether the element provided would be deleted if dropped on this + * area. + */ + wouldDelete(element: IDraggable): boolean { + if (element instanceof BlockSvg) { + const block = element; + const couldDeleteBlock = !block.getParent() && block.isDeletable(); + this.updateWouldDelete_(couldDeleteBlock); + } else { + this.updateWouldDelete_(isDeletable(element) && element.isDeletable()); + } + return this.wouldDelete_; + } + + /** + * Updates the internal wouldDelete_ state. + * + * @param wouldDelete The new value for the wouldDelete state. + */ + protected updateWouldDelete_(wouldDelete: boolean) { + this.wouldDelete_ = wouldDelete; + } +} diff --git a/core/dialog.ts b/core/dialog.ts new file mode 100644 index 00000000000..96631e9cbc7 --- /dev/null +++ b/core/dialog.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.dialog + +import type {ToastOptions} from './toast.js'; +import {Toast} from './toast.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +const defaultAlert = function (message: string, opt_callback?: () => void) { + window.alert(message); + if (opt_callback) { + opt_callback(); + } +}; + +let alertImplementation = defaultAlert; + +const defaultConfirm = function ( + message: string, + callback: (result: boolean) => void, +) { + callback(window.confirm(message)); +}; + +let confirmImplementation = defaultConfirm; + +const defaultPrompt = function ( + message: string, + defaultValue: string, + callback: (result: string | null) => void, +) { + // NOTE TO DEVELOPER: Ephemeral focus doesn't need to be taken for the native + // window prompt since it prevents focus from changing while open. + callback(window.prompt(message, defaultValue)); +}; + +let promptImplementation = defaultPrompt; + +const defaultToast = Toast.show.bind(Toast); +let toastImplementation = defaultToast; + +/** + * Wrapper to window.alert() that app developers may override via setAlert to + * provide alternatives to the modal browser window. + * + * @param message The message to display to the user. + * @param opt_callback The callback when the alert is dismissed. + */ +export function alert(message: string, opt_callback?: () => void) { + alertImplementation(message, opt_callback); +} + +/** + * Sets the function to be run when Blockly.dialog.alert() is called. + * + * @param alertFunction The function to be run, or undefined to restore the + * default implementation. + * @see Blockly.dialog.alert + */ +export function setAlert( + alertFunction: ( + message: string, + callback?: () => void, + ) => void = defaultAlert, +) { + alertImplementation = alertFunction; +} + +/** + * Wrapper to window.confirm() that app developers may override via setConfirm + * to provide alternatives to the modal browser window. + * + * @param message The message to display to the user. + * @param callback The callback for handling user response. + */ +export function confirm(message: string, callback: (result: boolean) => void) { + confirmImplementation(message, callback); +} + +/** + * Sets the function to be run when Blockly.dialog.confirm() is called. + * + * @param confirmFunction The function to be run, or undefined to restore the + * default implementation. + * @see Blockly.dialog.confirm + */ +export function setConfirm( + confirmFunction: ( + message: string, + callback: (result: boolean) => void, + ) => void = defaultConfirm, +) { + confirmImplementation = confirmFunction; +} + +/** + * Wrapper to window.prompt() that app developers may override via setPrompt to + * provide alternatives to the modal browser window. Built-in browser prompts + * are often used for better text input experience on mobile device. We strongly + * recommend testing mobile when overriding this. + * + * @param message The message to display to the user. + * @param defaultValue The value to initialize the prompt with. + * @param callback The callback for handling user response. + */ +export function prompt( + message: string, + defaultValue: string, + callback: (result: string | null) => void, +) { + promptImplementation(message, defaultValue, callback); +} + +/** + * Sets the function to be run when Blockly.dialog.prompt() is called. + * + * **Important**: When overridding this, be aware that non-native prompt + * experiences may require managing ephemeral focus in FocusManager. This isn't + * needed for the native window prompt because it prevents focus from being + * changed while open. + * + * @param promptFunction The function to be run, or undefined to restore the + * default implementation. + * @see Blockly.dialog.prompt + */ +export function setPrompt( + promptFunction: ( + message: string, + defaultValue: string, + callback: (result: string | null) => void, + ) => void = defaultPrompt, +) { + promptImplementation = promptFunction; +} + +/** + * Displays a temporary notification atop the workspace. Blockly provides a + * default toast implementation, but developers may provide their own via + * setToast. For simple appearance customization, CSS should be sufficient. + * + * @param workspace The workspace to display the toast notification atop. + * @param options Configuration options for the notification, including its + * message and duration. + */ +export function toast(workspace: WorkspaceSvg, options: ToastOptions) { + toastImplementation(workspace, options); +} + +/** + * Sets the function to be run when Blockly.dialog.toast() is called. + * + * @param toastFunction The function to be run, or undefined to restore the + * default implementation. + * @see Blockly.dialog.toast + */ +export function setToast( + toastFunction: ( + workspace: WorkspaceSvg, + options: ToastOptions, + ) => void = defaultToast, +) { + toastImplementation = toastFunction; +} diff --git a/core/drag_target.ts b/core/drag_target.ts new file mode 100644 index 00000000000..e973f2dd1c3 --- /dev/null +++ b/core/drag_target.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The abstract class for a component with custom behaviour when a + * block or bubble is dragged over or dropped on top of it. + * + * @class + */ +// Former goog.module ID: Blockly.DragTarget + +import type {IDragTarget} from './interfaces/i_drag_target.js'; +import type {IDraggable} from './interfaces/i_draggable.js'; +import type {Rect} from './utils/rect.js'; + +/** + * Abstract class for a component with custom behaviour when a block or bubble + * is dragged over or dropped on top of it. + */ +export class DragTarget implements IDragTarget { + /** + * The unique id for this component that is used to register with the + * ComponentManager. + */ + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + id!: string; + + /** + * Constructor for DragTarget. It exists to add the id property and should not + * be called directly, only by a subclass. + */ + constructor() {} + + /** + * Handles when a cursor with a block or bubble enters this drag target. + * + * @param _dragElement The block or bubble currently being dragged. + */ + onDragEnter(_dragElement: IDraggable) { + // no-op + } + + /** + * Handles when a cursor with a block or bubble is dragged over this drag + * target. + * + * @param _dragElement The block or bubble currently being dragged. + */ + onDragOver(_dragElement: IDraggable) { + // no-op + } + + /** + * Handles when a cursor with a block or bubble exits this drag target. + * + * @param _dragElement The block or bubble currently being dragged. + */ + onDragExit(_dragElement: IDraggable) { + // no-op + } + /** + * Handles when a block or bubble is dropped on this component. + * Should not handle delete here. + * + * @param _dragElement The block or bubble currently being dragged. + */ + onDrop(_dragElement: IDraggable) { + // no-op + } + + /** + * Returns the bounding rectangle of the drag target area in pixel units + * relative to the Blockly injection div. + * + * @returns The component's bounding box. Null if drag target area should be + * ignored. + */ + getClientRect(): Rect | null { + return null; + } + + /** + * Returns whether the provided block or bubble should not be moved after + * being dropped on this component. If true, the element will return to where + * it was when the drag started. + * + * @param _dragElement The block or bubble currently being dragged. + * @returns Whether the block or bubble provided should be returned to drag + * start. + */ + shouldPreventMove(_dragElement: IDraggable): boolean { + return false; + } +} diff --git a/core/dragged_connection_manager.js b/core/dragged_connection_manager.js deleted file mode 100644 index 1ebe2ef667f..00000000000 --- a/core/dragged_connection_manager.js +++ /dev/null @@ -1,237 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Class that controls updates to connections during drags. - * @author fenichel@google.com (Rachel Fenichel) - */ -'use strict'; - -goog.provide('Blockly.DraggedConnectionManager'); - -goog.require('Blockly.RenderedConnection'); - -goog.require('goog.math.Coordinate'); - - -/** - * Class that controls updates to connections during drags. It is primarily - * responsible for finding the closest eligible connection and highlighting or - * unhiglighting it as needed during a drag. - * @param {!Blockly.BlockSvg} block The top block in the stack being dragged. - * @constructor - */ -Blockly.DraggedConnectionManager = function(block) { - Blockly.selected = block; - - /** - * The top block in the stack being dragged. - * Does not change during a drag. - * @type {!Blockly.Block} - * @private - */ - this.topBlock_ = block; - - /** - * The workspace on which these connections are being dragged. - * Does not change during a drag. - * @type {!Blockly.WorkspaceSvg} - * @private - */ - this.workspace_ = block.workspace; - - /** - * The connections on the dragging blocks that are available to connect to - * other blocks. This includes all open connections on the top block, as well - * as the last connection on the block stack. - * Does not change during a drag. - * @type {!Array.} - * @private - */ - this.availableConnections_ = this.initAvailableConnections_(); - - /** - * The connection that this block would connect to if released immediately. - * Updated on every mouse move. - * @type {Blockly.RenderedConnection} - * @private - */ - this.closestConnection_ = null; - - /** - * The connection that would connect to this.closestConnection_ if this block - * were released immediately. - * Updated on every mouse move. - * @type {Blockly.RenderedConnection} - * @private - */ - this.localConnection_ = null; - - /** - * The distance between this.closestConnection_ and this.localConnection_, - * in workspace units. - * Updated on every mouse move. - * @type {number} - * @private - */ - this.radiusConnection_ = 0; - - /** - * Whether the block would be deleted if it were dropped immediately. - * Updated on every mouse move. - * @type {boolean} - * @private - */ - this.wouldDeleteBlock_ = false; -}; - -/** - * Sever all links from this object. - * @package - */ -Blockly.DraggedConnectionManager.prototype.dispose = function() { - this.topBlock_ = null; - this.workspace_ = null; - this.availableConnections_.length = 0; - this.closestConnection_ = null; - this.localConnection_ = null; -}; - -/** - * Return whether the block would be deleted if dropped immediately, based on - * information from the most recent move event. - * @return {boolean} true if the block would be deleted if dropped immediately. - * @package - */ -Blockly.DraggedConnectionManager.prototype.wouldDeleteBlock = function() { - return this.wouldDeleteBlock_; -}; - -/** - * Connect to the closest connection and render the results. - * This should be called at the end of a drag. - * @package - */ -Blockly.DraggedConnectionManager.prototype.applyConnections = function() { - if (this.closestConnection_) { - // Connect two blocks together. - this.localConnection_.connect(this.closestConnection_); - if (this.topBlock_.rendered) { - // Trigger a connection animation. - // Determine which connection is inferior (lower in the source stack). - var inferiorConnection = this.localConnection_.isSuperior() ? - this.closestConnection_ : this.localConnection_; - inferiorConnection.getSourceBlock().connectionUiEffect(); - } - this.removeHighlighting_(); - } -}; - -/** - * Update highlighted connections based on the most recent move location. - * @param {!goog.math.Coordinate} dxy Position relative to drag start, - * in workspace units. - * @param {?number} deleteArea One of {@link Blockly.DELETE_AREA_TRASH}, - * {@link Blockly.DELETE_AREA_TOOLBOX}, or {@link Blockly.DELETE_AREA_NONE}. - * @package - */ -Blockly.DraggedConnectionManager.prototype.update = function(dxy, deleteArea) { - var oldClosestConnection = this.closestConnection_; - var closestConnectionChanged = this.updateClosest_(dxy); - - if (closestConnectionChanged && oldClosestConnection) { - oldClosestConnection.unhighlight(); - } - - // Prefer connecting over dropping into the trash can, but prefer dragging to - // the toolbox over connecting to other blocks. - var wouldConnect = !!this.closestConnection_ && - deleteArea != Blockly.DELETE_AREA_TOOLBOX; - var wouldDelete = !!deleteArea && !this.topBlock_.getParent() && - this.topBlock_.isDeletable(); - this.wouldDeleteBlock_ = wouldDelete && !wouldConnect; - - if (!this.wouldDeleteBlock_ && closestConnectionChanged && - this.closestConnection_) { - this.addHighlighting_(); - } -}; - -/** - * Remove highlighting from the currently highlighted connection, if it exists. - * @private - */ -Blockly.DraggedConnectionManager.prototype.removeHighlighting_ = function() { - if (this.closestConnection_) { - this.closestConnection_.unhighlight(); - } -}; - -/** - * Add highlighting to the closest connection, if it exists. - * @private - */ -Blockly.DraggedConnectionManager.prototype.addHighlighting_ = function() { - if (this.closestConnection_) { - this.closestConnection_.highlight(); - } -}; - -/** - * Populate the list of available connections on this block stack. This should - * only be called once, at the beginning of a drag. - * @return {!Array.} a list of available - * connections. - * @private - */ -Blockly.DraggedConnectionManager.prototype.initAvailableConnections_ = function() { - var available = this.topBlock_.getConnections_(false); - // Also check the last connection on this stack - var lastOnStack = this.topBlock_.lastConnectionInStack_(); - if (lastOnStack && lastOnStack != this.topBlock_.nextConnection) { - available.push(lastOnStack); - } - return available; -}; - -/** - * Find the new closest connection, and update internal state in response. - * @param {!goog.math.Coordinate} dxy Position relative to the drag start, - * in workspace units. - * @return {boolean} Whether the closest connection has changed. - * @private - */ -Blockly.DraggedConnectionManager.prototype.updateClosest_ = function(dxy) { - var oldClosestConnection = this.closestConnection_; - - this.closestConnection_ = null; - this.localConnection_ = null; - this.radiusConnection_ = Blockly.SNAP_RADIUS; - for (var i = 0; i < this.availableConnections_.length; i++) { - var myConnection = this.availableConnections_[i]; - var neighbour = myConnection.closest(this.radiusConnection_, dxy); - if (neighbour.connection) { - this.closestConnection_ = neighbour.connection; - this.localConnection_ = myConnection; - this.radiusConnection_ = neighbour.radius; - } - } - return oldClosestConnection != this.closestConnection_; -}; diff --git a/core/dragging.ts b/core/dragging.ts new file mode 100644 index 00000000000..4ba85c49f7d --- /dev/null +++ b/core/dragging.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockDragStrategy} from './dragging/block_drag_strategy.js'; +import {BubbleDragStrategy} from './dragging/bubble_drag_strategy.js'; +import {CommentDragStrategy} from './dragging/comment_drag_strategy.js'; +import {Dragger} from './dragging/dragger.js'; + +export {BlockDragStrategy, BubbleDragStrategy, CommentDragStrategy, Dragger}; diff --git a/core/dragging/block_drag_strategy.ts b/core/dragging/block_drag_strategy.ts new file mode 100644 index 00000000000..76020f90b5b --- /dev/null +++ b/core/dragging/block_drag_strategy.ts @@ -0,0 +1,480 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Block} from '../block.js'; +import * as blockAnimation from '../block_animations.js'; +import {BlockSvg} from '../block_svg.js'; +import * as bumpObjects from '../bump_objects.js'; +import {config} from '../config.js'; +import {Connection} from '../connection.js'; +import {ConnectionType} from '../connection_type.js'; +import type {BlockMove} from '../events/events_block_move.js'; +import {EventType} from '../events/type.js'; +import * as eventUtils from '../events/utils.js'; +import {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js'; +import {IDragStrategy} from '../interfaces/i_draggable.js'; +import * as layers from '../layers.js'; +import * as registry from '../registry.js'; +import {finishQueuedRenders} from '../render_management.js'; +import {RenderedConnection} from '../rendered_connection.js'; +import {Coordinate} from '../utils.js'; +import * as dom from '../utils/dom.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +/** Represents a nearby valid connection. */ +interface ConnectionCandidate { + /** A connection on the dragging stack that is compatible with neighbour. */ + local: RenderedConnection; + + /** A nearby connection that is compatible with local. */ + neighbour: RenderedConnection; + + /** The distance between the local connection and the neighbour connection. */ + distance: number; +} + +export class BlockDragStrategy implements IDragStrategy { + private workspace: WorkspaceSvg; + + /** The parent block at the start of the drag. */ + private startParentConn: RenderedConnection | null = null; + + /** + * The child block at the start of the drag. Only gets set if + * `healStack` is true. + */ + private startChildConn: RenderedConnection | null = null; + + private startLoc: Coordinate | null = null; + + private connectionCandidate: ConnectionCandidate | null = null; + + private connectionPreviewer: IConnectionPreviewer | null = null; + + private dragging = false; + + /** + * If this is a shadow block, the offset between this block and the parent + * block, to add to the drag location. In workspace units. + */ + private dragOffset = new Coordinate(0, 0); + + /** Used to persist an event group when snapping is done async. */ + private originalEventGroup = ''; + + constructor(private block: BlockSvg) { + this.workspace = block.workspace; + } + + /** Returns true if the block is currently movable. False otherwise. */ + isMovable(): boolean { + if (this.block.isShadow()) { + return this.block.getParent()?.isMovable() ?? false; + } + + return ( + this.block.isOwnMovable() && + !this.block.isDeadOrDying() && + !this.workspace.isReadOnly() && + // We never drag blocks in the flyout, only create new blocks that are + // dragged. + !this.block.isInFlyout + ); + } + + /** + * Handles any setup for starting the drag, including disconnecting the block + * from any parent blocks. + */ + startDrag(e?: PointerEvent): void { + if (this.block.isShadow()) { + this.startDraggingShadow(e); + return; + } + + this.dragging = true; + this.fireDragStartEvent(); + + this.startLoc = this.block.getRelativeToSurfaceXY(); + + this.connectionCandidate = null; + const previewerConstructor = registry.getClassFromOptions( + registry.Type.CONNECTION_PREVIEWER, + this.workspace.options, + ); + this.connectionPreviewer = new previewerConstructor!(this.block); + + // During a drag there may be a lot of rerenders, but not field changes. + // Turn the cache on so we don't do spurious remeasures during the drag. + dom.startTextWidthCache(); + this.workspace.setResizesEnabled(false); + blockAnimation.disconnectUiStop(); + + const healStack = this.shouldHealStack(e); + + if (this.shouldDisconnect(healStack)) { + this.disconnectBlock(healStack); + } + this.block.setDragging(true); + this.workspace.getLayerManager()?.moveToDragLayer(this.block); + } + + /** + * Get whether the drag should act on a single block or a block stack. + * + * @param e The instigating pointer event, if any. + * @returns True if just the initial block should be dragged out, false + * if all following blocks should also be dragged. + */ + protected shouldHealStack(e: PointerEvent | undefined) { + return !!e && (e.altKey || e.ctrlKey || e.metaKey); + } + + /** Starts a drag on a shadow, recording the drag offset. */ + private startDraggingShadow(e?: PointerEvent) { + const parent = this.block.getParent(); + if (!parent) { + throw new Error( + 'Tried to drag a shadow block with no parent. ' + + 'Shadow blocks should always have parents.', + ); + } + this.dragOffset = Coordinate.difference( + parent.getRelativeToSurfaceXY(), + this.block.getRelativeToSurfaceXY(), + ); + parent.startDrag(e); + } + + /** + * Whether or not we should disconnect the block when a drag is started. + * + * @param healStack Whether or not to heal the stack after disconnecting. + * @returns True to disconnect the block, false otherwise. + */ + private shouldDisconnect(healStack: boolean): boolean { + return !!( + this.block.getParent() || + (healStack && + this.block.nextConnection && + this.block.nextConnection.targetBlock()) + ); + } + + /** + * Disconnects the block from any parents. If `healStack` is true and this is + * a stack block, we also disconnect from any next blocks and attempt to + * attach them to any parent. + * + * @param healStack Whether or not to heal the stack after disconnecting. + */ + private disconnectBlock(healStack: boolean) { + this.startParentConn = + this.block.outputConnection?.targetConnection ?? + this.block.previousConnection?.targetConnection; + if (healStack) { + this.startChildConn = this.block.nextConnection?.targetConnection; + } + + this.block.unplug(healStack); + blockAnimation.disconnectUiEffect(this.block); + } + + /** Fire a UI event at the start of a block drag. */ + private fireDragStartEvent() { + const event = new (eventUtils.get(EventType.BLOCK_DRAG))( + this.block, + true, + this.block.getDescendants(false), + ); + eventUtils.fire(event); + } + + /** Fire a UI event at the end of a block drag. */ + private fireDragEndEvent() { + const event = new (eventUtils.get(EventType.BLOCK_DRAG))( + this.block, + false, + this.block.getDescendants(false), + ); + eventUtils.fire(event); + } + + /** Fire a move event at the end of a block drag. */ + private fireMoveEvent() { + if (this.block.isDeadOrDying()) return; + const event = new (eventUtils.get(EventType.BLOCK_MOVE))( + this.block, + ) as BlockMove; + event.setReason(['drag']); + event.oldCoordinate = this.startLoc!; + event.recordNew(); + eventUtils.fire(event); + } + + /** Moves the block and updates any connection previews. */ + drag(newLoc: Coordinate): void { + if (this.block.isShadow()) { + this.block.getParent()?.drag(Coordinate.sum(newLoc, this.dragOffset)); + return; + } + + this.block.moveDuringDrag(newLoc); + this.updateConnectionPreview( + this.block, + Coordinate.difference(newLoc, this.startLoc!), + ); + } + + /** + * @param draggingBlock The block being dragged. + * @param delta How far the pointer has moved from the position + * at the start of the drag, in workspace units. + */ + private updateConnectionPreview(draggingBlock: BlockSvg, delta: Coordinate) { + const currCandidate = this.connectionCandidate; + const newCandidate = this.getConnectionCandidate(draggingBlock, delta); + if (!newCandidate) { + this.connectionPreviewer?.hidePreview(); + this.connectionCandidate = null; + return; + } + const candidate = + currCandidate && + this.currCandidateIsBetter(currCandidate, delta, newCandidate) + ? currCandidate + : newCandidate; + this.connectionCandidate = candidate; + + const {local, neighbour} = candidate; + const localIsOutputOrPrevious = + local.type === ConnectionType.OUTPUT_VALUE || + local.type === ConnectionType.PREVIOUS_STATEMENT; + const neighbourIsConnectedToRealBlock = + neighbour.isConnected() && !neighbour.targetBlock()?.isInsertionMarker(); + if ( + localIsOutputOrPrevious && + neighbourIsConnectedToRealBlock && + !this.orphanCanConnectAtEnd( + draggingBlock, + neighbour.targetBlock()!, + local.type, + ) + ) { + this.connectionPreviewer?.previewReplacement( + local, + neighbour, + neighbour.targetBlock()!, + ); + return; + } + this.connectionPreviewer?.previewConnection(local, neighbour); + } + + /** + * Returns true if the given orphan block can connect at the end of the + * top block's stack or row, false otherwise. + */ + private orphanCanConnectAtEnd( + topBlock: BlockSvg, + orphanBlock: BlockSvg, + localType: number, + ): boolean { + const orphanConnection = + localType === ConnectionType.OUTPUT_VALUE + ? orphanBlock.outputConnection + : orphanBlock.previousConnection; + return !!Connection.getConnectionForOrphanedConnection( + topBlock as Block, + orphanConnection as Connection, + ); + } + + /** + * Returns true if the current candidate is better than the new candidate. + * + * We slightly prefer the current candidate even if it is farther away. + */ + private currCandidateIsBetter( + currCandiate: ConnectionCandidate, + delta: Coordinate, + newCandidate: ConnectionCandidate, + ): boolean { + const {local: currLocal, neighbour: currNeighbour} = currCandiate; + const localPos = new Coordinate(currLocal.x, currLocal.y); + const neighbourPos = new Coordinate(currNeighbour.x, currNeighbour.y); + const currDistance = Coordinate.distance( + Coordinate.sum(localPos, delta), + neighbourPos, + ); + return ( + newCandidate.distance > currDistance - config.currentConnectionPreference + ); + } + + /** + * Returns the closest valid candidate connection, if one can be found. + * + * Valid neighbour connections are within the configured start radius, with a + * compatible type (input, output, etc) and connection check. + */ + private getConnectionCandidate( + draggingBlock: BlockSvg, + delta: Coordinate, + ): ConnectionCandidate | null { + const localConns = this.getLocalConnections(draggingBlock); + let radius = this.getSearchRadius(); + let candidate = null; + + for (const conn of localConns) { + const {connection: neighbour, radius: rad} = conn.closest(radius, delta); + if (neighbour) { + candidate = { + local: conn, + neighbour: neighbour, + distance: rad, + }; + radius = rad; + } + } + + return candidate; + } + + /** + * Get the radius to use when searching for a nearby valid connection. + */ + protected getSearchRadius() { + return this.connectionCandidate + ? config.connectingSnapRadius + : config.snapRadius; + } + + /** + * Returns all of the connections we might connect to blocks on the workspace. + * + * Includes any connections on the dragging block, and any last next + * connection on the stack (if one exists). + */ + private getLocalConnections(draggingBlock: BlockSvg): RenderedConnection[] { + const available = draggingBlock.getConnections_(false); + const lastOnStack = draggingBlock.lastConnectionInStack(true); + if (lastOnStack && lastOnStack !== draggingBlock.nextConnection) { + available.push(lastOnStack); + } + return available; + } + + /** + * Cleans up any state at the end of the drag. Applies any pending + * connections. + */ + endDrag(e?: PointerEvent): void { + if (this.block.isShadow()) { + this.block.getParent()?.endDrag(e); + return; + } + this.originalEventGroup = eventUtils.getGroup(); + + this.fireDragEndEvent(); + this.fireMoveEvent(); + + dom.stopTextWidthCache(); + + blockAnimation.disconnectUiStop(); + this.connectionPreviewer?.hidePreview(); + + if (!this.block.isDeadOrDying() && this.dragging) { + // These are expensive and don't need to be done if we're deleting, or + // if we've already stopped dragging because we moved back to the start. + this.workspace + .getLayerManager() + ?.moveOffDragLayer(this.block, layers.BLOCK); + this.block.setDragging(false); + } + + if (this.connectionCandidate) { + // Applying connections also rerenders the relevant blocks. + this.applyConnections(this.connectionCandidate); + this.disposeStep(); + } else { + this.block.queueRender().then(() => this.disposeStep()); + } + } + + /** Disposes of any state at the end of the drag. */ + private disposeStep() { + const newGroup = eventUtils.getGroup(); + eventUtils.setGroup(this.originalEventGroup); + this.block.snapToGrid(); + + // Must dispose after connections are applied to not break the dynamic + // connections plugin. See #7859 + this.connectionPreviewer?.dispose(); + this.workspace.setResizesEnabled(true); + eventUtils.setGroup(newGroup); + } + + /** Connects the given candidate connections. */ + private applyConnections(candidate: ConnectionCandidate) { + const {local, neighbour} = candidate; + local.connect(neighbour); + + const inferiorConnection = local.isSuperior() ? neighbour : local; + const rootBlock = this.block.getRootBlock(); + + finishQueuedRenders().then(() => { + blockAnimation.connectionUiEffect(inferiorConnection.getSourceBlock()); + // bringToFront is incredibly expensive. Delay until the next frame. + setTimeout(() => { + rootBlock.bringToFront(); + }, 0); + }); + } + + /** + * Moves the block back to where it was at the beginning of the drag, + * including reconnecting connections. + */ + revertDrag(): void { + if (this.block.isShadow()) { + this.block.getParent()?.revertDrag(); + return; + } + + this.connectionPreviewer?.hidePreview(); + this.connectionCandidate = null; + + this.startChildConn?.connect(this.block.nextConnection); + if (this.startParentConn) { + switch (this.startParentConn.type) { + case ConnectionType.INPUT_VALUE: + this.startParentConn.connect(this.block.outputConnection); + break; + case ConnectionType.NEXT_STATEMENT: + this.startParentConn.connect(this.block.previousConnection); + } + } else { + this.block.moveTo(this.startLoc!, ['drag']); + this.workspace + .getLayerManager() + ?.moveOffDragLayer(this.block, layers.BLOCK); + // Blocks dragged directly from a flyout may need to be bumped into + // bounds. + bumpObjects.bumpIntoBounds( + this.workspace, + this.workspace.getMetricsManager().getScrollMetrics(true), + this.block, + ); + } + + this.startChildConn = null; + this.startParentConn = null; + + this.block.setDragging(false); + this.dragging = false; + } +} diff --git a/core/dragging/bubble_drag_strategy.ts b/core/dragging/bubble_drag_strategy.ts new file mode 100644 index 00000000000..8a5a6783910 --- /dev/null +++ b/core/dragging/bubble_drag_strategy.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {IBubble, WorkspaceSvg} from '../blockly.js'; +import {IDragStrategy} from '../interfaces/i_draggable.js'; +import * as layers from '../layers.js'; +import {Coordinate} from '../utils.js'; + +export class BubbleDragStrategy implements IDragStrategy { + private startLoc: Coordinate | null = null; + + constructor( + private bubble: IBubble, + private workspace: WorkspaceSvg, + ) {} + + isMovable(): boolean { + return true; + } + + startDrag(): void { + this.startLoc = this.bubble.getRelativeToSurfaceXY(); + this.workspace.setResizesEnabled(false); + this.workspace.getLayerManager()?.moveToDragLayer(this.bubble); + if (this.bubble.setDragging) { + this.bubble.setDragging(true); + } + } + + drag(newLoc: Coordinate): void { + this.bubble.moveDuringDrag(newLoc); + } + + endDrag(): void { + this.workspace.setResizesEnabled(true); + + this.workspace + .getLayerManager() + ?.moveOffDragLayer(this.bubble, layers.BUBBLE); + this.bubble.setDragging(false); + } + + revertDrag(): void { + if (this.startLoc) this.bubble.moveDuringDrag(this.startLoc); + } +} diff --git a/core/dragging/comment_drag_strategy.ts b/core/dragging/comment_drag_strategy.ts new file mode 100644 index 00000000000..b7974d8b4ca --- /dev/null +++ b/core/dragging/comment_drag_strategy.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {RenderedWorkspaceComment} from '../comments.js'; +import {CommentMove} from '../events/events_comment_move.js'; +import {EventType} from '../events/type.js'; +import * as eventUtils from '../events/utils.js'; +import {IDragStrategy} from '../interfaces/i_draggable.js'; +import * as layers from '../layers.js'; +import {Coordinate} from '../utils.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +export class CommentDragStrategy implements IDragStrategy { + private startLoc: Coordinate | null = null; + + private workspace: WorkspaceSvg; + + constructor(private comment: RenderedWorkspaceComment) { + this.workspace = comment.workspace; + } + + isMovable(): boolean { + return ( + this.comment.isOwnMovable() && + !this.comment.isDeadOrDying() && + !this.workspace.isReadOnly() + ); + } + + startDrag(): void { + this.fireDragStartEvent(); + this.startLoc = this.comment.getRelativeToSurfaceXY(); + this.workspace.setResizesEnabled(false); + this.workspace.getLayerManager()?.moveToDragLayer(this.comment); + this.comment.setDragging(true); + } + + drag(newLoc: Coordinate): void { + this.comment.moveDuringDrag(newLoc); + } + + endDrag(): void { + this.fireDragEndEvent(); + this.fireMoveEvent(); + + this.workspace + .getLayerManager() + ?.moveOffDragLayer(this.comment, layers.BLOCK); + this.comment.setDragging(false); + + this.comment.snapToGrid(); + + this.workspace.setResizesEnabled(true); + } + + /** Fire a UI event at the start of a comment drag. */ + private fireDragStartEvent() { + const event = new (eventUtils.get(EventType.COMMENT_DRAG))( + this.comment, + true, + ); + eventUtils.fire(event); + } + + /** Fire a UI event at the end of a comment drag. */ + private fireDragEndEvent() { + const event = new (eventUtils.get(EventType.COMMENT_DRAG))( + this.comment, + false, + ); + eventUtils.fire(event); + } + + /** Fire a move event at the end of a comment drag. */ + private fireMoveEvent() { + if (this.comment.isDeadOrDying()) return; + const event = new (eventUtils.get(EventType.COMMENT_MOVE))( + this.comment, + ) as CommentMove; + event.setReason(['drag']); + event.oldCoordinate_ = this.startLoc!; + event.recordNew(); + eventUtils.fire(event); + } + + revertDrag(): void { + if (this.startLoc) this.comment.moveDuringDrag(this.startLoc); + } +} diff --git a/core/dragging/dragger.ts b/core/dragging/dragger.ts new file mode 100644 index 00000000000..02e9e2bfb79 --- /dev/null +++ b/core/dragging/dragger.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as blockAnimations from '../block_animations.js'; +import {BlockSvg} from '../block_svg.js'; +import {ComponentManager} from '../component_manager.js'; +import * as eventUtils from '../events/utils.js'; +import {getFocusManager} from '../focus_manager.js'; +import {IDeletable, isDeletable} from '../interfaces/i_deletable.js'; +import {IDeleteArea} from '../interfaces/i_delete_area.js'; +import {IDragTarget} from '../interfaces/i_drag_target.js'; +import {IDraggable} from '../interfaces/i_draggable.js'; +import {IDragger} from '../interfaces/i_dragger.js'; +import {isFocusableNode} from '../interfaces/i_focusable_node.js'; +import * as registry from '../registry.js'; +import {Coordinate} from '../utils/coordinate.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +export class Dragger implements IDragger { + protected startLoc: Coordinate; + + protected dragTarget: IDragTarget | null = null; + + constructor( + protected draggable: IDraggable, + protected workspace: WorkspaceSvg, + ) { + this.startLoc = draggable.getRelativeToSurfaceXY(); + } + + /** Handles any drag startup. */ + onDragStart(e: PointerEvent) { + if (!eventUtils.getGroup()) { + eventUtils.setGroup(true); + } + this.draggable.startDrag(e); + } + + /** + * Handles calculating where the element should actually be moved to. + * + * @param totalDelta The total amount in pixel coordinates the mouse has moved + * since the start of the drag. + */ + onDrag(e: PointerEvent, totalDelta: Coordinate) { + this.moveDraggable(e, totalDelta); + const root = this.getRoot(this.draggable); + + // Must check `wouldDelete` before calling other hooks on drag targets + // since we have documented that we would do so. + if (isDeletable(root)) { + root.setDeleteStyle(this.wouldDeleteDraggable(e, root)); + } + this.updateDragTarget(e); + } + + /** Updates the drag target under the pointer (if there is one). */ + protected updateDragTarget(e: PointerEvent) { + const newDragTarget = this.workspace.getDragTarget(e); + const root = this.getRoot(this.draggable); + if (this.dragTarget !== newDragTarget) { + this.dragTarget?.onDragExit(root); + newDragTarget?.onDragEnter(root); + } + newDragTarget?.onDragOver(root); + this.dragTarget = newDragTarget; + } + + /** + * Calculates the correct workspace coordinate for the movable and tells + * the draggable to go to that location. + */ + private moveDraggable(e: PointerEvent, totalDelta: Coordinate) { + const delta = this.pixelsToWorkspaceUnits(totalDelta); + const newLoc = Coordinate.sum(this.startLoc, delta); + this.draggable.drag(newLoc, e); + } + + /** + * Returns true if we would delete the draggable if it was dropped + * at the current location. + */ + protected wouldDeleteDraggable( + e: PointerEvent, + rootDraggable: IDraggable & IDeletable, + ) { + const dragTarget = this.workspace.getDragTarget(e); + if (!dragTarget) return false; + + const componentManager = this.workspace.getComponentManager(); + const isDeleteArea = componentManager.hasCapability( + dragTarget.id, + ComponentManager.Capability.DELETE_AREA, + ); + if (!isDeleteArea) return false; + + return (dragTarget as IDeleteArea).wouldDelete(rootDraggable); + } + + /** Handles any drag cleanup. */ + onDragEnd(e: PointerEvent) { + const origGroup = eventUtils.getGroup(); + const dragTarget = this.workspace.getDragTarget(e); + const root = this.getRoot(this.draggable); + + if (dragTarget) { + this.dragTarget?.onDrop(root); + } + + if (this.shouldReturnToStart(e, root)) { + this.draggable.revertDrag(); + } + + const wouldDelete = isDeletable(root) && this.wouldDeleteDraggable(e, root); + + // TODO(#8148): use a generalized API instead of an instanceof check. + if (wouldDelete && this.draggable instanceof BlockSvg) { + blockAnimations.disposeUiEffect(this.draggable.getRootBlock()); + } + + this.draggable.endDrag(e); + + if (wouldDelete && isDeletable(root)) { + // We want to make sure the delete gets grouped with any possible move + // event. In core Blockly this shouldn't happen, but due to a change + // in behavior older custom draggables might still clear the group. + eventUtils.setGroup(origGroup); + root.dispose(); + } + eventUtils.setGroup(false); + + if (!wouldDelete && isFocusableNode(this.draggable)) { + // Ensure focusable nodes that have finished dragging (but aren't being + // deleted) end with focus and selection. + getFocusManager().focusNode(this.draggable); + } + } + + // We need to special case blocks for now so that we look at the root block + // instead of the one actually being dragged in most cases. + private getRoot(draggable: IDraggable): IDraggable { + return draggable instanceof BlockSvg ? draggable.getRootBlock() : draggable; + } + + /** + * Returns true if we should return the draggable to its original location + * at the end of the drag. + */ + protected shouldReturnToStart(e: PointerEvent, rootDraggable: IDraggable) { + const dragTarget = this.workspace.getDragTarget(e); + if (!dragTarget) return false; + return dragTarget.shouldPreventMove(rootDraggable); + } + + protected pixelsToWorkspaceUnits(pixelCoord: Coordinate): Coordinate { + const result = new Coordinate( + pixelCoord.x / this.workspace.scale, + pixelCoord.y / this.workspace.scale, + ); + if (this.workspace.isMutator) { + // If we're in a mutator, its scale is always 1, purely because of some + // oddities in our rendering optimizations. The actual scale is the same + // as the scale on the parent workspace. Fix that for dragging. + const mainScale = this.workspace.options.parentWorkspace!.scale; + result.scale(1 / mainScale); + } + return result; + } +} + +registry.register(registry.Type.BLOCK_DRAGGER, registry.DEFAULT, Dragger); diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts new file mode 100644 index 00000000000..ceab467a895 --- /dev/null +++ b/core/dropdowndiv.ts @@ -0,0 +1,792 @@ +/** + * @license + * Copyright 2016 Massachusetts Institute of Technology + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A div that floats on top of the workspace, for drop-down menus. + * + * @class + */ +// Former goog.module ID: Blockly.dropDownDiv + +import type {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; +import * as common from './common.js'; +import type {Field} from './field.js'; +import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js'; +import * as dom from './utils/dom.js'; +import * as math from './utils/math.js'; +import {Rect} from './utils/rect.js'; +import type {Size} from './utils/size.js'; +import * as style from './utils/style.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +/** + * Arrow size in px. Should match the value in CSS + * (need to position pre-render). + */ +export const ARROW_SIZE = 16; + +/** + * Drop-down border size in px. Should match the value in CSS (need to position + * the arrow). + */ +export const BORDER_SIZE = 1; + +/** + * Amount the arrow must be kept away from the edges of the main drop-down div, + * in px. + */ +export const ARROW_HORIZONTAL_PADDING = 12; + +/** Amount drop-downs should be padded away from the source, in px. */ +export const PADDING_Y = 16; + +/** Length of animations in seconds. */ +export const ANIMATION_TIME = 0.25; + +/** + * Timer for animation out, to be cleared if we need to immediately hide + * without disrupting new shows. + */ +let animateOutTimer: ReturnType | null = null; + +/** Callback for when the drop-down is hidden. */ +let onHide: (() => void) | null = null; + +/** A class name representing the current owner's workspace renderer. */ +let renderedClassName = ''; + +/** A class name representing the current owner's workspace theme. */ +let themeClassName = ''; + +/** The content element. */ +let div: HTMLDivElement; + +/** The content element. */ +let content: HTMLDivElement; + +/** The arrow element. */ +let arrow: HTMLDivElement; + +/** + * Drop-downs will appear within the bounds of this element if possible. + * Set in setBoundsElement. + */ +let boundsElement: Element | null = null; + +/** The object currently using the drop-down. */ +let owner: Field | null = null; + +/** Whether the dropdown was positioned to a field or the source block. */ +let positionToField: boolean | null = null; + +/** Callback to FocusManager to return ephemeral focus when the div closes. */ +let returnEphemeralFocus: ReturnEphemeralFocus | null = null; + +/** Identifier for shortcut keydown listener used to unbind it. */ +let keydownListener: browserEvents.Data | null = null; + +/** + * Dropdown bounds info object used to encapsulate sizing information about a + * bounding element (bounding box and width/height). + */ +export interface BoundsInfo { + top: number; + left: number; + bottom: number; + right: number; + width: number; + height: number; +} + +/** Dropdown position metrics. */ +export interface PositionMetrics { + initialX: number; + initialY: number; + finalX: number; + finalY: number; + arrowX: number | null; + arrowY: number | null; + arrowAtTop: boolean | null; + arrowVisible: boolean; +} + +/** + * Create and insert the DOM element for this div. + * + * @internal + */ +export function createDom() { + if (document.querySelector('.blocklyDropDownDiv')) { + return; // Already created. + } + div = document.createElement('div'); + div.className = 'blocklyDropDownDiv'; + div.tabIndex = -1; + const parentDiv = common.getParentContainer() || document.body; + parentDiv.appendChild(div); + + content = document.createElement('div'); + content.className = 'blocklyDropDownContent'; + div.appendChild(content); + + keydownListener = browserEvents.conditionalBind( + content, + 'keydown', + null, + common.globalShortcutHandler, + ); + + arrow = document.createElement('div'); + arrow.className = 'blocklyDropDownArrow'; + div.appendChild(arrow); + + div.style.opacity = '0'; + // Transition animation for transform: translate() and opacity. + div.style.transition = + 'transform ' + ANIMATION_TIME + 's, ' + 'opacity ' + ANIMATION_TIME + 's'; +} + +/** + * Set an element to maintain bounds within. Drop-downs will appear + * within the box of this element if possible. + * + * @param boundsElem Element to bind drop-down to. + */ +export function setBoundsElement(boundsElem: Element | null) { + boundsElement = boundsElem; +} + +/** + * @returns The field that currently owns this, or null. + */ +export function getOwner(): Field | null { + return owner; +} + +/** + * Provide the div for inserting content into the drop-down. + * + * @returns Div to populate with content. + */ +export function getContentDiv(): HTMLDivElement { + return content; +} + +/** Clear the content of the drop-down. */ +export function clearContent() { + if (keydownListener) { + browserEvents.unbind(keydownListener); + keydownListener = null; + } + div.remove(); + createDom(); +} + +/** + * Set the colour for the drop-down. + * + * @param backgroundColour Any CSS colour for the background. + * @param borderColour Any CSS colour for the border. + */ +export function setColour(backgroundColour: string, borderColour: string) { + div.style.backgroundColor = backgroundColour; + div.style.borderColor = borderColour; +} + +/** + * Shortcut to show and place the drop-down with positioning determined + * by a particular block. The primary position will be below the block, + * and the secondary position above the block. Drop-down will be + * constrained to the block's workspace. + * + * @param field The field showing the drop-down. + * @param block Block to position the drop-down around. + * @param opt_onHide Optional callback for when the drop-down is hidden. + * @param opt_secondaryYOffset Optional Y offset for above-block positioning. + * @param manageEphemeralFocus Whether ephemeral focus should be managed + * according to the drop-down div's lifetime. Note that if a false value is + * passed in here then callers should manage ephemeral focus directly + * otherwise focus may not properly restore when the widget closes. Defaults + * to true. + * @returns True if the menu rendered below block; false if above. + */ +export function showPositionedByBlock( + field: Field, + block: BlockSvg, + opt_onHide?: () => void, + opt_secondaryYOffset?: number, + manageEphemeralFocus: boolean = true, +): boolean { + return showPositionedByRect( + getScaledBboxOfBlock(block), + field as Field, + manageEphemeralFocus, + opt_onHide, + opt_secondaryYOffset, + ); +} + +/** + * Shortcut to show and place the drop-down with positioning determined + * by a particular field. The primary position will be below the field, + * and the secondary position above the field. Drop-down will be + * constrained to the block's workspace. + * + * @param field The field to position the dropdown against. + * @param opt_onHide Optional callback for when the drop-down is hidden. + * @param opt_secondaryYOffset Optional Y offset for above-block positioning. + * @param manageEphemeralFocus Whether ephemeral focus should be managed + * according to the drop-down div's lifetime. Note that if a false value is + * passed in here then callers should manage ephemeral focus directly + * otherwise focus may not properly restore when the widget closes. Defaults + * to true. + * @returns True if the menu rendered below block; false if above. + */ +export function showPositionedByField( + field: Field, + opt_onHide?: () => void, + opt_secondaryYOffset?: number, + manageEphemeralFocus: boolean = true, +): boolean { + positionToField = true; + return showPositionedByRect( + getScaledBboxOfField(field as Field), + field as Field, + manageEphemeralFocus, + opt_onHide, + opt_secondaryYOffset, + ); +} +/** + * Get the scaled bounding box of a block. + * + * @param block The block. + * @returns The scaled bounding box of the block. + */ +function getScaledBboxOfBlock(block: BlockSvg): Rect { + const blockSvg = block.getSvgRoot(); + const scale = block.workspace.scale; + const scaledHeight = block.height * scale; + const scaledWidth = block.width * scale; + const xy = style.getPageOffset(blockSvg); + return new Rect(xy.y, xy.y + scaledHeight, xy.x, xy.x + scaledWidth); +} + +/** + * Get the scaled bounding box of a field. + * + * @param field The field. + * @returns The scaled bounding box of the field. + */ +function getScaledBboxOfField(field: Field): Rect { + const bBox = field.getScaledBBox(); + return new Rect(bBox.top, bBox.bottom, bBox.left, bBox.right); +} + +/** + * Helper method to show and place the drop-down with positioning determined + * by a scaled bounding box. The primary position will be below the rect, + * and the secondary position above the rect. Drop-down will be constrained to + * the block's workspace. + * + * @param bBox The scaled bounding box. + * @param field The field to position the dropdown against. + * @param opt_onHide Optional callback for when the drop-down is hidden. + * @param opt_secondaryYOffset Optional Y offset for above-block positioning. + * @param manageEphemeralFocus Whether ephemeral focus should be managed + * according to the drop-down div's lifetime. Note that if a false value is + * passed in here then callers should manage ephemeral focus directly + * otherwise focus may not properly restore when the widget closes. + * @returns True if the menu rendered below block; false if above. + */ +function showPositionedByRect( + bBox: Rect, + field: Field, + manageEphemeralFocus: boolean, + opt_onHide?: () => void, + opt_secondaryYOffset?: number, +): boolean { + // If we can fit it, render below the block. + const primaryX = bBox.left + (bBox.right - bBox.left) / 2; + const primaryY = bBox.bottom; + // If we can't fit it, render above the entire parent block. + const secondaryX = primaryX; + let secondaryY = bBox.top; + if (opt_secondaryYOffset) { + secondaryY += opt_secondaryYOffset; + } + const sourceBlock = field.getSourceBlock() as BlockSvg; + // Set bounds to main workspace; show the drop-down. + let workspace = sourceBlock.workspace; + while (workspace.options.parentWorkspace) { + workspace = workspace.options.parentWorkspace; + } + setBoundsElement(workspace.getParentSvg().parentNode as Element | null); + return show( + field, + sourceBlock.RTL, + primaryX, + primaryY, + secondaryX, + secondaryY, + manageEphemeralFocus, + opt_onHide, + ); +} + +/** + * Show and place the drop-down. + * The drop-down is placed with an absolute "origin point" (x, y) - i.e., + * the arrow will point at this origin and box will positioned below or above + * it. If we can maintain the container bounds at the primary point, the arrow + * will point there, and the container will be positioned below it. + * If we can't maintain the container bounds at the primary point, fall-back to + * the secondary point and position above. + * + * @param newOwner The object showing the drop-down + * @param rtl Right-to-left (true) or left-to-right (false). + * @param primaryX Desired origin point x, in absolute px. + * @param primaryY Desired origin point y, in absolute px. + * @param secondaryX Secondary/alternative origin point x, in absolute px. + * @param secondaryY Secondary/alternative origin point y, in absolute px. + * @param opt_onHide Optional callback for when the drop-down is hidden. + * @param manageEphemeralFocus Whether ephemeral focus should be managed + * according to the widget div's lifetime. + * @returns True if the menu rendered at the primary origin point. + * @internal + */ +export function show( + newOwner: Field, + rtl: boolean, + primaryX: number, + primaryY: number, + secondaryX: number, + secondaryY: number, + manageEphemeralFocus: boolean, + opt_onHide?: () => void, +): boolean { + owner = newOwner as Field; + onHide = opt_onHide || null; + // Set direction. + div.style.direction = rtl ? 'rtl' : 'ltr'; + + const mainWorkspace = common.getMainWorkspace() as WorkspaceSvg; + renderedClassName = mainWorkspace.getRenderer().getClassName(); + themeClassName = mainWorkspace.getTheme().getClassName(); + dom.addClass(div, renderedClassName); + dom.addClass(div, themeClassName); + + // When we change `translate` multiple times in close succession, + // Chrome may choose to wait and apply them all at once. + // Since we want the translation to initial X, Y to be immediate, + // and the translation to final X, Y to be animated, + // we saw problems where both would be applied after animation was turned on, + // making the dropdown appear to fly in from (0, 0). + // Using both `left`, `top` for the initial translation and then `translate` + // for the animated transition to final X, Y is a workaround. + const atOrigin = positionInternal(primaryX, primaryY, secondaryX, secondaryY); + + // Ephemeral focus must happen after the div is fully visible in order to + // ensure that it properly receives focus. + if (manageEphemeralFocus) { + returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); + } + + return atOrigin; +} + +const internal = { + /** + * Get sizing info about the bounding element. + * + * @returns An object containing size information about the bounding element + * (bounding box and width/height). + */ + getBoundsInfo: function (): BoundsInfo { + const boundPosition = style.getPageOffset(boundsElement as Element); + const boundSize = style.getSize(boundsElement as Element); + + return { + left: boundPosition.x, + right: boundPosition.x + boundSize.width, + top: boundPosition.y, + bottom: boundPosition.y + boundSize.height, + width: boundSize.width, + height: boundSize.height, + }; + }, + + /** + * Helper to position the drop-down and the arrow, maintaining bounds. + * See explanation of origin points in show. + * + * @param primaryX Desired origin point x, in absolute px. + * @param primaryY Desired origin point y, in absolute px. + * @param secondaryX Secondary/alternative origin point x, in absolute px. + * @param secondaryY Secondary/alternative origin point y, in absolute px. + * @returns Various final metrics, including rendered positions for drop-down + * and arrow. + */ + getPositionMetrics: function ( + primaryX: number, + primaryY: number, + secondaryX: number, + secondaryY: number, + ): PositionMetrics { + const boundsInfo = internal.getBoundsInfo(); + const divSize = style.getSize(div as Element); + + // Can we fit in-bounds below the target? + if (primaryY + divSize.height < boundsInfo.bottom) { + return getPositionBelowMetrics(primaryX, primaryY, boundsInfo, divSize); + } + // Can we fit in-bounds above the target? + if (secondaryY - divSize.height > boundsInfo.top) { + return getPositionAboveMetrics( + secondaryX, + secondaryY, + boundsInfo, + divSize, + ); + } + // Can we fit outside the workspace bounds (but inside the window) + // below? + if (primaryY + divSize.height < document.documentElement.clientHeight) { + return getPositionBelowMetrics(primaryX, primaryY, boundsInfo, divSize); + } + // Can we fit outside the workspace bounds (but inside the window) + // above? + if (secondaryY - divSize.height > document.documentElement.clientTop) { + return getPositionAboveMetrics( + secondaryX, + secondaryY, + boundsInfo, + divSize, + ); + } + + // Last resort, render at top of page. + return getPositionTopOfPageMetrics(primaryX, boundsInfo, divSize); + }, +}; + +/** + * Get the metrics for positioning the div below the source. + * + * @param primaryX Desired origin point x, in absolute px. + * @param primaryY Desired origin point y, in absolute px. + * @param boundsInfo An object containing size information about the bounding + * element (bounding box and width/height). + * @param divSize An object containing information about the size of the + * DropDownDiv (width & height). + * @returns Various final metrics, including rendered positions for drop-down + * and arrow. + */ +function getPositionBelowMetrics( + primaryX: number, + primaryY: number, + boundsInfo: BoundsInfo, + divSize: Size, +): PositionMetrics { + const xCoords = getPositionX( + primaryX, + boundsInfo.left, + boundsInfo.right, + divSize.width, + ); + + const arrowY = -(ARROW_SIZE / 2 + BORDER_SIZE); + const finalY = primaryY + PADDING_Y; + + return { + initialX: xCoords.divX, + initialY: primaryY, + finalX: xCoords.divX, // X position remains constant during animation. + finalY, + arrowX: xCoords.arrowX, + arrowY, + arrowAtTop: true, + arrowVisible: true, + }; +} + +/** + * Get the metrics for positioning the div above the source. + * + * @param secondaryX Secondary/alternative origin point x, in absolute px. + * @param secondaryY Secondary/alternative origin point y, in absolute px. + * @param boundsInfo An object containing size information about the bounding + * element (bounding box and width/height). + * @param divSize An object containing information about the size of the + * DropDownDiv (width & height). + * @returns Various final metrics, including rendered positions for drop-down + * and arrow. + */ +function getPositionAboveMetrics( + secondaryX: number, + secondaryY: number, + boundsInfo: BoundsInfo, + divSize: Size, +): PositionMetrics { + const xCoords = getPositionX( + secondaryX, + boundsInfo.left, + boundsInfo.right, + divSize.width, + ); + + const arrowY = divSize.height - BORDER_SIZE * 2 - ARROW_SIZE / 2; + const finalY = secondaryY - divSize.height - PADDING_Y; + const initialY = secondaryY - divSize.height; // No padding on Y. + + return { + initialX: xCoords.divX, + initialY, + finalX: xCoords.divX, // X position remains constant during animation. + finalY, + arrowX: xCoords.arrowX, + arrowY, + arrowAtTop: false, + arrowVisible: true, + }; +} + +/** + * Get the metrics for positioning the div at the top of the page. + * + * @param sourceX Desired origin point x, in absolute px. + * @param boundsInfo An object containing size information about the bounding + * element (bounding box and width/height). + * @param divSize An object containing information about the size of the + * DropDownDiv (width & height). + * @returns Various final metrics, including rendered positions for drop-down + * and arrow. + */ +function getPositionTopOfPageMetrics( + sourceX: number, + boundsInfo: BoundsInfo, + divSize: Size, +): PositionMetrics { + const xCoords = getPositionX( + sourceX, + boundsInfo.left, + boundsInfo.right, + divSize.width, + ); + + // No need to provide arrow-specific information because it won't be visible. + return { + initialX: xCoords.divX, + initialY: 0, + finalX: xCoords.divX, // X position remains constant during animation. + finalY: 0, // Y position remains constant during animation. + arrowAtTop: null, + arrowX: null, + arrowY: null, + arrowVisible: false, + }; +} + +/** + * Get the x positions for the left side of the DropDownDiv and the arrow, + * accounting for the bounds of the workspace. + * + * @param sourceX Desired origin point x, in absolute px. + * @param boundsLeft The left edge of the bounding element, in absolute px. + * @param boundsRight The right edge of the bounding element, in absolute px. + * @param divWidth The width of the div in px. + * @returns An object containing metrics for the x positions of the left side of + * the DropDownDiv and the arrow. + * @internal + */ +export function getPositionX( + sourceX: number, + boundsLeft: number, + boundsRight: number, + divWidth: number, +): {divX: number; arrowX: number} { + let divX = sourceX; + // Offset the topLeft coord so that the dropdowndiv is centered. + divX -= divWidth / 2; + // Fit the dropdowndiv within the bounds of the workspace. + divX = math.clamp(boundsLeft, divX, boundsRight - divWidth); + + let arrowX = sourceX; + // Offset the arrow coord so that the arrow is centered. + arrowX -= ARROW_SIZE / 2; + // Convert the arrow position to be relative to the top left of the div. + let relativeArrowX = arrowX - divX; + const horizPadding = ARROW_HORIZONTAL_PADDING; + // Clamp the arrow position so that it stays attached to the dropdowndiv. + relativeArrowX = math.clamp( + horizPadding, + relativeArrowX, + divWidth - horizPadding - ARROW_SIZE, + ); + + return {arrowX: relativeArrowX, divX}; +} + +/** + * Is the container visible? + * + * @returns True if visible. + */ +export function isVisible(): boolean { + return !!owner; +} + +/** + * Hide the menu only if it is owned by the provided object. + * + * @param divOwner Object which must be owning the drop-down to hide. + * @param opt_withoutAnimation True if we should hide the dropdown without + * animating. + * @returns True if hidden. + */ +export function hideIfOwner( + divOwner: Field, + opt_withoutAnimation?: boolean, +): boolean { + if (owner === divOwner) { + if (opt_withoutAnimation) { + hideWithoutAnimation(); + } else { + hide(); + } + return true; + } + return false; +} + +/** Hide the menu, triggering animation. */ +export function hide() { + // Start the animation by setting the translation and fading out. + // Reset to (initialX, initialY) - i.e., no translation. + div.style.transform = 'translate(0, 0)'; + div.style.opacity = '0'; + // Finish animation - reset all values to default. + animateOutTimer = setTimeout(function () { + hideWithoutAnimation(); + }, ANIMATION_TIME * 1000); + if (onHide) { + onHide(); + onHide = null; + } +} + +/** Hide the menu, without animation. */ +export function hideWithoutAnimation() { + if (!isVisible()) { + return; + } + if (animateOutTimer) { + clearTimeout(animateOutTimer); + } + + if (onHide) { + onHide(); + onHide = null; + } + clearContent(); + owner = null; + + (common.getMainWorkspace() as WorkspaceSvg).markFocused(); + + if (returnEphemeralFocus) { + returnEphemeralFocus(); + returnEphemeralFocus = null; + } +} + +/** + * Set the dropdown div's position. + * + * @param primaryX Desired origin point x, in absolute px. + * @param primaryY Desired origin point y, in absolute px. + * @param secondaryX Secondary/alternative origin point x, in absolute px. + * @param secondaryY Secondary/alternative origin point y, in absolute px. + * @returns True if the menu rendered at the primary origin point. + */ +function positionInternal( + primaryX: number, + primaryY: number, + secondaryX: number, + secondaryY: number, +): boolean { + const metrics = internal.getPositionMetrics( + primaryX, + primaryY, + secondaryX, + secondaryY, + ); + + // Update arrow CSS. + if (metrics.arrowVisible) { + const x = metrics.arrowX; + const y = metrics.arrowY; + const rotation = metrics.arrowAtTop ? 45 : 225; + arrow.style.display = ''; + arrow.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`; + arrow.setAttribute('class', 'blocklyDropDownArrow'); + } else { + arrow.style.display = 'none'; + } + + const initialX = Math.floor(metrics.initialX); + const initialY = Math.floor(metrics.initialY); + const finalX = Math.floor(metrics.finalX); + const finalY = Math.floor(metrics.finalY); + + // First apply initial translation. + div.style.left = initialX + 'px'; + div.style.top = initialY + 'px'; + + // Show the div. + div.style.display = 'block'; + div.style.opacity = '1'; + // Add final translate, animated through `transition`. + // Coordinates are relative to (initialX, initialY), + // where the drop-down is absolutely positioned. + const dx = finalX - initialX; + const dy = finalY - initialY; + div.style.transform = 'translate(' + dx + 'px,' + dy + 'px)'; + + return !!metrics.arrowAtTop; +} + +/** + * Repositions the dropdownDiv on window resize. If it doesn't know how to + * calculate the new position, it will just hide it instead. + * + * @internal + */ +export function repositionForWindowResize() { + // This condition mainly catches the dropdown div when it is being used as a + // dropdown. It is important not to close it in this case because on Android, + // when a field is focused, the soft keyboard opens triggering a window resize + // event and we want the dropdown div to stick around so users can type into + // it. + if (owner) { + const block = owner.getSourceBlock() as BlockSvg; + const bBox = positionToField + ? getScaledBboxOfField(owner) + : getScaledBboxOfBlock(block); + // If we can fit it, render below the block. + const primaryX = bBox.left + (bBox.right - bBox.left) / 2; + const primaryY = bBox.bottom; + // If we can't fit it, render above the entire parent block. + const secondaryX = primaryX; + const secondaryY = bBox.top; + positionInternal(primaryX, primaryY, secondaryX, secondaryY); + } else { + hide(); + } +} + +export const TEST_ONLY = internal; diff --git a/core/events.js b/core/events.js deleted file mode 100644 index 3e196dc567d..00000000000 --- a/core/events.js +++ /dev/null @@ -1,1116 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Events fired as a result of actions in Blockly's editor. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -/** - * Events fired as a result of actions in Blockly's editor. - * @namespace Blockly.Events - */ -goog.provide('Blockly.Events'); - -goog.require('goog.array'); -goog.require('goog.math.Coordinate'); - - -/** - * Group ID for new events. Grouped events are indivisible. - * @type {string} - * @private - */ -Blockly.Events.group_ = ''; - -/** - * Sets whether events should be added to the undo stack. - * @type {boolean} - */ -Blockly.Events.recordUndo = true; - -/** - * Allow change events to be created and fired. - * @type {number} - * @private - */ -Blockly.Events.disabled_ = 0; - -/** - * Name of event that creates a block. Will be deprecated for BLOCK_CREATE. - * @const - */ -Blockly.Events.CREATE = 'create'; - -/** - * Name of event that creates a block. - * @const - */ -Blockly.Events.BLOCK_CREATE = Blockly.Events.CREATE; - -/** - * Name of event that deletes a block. Will be deprecated for BLOCK_DELETE. - * @const - */ -Blockly.Events.DELETE = 'delete'; - -/** - * Name of event that deletes a block. - * @const - */ -Blockly.Events.BLOCK_DELETE = Blockly.Events.DELETE; - -/** - * Name of event that changes a block. Will be deprecated for BLOCK_CHANGE. - * @const - */ -Blockly.Events.CHANGE = 'change'; - -/** - * Name of event that changes a block. - * @const - */ -Blockly.Events.BLOCK_CHANGE = Blockly.Events.CHANGE; - -/** - * Name of event that moves a block. Will be deprecated for BLOCK_MOVE. - * @const - */ -Blockly.Events.MOVE = 'move'; - -/** - * Name of event that moves a block. - * @const - */ -Blockly.Events.BLOCK_MOVE = Blockly.Events.MOVE; - -/** - * Name of event that creates a variable. - * @const - */ -Blockly.Events.VAR_CREATE = 'var_create'; - -/** - * Name of event that deletes a variable. - * @const - */ -Blockly.Events.VAR_DELETE = 'var_delete'; - -/** - * Name of event that renames a variable. - * @const - */ -Blockly.Events.VAR_RENAME = 'var_rename'; - -/** - * Name of event that records a UI change. - * @const - */ -Blockly.Events.UI = 'ui'; - -/** - * List of events queued for firing. - * @private - */ -Blockly.Events.FIRE_QUEUE_ = []; - -/** - * Create a custom event and fire it. - * @param {!Blockly.Events.Abstract} event Custom data for event. - */ -Blockly.Events.fire = function(event) { - if (!Blockly.Events.isEnabled()) { - return; - } - if (!Blockly.Events.FIRE_QUEUE_.length) { - // First event added; schedule a firing of the event queue. - setTimeout(Blockly.Events.fireNow_, 0); - } - Blockly.Events.FIRE_QUEUE_.push(event); -}; - -/** - * Fire all queued events. - * @private - */ -Blockly.Events.fireNow_ = function() { - var queue = Blockly.Events.filter(Blockly.Events.FIRE_QUEUE_, true); - Blockly.Events.FIRE_QUEUE_.length = 0; - for (var i = 0, event; event = queue[i]; i++) { - var workspace = Blockly.Workspace.getById(event.workspaceId); - if (workspace) { - workspace.fireChangeListener(event); - } - } -}; - -/** - * Filter the queued events and merge duplicates. - * @param {!Array.} queueIn Array of events. - * @param {boolean} forward True if forward (redo), false if backward (undo). - * @return {!Array.} Array of filtered events. - */ -Blockly.Events.filter = function(queueIn, forward) { - var queue = goog.array.clone(queueIn); - if (!forward) { - // Undo is merged in reverse order. - queue.reverse(); - } - // Merge duplicates. O(n^2), but n should be very small. - for (var i = 0, event1; event1 = queue[i]; i++) { - for (var j = i + 1, event2; event2 = queue[j]; j++) { - if (event1.type == event2.type && - event1.blockId == event2.blockId && - event1.workspaceId == event2.workspaceId) { - if (event1.type == Blockly.Events.MOVE) { - // Merge move events. - event1.newParentId = event2.newParentId; - event1.newInputName = event2.newInputName; - event1.newCoordinate = event2.newCoordinate; - queue.splice(j, 1); - j--; - } else if (event1.type == Blockly.Events.CHANGE && - event1.element == event2.element && - event1.name == event2.name) { - // Merge change events. - event1.newValue = event2.newValue; - queue.splice(j, 1); - j--; - } else if (event1.type == Blockly.Events.UI && - event2.element == 'click' && - (event1.element == 'commentOpen' || - event1.element == 'mutatorOpen' || - event1.element == 'warningOpen')) { - // Merge change events. - event1.newValue = event2.newValue; - queue.splice(j, 1); - j--; - } - } - } - } - // Remove null events. - for (var i = queue.length - 1; i >= 0; i--) { - if (queue[i].isNull()) { - queue.splice(i, 1); - } - } - if (!forward) { - // Restore undo order. - queue.reverse(); - } - // Move mutation events to the top of the queue. - // Intentionally skip first event. - for (var i = 1, event; event = queue[i]; i++) { - if (event.type == Blockly.Events.CHANGE && - event.element == 'mutation') { - queue.unshift(queue.splice(i, 1)[0]); - } - } - return queue; -}; - -/** - * Modify pending undo events so that when they are fired they don't land - * in the undo stack. Called by Blockly.Workspace.clearUndo. - */ -Blockly.Events.clearPendingUndo = function() { - for (var i = 0, event; event = Blockly.Events.FIRE_QUEUE_[i]; i++) { - event.recordUndo = false; - } -}; - -/** - * Stop sending events. Every call to this function MUST also call enable. - */ -Blockly.Events.disable = function() { - Blockly.Events.disabled_++; -}; - -/** - * Start sending events. Unless events were already disabled when the - * corresponding call to disable was made. - */ -Blockly.Events.enable = function() { - Blockly.Events.disabled_--; -}; - -/** - * Returns whether events may be fired or not. - * @return {boolean} True if enabled. - */ -Blockly.Events.isEnabled = function() { - return Blockly.Events.disabled_ == 0; -}; - -/** - * Current group. - * @return {string} ID string. - */ -Blockly.Events.getGroup = function() { - return Blockly.Events.group_; -}; - -/** - * Start or stop a group. - * @param {boolean|string} state True to start new group, false to end group. - * String to set group explicitly. - */ -Blockly.Events.setGroup = function(state) { - if (typeof state == 'boolean') { - Blockly.Events.group_ = state ? Blockly.utils.genUid() : ''; - } else { - Blockly.Events.group_ = state; - } -}; - -/** - * Compute a list of the IDs of the specified block and all its descendants. - * @param {!Blockly.Block} block The root block. - * @return {!Array.} List of block IDs. - * @private - */ -Blockly.Events.getDescendantIds_ = function(block) { - var ids = []; - var descendants = block.getDescendants(); - for (var i = 0, descendant; descendant = descendants[i]; i++) { - ids[i] = descendant.id; - } - return ids; -}; - -/** - * Decode the JSON into an event. - * @param {!Object} json JSON representation. - * @param {!Blockly.Workspace} workspace Target workspace for event. - * @return {!Blockly.Events.Abstract} The event represented by the JSON. - */ -Blockly.Events.fromJson = function(json, workspace) { - var event; - switch (json.type) { - case Blockly.Events.CREATE: - event = new Blockly.Events.Create(null); - break; - case Blockly.Events.DELETE: - event = new Blockly.Events.Delete(null); - break; - case Blockly.Events.CHANGE: - event = new Blockly.Events.Change(null); - break; - case Blockly.Events.MOVE: - event = new Blockly.Events.Move(null); - break; - case Blockly.Events.VAR_CREATE: - event = new Blockly.Events.VarCreate(null); - break; - case Blockly.Events.VAR_DELETE: - event = new Blockly.Events.VarDelete(null); - break; - case Blockly.Events.VAR_RENAME: - event = new Blockly.Events.VarRename(null); - break; - case Blockly.Events.UI: - event = new Blockly.Events.Ui(null); - break; - default: - throw 'Unknown event type.'; - } - event.fromJson(json); - event.workspaceId = workspace.id; - return event; -}; - -/** - * Abstract class for an event. - * @param {Blockly.Block|Blockly.VariableModel} elem The block or variable. - * @constructor - */ -Blockly.Events.Abstract = function(elem) { - if (elem instanceof Blockly.Block) { - this.blockId = elem.id; - this.workspaceId = elem.workspace.id; - } - else if (elem instanceof Blockly.VariableModel){ - this.workspaceId = elem.workspace.id; - this.varId = elem.getId(); - } - this.group = Blockly.Events.group_; - this.recordUndo = Blockly.Events.recordUndo; -}; - -/** - * Encode the event as JSON. - * @return {!Object} JSON representation. - */ -Blockly.Events.Abstract.prototype.toJson = function() { - var json = { - 'type': this.type - }; - if (this.blockId) { - json['blockId'] = this.blockId; - } - if (this.varId) { - json['varId'] = this.varId; - } - if (this.group) { - json['group'] = this.group; - } - return json; -}; - -/** - * Decode the JSON event. - * @param {!Object} json JSON representation. - */ -Blockly.Events.Abstract.prototype.fromJson = function(json) { - this.blockId = json['blockId']; - this.varId = json['varId']; - this.group = json['group']; -}; - -/** - * Does this event record any change of state? - * @return {boolean} True if null, false if something changed. - */ -Blockly.Events.Abstract.prototype.isNull = function() { - return false; -}; - -/** - * Run an event. - * @param {boolean} forward True if run forward, false if run backward (undo). - */ -Blockly.Events.Abstract.prototype.run = function(forward) { - // Defined by subclasses. -}; - -/** - * Get workspace the event belongs to. - * @return {Blockly.Workspace} The workspace the event belongs to. - * @throws {Error} if workspace is null. - * @private - */ -Blockly.Events.Abstract.prototype.getEventWorkspace_ = function() { - var workspace = Blockly.Workspace.getById(this.workspaceId); - if (!workspace) { - throw Error('Workspace is null. Event must have been generated from real' + - ' Blockly events.'); - } - return workspace; -}; - -/** - * Class for a block creation event. - * @param {Blockly.Block} block The created block. Null for a blank event. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.Create = function(block) { - if (!block) { - return; // Blank event to be populated by fromJson. - } - Blockly.Events.Create.superClass_.constructor.call(this, block); - - if (block.workspace.rendered) { - this.xml = Blockly.Xml.blockToDomWithXY(block); - } else { - this.xml = Blockly.Xml.blockToDom(block); - } - this.ids = Blockly.Events.getDescendantIds_(block); -}; -goog.inherits(Blockly.Events.Create, Blockly.Events.Abstract); - -/** - * Class for a block creation event. - * @param {Blockly.Block} block The created block. Null for a blank event. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.BlockCreate = Blockly.Events.Create; - -/** - * Type of this event. - * @type {string} - */ -Blockly.Events.Create.prototype.type = Blockly.Events.CREATE; - -/** - * Encode the event as JSON. - * @return {!Object} JSON representation. - */ -Blockly.Events.Create.prototype.toJson = function() { - var json = Blockly.Events.Create.superClass_.toJson.call(this); - json['xml'] = Blockly.Xml.domToText(this.xml); - json['ids'] = this.ids; - return json; -}; - -/** - * Decode the JSON event. - * @param {!Object} json JSON representation. - */ -Blockly.Events.Create.prototype.fromJson = function(json) { - Blockly.Events.Create.superClass_.fromJson.call(this, json); - this.xml = Blockly.Xml.textToDom('' + json['xml'] + '').firstChild; - this.ids = json['ids']; -}; - -/** - * Run a creation event. - * @param {boolean} forward True if run forward, false if run backward (undo). - */ -Blockly.Events.Create.prototype.run = function(forward) { - var workspace = this.getEventWorkspace_(); - if (forward) { - var xml = goog.dom.createDom('xml'); - xml.appendChild(this.xml); - Blockly.Xml.domToWorkspace(xml, workspace); - } else { - for (var i = 0, id; id = this.ids[i]; i++) { - var block = workspace.getBlockById(id); - if (block) { - block.dispose(false, false); - } else if (id == this.blockId) { - // Only complain about root-level block. - console.warn("Can't uncreate non-existant block: " + id); - } - } - } -}; - -/** - * Class for a block deletion event. - * @param {Blockly.Block} block The deleted block. Null for a blank event. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.Delete = function(block) { - if (!block) { - return; // Blank event to be populated by fromJson. - } - if (block.getParent()) { - throw 'Connected blocks cannot be deleted.'; - } - Blockly.Events.Delete.superClass_.constructor.call(this, block); - - if (block.workspace.rendered) { - this.oldXml = Blockly.Xml.blockToDomWithXY(block); - } else { - this.oldXml = Blockly.Xml.blockToDom(block); - } - this.ids = Blockly.Events.getDescendantIds_(block); -}; -goog.inherits(Blockly.Events.Delete, Blockly.Events.Abstract); - -/** - * Class for a block deletion event. - * @param {Blockly.Block} block The deleted block. Null for a blank event. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.BlockDelete = Blockly.Events.Delete; - -/** - * Type of this event. - * @type {string} - */ -Blockly.Events.Delete.prototype.type = Blockly.Events.DELETE; - -/** - * Encode the event as JSON. - * @return {!Object} JSON representation. - */ -Blockly.Events.Delete.prototype.toJson = function() { - var json = Blockly.Events.Delete.superClass_.toJson.call(this); - json['ids'] = this.ids; - return json; -}; - -/** - * Decode the JSON event. - * @param {!Object} json JSON representation. - */ -Blockly.Events.Delete.prototype.fromJson = function(json) { - Blockly.Events.Delete.superClass_.fromJson.call(this, json); - this.ids = json['ids']; -}; - -/** - * Run a deletion event. - * @param {boolean} forward True if run forward, false if run backward (undo). - */ -Blockly.Events.Delete.prototype.run = function(forward) { - var workspace = this.getEventWorkspace_(); - if (forward) { - for (var i = 0, id; id = this.ids[i]; i++) { - var block = workspace.getBlockById(id); - if (block) { - block.dispose(false, false); - } else if (id == this.blockId) { - // Only complain about root-level block. - console.warn("Can't delete non-existant block: " + id); - } - } - } else { - var xml = goog.dom.createDom('xml'); - xml.appendChild(this.oldXml); - Blockly.Xml.domToWorkspace(xml, workspace); - } -}; - -/** - * Class for a block change event. - * @param {Blockly.Block} block The changed block. Null for a blank event. - * @param {string} element One of 'field', 'comment', 'disabled', etc. - * @param {?string} name Name of input or field affected, or null. - * @param {string} oldValue Previous value of element. - * @param {string} newValue New value of element. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.Change = function(block, element, name, oldValue, newValue) { - if (!block) { - return; // Blank event to be populated by fromJson. - } - Blockly.Events.Change.superClass_.constructor.call(this, block); - this.element = element; - this.name = name; - this.oldValue = oldValue; - this.newValue = newValue; -}; -goog.inherits(Blockly.Events.Change, Blockly.Events.Abstract); - -/** - * Class for a block change event. - * @param {Blockly.Block} block The changed block. Null for a blank event. - * @param {string} element One of 'field', 'comment', 'disabled', etc. - * @param {?string} name Name of input or field affected, or null. - * @param {string} oldValue Previous value of element. - * @param {string} newValue New value of element. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.BlockChange = Blockly.Events.Change; - -/** - * Type of this event. - * @type {string} - */ -Blockly.Events.Change.prototype.type = Blockly.Events.CHANGE; - -/** - * Encode the event as JSON. - * @return {!Object} JSON representation. - */ -Blockly.Events.Change.prototype.toJson = function() { - var json = Blockly.Events.Change.superClass_.toJson.call(this); - json['element'] = this.element; - if (this.name) { - json['name'] = this.name; - } - json['newValue'] = this.newValue; - return json; -}; - -/** - * Decode the JSON event. - * @param {!Object} json JSON representation. - */ -Blockly.Events.Change.prototype.fromJson = function(json) { - Blockly.Events.Change.superClass_.fromJson.call(this, json); - this.element = json['element']; - this.name = json['name']; - this.newValue = json['newValue']; -}; - -/** - * Does this event record any change of state? - * @return {boolean} True if something changed. - */ -Blockly.Events.Change.prototype.isNull = function() { - return this.oldValue == this.newValue; -}; - -/** - * Run a change event. - * @param {boolean} forward True if run forward, false if run backward (undo). - */ -Blockly.Events.Change.prototype.run = function(forward) { - var workspace = this.getEventWorkspace_(); - var block = workspace.getBlockById(this.blockId); - if (!block) { - console.warn("Can't change non-existant block: " + this.blockId); - return; - } - if (block.mutator) { - // Close the mutator (if open) since we don't want to update it. - block.mutator.setVisible(false); - } - var value = forward ? this.newValue : this.oldValue; - switch (this.element) { - case 'field': - var field = block.getField(this.name); - if (field) { - // Run the validator for any side-effects it may have. - // The validator's opinion on validity is ignored. - field.callValidator(value); - field.setValue(value); - } else { - console.warn("Can't set non-existant field: " + this.name); - } - break; - case 'comment': - block.setCommentText(value || null); - break; - case 'collapsed': - block.setCollapsed(value); - break; - case 'disabled': - block.setDisabled(value); - break; - case 'inline': - block.setInputsInline(value); - break; - case 'mutation': - var oldMutation = ''; - if (block.mutationToDom) { - var oldMutationDom = block.mutationToDom(); - oldMutation = oldMutationDom && Blockly.Xml.domToText(oldMutationDom); - } - if (block.domToMutation) { - value = value || ''; - var dom = Blockly.Xml.textToDom('' + value + ''); - block.domToMutation(dom.firstChild); - } - Blockly.Events.fire(new Blockly.Events.Change( - block, 'mutation', null, oldMutation, value)); - break; - default: - console.warn('Unknown change type: ' + this.element); - } -}; - -/** - * Class for a block move event. Created before the move. - * @param {Blockly.Block} block The moved block. Null for a blank event. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.Move = function(block) { - if (!block) { - return; // Blank event to be populated by fromJson. - } - Blockly.Events.Move.superClass_.constructor.call(this, block); - var location = this.currentLocation_(); - this.oldParentId = location.parentId; - this.oldInputName = location.inputName; - this.oldCoordinate = location.coordinate; -}; -goog.inherits(Blockly.Events.Move, Blockly.Events.Abstract); - - -/** - * Class for a block move event. Created before the move. - * @param {Blockly.Block} block The moved block. Null for a blank event. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.BlockMove = Blockly.Events.Move; - -/** - * Type of this event. - * @type {string} - */ -Blockly.Events.Move.prototype.type = Blockly.Events.MOVE; - -/** - * Encode the event as JSON. - * @return {!Object} JSON representation. - */ -Blockly.Events.Move.prototype.toJson = function() { - var json = Blockly.Events.Move.superClass_.toJson.call(this); - if (this.newParentId) { - json['newParentId'] = this.newParentId; - } - if (this.newInputName) { - json['newInputName'] = this.newInputName; - } - if (this.newCoordinate) { - json['newCoordinate'] = Math.round(this.newCoordinate.x) + ',' + - Math.round(this.newCoordinate.y); - } - return json; -}; - -/** - * Decode the JSON event. - * @param {!Object} json JSON representation. - */ -Blockly.Events.Move.prototype.fromJson = function(json) { - Blockly.Events.Move.superClass_.fromJson.call(this, json); - this.newParentId = json['newParentId']; - this.newInputName = json['newInputName']; - if (json['newCoordinate']) { - var xy = json['newCoordinate'].split(','); - this.newCoordinate = - new goog.math.Coordinate(parseFloat(xy[0]), parseFloat(xy[1])); - } -}; - -/** - * Record the block's new location. Called after the move. - */ -Blockly.Events.Move.prototype.recordNew = function() { - var location = this.currentLocation_(); - this.newParentId = location.parentId; - this.newInputName = location.inputName; - this.newCoordinate = location.coordinate; -}; - -/** - * Returns the parentId and input if the block is connected, - * or the XY location if disconnected. - * @return {!Object} Collection of location info. - * @private - */ -Blockly.Events.Move.prototype.currentLocation_ = function() { - var workspace = Blockly.Workspace.getById(this.workspaceId); - var block = workspace.getBlockById(this.blockId); - var location = {}; - var parent = block.getParent(); - if (parent) { - location.parentId = parent.id; - var input = parent.getInputWithBlock(block); - if (input) { - location.inputName = input.name; - } - } else { - location.coordinate = block.getRelativeToSurfaceXY(); - } - return location; -}; - -/** - * Does this event record any change of state? - * @return {boolean} True if something changed. - */ -Blockly.Events.Move.prototype.isNull = function() { - return this.oldParentId == this.newParentId && - this.oldInputName == this.newInputName && - goog.math.Coordinate.equals(this.oldCoordinate, this.newCoordinate); -}; - -/** - * Run a move event. - * @param {boolean} forward True if run forward, false if run backward (undo). - */ -Blockly.Events.Move.prototype.run = function(forward) { - var workspace = this.getEventWorkspace_(); - var block = workspace.getBlockById(this.blockId); - if (!block) { - console.warn("Can't move non-existant block: " + this.blockId); - return; - } - var parentId = forward ? this.newParentId : this.oldParentId; - var inputName = forward ? this.newInputName : this.oldInputName; - var coordinate = forward ? this.newCoordinate : this.oldCoordinate; - var parentBlock = null; - if (parentId) { - parentBlock = workspace.getBlockById(parentId); - if (!parentBlock) { - console.warn("Can't connect to non-existant block: " + parentId); - return; - } - } - if (block.getParent()) { - block.unplug(); - } - if (coordinate) { - var xy = block.getRelativeToSurfaceXY(); - block.moveBy(coordinate.x - xy.x, coordinate.y - xy.y); - } else { - var blockConnection = block.outputConnection || block.previousConnection; - var parentConnection; - if (inputName) { - var input = parentBlock.getInput(inputName); - if (input) { - parentConnection = input.connection; - } - } else if (blockConnection.type == Blockly.PREVIOUS_STATEMENT) { - parentConnection = parentBlock.nextConnection; - } - if (parentConnection) { - blockConnection.connect(parentConnection); - } else { - console.warn("Can't connect to non-existant input: " + inputName); - } - } -}; - -/** - * Class for a UI event. - * @param {Blockly.Block} block The affected block. - * @param {string} element One of 'selected', 'comment', 'mutator', etc. - * @param {string} oldValue Previous value of element. - * @param {string} newValue New value of element. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.Ui = function(block, element, oldValue, newValue) { - Blockly.Events.Ui.superClass_.constructor.call(this, block); - this.element = element; - this.oldValue = oldValue; - this.newValue = newValue; - this.recordUndo = false; -}; -goog.inherits(Blockly.Events.Ui, Blockly.Events.Abstract); - -/** - * Type of this event. - * @type {string} - */ -Blockly.Events.Ui.prototype.type = Blockly.Events.UI; - -/** - * Encode the event as JSON. - * @return {!Object} JSON representation. - */ -Blockly.Events.Ui.prototype.toJson = function() { - var json = Blockly.Events.Ui.superClass_.toJson.call(this); - json['element'] = this.element; - if (this.newValue !== undefined) { - json['newValue'] = this.newValue; - } - return json; -}; - -/** - * Decode the JSON event. - * @param {!Object} json JSON representation. - */ -Blockly.Events.Ui.prototype.fromJson = function(json) { - Blockly.Events.Ui.superClass_.fromJson.call(this, json); - this.element = json['element']; - this.newValue = json['newValue']; -}; - -/** - * Class for a variable creation event. - * @param {Blockly.VariableModel} variable The created variable. - * Null for a blank event. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.VarCreate = function(variable) { - if (!variable) { - return; // Blank event to be populated by fromJson. - } - Blockly.Events.VarCreate.superClass_.constructor.call(this, variable); - this.varType = variable.type; - this.varName = variable.name; -}; -goog.inherits(Blockly.Events.VarCreate, Blockly.Events.Abstract); - -/** - * Type of this event. - * @type {string} - */ -Blockly.Events.VarCreate.prototype.type = Blockly.Events.VAR_CREATE; - -/** - * Encode the event as JSON. - * @return {!Object} JSON representation. - */ -Blockly.Events.VarCreate.prototype.toJson = function() { - var json = Blockly.Events.VarCreate.superClass_.toJson.call(this); - json['varType'] = this.varType; - json['varName'] = this.varName; - return json; -}; - -/** - * Decode the JSON event. - * @param {!Object} json JSON representation. - */ -Blockly.Events.VarCreate.prototype.fromJson = function(json) { - Blockly.Events.VarCreate.superClass_.fromJson.call(this, json); - this.varType = json['varType']; - this.varName = json['varName']; -}; - -/** - * Run a variable creation event. - * @param {boolean} forward True if run forward, false if run backward (undo). - */ -Blockly.Events.VarCreate.prototype.run = function(forward) { - var workspace = this.getEventWorkspace_(); - if (forward) { - workspace.createVariable(this.varName, this.varType, this.varId); - } else { - workspace.deleteVariableById(this.varId); - } -}; - -/** - * Class for a variable deletion event. - * @param {Blockly.VariableModel} variable The deleted variable. - * Null for a blank event. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.VarDelete = function(variable) { - if (!variable) { - return; // Blank event to be populated by fromJson. - } - Blockly.Events.VarDelete.superClass_.constructor.call(this, variable); - this.varType = variable.type; - this.varName = variable.name; -}; -goog.inherits(Blockly.Events.VarDelete, Blockly.Events.Abstract); - -/** - * Type of this event. - * @type {string} - */ -Blockly.Events.VarDelete.prototype.type = Blockly.Events.VAR_DELETE; - -/** - * Encode the event as JSON. - * @return {!Object} JSON representation. - */ -Blockly.Events.VarDelete.prototype.toJson = function() { - var json = Blockly.Events.VarDelete.superClass_.toJson.call(this); - json['varType'] = this.varType; - json['varName'] = this.varName; - return json; -}; - -/** - * Decode the JSON event. - * @param {!Object} json JSON representation. - */ -Blockly.Events.VarDelete.prototype.fromJson = function(json) { - Blockly.Events.VarDelete.superClass_.fromJson.call(this, json); - this.varType = json['varType']; - this.varName = json['varName']; -}; - -/** - * Run a variable deletion event. - * @param {boolean} forward True if run forward, false if run backward (undo). - */ -Blockly.Events.VarDelete.prototype.run = function(forward) { - var workspace = this.getEventWorkspace_(); - if (forward) { - workspace.deleteVariableById(this.varId); - } else { - workspace.createVariable(this.varName, this.varType, this.varId); - } -}; - -/** - * Class for a variable rename event. - * @param {Blockly.VariableModel} variable The renamed variable. - * Null for a blank event. - * @param {string} newName The new name the variable will be changed to. - * @extends {Blockly.Events.Abstract} - * @constructor - */ -Blockly.Events.VarRename = function(variable, newName) { - if (!variable) { - return; // Blank event to be populated by fromJson. - } - Blockly.Events.VarRename.superClass_.constructor.call(this, variable); - this.oldName = variable.name; - this.newName = newName; -}; -goog.inherits(Blockly.Events.VarRename, Blockly.Events.Abstract); - -/** - * Type of this event. - * @type {string} - */ -Blockly.Events.VarRename.prototype.type = Blockly.Events.VAR_RENAME; - -/** - * Encode the event as JSON. - * @return {!Object} JSON representation. - */ -Blockly.Events.VarRename.prototype.toJson = function() { - var json = Blockly.Events.VarRename.superClass_.toJson.call(this); - json['oldName'] = this.oldName; - json['newName'] = this.newName; - return json; -}; - -/** - * Decode the JSON event. - * @param {!Object} json JSON representation. - */ -Blockly.Events.VarRename.prototype.fromJson = function(json) { - Blockly.Events.VarRename.superClass_.fromJson.call(this, json); - this.oldName = json['oldName']; - this.newName = json['newName']; -}; - -/** - * Run a variable rename event. - * @param {boolean} forward True if run forward, false if run backward (undo). - */ -Blockly.Events.VarRename.prototype.run = function(forward) { - var workspace = this.getEventWorkspace_(); - if (forward) { - workspace.renameVariableById(this.varId, this.newName); - } else { - workspace.renameVariableById(this.varId, this.oldName); - } -}; - -/** - * Enable/disable a block depending on whether it is properly connected. - * Use this on applications where all blocks should be connected to a top block. - * Recommend setting the 'disable' option to 'false' in the config so that - * users don't try to reenable disabled orphan blocks. - * @param {!Blockly.Events.Abstract} event Custom data for event. - */ -Blockly.Events.disableOrphans = function(event) { - if (event.type == Blockly.Events.MOVE || - event.type == Blockly.Events.CREATE) { - Blockly.Events.disable(); - var workspace = Blockly.Workspace.getById(event.workspaceId); - var block = workspace.getBlockById(event.blockId); - if (block) { - if (block.getParent() && !block.getParent().disabled) { - var children = block.getDescendants(); - for (var i = 0, child; child = children[i]; i++) { - child.setDisabled(false); - } - } else if ((block.outputConnection || block.previousConnection) && - !workspace.isDragging()) { - do { - block.setDisabled(true); - block = block.getNextBlock(); - } while (block); - } - } - Blockly.Events.enable(); - } -}; diff --git a/core/events/events.ts b/core/events/events.ts new file mode 100644 index 00000000000..dcddf19a925 --- /dev/null +++ b/core/events/events.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.Events + +import {EventType} from './type.js'; + +// Events. +export {Abstract, AbstractEventJson} from './events_abstract.js'; +export {BlockBase, BlockBaseJson} from './events_block_base.js'; +export {BlockChange, BlockChangeJson} from './events_block_change.js'; +export {BlockCreate, BlockCreateJson} from './events_block_create.js'; +export {BlockDelete, BlockDeleteJson} from './events_block_delete.js'; +export {BlockDrag, BlockDragJson} from './events_block_drag.js'; +export { + BlockFieldIntermediateChange, + BlockFieldIntermediateChangeJson, +} from './events_block_field_intermediate_change.js'; +export {BlockMove, BlockMoveJson} from './events_block_move.js'; +export {BubbleOpen, BubbleOpenJson, BubbleType} from './events_bubble_open.js'; +export {Click, ClickJson, ClickTarget} from './events_click.js'; +export {CommentBase, CommentBaseJson} from './events_comment_base.js'; +export {CommentChange, CommentChangeJson} from './events_comment_change.js'; +export { + CommentCollapse, + CommentCollapseJson, +} from './events_comment_collapse.js'; +export {CommentCreate, CommentCreateJson} from './events_comment_create.js'; +export {CommentDelete} from './events_comment_delete.js'; +export {CommentDrag, CommentDragJson} from './events_comment_drag.js'; +export {CommentMove, CommentMoveJson} from './events_comment_move.js'; +export {CommentResize, CommentResizeJson} from './events_comment_resize.js'; +export {Selected, SelectedJson} from './events_selected.js'; +export {ThemeChange, ThemeChangeJson} from './events_theme_change.js'; +export { + ToolboxItemSelect, + ToolboxItemSelectJson, +} from './events_toolbox_item_select.js'; + +// Events. +export {TrashcanOpen, TrashcanOpenJson} from './events_trashcan_open.js'; +export {UiBase} from './events_ui_base.js'; +export {VarBase, VarBaseJson} from './events_var_base.js'; +export {VarCreate, VarCreateJson} from './events_var_create.js'; +export {VarDelete, VarDeleteJson} from './events_var_delete.js'; +export {VarRename, VarRenameJson} from './events_var_rename.js'; +export {VarTypeChange, VarTypeChangeJson} from './events_var_type_change.js'; +export {ViewportChange, ViewportChangeJson} from './events_viewport.js'; +export {FinishedLoading} from './workspace_events.js'; + +export type {BumpEvent} from './utils.js'; + +// Event types. +export const BLOCK_CHANGE = EventType.BLOCK_CHANGE; +export const BLOCK_CREATE = EventType.BLOCK_CREATE; +export const BLOCK_DELETE = EventType.BLOCK_DELETE; +export const BLOCK_DRAG = EventType.BLOCK_DRAG; +export const BLOCK_MOVE = EventType.BLOCK_MOVE; +export const BLOCK_FIELD_INTERMEDIATE_CHANGE = + EventType.BLOCK_FIELD_INTERMEDIATE_CHANGE; +export const BUBBLE_OPEN = EventType.BUBBLE_OPEN; +/** @deprecated Use BLOCK_CHANGE instead */ +export const CHANGE = EventType.BLOCK_CHANGE; +export const CLICK = EventType.CLICK; +export const COMMENT_CHANGE = EventType.COMMENT_CHANGE; +export const COMMENT_CREATE = EventType.COMMENT_CREATE; +export const COMMENT_DELETE = EventType.COMMENT_DELETE; +export const COMMENT_MOVE = EventType.COMMENT_MOVE; +export const COMMENT_RESIZE = EventType.COMMENT_RESIZE; +export const COMMENT_DRAG = EventType.COMMENT_DRAG; +/** @deprecated Use BLOCK_CREATE instead */ +export const CREATE = EventType.BLOCK_CREATE; +/** @deprecated Use BLOCK_DELETE instead */ +export const DELETE = EventType.BLOCK_DELETE; +export const FINISHED_LOADING = EventType.FINISHED_LOADING; +/** @deprecated Use BLOCK_MOVE instead */ +export const MOVE = EventType.BLOCK_MOVE; +export const SELECTED = EventType.SELECTED; +export const THEME_CHANGE = EventType.THEME_CHANGE; +export const TOOLBOX_ITEM_SELECT = EventType.TOOLBOX_ITEM_SELECT; +export const TRASHCAN_OPEN = EventType.TRASHCAN_OPEN; +export const UI = EventType.UI; +export const VAR_CREATE = EventType.VAR_CREATE; +export const VAR_DELETE = EventType.VAR_DELETE; +export const VAR_RENAME = EventType.VAR_RENAME; +export const VIEWPORT_CHANGE = EventType.VIEWPORT_CHANGE; + +export {BUMP_EVENTS} from './type.js'; + +// Event utils. +export { + clearPendingUndo, + disable, + disableOrphans, + enable, + filter, + fire, + fromJson, + get, + getDescendantIds, + getGroup, + getRecordUndo, + isEnabled, + setGroup, + setRecordUndo, +} from './utils.js'; diff --git a/core/events/events_abstract.ts b/core/events/events_abstract.ts new file mode 100644 index 00000000000..e5a77dc7d6b --- /dev/null +++ b/core/events/events_abstract.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Abstract class for events fired as a result of actions in + * Blockly's editor. + * + * @class + */ +// Former goog.module ID: Blockly.Events.Abstract + +import * as common from '../common.js'; +import type {Workspace} from '../workspace.js'; +import {getGroup, getRecordUndo} from './utils.js'; + +/** + * Abstract class for an event. + */ +export abstract class Abstract { + /** + * Whether or not the event was constructed without necessary parameters + * (to be populated by fromJson). + */ + abstract isBlank: boolean; + + /** The workspace identifier for this event. */ + workspaceId?: string = undefined; + + /** + * An ID for the group of events this block is associated with. + * + * Groups define events that should be treated as an single action from the + * user's perspective, and should be undone together. + */ + group: string; + + /** Whether this event is undoable or not. */ + recordUndo: boolean; + + /** Whether or not the event is a UI event. */ + isUiEvent = false; + + /** Type of this event. */ + type = ''; + + constructor() { + this.group = getGroup(); + this.recordUndo = getRecordUndo(); + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + toJson(): AbstractEventJson { + return { + 'type': this.type, + 'group': this.group, + }; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of Abstract (like all events), but we can't specify that due to the + * fact that parameters to static methods in subclasses must be + * supertypes of parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: AbstractEventJson, + workspace: Workspace, + event: any, + ): Abstract { + event.isBlank = false; + event.group = json['group'] || ''; + event.workspaceId = workspace.id; + return event; + } + + /** + * Does this event record any change of state? + * + * @returns True if null, false if something changed. + */ + isNull(): boolean { + return false; + } + + /** + * Run an event. + * + * @param _forward True if run forward, false if run backward (undo). + */ + run(_forward: boolean) { + // Defined by subclasses. Cannot be abstract b/c UI events do /not/ define + // this. + } + + /** + * Get workspace the event belongs to. + * + * @returns The workspace the event belongs to. + * @throws {Error} if workspace is null. + */ + getEventWorkspace_(): Workspace { + let workspace; + if (this.workspaceId) { + workspace = common.getWorkspaceById(this.workspaceId); + } + if (!workspace) { + throw Error( + 'Workspace is null. Event must have been generated from real' + + ' Blockly events.', + ); + } + return workspace; + } +} + +export interface AbstractEventJson { + type: string; + group: string; +} diff --git a/core/events/events_block_base.ts b/core/events/events_block_base.ts new file mode 100644 index 00000000000..d15b8e439ed --- /dev/null +++ b/core/events/events_block_base.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Base class for all types of block events. + * + * @class + */ +// Former goog.module ID: Blockly.Events.BlockBase + +import type {Block} from '../block.js'; +import type {Workspace} from '../workspace.js'; +import { + Abstract as AbstractEvent, + AbstractEventJson, +} from './events_abstract.js'; + +/** + * Abstract class for any event related to blocks. + */ +export class BlockBase extends AbstractEvent { + override isBlank = true; + + /** The ID of the block associated with this event. */ + blockId?: string; + + /** + * @param opt_block The block this event corresponds to. + * Undefined for a blank event. + */ + constructor(opt_block?: Block) { + super(); + this.isBlank = !opt_block; + + if (!opt_block) return; + + this.blockId = opt_block.id; + this.workspaceId = opt_block.workspace.id; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): BlockBaseJson { + const json = super.toJson() as BlockBaseJson; + if (!this.blockId) { + throw new Error( + 'The block ID is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + json['blockId'] = this.blockId; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of BlockBase, but we can't specify that due to the fact that parameters + * to static methods in subclasses must be supertypes of parameters to + * static methods in superclasses. + * @internal + */ + static fromJson( + json: BlockBaseJson, + workspace: Workspace, + event?: any, + ): BlockBase { + const newEvent = super.fromJson( + json, + workspace, + event ?? new BlockBase(), + ) as BlockBase; + newEvent.blockId = json['blockId']; + return newEvent; + } +} + +export interface BlockBaseJson extends AbstractEventJson { + blockId: string; +} diff --git a/core/events/events_block_change.ts b/core/events/events_block_change.ts new file mode 100644 index 00000000000..e71eabb1747 --- /dev/null +++ b/core/events/events_block_change.ts @@ -0,0 +1,259 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for a block change event. + * + * @class + */ +// Former goog.module ID: Blockly.Events.BlockChange + +import type {Block} from '../block.js'; +import type {BlockSvg} from '../block_svg.js'; +import {MANUALLY_DISABLED} from '../constants.js'; +import {IconType} from '../icons/icon_types.js'; +import {hasBubble} from '../interfaces/i_has_bubble.js'; +import * as registry from '../registry.js'; +import * as utilsXml from '../utils/xml.js'; +import {Workspace} from '../workspace.js'; +import * as Xml from '../xml.js'; +import {BlockBase, BlockBaseJson} from './events_block_base.js'; +import {EventType} from './type.js'; +import * as eventUtils from './utils.js'; + +/** + * Notifies listeners when some element of a block has changed (e.g. + * field values, comments, etc). + */ +export class BlockChange extends BlockBase { + override type = EventType.BLOCK_CHANGE; + /** + * The element that changed; one of 'field', 'comment', 'collapsed', + * 'disabled', 'inline', or 'mutation' + */ + element?: string; + + /** The name of the field that changed, if this is a change to a field. */ + name?: string; + + /** The original value of the element. */ + oldValue: unknown; + + /** The new value of the element. */ + newValue: unknown; + + /** + * If element is 'disabled', this is the language-neutral identifier of the + * reason why the block was or was not disabled. + */ + private disabledReason?: string; + + /** + * @param opt_block The changed block. Undefined for a blank event. + * @param opt_element One of 'field', 'comment', 'disabled', etc. + * @param opt_name Name of input or field affected, or null. + * @param opt_oldValue Previous value of element. + * @param opt_newValue New value of element. + */ + constructor( + opt_block?: Block, + opt_element?: string, + opt_name?: string | null, + opt_oldValue?: unknown, + opt_newValue?: unknown, + ) { + super(opt_block); + + if (!opt_block) { + return; // Blank event to be populated by fromJson. + } + this.element = opt_element; + this.name = opt_name || undefined; + this.oldValue = opt_oldValue; + this.newValue = opt_newValue; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): BlockChangeJson { + const json = super.toJson() as BlockChangeJson; + if (!this.element) { + throw new Error( + 'The changed element is undefined. Either pass an ' + + 'element to the constructor, or call fromJson', + ); + } + json['element'] = this.element; + json['name'] = this.name; + json['oldValue'] = this.oldValue; + json['newValue'] = this.newValue; + if (this.disabledReason) { + json['disabledReason'] = this.disabledReason; + } + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of BlockChange, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: BlockChangeJson, + workspace: Workspace, + event?: any, + ): BlockChange { + const newEvent = super.fromJson( + json, + workspace, + event ?? new BlockChange(), + ) as BlockChange; + newEvent.element = json['element']; + newEvent.name = json['name']; + newEvent.oldValue = json['oldValue']; + newEvent.newValue = json['newValue']; + if (json['disabledReason'] !== undefined) { + newEvent.disabledReason = json['disabledReason']; + } + return newEvent; + } + + /** + * Set the language-neutral identifier for the reason why the block was or was + * not disabled. This is only valid for events where element is 'disabled'. + * Defaults to 'MANUALLY_DISABLED'. + * + * @param disabledReason The identifier of the reason why the block was or was + * not disabled. + */ + setDisabledReason(disabledReason: string) { + if (this.element !== 'disabled') { + throw new Error( + 'Cannot set the disabled reason for a BlockChange event if the ' + + 'element is not "disabled".', + ); + } + this.disabledReason = disabledReason; + } + + /** + * Does this event record any change of state? + * + * @returns False if something changed. + */ + override isNull(): boolean { + return this.oldValue === this.newValue; + } + + /** + * Run a change event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.blockId) { + throw new Error( + 'The block ID is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + const block = workspace.getBlockById(this.blockId); + if (!block) { + throw new Error( + 'The associated block is undefined. Either pass a ' + + 'block to the constructor, or call fromJson', + ); + } + // Assume the block is rendered so that then we can check. + const icon = block.getIcon(IconType.MUTATOR); + if (icon && hasBubble(icon) && icon.bubbleIsVisible()) { + // Close the mutator (if open) since we don't want to update it. + icon.setBubbleVisible(false); + } + const value = forward ? this.newValue : this.oldValue; + switch (this.element) { + case 'field': { + const field = block.getField(this.name!); + if (field) { + field.setValue(value); + } else { + console.warn("Can't set non-existent field: " + this.name); + } + break; + } + case 'comment': + block.setCommentText((value as string) || null); + break; + case 'collapsed': + block.setCollapsed(!!value); + break; + case 'disabled': + block.setDisabledReason( + !!value, + this.disabledReason ?? MANUALLY_DISABLED, + ); + break; + case 'inline': + block.setInputsInline(!!value); + break; + case 'mutation': { + const oldState = BlockChange.getExtraBlockState_(block as BlockSvg); + if (block.loadExtraState) { + block.loadExtraState(JSON.parse((value as string) || '{}')); + } else if (block.domToMutation) { + block.domToMutation( + utilsXml.textToDom((value as string) || ''), + ); + } + eventUtils.fire( + new BlockChange(block, 'mutation', null, oldState, value), + ); + break; + } + default: + console.warn('Unknown change type: ' + this.element); + } + } + + // TODO (#5397): Encapsulate this in the BlocklyMutationChange event when + // refactoring change events. + /** + * Returns the extra state of the given block (either as XML or a JSO, + * depending on the block's definition). + * + * @param block The block to get the extra state of. + * @returns A stringified version of the extra state of the given block. + * @internal + */ + static getExtraBlockState_(block: BlockSvg): string { + if (block.saveExtraState) { + const state = block.saveExtraState(true); + return state ? JSON.stringify(state) : ''; + } else if (block.mutationToDom) { + const state = block.mutationToDom(); + return state ? Xml.domToText(state) : ''; + } + return ''; + } +} + +export interface BlockChangeJson extends BlockBaseJson { + element: string; + name?: string; + newValue: unknown; + oldValue: unknown; + disabledReason?: string; +} + +registry.register(registry.Type.EVENT, EventType.BLOCK_CHANGE, BlockChange); diff --git a/core/events/events_block_create.ts b/core/events/events_block_create.ts new file mode 100644 index 00000000000..ca697945488 --- /dev/null +++ b/core/events/events_block_create.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for a block creation event. + * + * @class + */ +// Former goog.module ID: Blockly.Events.BlockCreate + +import type {Block} from '../block.js'; +import * as registry from '../registry.js'; +import * as blocks from '../serialization/blocks.js'; +import * as utilsXml from '../utils/xml.js'; +import {Workspace} from '../workspace.js'; +import * as Xml from '../xml.js'; +import {BlockBase, BlockBaseJson} from './events_block_base.js'; +import {EventType} from './type.js'; +import * as eventUtils from './utils.js'; + +/** + * Notifies listeners when a block (or connected stack of blocks) is + * created. + */ +export class BlockCreate extends BlockBase { + override type = EventType.BLOCK_CREATE; + + /** The XML representation of the created block(s). */ + xml?: Element | DocumentFragment; + + /** The JSON respresentation of the created block(s). */ + json?: blocks.State; + + /** All of the IDs of created blocks. */ + ids?: string[]; + + /** @param opt_block The created block. Undefined for a blank event. */ + constructor(opt_block?: Block) { + super(opt_block); + + if (!opt_block) { + return; // Blank event to be populated by fromJson. + } + + if (opt_block.isShadow()) { + // Moving shadow blocks is handled via disconnection. + this.recordUndo = false; + } + + this.xml = Xml.blockToDomWithXY(opt_block); + this.ids = eventUtils.getDescendantIds(opt_block); + + this.json = blocks.save(opt_block, {addCoordinates: true}) as blocks.State; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): BlockCreateJson { + const json = super.toJson() as BlockCreateJson; + if (!this.xml) { + throw new Error( + 'The block XML is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.ids) { + throw new Error( + 'The block IDs are undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.json) { + throw new Error( + 'The block JSON is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + json['xml'] = Xml.domToText(this.xml); + json['ids'] = this.ids; + json['json'] = this.json; + if (!this.recordUndo) { + json['recordUndo'] = this.recordUndo; + } + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of BlockCreate, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: BlockCreateJson, + workspace: Workspace, + event?: any, + ): BlockCreate { + const newEvent = super.fromJson( + json, + workspace, + event ?? new BlockCreate(), + ) as BlockCreate; + newEvent.xml = utilsXml.textToDom(json['xml']); + newEvent.ids = json['ids']; + newEvent.json = json['json'] as blocks.State; + if (json['recordUndo'] !== undefined) { + newEvent.recordUndo = json['recordUndo']; + } + return newEvent; + } + + /** + * Run a creation event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.json) { + throw new Error( + 'The block JSON is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.ids) { + throw new Error( + 'The block IDs are undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + if (allShadowBlocks(workspace, this.ids)) return; + if (forward) { + blocks.append(this.json, workspace); + } else { + for (let i = 0; i < this.ids.length; i++) { + const id = this.ids[i]; + const block = workspace.getBlockById(id); + if (block) { + block.dispose(false); + } else if (id === this.blockId) { + // Only complain about root-level block. + console.warn("Can't uncreate non-existent block: " + id); + } + } + } + } +} +/** + * Returns true if all blocks in the list are shadow blocks. If so, that means + * the top-level block being created is a shadow block. This only happens when a + * block that was covering up a shadow block is removed. We don't need to create + * an additional block in that case because the original block still has its + * shadow block. + * + * @param workspace Workspace to check for blocks + * @param ids A list of block ids that were created in this event + * @returns True if all block ids in the list are shadow blocks + */ +const allShadowBlocks = function ( + workspace: Workspace, + ids: string[], +): boolean { + const shadows = ids + .map((id) => workspace.getBlockById(id)) + .filter((block) => block && block.isShadow()); + return shadows.length === ids.length; +}; + +export interface BlockCreateJson extends BlockBaseJson { + xml: string; + ids: string[]; + json: object; + recordUndo?: boolean; +} + +registry.register(registry.Type.EVENT, EventType.BLOCK_CREATE, BlockCreate); diff --git a/core/events/events_block_delete.ts b/core/events/events_block_delete.ts new file mode 100644 index 00000000000..5dd23160642 --- /dev/null +++ b/core/events/events_block_delete.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for a block delete event. + * + * @class + */ +// Former goog.module ID: Blockly.Events.BlockDelete + +import type {Block} from '../block.js'; +import * as registry from '../registry.js'; +import * as blocks from '../serialization/blocks.js'; +import * as utilsXml from '../utils/xml.js'; +import {Workspace} from '../workspace.js'; +import * as Xml from '../xml.js'; +import {BlockBase, BlockBaseJson} from './events_block_base.js'; +import {EventType} from './type.js'; +import * as eventUtils from './utils.js'; + +/** + * Notifies listeners when a block (or connected stack of blocks) is + * deleted. + */ +export class BlockDelete extends BlockBase { + /** The XML representation of the deleted block(s). */ + oldXml?: Element | DocumentFragment; + + /** The JSON respresentation of the deleted block(s). */ + oldJson?: blocks.State; + + /** All of the IDs of deleted blocks. */ + ids?: string[]; + + /** True if the deleted block was a shadow block, false otherwise. */ + wasShadow?: boolean; + + override type = EventType.BLOCK_DELETE; + + /** @param opt_block The deleted block. Undefined for a blank event. */ + constructor(opt_block?: Block) { + super(opt_block); + + if (!opt_block) { + return; // Blank event to be populated by fromJson. + } + + if (opt_block.getParent()) { + throw Error('Connected blocks cannot be deleted.'); + } + if (opt_block.isShadow()) { + // Respawning shadow blocks is handled via disconnection. + this.recordUndo = false; + } + + this.oldXml = Xml.blockToDomWithXY(opt_block); + this.ids = eventUtils.getDescendantIds(opt_block); + this.wasShadow = opt_block.isShadow(); + this.oldJson = blocks.save(opt_block, { + addCoordinates: true, + }) as blocks.State; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): BlockDeleteJson { + const json = super.toJson() as BlockDeleteJson; + if (!this.oldXml) { + throw new Error( + 'The old block XML is undefined. Either pass a block ' + + 'to the constructor, or call fromJson', + ); + } + if (!this.ids) { + throw new Error( + 'The block IDs are undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + if (this.wasShadow === undefined) { + throw new Error( + 'Whether the block was a shadow is undefined. Either ' + + 'pass a block to the constructor, or call fromJson', + ); + } + if (!this.oldJson) { + throw new Error( + 'The old block JSON is undefined. Either pass a block ' + + 'to the constructor, or call fromJson', + ); + } + json['oldXml'] = Xml.domToText(this.oldXml); + json['ids'] = this.ids; + json['wasShadow'] = this.wasShadow; + json['oldJson'] = this.oldJson; + if (!this.recordUndo) { + json['recordUndo'] = this.recordUndo; + } + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of BlockDelete, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: BlockDeleteJson, + workspace: Workspace, + event?: any, + ): BlockDelete { + const newEvent = super.fromJson( + json, + workspace, + event ?? new BlockDelete(), + ) as BlockDelete; + newEvent.oldXml = utilsXml.textToDom(json['oldXml']); + newEvent.ids = json['ids']; + newEvent.wasShadow = + json['wasShadow'] || newEvent.oldXml.tagName.toLowerCase() === 'shadow'; + newEvent.oldJson = json['oldJson']; + if (json['recordUndo'] !== undefined) { + newEvent.recordUndo = json['recordUndo']; + } + return newEvent; + } + + /** + * Run a deletion event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.ids) { + throw new Error( + 'The block IDs are undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.oldJson) { + throw new Error( + 'The old block JSON is undefined. Either pass a block ' + + 'to the constructor, or call fromJson', + ); + } + if (forward) { + for (let i = 0; i < this.ids.length; i++) { + const id = this.ids[i]; + const block = workspace.getBlockById(id); + if (block) { + block.dispose(false); + } else if (id === this.blockId) { + // Only complain about root-level block. + console.warn("Can't delete non-existent block: " + id); + } + } + } else { + blocks.append(this.oldJson, workspace); + } + } +} + +export interface BlockDeleteJson extends BlockBaseJson { + oldXml: string; + ids: string[]; + wasShadow: boolean; + oldJson: blocks.State; + recordUndo?: boolean; +} + +registry.register(registry.Type.EVENT, EventType.BLOCK_DELETE, BlockDelete); diff --git a/core/events/events_block_drag.ts b/core/events/events_block_drag.ts new file mode 100644 index 00000000000..4a91c4d112d --- /dev/null +++ b/core/events/events_block_drag.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Events fired as a block drag. + * + * @class + */ +// Former goog.module ID: Blockly.Events.BlockDrag + +import type {Block} from '../block.js'; +import * as registry from '../registry.js'; +import {Workspace} from '../workspace.js'; +import {AbstractEventJson} from './events_abstract.js'; +import {UiBase} from './events_ui_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners when a block is being manually dragged/dropped. + */ +export class BlockDrag extends UiBase { + /** The ID of the top-level block being dragged. */ + blockId?: string; + + /** True if this is the start of a drag, false if this is the end of one. */ + isStart?: boolean; + + /** + * A list of all of the blocks (i.e. all descendants of the block associated + * with the block ID) being dragged. + */ + blocks?: Block[]; + + override type = EventType.BLOCK_DRAG; + + /** + * @param opt_block The top block in the stack that is being dragged. + * Undefined for a blank event. + * @param opt_isStart Whether this is the start of a block drag. + * Undefined for a blank event. + * @param opt_blocks The blocks affected by this drag. Undefined for a blank + * event. + */ + constructor(opt_block?: Block, opt_isStart?: boolean, opt_blocks?: Block[]) { + const workspaceId = opt_block ? opt_block.workspace.id : undefined; + super(workspaceId); + if (!opt_block) return; + + this.blockId = opt_block.id; + this.isStart = opt_isStart; + this.blocks = opt_blocks; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): BlockDragJson { + const json = super.toJson() as BlockDragJson; + if (this.isStart === undefined) { + throw new Error( + 'Whether this event is the start of a drag is undefined. ' + + 'Either pass the value to the constructor, or call fromJson', + ); + } + if (this.blockId === undefined) { + throw new Error( + 'The block ID is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + json['isStart'] = this.isStart; + json['blockId'] = this.blockId; + // TODO: I don't think we should actually apply the blocks array to the JSON + // object b/c they have functions and aren't actually serializable. + json['blocks'] = this.blocks; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of BlockDrag, but we can't specify that due to the fact that parameters + * to static methods in subclasses must be supertypes of parameters to + * static methods in superclasses.. + * @internal + */ + static fromJson( + json: BlockDragJson, + workspace: Workspace, + event?: any, + ): BlockDrag { + const newEvent = super.fromJson( + json, + workspace, + event ?? new BlockDrag(), + ) as BlockDrag; + newEvent.isStart = json['isStart']; + newEvent.blockId = json['blockId']; + newEvent.blocks = json['blocks']; + return newEvent; + } +} + +export interface BlockDragJson extends AbstractEventJson { + isStart: boolean; + blockId: string; + blocks?: Block[]; +} + +registry.register(registry.Type.EVENT, EventType.BLOCK_DRAG, BlockDrag); diff --git a/core/events/events_block_field_intermediate_change.ts b/core/events/events_block_field_intermediate_change.ts new file mode 100644 index 00000000000..49280cf2b4a --- /dev/null +++ b/core/events/events_block_field_intermediate_change.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for an event representing an intermediate change to a block's field's + * value. + * + * @class + */ +// Former goog.module ID: Blockly.Events.BlockFieldIntermediateChange + +import type {Block} from '../block.js'; +import * as registry from '../registry.js'; +import {Workspace} from '../workspace.js'; +import {BlockBase, BlockBaseJson} from './events_block_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners when the value of a block's field has changed but the + * change is not yet complete, and is expected to be followed by a block change + * event. + */ +export class BlockFieldIntermediateChange extends BlockBase { + override type = EventType.BLOCK_FIELD_INTERMEDIATE_CHANGE; + + // Intermediate events do not undo or redo. They may be fired frequently while + // the field editor widget is open. A separate BLOCK_CHANGE event is fired + // when the editor is closed, which combines all of the field value changes + // into a single change that is recorded in the undo history instead. The + // intermediate changes are important for reacting to immediate changes, but + // some event handlers would prefer to handle the less frequent final events, + // like when triggering workspace serialization. Technically, this method of + // grouping changes can result in undo perfoming actions out of order if some + // other event occurs between opening and closing the field editor, but such + // events are unlikely to cause a broken state. + override recordUndo = false; + + /** The name of the field that changed. */ + name?: string; + + /** The original value of the element. */ + oldValue: unknown; + + /** The new value of the element. */ + newValue: unknown; + + /** + * @param opt_block The changed block. Undefined for a blank event. + * @param opt_name Name of the field affected. + * @param opt_oldValue Previous value of element. + * @param opt_newValue New value of element. + */ + constructor( + opt_block?: Block, + opt_name?: string, + opt_oldValue?: unknown, + opt_newValue?: unknown, + ) { + super(opt_block); + if (!opt_block) { + return; // Blank event to be populated by fromJson. + } + + this.name = opt_name; + this.oldValue = opt_oldValue; + this.newValue = opt_newValue; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): BlockFieldIntermediateChangeJson { + const json = super.toJson() as BlockFieldIntermediateChangeJson; + if (!this.name) { + throw new Error( + 'The changed field name is undefined. Either pass a ' + + 'name to the constructor, or call fromJson.', + ); + } + json['name'] = this.name; + json['oldValue'] = this.oldValue; + json['newValue'] = this.newValue; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of BlockFieldIntermediateChange, but we can't specify that due to the + * fact that parameters to static methods in subclasses must be supertypes + * of parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: BlockFieldIntermediateChangeJson, + workspace: Workspace, + event?: any, + ): BlockFieldIntermediateChange { + const newEvent = super.fromJson( + json, + workspace, + event ?? new BlockFieldIntermediateChange(), + ) as BlockFieldIntermediateChange; + newEvent.name = json['name']; + newEvent.oldValue = json['oldValue']; + newEvent.newValue = json['newValue']; + return newEvent; + } + + /** + * Does this event record any change of state? + * + * @returns False if something changed. + */ + override isNull(): boolean { + return this.oldValue === this.newValue; + } + + /** + * Run a change event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.blockId) { + throw new Error( + 'The block ID is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + const block = workspace.getBlockById(this.blockId); + if (!block) { + throw new Error( + 'The associated block is undefined. Either pass a ' + + 'block to the constructor, or call fromJson', + ); + } + + const value = forward ? this.newValue : this.oldValue; + const field = block.getField(this.name!); + if (field) { + field.setValue(value); + } else { + console.warn("Can't set non-existent field: " + this.name); + } + } +} + +export interface BlockFieldIntermediateChangeJson extends BlockBaseJson { + name: string; + newValue: unknown; + oldValue: unknown; +} + +registry.register( + registry.Type.EVENT, + EventType.BLOCK_FIELD_INTERMEDIATE_CHANGE, + BlockFieldIntermediateChange, +); diff --git a/core/events/events_block_move.ts b/core/events/events_block_move.ts new file mode 100644 index 00000000000..99e1622896e --- /dev/null +++ b/core/events/events_block_move.ts @@ -0,0 +1,306 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for a block move event. + * + * @class + */ +// Former goog.module ID: Blockly.Events.BlockMove + +import type {Block} from '../block.js'; +import {ConnectionType} from '../connection_type.js'; +import * as registry from '../registry.js'; +import {Coordinate} from '../utils/coordinate.js'; +import type {Workspace} from '../workspace.js'; +import {BlockBase, BlockBaseJson} from './events_block_base.js'; +import {EventType} from './type.js'; + +interface BlockLocation { + parentId?: string; + inputName?: string; + coordinate?: Coordinate; +} + +/** + * Notifies listeners when a block is moved. This could be from one + * connection to another, or from one location on the workspace to another. + */ +export class BlockMove extends BlockBase { + override type = EventType.BLOCK_MOVE; + + /** The ID of the old parent block. Undefined if it was a top-level block. */ + oldParentId?: string; + + /** + * The name of the old input. Undefined if it was a top-level block or the + * parent's next block. + */ + oldInputName?: string; + + /** + * The old X and Y workspace coordinates of the block if it was a top level + * block. Undefined if it was not a top level block. + */ + oldCoordinate?: Coordinate; + + /** The ID of the new parent block. Undefined if it is a top-level block. */ + newParentId?: string; + + /** + * The name of the new input. Undefined if it is a top-level block or the + * parent's next block. + */ + newInputName?: string; + + /** + * The new X and Y workspace coordinates of the block if it is a top-level + * block. Undefined if it is not a top level block. + */ + newCoordinate?: Coordinate; + + /** + * An explanation of what this move is for. Known values include: + * 'drag' -- A drag operation completed. + * 'bump' -- Block got bumped away from an invalid connection. + * 'snap' -- Block got shifted to line up with the grid. + * 'inbounds' -- Block got pushed back into a non-scrolling workspace. + * 'connect' -- Block got connected to another block. + * 'disconnect' -- Block got disconnected from another block. + * 'create' -- Block created via XML. + * 'cleanup' -- Workspace aligned top-level blocks. + * Event merging may create multiple reasons: ['drag', 'bump', 'snap']. + */ + reason?: string[]; + + /** @param opt_block The moved block. Undefined for a blank event. */ + constructor(opt_block?: Block) { + super(opt_block); + + if (!opt_block) { + return; + } + // Blank event to be populated by fromJson. + if (opt_block.isShadow()) { + // Moving shadow blocks is handled via disconnection. + this.recordUndo = false; + } + + const location = this.currentLocation(); + this.oldParentId = location.parentId; + this.oldInputName = location.inputName; + this.oldCoordinate = location.coordinate; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): BlockMoveJson { + const json = super.toJson() as BlockMoveJson; + json['oldParentId'] = this.oldParentId; + json['oldInputName'] = this.oldInputName; + if (this.oldCoordinate) { + json['oldCoordinate'] = + `${Math.round(this.oldCoordinate.x)}, ` + + `${Math.round(this.oldCoordinate.y)}`; + } + json['newParentId'] = this.newParentId; + json['newInputName'] = this.newInputName; + if (this.newCoordinate) { + json['newCoordinate'] = + `${Math.round(this.newCoordinate.x)}, ` + + `${Math.round(this.newCoordinate.y)}`; + } + if (this.reason) { + json['reason'] = this.reason; + } + if (!this.recordUndo) { + json['recordUndo'] = this.recordUndo; + } + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of BlockMove, but we can't specify that due to the fact that parameters + * to static methods in subclasses must be supertypes of parameters to + * static methods in superclasses. + * @internal + */ + static fromJson( + json: BlockMoveJson, + workspace: Workspace, + event?: any, + ): BlockMove { + const newEvent = super.fromJson( + json, + workspace, + event ?? new BlockMove(), + ) as BlockMove; + newEvent.oldParentId = json['oldParentId']; + newEvent.oldInputName = json['oldInputName']; + if (json['oldCoordinate']) { + const xy = json['oldCoordinate'].split(','); + newEvent.oldCoordinate = new Coordinate(Number(xy[0]), Number(xy[1])); + } + newEvent.newParentId = json['newParentId']; + newEvent.newInputName = json['newInputName']; + if (json['newCoordinate']) { + const xy = json['newCoordinate'].split(','); + newEvent.newCoordinate = new Coordinate(Number(xy[0]), Number(xy[1])); + } + if (json['reason'] !== undefined) { + newEvent.reason = json['reason']; + } + if (json['recordUndo'] !== undefined) { + newEvent.recordUndo = json['recordUndo']; + } + return newEvent; + } + + /** Record the block's new location. Called after the move. */ + recordNew() { + const location = this.currentLocation(); + this.newParentId = location.parentId; + this.newInputName = location.inputName; + this.newCoordinate = location.coordinate; + } + + /** + * Set the reason for a move event. + * + * @param reason Why is this move happening? 'drag', 'bump', 'snap', ... + */ + setReason(reason: string[]) { + this.reason = reason; + } + + /** + * Returns the parentId and input if the block is connected, + * or the XY location if disconnected. + * + * @returns Collection of location info. + */ + private currentLocation(): BlockLocation { + const workspace = this.getEventWorkspace_(); + if (!this.blockId) { + throw new Error( + 'The block ID is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + const block = workspace.getBlockById(this.blockId); + if (!block) { + throw new Error( + 'The block associated with the block move event ' + + 'could not be found', + ); + } + const location = {} as BlockLocation; + const parent = block.getParent(); + if (parent) { + location.parentId = parent.id; + const input = parent.getInputWithBlock(block); + if (input) { + location.inputName = input.name; + } + } else { + location.coordinate = block.getRelativeToSurfaceXY(); + } + return location; + } + + /** + * Does this event record any change of state? + * + * @returns False if something changed. + */ + override isNull(): boolean { + return ( + this.oldParentId === this.newParentId && + this.oldInputName === this.newInputName && + Coordinate.equals(this.oldCoordinate, this.newCoordinate) + ); + } + + /** + * Run a move event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.blockId) { + throw new Error( + 'The block ID is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + const block = workspace.getBlockById(this.blockId); + if (!block) { + console.warn("Can't move non-existent block: " + this.blockId); + return; + } + const parentId = forward ? this.newParentId : this.oldParentId; + const inputName = forward ? this.newInputName : this.oldInputName; + const coordinate = forward ? this.newCoordinate : this.oldCoordinate; + let parentBlock: Block | null; + if (parentId) { + parentBlock = workspace.getBlockById(parentId); + if (!parentBlock) { + console.warn("Can't connect to non-existent block: " + parentId); + return; + } + } + if (block.getParent()) { + block.unplug(); + } + if (coordinate) { + const xy = block.getRelativeToSurfaceXY(); + block.moveBy(coordinate.x - xy.x, coordinate.y - xy.y, this.reason); + } else { + let blockConnection = block.outputConnection; + if ( + !blockConnection || + (block.previousConnection && block.previousConnection.isConnected()) + ) { + blockConnection = block.previousConnection; + } + let parentConnection; + const connectionType = blockConnection?.type; + if (inputName) { + const input = parentBlock!.getInput(inputName); + if (input) { + parentConnection = input.connection; + } + } else if (connectionType === ConnectionType.PREVIOUS_STATEMENT) { + parentConnection = parentBlock!.nextConnection; + } + if (parentConnection && blockConnection) { + blockConnection.connect(parentConnection); + } else { + console.warn("Can't connect to non-existent input: " + inputName); + } + } + } +} + +export interface BlockMoveJson extends BlockBaseJson { + oldParentId?: string; + oldInputName?: string; + oldCoordinate?: string; + newParentId?: string; + newInputName?: string; + newCoordinate?: string; + reason?: string[]; + recordUndo?: boolean; +} + +registry.register(registry.Type.EVENT, EventType.BLOCK_MOVE, BlockMove); diff --git a/core/events/events_bubble_open.ts b/core/events/events_bubble_open.ts new file mode 100644 index 00000000000..a36bbcd6a93 --- /dev/null +++ b/core/events/events_bubble_open.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Events fired as a result of bubble open. + * + * @class + */ + +// Former goog.module ID: Blockly.Events.BubbleOpen + +import type {BlockSvg} from '../block_svg.js'; +import * as registry from '../registry.js'; +import type {Workspace} from '../workspace.js'; +import type {AbstractEventJson} from './events_abstract.js'; +import {UiBase} from './events_ui_base.js'; +import {EventType} from './type.js'; + +/** + * Class for a bubble open event. + */ +export class BubbleOpen extends UiBase { + /** The ID of the block the bubble is attached to. */ + blockId?: string; + + /** True if the bubble is opening, false if closing. */ + isOpen?: boolean; + + /** The type of bubble; one of 'mutator', 'comment', or 'warning'. */ + bubbleType?: BubbleType; + + override type = EventType.BUBBLE_OPEN; + + /** + * @param opt_block The associated block. Undefined for a blank event. + * @param opt_isOpen Whether the bubble is opening (false if closing). + * Undefined for a blank event. + * @param opt_bubbleType The type of bubble. One of 'mutator', 'comment' or + * 'warning'. Undefined for a blank event. + */ + constructor( + opt_block?: BlockSvg, + opt_isOpen?: boolean, + opt_bubbleType?: BubbleType, + ) { + const workspaceId = opt_block ? opt_block.workspace.id : undefined; + super(workspaceId); + if (!opt_block) return; + + this.blockId = opt_block.id; + this.isOpen = opt_isOpen; + this.bubbleType = opt_bubbleType; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): BubbleOpenJson { + const json = super.toJson() as BubbleOpenJson; + if (this.isOpen === undefined) { + throw new Error( + 'Whether this event is for opening the bubble is undefined. ' + + 'Either pass the value to the constructor, or call fromJson', + ); + } + if (!this.bubbleType) { + throw new Error( + 'The type of bubble is undefined. Either pass the ' + + 'value to the constructor, or call fromJson', + ); + } + json['isOpen'] = this.isOpen; + json['bubbleType'] = this.bubbleType; + json['blockId'] = this.blockId || ''; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of BubbleOpen, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: BubbleOpenJson, + workspace: Workspace, + event?: any, + ): BubbleOpen { + const newEvent = super.fromJson( + json, + workspace, + event ?? new BubbleOpen(), + ) as BubbleOpen; + newEvent.isOpen = json['isOpen']; + newEvent.bubbleType = json['bubbleType']; + newEvent.blockId = json['blockId']; + return newEvent; + } +} + +export enum BubbleType { + MUTATOR = 'mutator', + COMMENT = 'comment', + WARNING = 'warning', +} + +export interface BubbleOpenJson extends AbstractEventJson { + isOpen: boolean; + bubbleType: BubbleType; + blockId: string; +} + +registry.register(registry.Type.EVENT, EventType.BUBBLE_OPEN, BubbleOpen); diff --git a/core/events/events_click.ts b/core/events/events_click.ts new file mode 100644 index 00000000000..c023f20f152 --- /dev/null +++ b/core/events/events_click.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Events fired as a result of UI click in Blockly's editor. + * + * @class + */ + +// Former goog.module ID: Blockly.Events.Click + +import type {Block} from '../block.js'; +import * as registry from '../registry.js'; +import {Workspace} from '../workspace.js'; +import {AbstractEventJson} from './events_abstract.js'; +import {UiBase} from './events_ui_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that some blockly element was clicked. + */ +export class Click extends UiBase { + /** The ID of the block that was clicked, if a block was clicked. */ + blockId?: string; + + /** + * The type of element that was clicked; one of 'block', 'workspace', + * or 'zoom_controls'. + */ + targetType?: ClickTarget; + override type = EventType.CLICK; + + /** + * @param opt_block The affected block. Null for click events that do not have + * an associated block (i.e. workspace click). Undefined for a blank + * event. + * @param opt_workspaceId The workspace identifier for this event. + * Not used if block is passed. Undefined for a blank event. + * @param opt_targetType The type of element targeted by this click event. + * Undefined for a blank event. + */ + constructor( + opt_block?: Block | null, + opt_workspaceId?: string | null, + opt_targetType?: ClickTarget, + ) { + let workspaceId = opt_block ? opt_block.workspace.id : opt_workspaceId; + if (workspaceId === null) { + workspaceId = undefined; + } + super(workspaceId); + + this.blockId = opt_block ? opt_block.id : undefined; + this.targetType = opt_targetType; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): ClickJson { + const json = super.toJson() as ClickJson; + if (!this.targetType) { + throw new Error( + 'The click target type is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + json['targetType'] = this.targetType; + json['blockId'] = this.blockId; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of Click, but we can't specify that due to the fact that parameters to + * static methods in subclasses must be supertypes of parameters to + * static methods in superclasses. + * @internal + */ + static fromJson(json: ClickJson, workspace: Workspace, event?: any): Click { + const newEvent = super.fromJson( + json, + workspace, + event ?? new Click(), + ) as Click; + newEvent.targetType = json['targetType']; + newEvent.blockId = json['blockId']; + return newEvent; + } +} + +export enum ClickTarget { + BLOCK = 'block', + WORKSPACE = 'workspace', + ZOOM_CONTROLS = 'zoom_controls', +} + +export interface ClickJson extends AbstractEventJson { + targetType: ClickTarget; + blockId?: string; +} + +registry.register(registry.Type.EVENT, EventType.CLICK, Click); diff --git a/core/events/events_comment_base.ts b/core/events/events_comment_base.ts new file mode 100644 index 00000000000..e4b76c8e547 --- /dev/null +++ b/core/events/events_comment_base.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Base class for comment events. + * + * @class + */ +// Former goog.module ID: Blockly.Events.CommentBase + +import type {WorkspaceComment} from '../comments/workspace_comment.js'; +import * as comments from '../serialization/workspace_comments.js'; +import type {Workspace} from '../workspace.js'; +import { + Abstract as AbstractEvent, + AbstractEventJson, +} from './events_abstract.js'; +import type {CommentCreate} from './events_comment_create.js'; +import type {CommentDelete} from './events_comment_delete.js'; +import {getGroup, getRecordUndo} from './utils.js'; + +/** + * Abstract class for a comment event. + */ +export class CommentBase extends AbstractEvent { + override isBlank = true; + + /** The ID of the comment that this event references. */ + commentId?: string; + + /** + * @param opt_comment The comment this event corresponds to. Undefined for a + * blank event. + */ + constructor(opt_comment?: WorkspaceComment) { + super(); + /** Whether or not an event is blank. */ + this.isBlank = !opt_comment; + + if (!opt_comment) return; + + this.commentId = opt_comment.id; + this.workspaceId = opt_comment.workspace.id; + this.group = getGroup(); + this.recordUndo = getRecordUndo(); + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): CommentBaseJson { + const json = super.toJson() as CommentBaseJson; + if (!this.commentId) { + throw new Error( + 'The comment ID is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + json['commentId'] = this.commentId; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of CommentBase, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: CommentBaseJson, + workspace: Workspace, + event?: any, + ): CommentBase { + const newEvent = super.fromJson( + json, + workspace, + event ?? new CommentBase(), + ) as CommentBase; + newEvent.commentId = json['commentId']; + return newEvent; + } + + /** + * Helper function for Comment[Create|Delete] + * + * @param event The event to run. + * @param create if True then Create, if False then Delete + */ + static CommentCreateDeleteHelper( + event: CommentCreate | CommentDelete, + create: boolean, + ) { + const workspace = event.getEventWorkspace_(); + if (create) { + if (!event.json) { + throw new Error('Encountered a comment event without proper json'); + } + comments.append(event.json, workspace); + } else { + if (!event.commentId) { + throw new Error( + 'The comment ID is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + const comment = workspace.getCommentById(event.commentId); + if (comment) { + comment.dispose(); + } else { + console.warn("Can't delete non-existent comment: " + event.commentId); + } + } + } +} + +export interface CommentBaseJson extends AbstractEventJson { + commentId: string; +} diff --git a/core/events/events_comment_change.ts b/core/events/events_comment_change.ts new file mode 100644 index 00000000000..4d944ea39af --- /dev/null +++ b/core/events/events_comment_change.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for comment change event. + * + * @class + */ +// Former goog.module ID: Blockly.Events.CommentChange + +import type {WorkspaceComment} from '../comments/workspace_comment.js'; +import * as registry from '../registry.js'; +import type {Workspace} from '../workspace.js'; +import {CommentBase, CommentBaseJson} from './events_comment_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that the contents of a workspace comment has changed. + */ +export class CommentChange extends CommentBase { + override type = EventType.COMMENT_CHANGE; + + // TODO(#6774): We should remove underscores. + /** The previous contents of the comment. */ + oldContents_?: string; + + /** The new contents of the comment. */ + newContents_?: string; + + /** + * @param opt_comment The comment that is being changed. Undefined for a + * blank event. + * @param opt_oldContents Previous contents of the comment. + * @param opt_newContents New contents of the comment. + */ + constructor( + opt_comment?: WorkspaceComment, + opt_oldContents?: string, + opt_newContents?: string, + ) { + super(opt_comment); + + if (!opt_comment) { + return; // Blank event to be populated by fromJson. + } + + this.oldContents_ = + typeof opt_oldContents === 'undefined' ? '' : opt_oldContents; + this.newContents_ = + typeof opt_newContents === 'undefined' ? '' : opt_newContents; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): CommentChangeJson { + const json = super.toJson() as CommentChangeJson; + if (!this.oldContents_) { + throw new Error( + 'The old contents is undefined. Either pass a value to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.newContents_) { + throw new Error( + 'The new contents is undefined. Either pass a value to ' + + 'the constructor, or call fromJson', + ); + } + json['oldContents'] = this.oldContents_; + json['newContents'] = this.newContents_; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of CommentChange, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: CommentChangeJson, + workspace: Workspace, + event?: any, + ): CommentChange { + const newEvent = super.fromJson( + json, + workspace, + event ?? new CommentChange(), + ) as CommentChange; + newEvent.oldContents_ = json['oldContents']; + newEvent.newContents_ = json['newContents']; + return newEvent; + } + + /** + * Does this event record any change of state? + * + * @returns False if something changed. + */ + override isNull(): boolean { + return this.oldContents_ === this.newContents_; + } + + /** + * Run a change event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.commentId) { + throw new Error( + 'The comment ID is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + // TODO: Remove the cast when we fix the type of getCommentById. + const comment = workspace.getCommentById( + this.commentId, + ) as unknown as WorkspaceComment; + if (!comment) { + console.warn("Can't change non-existent comment: " + this.commentId); + return; + } + const contents = forward ? this.newContents_ : this.oldContents_; + if (contents === undefined) { + if (forward) { + throw new Error( + 'The new contents is undefined. Either pass a value to ' + + 'the constructor, or call fromJson', + ); + } + throw new Error( + 'The old contents is undefined. Either pass a value to ' + + 'the constructor, or call fromJson', + ); + } + comment.setText(contents); + } +} + +export interface CommentChangeJson extends CommentBaseJson { + oldContents: string; + newContents: string; +} + +registry.register(registry.Type.EVENT, EventType.COMMENT_CHANGE, CommentChange); diff --git a/core/events/events_comment_collapse.ts b/core/events/events_comment_collapse.ts new file mode 100644 index 00000000000..0f718a040bf --- /dev/null +++ b/core/events/events_comment_collapse.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {WorkspaceComment} from '../comments/workspace_comment.js'; +import * as registry from '../registry.js'; +import type {Workspace} from '../workspace.js'; +import {CommentBase, CommentBaseJson} from './events_comment_base.js'; +import {EventType} from './type.js'; + +export class CommentCollapse extends CommentBase { + override type = EventType.COMMENT_COLLAPSE; + + constructor( + comment?: WorkspaceComment, + public newCollapsed?: boolean, + ) { + super(comment); + + if (!comment) { + return; // Blank event to be populated by fromJson. + } + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): CommentCollapseJson { + const json = super.toJson() as CommentCollapseJson; + if (this.newCollapsed === undefined) { + throw new Error( + 'The new collapse value undefined. Either call recordNew, or ' + + 'call fromJson', + ); + } + json['newCollapsed'] = this.newCollapsed; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of CommentCollapse, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: CommentCollapseJson, + workspace: Workspace, + event?: any, + ): CommentCollapse { + const newEvent = super.fromJson( + json, + workspace, + event ?? new CommentCollapse(), + ) as CommentCollapse; + newEvent.newCollapsed = json.newCollapsed; + return newEvent; + } + + /** + * Run a collapse event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.commentId) { + throw new Error( + 'The comment ID is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + // TODO: Remove cast when we update getCommentById. + const comment = workspace.getCommentById( + this.commentId, + ) as unknown as WorkspaceComment; + if (!comment) { + console.warn( + "Can't collapse or uncollapse non-existent comment: " + this.commentId, + ); + return; + } + + comment.setCollapsed(forward ? !!this.newCollapsed : !this.newCollapsed); + } +} + +export interface CommentCollapseJson extends CommentBaseJson { + newCollapsed: boolean; +} + +registry.register( + registry.Type.EVENT, + EventType.COMMENT_COLLAPSE, + CommentCollapse, +); diff --git a/core/events/events_comment_create.ts b/core/events/events_comment_create.ts new file mode 100644 index 00000000000..637107e3f55 --- /dev/null +++ b/core/events/events_comment_create.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for comment creation event. + * + * @class + */ +// Former goog.module ID: Blockly.Events.CommentCreate + +import type {WorkspaceComment} from '../comments/workspace_comment.js'; +import * as registry from '../registry.js'; +import * as comments from '../serialization/workspace_comments.js'; +import * as utilsXml from '../utils/xml.js'; +import type {Workspace} from '../workspace.js'; +import * as Xml from '../xml.js'; +import {CommentBase, CommentBaseJson} from './events_comment_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that a workspace comment was created. + */ +export class CommentCreate extends CommentBase { + override type = EventType.COMMENT_CREATE; + + /** The XML representation of the created workspace comment. */ + xml?: Element | DocumentFragment; + + /** The JSON representation of the created workspace comment. */ + json?: comments.State; + + /** + * @param opt_comment The created comment. + * Undefined for a blank event. + */ + constructor(opt_comment?: WorkspaceComment) { + super(opt_comment); + + if (!opt_comment) { + return; // Blank event to be populated by fromJson. + } + + this.xml = Xml.saveWorkspaceComment(opt_comment); + this.json = comments.save(opt_comment, {addCoordinates: true}); + } + + // TODO (#1266): "Full" and "minimal" serialization. + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): CommentCreateJson { + const json = super.toJson() as CommentCreateJson; + if (!this.xml) { + throw new Error( + 'The comment XML is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.json) { + throw new Error( + 'The comment JSON is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + json['xml'] = Xml.domToText(this.xml); + json['json'] = this.json; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of CommentCreate, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: CommentCreateJson, + workspace: Workspace, + event?: any, + ): CommentCreate { + const newEvent = super.fromJson( + json, + workspace, + event ?? new CommentCreate(), + ) as CommentCreate; + newEvent.xml = utilsXml.textToDom(json['xml']); + newEvent.json = json['json']; + return newEvent; + } + + /** + * Run a creation event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + CommentBase.CommentCreateDeleteHelper(this, forward); + } +} + +export interface CommentCreateJson extends CommentBaseJson { + xml: string; + json: object; +} + +registry.register(registry.Type.EVENT, EventType.COMMENT_CREATE, CommentCreate); diff --git a/core/events/events_comment_delete.ts b/core/events/events_comment_delete.ts new file mode 100644 index 00000000000..579131e5033 --- /dev/null +++ b/core/events/events_comment_delete.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for comment deletion event. + * + * @class + */ +// Former goog.module ID: Blockly.Events.CommentDelete + +import type {WorkspaceComment} from '../comments/workspace_comment.js'; +import * as registry from '../registry.js'; +import * as comments from '../serialization/workspace_comments.js'; +import * as utilsXml from '../utils/xml.js'; +import type {Workspace} from '../workspace.js'; +import * as Xml from '../xml.js'; +import {CommentBase, CommentBaseJson} from './events_comment_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that a workspace comment has been deleted. + */ +export class CommentDelete extends CommentBase { + override type = EventType.COMMENT_DELETE; + + /** The XML representation of the deleted workspace comment. */ + xml?: Element; + + /** The JSON representation of the created workspace comment. */ + json?: comments.State; + + /** + * @param opt_comment The deleted comment. + * Undefined for a blank event. + */ + constructor(opt_comment?: WorkspaceComment) { + super(opt_comment); + + if (!opt_comment) { + return; // Blank event to be populated by fromJson. + } + + this.xml = Xml.saveWorkspaceComment(opt_comment); + this.json = comments.save(opt_comment, {addCoordinates: true}); + } + + /** + * Run a creation event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + CommentBase.CommentCreateDeleteHelper(this, !forward); + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): CommentDeleteJson { + const json = super.toJson() as CommentDeleteJson; + if (!this.xml) { + throw new Error( + 'The comment XML is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.json) { + throw new Error( + 'The comment JSON is undefined. Either pass a block to ' + + 'the constructor, or call fromJson', + ); + } + json['xml'] = Xml.domToText(this.xml); + json['json'] = this.json; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of CommentDelete, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: CommentDeleteJson, + workspace: Workspace, + event?: any, + ): CommentDelete { + const newEvent = super.fromJson( + json, + workspace, + event ?? new CommentDelete(), + ) as CommentDelete; + newEvent.xml = utilsXml.textToDom(json['xml']); + newEvent.json = json['json']; + return newEvent; + } +} + +export interface CommentDeleteJson extends CommentBaseJson { + xml: string; + json: object; +} + +registry.register(registry.Type.EVENT, EventType.COMMENT_DELETE, CommentDelete); diff --git a/core/events/events_comment_drag.ts b/core/events/events_comment_drag.ts new file mode 100644 index 00000000000..b25ca5b7382 --- /dev/null +++ b/core/events/events_comment_drag.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Events fired when a workspace comment is dragged. + */ + +import type {WorkspaceComment} from '../comments/workspace_comment.js'; +import * as registry from '../registry.js'; +import {Workspace} from '../workspace.js'; +import {AbstractEventJson} from './events_abstract.js'; +import {UiBase} from './events_ui_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners when a comment is being manually dragged/dropped. + */ +export class CommentDrag extends UiBase { + /** The ID of the top-level comment being dragged. */ + commentId?: string; + + /** True if this is the start of a drag, false if this is the end of one. */ + isStart?: boolean; + + override type = EventType.COMMENT_DRAG; + + /** + * @param opt_comment The comment that is being dragged. + * Undefined for a blank event. + * @param opt_isStart Whether this is the start of a comment drag. + * Undefined for a blank event. + */ + constructor(opt_comment?: WorkspaceComment, opt_isStart?: boolean) { + const workspaceId = opt_comment ? opt_comment.workspace.id : undefined; + super(workspaceId); + if (!opt_comment) return; + + this.commentId = opt_comment.id; + this.isStart = opt_isStart; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): CommentDragJson { + const json = super.toJson() as CommentDragJson; + if (this.isStart === undefined) { + throw new Error( + 'Whether this event is the start of a drag is undefined. ' + + 'Either pass the value to the constructor, or call fromJson', + ); + } + if (this.commentId === undefined) { + throw new Error( + 'The comment ID is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + json['isStart'] = this.isStart; + json['commentId'] = this.commentId; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of CommentDrag, but we can't specify that due to the fact that parameters + * to static methods in subclasses must be supertypes of parameters to + * static methods in superclasses. + * @internal + */ + static fromJson( + json: CommentDragJson, + workspace: Workspace, + event?: any, + ): CommentDrag { + const newEvent = super.fromJson( + json, + workspace, + event ?? new CommentDrag(), + ) as CommentDrag; + newEvent.isStart = json['isStart']; + newEvent.commentId = json['commentId']; + return newEvent; + } +} + +export interface CommentDragJson extends AbstractEventJson { + isStart: boolean; + commentId: string; +} + +registry.register(registry.Type.EVENT, EventType.COMMENT_DRAG, CommentDrag); diff --git a/core/events/events_comment_move.ts b/core/events/events_comment_move.ts new file mode 100644 index 00000000000..af5e336165d --- /dev/null +++ b/core/events/events_comment_move.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for comment move event. + * + * @class + */ +// Former goog.module ID: Blockly.Events.CommentMove + +import type {WorkspaceComment} from '../comments/workspace_comment.js'; +import * as registry from '../registry.js'; +import {Coordinate} from '../utils/coordinate.js'; +import type {Workspace} from '../workspace.js'; +import {CommentBase, CommentBaseJson} from './events_comment_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that a workspace comment has moved. + */ +export class CommentMove extends CommentBase { + override type = EventType.COMMENT_MOVE; + + /** The comment that is being moved. */ + comment_?: WorkspaceComment; + + // TODO(#6774): We should remove underscores. + /** The location of the comment before the move, in workspace coordinates. */ + oldCoordinate_?: Coordinate; + + /** The location of the comment after the move, in workspace coordinates. */ + newCoordinate_?: Coordinate; + + /** + * An explanation of what this move is for. Known values include: + * 'drag' -- A drag operation completed. + * 'snap' -- Comment got shifted to line up with the grid. + * 'inbounds' -- Block got pushed back into a non-scrolling workspace. + * 'create' -- Block created via deserialization. + * 'cleanup' -- Workspace aligned top-level blocks. + * Event merging may create multiple reasons: ['drag', 'inbounds', 'snap']. + */ + reason?: string[]; + + /** + * @param opt_comment The comment that is being moved. Undefined for a blank + * event. + */ + constructor(opt_comment?: WorkspaceComment) { + super(opt_comment); + + if (!opt_comment) { + return; // Blank event to be populated by fromJson. + } + + this.comment_ = opt_comment; + this.oldCoordinate_ = opt_comment.getRelativeToSurfaceXY(); + } + + /** + * Record the comment's new location. Called after the move. Can only be + * called once. + */ + recordNew() { + if (this.newCoordinate_) { + throw Error( + 'Tried to record the new position of a comment on the ' + + 'same event twice.', + ); + } + if (!this.comment_) { + throw new Error( + 'The comment is undefined. Pass a comment to ' + + 'the constructor if you want to use the record functionality', + ); + } + this.newCoordinate_ = this.comment_.getRelativeToSurfaceXY(); + } + + /** + * Sets the reason for a move event. + * + * @param reason Why is this move happening? 'drag', 'bump', 'snap', ... + */ + setReason(reason: string[]) { + this.reason = reason; + } + + /** + * Override the location before the move. Use this if you don't create the + * event until the end of the move, but you know the original location. + * + * @param xy The location before the move, in workspace coordinates. + */ + setOldCoordinate(xy: Coordinate) { + this.oldCoordinate_ = xy; + } + + // TODO (#1266): "Full" and "minimal" serialization. + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): CommentMoveJson { + const json = super.toJson() as CommentMoveJson; + if (!this.oldCoordinate_) { + throw new Error( + 'The old comment position is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.newCoordinate_) { + throw new Error( + 'The new comment position is undefined. Either call recordNew, or ' + + 'call fromJson', + ); + } + json['oldCoordinate'] = + `${Math.round(this.oldCoordinate_.x)}, ` + + `${Math.round(this.oldCoordinate_.y)}`; + json['newCoordinate'] = + Math.round(this.newCoordinate_.x) + + ',' + + Math.round(this.newCoordinate_.y); + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of CommentMove, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: CommentMoveJson, + workspace: Workspace, + event?: any, + ): CommentMove { + const newEvent = super.fromJson( + json, + workspace, + event ?? new CommentMove(), + ) as CommentMove; + let xy = json['oldCoordinate'].split(','); + newEvent.oldCoordinate_ = new Coordinate(Number(xy[0]), Number(xy[1])); + xy = json['newCoordinate'].split(','); + newEvent.newCoordinate_ = new Coordinate(Number(xy[0]), Number(xy[1])); + return newEvent; + } + + /** + * Does this event record any change of state? + * + * @returns False if something changed. + */ + override isNull(): boolean { + return Coordinate.equals(this.oldCoordinate_, this.newCoordinate_); + } + + /** + * Run a move event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.commentId) { + throw new Error( + 'The comment ID is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + // TODO: Remove cast when we update getCommentById. + const comment = workspace.getCommentById( + this.commentId, + ) as unknown as WorkspaceComment; + if (!comment) { + console.warn("Can't move non-existent comment: " + this.commentId); + return; + } + + const target = forward ? this.newCoordinate_ : this.oldCoordinate_; + if (!target) { + throw new Error( + 'Either oldCoordinate_ or newCoordinate_ is undefined. ' + + 'Either pass a comment to the constructor and call recordNew, ' + + 'or call fromJson', + ); + } + comment.moveTo(target); + } +} + +export interface CommentMoveJson extends CommentBaseJson { + oldCoordinate: string; + newCoordinate: string; +} + +registry.register(registry.Type.EVENT, EventType.COMMENT_MOVE, CommentMove); diff --git a/core/events/events_comment_resize.ts b/core/events/events_comment_resize.ts new file mode 100644 index 00000000000..0c59177d9c4 --- /dev/null +++ b/core/events/events_comment_resize.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for comment resize event. + */ + +import type {WorkspaceComment} from '../comments/workspace_comment.js'; +import * as registry from '../registry.js'; +import {Size} from '../utils/size.js'; +import type {Workspace} from '../workspace.js'; +import {CommentBase, CommentBaseJson} from './events_comment_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that a workspace comment has resized. + */ +export class CommentResize extends CommentBase { + override type = EventType.COMMENT_RESIZE; + + /** The size of the comment before the resize. */ + oldSize?: Size; + + /** The size of the comment after the resize. */ + newSize?: Size; + + /** + * @param opt_comment The comment that is being resized. Undefined for a blank + * event. + */ + constructor(opt_comment?: WorkspaceComment) { + super(opt_comment); + + if (!opt_comment) { + return; // Blank event to be populated by fromJson. + } + + this.oldSize = opt_comment.getSize(); + } + + /** + * Record the comment's new size. Called after the resize. Can only be + * called once. + */ + recordCurrentSizeAsNewSize() { + if (this.newSize) { + throw Error( + 'Tried to record the new size of a comment on the ' + + 'same event twice.', + ); + } + const workspace = this.getEventWorkspace_(); + if (!this.commentId) { + throw new Error( + 'The comment ID is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + const comment = workspace.getCommentById(this.commentId); + if (!comment) { + throw new Error( + 'The comment associated with the comment resize event ' + + 'could not be found', + ); + } + this.newSize = comment.getSize(); + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): CommentResizeJson { + const json = super.toJson() as CommentResizeJson; + if (!this.oldSize) { + throw new Error( + 'The old comment size is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.newSize) { + throw new Error( + 'The new comment size is undefined. Either call ' + + 'recordCurrentSizeAsNewSize, or call fromJson', + ); + } + json['oldWidth'] = Math.round(this.oldSize.width); + json['oldHeight'] = Math.round(this.oldSize.height); + json['newWidth'] = Math.round(this.newSize.width); + json['newHeight'] = Math.round(this.newSize.height); + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of CommentResize, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: CommentResizeJson, + workspace: Workspace, + event?: any, + ): CommentResize { + const newEvent = super.fromJson( + json, + workspace, + event ?? new CommentResize(), + ) as CommentResize; + newEvent.oldSize = new Size(json['oldWidth'], json['oldHeight']); + newEvent.newSize = new Size(json['newWidth'], json['newHeight']); + return newEvent; + } + + /** + * Does this event record any change of state? + * + * @returns False if something changed. + */ + override isNull(): boolean { + return Size.equals(this.oldSize, this.newSize); + } + + /** + * Run a resize event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.commentId) { + throw new Error( + 'The comment ID is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + const comment = workspace.getCommentById(this.commentId); + if (!comment) { + console.warn("Can't resize non-existent comment: " + this.commentId); + return; + } + + const size = forward ? this.newSize : this.oldSize; + if (!size) { + throw new Error( + 'Either oldSize or newSize is undefined. ' + + 'Either pass a comment to the constructor and call ' + + 'recordCurrentSizeAsNewSize, or call fromJson', + ); + } + comment.setSize(size); + } +} + +export interface CommentResizeJson extends CommentBaseJson { + oldWidth: number; + oldHeight: number; + newWidth: number; + newHeight: number; +} + +registry.register(registry.Type.EVENT, EventType.COMMENT_RESIZE, CommentResize); diff --git a/core/events/events_selected.ts b/core/events/events_selected.ts new file mode 100644 index 00000000000..e4a7774966b --- /dev/null +++ b/core/events/events_selected.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Events fired as a result of element select action. + * + * @class + */ +// Former goog.module ID: Blockly.Events.Selected + +import * as registry from '../registry.js'; +import type {Workspace} from '../workspace.js'; +import {AbstractEventJson} from './events_abstract.js'; +import {UiBase} from './events_ui_base.js'; +import {EventType} from './type.js'; + +/** + * Class for a selected event. + * Notifies listeners that a new element has been selected. + */ +export class Selected extends UiBase { + /** The id of the last selected selectable element. */ + oldElementId?: string; + + /** + * The id of the newly selected selectable element, + * or undefined if unselected. + */ + newElementId?: string; + + override type = EventType.SELECTED; + + /** + * @param opt_oldElementId The ID of the previously selected element. Null if + * no element last selected. Undefined for a blank event. + * @param opt_newElementId The ID of the selected element. Null if no element + * currently selected (deselect). Undefined for a blank event. + * @param opt_workspaceId The workspace identifier for this event. + * Null if no element previously selected. Undefined for a blank event. + */ + constructor( + opt_oldElementId?: string | null, + opt_newElementId?: string | null, + opt_workspaceId?: string, + ) { + super(opt_workspaceId); + + this.oldElementId = opt_oldElementId ?? undefined; + this.newElementId = opt_newElementId ?? undefined; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): SelectedJson { + const json = super.toJson() as SelectedJson; + json['oldElementId'] = this.oldElementId; + json['newElementId'] = this.newElementId; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of Selected, but we can't specify that due to the fact that parameters + * to static methods in subclasses must be supertypes of parameters to + * static methods in superclasses. + * @internal + */ + static fromJson( + json: SelectedJson, + workspace: Workspace, + event?: any, + ): Selected { + const newEvent = super.fromJson( + json, + workspace, + event ?? new Selected(), + ) as Selected; + newEvent.oldElementId = json['oldElementId']; + newEvent.newElementId = json['newElementId']; + return newEvent; + } +} + +export interface SelectedJson extends AbstractEventJson { + oldElementId?: string; + newElementId?: string; +} + +registry.register(registry.Type.EVENT, EventType.SELECTED, Selected); diff --git a/core/events/events_theme_change.ts b/core/events/events_theme_change.ts new file mode 100644 index 00000000000..b142b9f148b --- /dev/null +++ b/core/events/events_theme_change.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Events fired as a result of a theme update. + * + * @class + */ +// Former goog.module ID: Blockly.Events.ThemeChange + +import * as registry from '../registry.js'; +import type {Workspace} from '../workspace.js'; +import {AbstractEventJson} from './events_abstract.js'; +import {UiBase} from './events_ui_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that the workspace theme has changed. + */ +export class ThemeChange extends UiBase { + /** The name of the new theme that has been set. */ + themeName?: string; + + override type = EventType.THEME_CHANGE; + + /** + * @param opt_themeName The theme name. Undefined for a blank event. + * @param opt_workspaceId The workspace identifier for this event. + * event. Undefined for a blank event. + */ + constructor(opt_themeName?: string, opt_workspaceId?: string) { + super(opt_workspaceId); + this.themeName = opt_themeName; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): ThemeChangeJson { + const json = super.toJson() as ThemeChangeJson; + if (!this.themeName) { + throw new Error( + 'The theme name is undefined. Either pass a theme name to ' + + 'the constructor, or call fromJson', + ); + } + json['themeName'] = this.themeName; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of ThemeChange, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: ThemeChangeJson, + workspace: Workspace, + event?: any, + ): ThemeChange { + const newEvent = super.fromJson( + json, + workspace, + event ?? new ThemeChange(), + ) as ThemeChange; + newEvent.themeName = json['themeName']; + return newEvent; + } +} + +export interface ThemeChangeJson extends AbstractEventJson { + themeName: string; +} + +registry.register(registry.Type.EVENT, EventType.THEME_CHANGE, ThemeChange); diff --git a/core/events/events_toolbox_item_select.ts b/core/events/events_toolbox_item_select.ts new file mode 100644 index 00000000000..6a93dbfde2f --- /dev/null +++ b/core/events/events_toolbox_item_select.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Events fired as a result of selecting an item on the toolbox. + * + * @class + */ +// Former goog.module ID: Blockly.Events.ToolboxItemSelect + +import * as registry from '../registry.js'; +import type {Workspace} from '../workspace.js'; +import {AbstractEventJson} from './events_abstract.js'; +import {UiBase} from './events_ui_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that a toolbox item has been selected. + */ +export class ToolboxItemSelect extends UiBase { + /** The previously selected toolbox item. */ + oldItem?: string; + + /** The newly selected toolbox item. */ + newItem?: string; + + override type = EventType.TOOLBOX_ITEM_SELECT; + + /** + * @param opt_oldItem The previously selected toolbox item. + * Undefined for a blank event. + * @param opt_newItem The newly selected toolbox item. Undefined for a blank + * event. + * @param opt_workspaceId The workspace identifier for this event. + * Undefined for a blank event. + */ + constructor( + opt_oldItem?: string | null, + opt_newItem?: string | null, + opt_workspaceId?: string, + ) { + super(opt_workspaceId); + this.oldItem = opt_oldItem ?? undefined; + this.newItem = opt_newItem ?? undefined; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): ToolboxItemSelectJson { + const json = super.toJson() as ToolboxItemSelectJson; + json['oldItem'] = this.oldItem; + json['newItem'] = this.newItem; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of ToolboxItemSelect, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: ToolboxItemSelectJson, + workspace: Workspace, + event?: any, + ): ToolboxItemSelect { + const newEvent = super.fromJson( + json, + workspace, + event ?? new ToolboxItemSelect(), + ) as ToolboxItemSelect; + newEvent.oldItem = json['oldItem']; + newEvent.newItem = json['newItem']; + return newEvent; + } +} + +export interface ToolboxItemSelectJson extends AbstractEventJson { + oldItem?: string; + newItem?: string; +} + +registry.register( + registry.Type.EVENT, + EventType.TOOLBOX_ITEM_SELECT, + ToolboxItemSelect, +); diff --git a/core/events/events_trashcan_open.ts b/core/events/events_trashcan_open.ts new file mode 100644 index 00000000000..af06d9f8f4f --- /dev/null +++ b/core/events/events_trashcan_open.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Events fired as a result of trashcan flyout open and close. + * + * @class + */ +// Former goog.module ID: Blockly.Events.TrashcanOpen + +import * as registry from '../registry.js'; +import type {Workspace} from '../workspace.js'; +import {AbstractEventJson} from './events_abstract.js'; +import {UiBase} from './events_ui_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners when the trashcan is opening or closing. + */ +export class TrashcanOpen extends UiBase { + /** + * True if the trashcan is currently opening (previously closed). + * False if it is currently closing (previously open). + */ + isOpen?: boolean; + override type = EventType.TRASHCAN_OPEN; + + /** + * @param opt_isOpen Whether the trashcan flyout is opening (false if + * opening). Undefined for a blank event. + * @param opt_workspaceId The workspace identifier for this event. + * Undefined for a blank event. + */ + constructor(opt_isOpen?: boolean, opt_workspaceId?: string) { + super(opt_workspaceId); + this.isOpen = opt_isOpen; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): TrashcanOpenJson { + const json = super.toJson() as TrashcanOpenJson; + if (this.isOpen === undefined) { + throw new Error( + 'Whether this is already open or not is undefined. Either pass ' + + 'a value to the constructor, or call fromJson', + ); + } + json['isOpen'] = this.isOpen; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of TrashcanOpen, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: TrashcanOpenJson, + workspace: Workspace, + event?: any, + ): TrashcanOpen { + const newEvent = super.fromJson( + json, + workspace, + event ?? new TrashcanOpen(), + ) as TrashcanOpen; + newEvent.isOpen = json['isOpen']; + return newEvent; + } +} + +export interface TrashcanOpenJson extends AbstractEventJson { + isOpen: boolean; +} + +registry.register(registry.Type.EVENT, EventType.TRASHCAN_OPEN, TrashcanOpen); diff --git a/core/events/events_ui_base.ts b/core/events/events_ui_base.ts new file mode 100644 index 00000000000..23fe3b4e273 --- /dev/null +++ b/core/events/events_ui_base.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Base class for events fired as a result of UI actions in + * Blockly's editor. + * + * @class + */ +// Former goog.module ID: Blockly.Events.UiBase + +import {Abstract as AbstractEvent} from './events_abstract.js'; + +/** + * Base class for a UI event. + * UI events are events that don't need to be sent over the wire for multi-user + * editing to work (e.g. scrolling the workspace, zooming, opening toolbox + * categories). + * UI events do not undo or redo. + */ +export class UiBase extends AbstractEvent { + override isBlank = true; + override workspaceId: string; + + // UI events do not undo or redo. + override recordUndo = false; + + /** Whether or not the event is a UI event. */ + override isUiEvent = true; + + /** + * @param opt_workspaceId The workspace identifier for this event. + * Undefined for a blank event. + */ + constructor(opt_workspaceId?: string) { + super(); + + /** Whether or not the event is blank (to be populated by fromJson). */ + this.isBlank = typeof opt_workspaceId === 'undefined'; + + /** The workspace identifier for this event. */ + this.workspaceId = opt_workspaceId ? opt_workspaceId : ''; + } +} diff --git a/core/events/events_var_base.ts b/core/events/events_var_base.ts new file mode 100644 index 00000000000..f128f67b410 --- /dev/null +++ b/core/events/events_var_base.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Abstract class for a variable event. + * + * @class + */ +// Former goog.module ID: Blockly.Events.VarBase + +import type { + IVariableModel, + IVariableState, +} from '../interfaces/i_variable_model.js'; +import type {Workspace} from '../workspace.js'; +import { + Abstract as AbstractEvent, + AbstractEventJson, +} from './events_abstract.js'; + +/** + * Abstract class for a variable event. + */ +export class VarBase extends AbstractEvent { + override isBlank = true; + /** The ID of the variable this event references. */ + varId?: string; + + /** + * @param opt_variable The variable this event corresponds to. Undefined for + * a blank event. + */ + constructor(opt_variable?: IVariableModel) { + super(); + this.isBlank = typeof opt_variable === 'undefined'; + if (!opt_variable) return; + + this.varId = opt_variable.getId(); + this.workspaceId = opt_variable.getWorkspace().id; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): VarBaseJson { + const json = super.toJson() as VarBaseJson; + if (!this.varId) { + throw new Error( + 'The var ID is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + json['varId'] = this.varId; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of VarBase, but we can't specify that due to the fact that parameters + * to static methods in subclasses must be supertypes of parameters to + * static methods in superclasses. + * @internal + */ + static fromJson( + json: VarBaseJson, + workspace: Workspace, + event?: any, + ): VarBase { + const newEvent = super.fromJson( + json, + workspace, + event ?? new VarBase(), + ) as VarBase; + newEvent.varId = json['varId']; + return newEvent; + } +} + +export interface VarBaseJson extends AbstractEventJson { + varId: string; +} diff --git a/core/events/events_var_create.ts b/core/events/events_var_create.ts new file mode 100644 index 00000000000..c34c7ff57ae --- /dev/null +++ b/core/events/events_var_create.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for a variable creation event. + * + * @class + */ +// Former goog.module ID: Blockly.Events.VarCreate + +import type { + IVariableModel, + IVariableState, +} from '../interfaces/i_variable_model.js'; +import * as registry from '../registry.js'; + +import type {Workspace} from '../workspace.js'; +import {VarBase, VarBaseJson} from './events_var_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that a variable model has been created. + */ +export class VarCreate extends VarBase { + override type = EventType.VAR_CREATE; + + /** The type of the variable that was created. */ + varType?: string; + + /** The name of the variable that was created. */ + varName?: string; + + /** + * @param opt_variable The created variable. Undefined for a blank event. + */ + constructor(opt_variable?: IVariableModel) { + super(opt_variable); + + if (!opt_variable) { + return; // Blank event to be populated by fromJson. + } + this.varType = opt_variable.getType(); + this.varName = opt_variable.getName(); + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): VarCreateJson { + const json = super.toJson() as VarCreateJson; + if (this.varType === undefined) { + throw new Error( + 'The var type is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.varName) { + throw new Error( + 'The var name is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + json['varType'] = this.varType; + json['varName'] = this.varName; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of VarCreate, but we can't specify that due to the fact that parameters + * to static methods in subclasses must be supertypes of parameters to + * static methods in superclasses. + * @internal + */ + static fromJson( + json: VarCreateJson, + workspace: Workspace, + event?: any, + ): VarCreate { + const newEvent = super.fromJson( + json, + workspace, + event ?? new VarCreate(), + ) as VarCreate; + newEvent.varType = json['varType']; + newEvent.varName = json['varName']; + return newEvent; + } + + /** + * Run a variable creation event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.varId) { + throw new Error( + 'The var ID is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.varName) { + throw new Error( + 'The var name is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + const variableMap = workspace.getVariableMap(); + if (forward) { + variableMap.createVariable(this.varName, this.varType, this.varId); + } else { + const variable = variableMap.getVariableById(this.varId); + if (variable) variableMap.deleteVariable(variable); + } + } +} + +export interface VarCreateJson extends VarBaseJson { + varType: string; + varName: string; +} + +registry.register(registry.Type.EVENT, EventType.VAR_CREATE, VarCreate); diff --git a/core/events/events_var_delete.ts b/core/events/events_var_delete.ts new file mode 100644 index 00000000000..62317e36c50 --- /dev/null +++ b/core/events/events_var_delete.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.Events.VarDelete + +import type { + IVariableModel, + IVariableState, +} from '../interfaces/i_variable_model.js'; +import * as registry from '../registry.js'; + +import type {Workspace} from '../workspace.js'; +import {VarBase, VarBaseJson} from './events_var_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that a variable model has been deleted. + */ +export class VarDelete extends VarBase { + override type = EventType.VAR_DELETE; + /** The type of the variable that was deleted. */ + varType?: string; + /** The name of the variable that was deleted. */ + varName?: string; + + /** + * @param opt_variable The deleted variable. Undefined for a blank event. + */ + constructor(opt_variable?: IVariableModel) { + super(opt_variable); + + if (!opt_variable) { + return; // Blank event to be populated by fromJson. + } + this.varType = opt_variable.getType(); + this.varName = opt_variable.getName(); + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): VarDeleteJson { + const json = super.toJson() as VarDeleteJson; + if (this.varType === undefined) { + throw new Error( + 'The var type is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.varName) { + throw new Error( + 'The var name is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + json['varType'] = this.varType; + json['varName'] = this.varName; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of VarDelete, but we can't specify that due to the fact that parameters + * to static methods in subclasses must be supertypes of parameters to + * static methods in superclasses. + * @internal + */ + static fromJson( + json: VarDeleteJson, + workspace: Workspace, + event?: any, + ): VarDelete { + const newEvent = super.fromJson( + json, + workspace, + event ?? new VarDelete(), + ) as VarDelete; + newEvent.varType = json['varType']; + newEvent.varName = json['varName']; + return newEvent; + } + + /** + * Run a variable deletion event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.varId) { + throw new Error( + 'The var ID is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.varName) { + throw new Error( + 'The var name is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + const variableMap = workspace.getVariableMap(); + if (forward) { + const variable = variableMap.getVariableById(this.varId); + if (variable) variableMap.deleteVariable(variable); + } else { + variableMap.createVariable(this.varName, this.varType, this.varId); + } + } +} + +export interface VarDeleteJson extends VarBaseJson { + varType: string; + varName: string; +} + +registry.register(registry.Type.EVENT, EventType.VAR_DELETE, VarDelete); diff --git a/core/events/events_var_rename.ts b/core/events/events_var_rename.ts new file mode 100644 index 00000000000..a1758738c22 --- /dev/null +++ b/core/events/events_var_rename.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.Events.VarRename + +import type { + IVariableModel, + IVariableState, +} from '../interfaces/i_variable_model.js'; +import * as registry from '../registry.js'; + +import type {Workspace} from '../workspace.js'; +import {VarBase, VarBaseJson} from './events_var_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that a variable model was renamed. + */ +export class VarRename extends VarBase { + override type = EventType.VAR_RENAME; + + /** The previous name of the variable. */ + oldName?: string; + + /** The new name of the variable. */ + newName?: string; + + /** + * @param opt_variable The renamed variable. Undefined for a blank event. + * @param newName The new name the variable will be changed to. + */ + constructor(opt_variable?: IVariableModel, newName?: string) { + super(opt_variable); + + if (!opt_variable) { + return; // Blank event to be populated by fromJson. + } + this.oldName = opt_variable.getName(); + this.newName = typeof newName === 'undefined' ? '' : newName; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): VarRenameJson { + const json = super.toJson() as VarRenameJson; + if (!this.oldName) { + throw new Error( + 'The old var name is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.newName) { + throw new Error( + 'The new var name is undefined. Either pass a value to ' + + 'the constructor, or call fromJson', + ); + } + json['oldName'] = this.oldName; + json['newName'] = this.newName; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of VarRename, but we can't specify that due to the fact that parameters + * to static methods in subclasses must be supertypes of parameters to + * static methods in superclasses. + * @internal + */ + static fromJson( + json: VarRenameJson, + workspace: Workspace, + event?: any, + ): VarRename { + const newEvent = super.fromJson( + json, + workspace, + event ?? new VarRename(), + ) as VarRename; + newEvent.oldName = json['oldName']; + newEvent.newName = json['newName']; + return newEvent; + } + + /** + * Run a variable rename event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.varId) { + throw new Error( + 'The var ID is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.oldName) { + throw new Error( + 'The old var name is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.newName) { + throw new Error( + 'The new var name is undefined. Either pass a value to ' + + 'the constructor, or call fromJson', + ); + } + const variableMap = workspace.getVariableMap(); + const variable = variableMap.getVariableById(this.varId); + if (forward) { + if (variable) variableMap.renameVariable(variable, this.newName); + } else { + if (variable) variableMap.renameVariable(variable, this.oldName); + } + } +} + +export interface VarRenameJson extends VarBaseJson { + oldName: string; + newName: string; +} + +registry.register(registry.Type.EVENT, EventType.VAR_RENAME, VarRename); diff --git a/core/events/events_var_type_change.ts b/core/events/events_var_type_change.ts new file mode 100644 index 00000000000..c02a7e45435 --- /dev/null +++ b/core/events/events_var_type_change.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for a variable type change event. + * + * @class + */ + +import type { + IVariableModel, + IVariableState, +} from '../interfaces/i_variable_model.js'; +import * as registry from '../registry.js'; + +import type {Workspace} from '../workspace.js'; +import {VarBase, VarBaseJson} from './events_var_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that a variable's type has changed. + */ +export class VarTypeChange extends VarBase { + override type = EventType.VAR_TYPE_CHANGE; + + /** + * @param variable The variable whose type changed. Undefined for a blank event. + * @param oldType The old type of the variable. Undefined for a blank event. + * @param newType The new type of the variable. Undefined for a blank event. + */ + constructor( + variable?: IVariableModel, + public oldType?: string, + public newType?: string, + ) { + super(variable); + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): VarTypeChangeJson { + const json = super.toJson() as VarTypeChangeJson; + if (!this.oldType || !this.newType) { + throw new Error( + "The variable's types are undefined. Either pass them to " + + 'the constructor, or call fromJson', + ); + } + json['oldType'] = this.oldType; + json['newType'] = this.newType; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of VarTypeChange, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: VarTypeChangeJson, + workspace: Workspace, + event?: any, + ): VarTypeChange { + const newEvent = super.fromJson( + json, + workspace, + event ?? new VarTypeChange(), + ) as VarTypeChange; + newEvent.oldType = json['oldType']; + newEvent.newType = json['newType']; + return newEvent; + } + + /** + * Run a variable type change event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.varId) { + throw new Error( + 'The var ID is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.oldType || !this.newType) { + throw new Error( + "The variable's types are undefined. Either pass them to " + + 'the constructor, or call fromJson', + ); + } + const variable = workspace.getVariableMap().getVariableById(this.varId); + if (!variable) return; + if (forward) { + workspace.getVariableMap().changeVariableType(variable, this.newType); + } else { + workspace.getVariableMap().changeVariableType(variable, this.oldType); + } + } +} + +export interface VarTypeChangeJson extends VarBaseJson { + oldType: string; + newType: string; +} + +registry.register( + registry.Type.EVENT, + EventType.VAR_TYPE_CHANGE, + VarTypeChange, +); diff --git a/core/events/events_viewport.ts b/core/events/events_viewport.ts new file mode 100644 index 00000000000..b7a05b8d61e --- /dev/null +++ b/core/events/events_viewport.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Events fired as a result of a viewport change. + * + * @class + */ +// Former goog.module ID: Blockly.Events.ViewportChange + +import * as registry from '../registry.js'; +import type {Workspace} from '../workspace.js'; +import {AbstractEventJson} from './events_abstract.js'; +import {UiBase} from './events_ui_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that the workspace surface's position or scale has + * changed. + * + * Does not notify when the workspace itself resizes. + */ +export class ViewportChange extends UiBase { + /** + * Top edge of the visible portion of the workspace, relative to the + * workspace origin. + */ + viewTop?: number; + + /** + * The left edge of the visible portion of the workspace, relative to + * the workspace origin. + */ + viewLeft?: number; + + /** The scale of the workpace. */ + scale?: number; + + /** The previous scale of the workspace. */ + oldScale?: number; + + override type = EventType.VIEWPORT_CHANGE; + + /** + * @param opt_top Top-edge of the visible portion of the workspace, relative + * to the workspace origin. Undefined for a blank event. + * @param opt_left Left-edge of the visible portion of the workspace relative + * to the workspace origin. Undefined for a blank event. + * @param opt_scale The scale of the workspace. Undefined for a blank event. + * @param opt_workspaceId The workspace identifier for this event. + * Undefined for a blank event. + * @param opt_oldScale The old scale of the workspace. Undefined for a blank + * event. + */ + constructor( + opt_top?: number, + opt_left?: number, + opt_scale?: number, + opt_workspaceId?: string, + opt_oldScale?: number, + ) { + super(opt_workspaceId); + + this.viewTop = opt_top; + this.viewLeft = opt_left; + this.scale = opt_scale; + this.oldScale = opt_oldScale; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): ViewportChangeJson { + const json = super.toJson() as ViewportChangeJson; + if (this.viewTop === undefined) { + throw new Error( + 'The view top is undefined. Either pass a value to ' + + 'the constructor, or call fromJson', + ); + } + if (this.viewLeft === undefined) { + throw new Error( + 'The view left is undefined. Either pass a value to ' + + 'the constructor, or call fromJson', + ); + } + if (this.scale === undefined) { + throw new Error( + 'The scale is undefined. Either pass a value to ' + + 'the constructor, or call fromJson', + ); + } + if (this.oldScale === undefined) { + throw new Error( + 'The old scale is undefined. Either pass a value to ' + + 'the constructor, or call fromJson', + ); + } + json['viewTop'] = this.viewTop; + json['viewLeft'] = this.viewLeft; + json['scale'] = this.scale; + json['oldScale'] = this.oldScale; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of Viewport, but we can't specify that due to the fact that parameters + * to static methods in subclasses must be supertypes of parameters to + * static methods in superclasses. + * @internal + */ + static fromJson( + json: ViewportChangeJson, + workspace: Workspace, + event?: any, + ): ViewportChange { + const newEvent = super.fromJson( + json, + workspace, + event ?? new ViewportChange(), + ) as ViewportChange; + newEvent.viewTop = json['viewTop']; + newEvent.viewLeft = json['viewLeft']; + newEvent.scale = json['scale']; + newEvent.oldScale = json['oldScale']; + return newEvent; + } +} + +export interface ViewportChangeJson extends AbstractEventJson { + viewTop: number; + viewLeft: number; + scale: number; + oldScale: number; +} + +registry.register( + registry.Type.EVENT, + EventType.VIEWPORT_CHANGE, + ViewportChange, +); diff --git a/core/events/predicates.ts b/core/events/predicates.ts new file mode 100644 index 00000000000..9e8ce3b3a59 --- /dev/null +++ b/core/events/predicates.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file Predicates for testing Abstract event subclasses based on + * their .type properties. These are useful because there are places + * where it is not possible to use instanceof tests + * for type narrowing due to load ordering issues that would be caused + * by the need to import (rather than just import type) the class + * constructors in question. + */ + +import type {Abstract} from './events_abstract.js'; +import type {BlockChange} from './events_block_change.js'; +import type {BlockCreate} from './events_block_create.js'; +import type {BlockDelete} from './events_block_delete.js'; +import type {BlockDrag} from './events_block_drag.js'; +import type {BlockFieldIntermediateChange} from './events_block_field_intermediate_change.js'; +import type {BlockMove} from './events_block_move.js'; +import type {BubbleOpen} from './events_bubble_open.js'; +import type {Click} from './events_click.js'; +import type {CommentChange} from './events_comment_change.js'; +import type {CommentCollapse} from './events_comment_collapse.js'; +import type {CommentCreate} from './events_comment_create.js'; +import type {CommentDelete} from './events_comment_delete.js'; +import type {CommentDrag} from './events_comment_drag.js'; +import type {CommentMove} from './events_comment_move.js'; +import type {CommentResize} from './events_comment_resize.js'; +import type {Selected} from './events_selected.js'; +import type {ThemeChange} from './events_theme_change.js'; +import type {ToolboxItemSelect} from './events_toolbox_item_select.js'; +import type {TrashcanOpen} from './events_trashcan_open.js'; +import type {VarCreate} from './events_var_create.js'; +import type {VarDelete} from './events_var_delete.js'; +import type {VarRename} from './events_var_rename.js'; +import type {ViewportChange} from './events_viewport.js'; +import type {FinishedLoading} from './workspace_events.js'; + +import {EventType} from './type.js'; + +/** @returns true iff event.type is EventType.BLOCK_CREATE */ +export function isBlockCreate(event: Abstract): event is BlockCreate { + return event.type === EventType.BLOCK_CREATE; +} + +/** @returns true iff event.type is EventType.BLOCK_DELETE */ +export function isBlockDelete(event: Abstract): event is BlockDelete { + return event.type === EventType.BLOCK_DELETE; +} + +/** @returns true iff event.type is EventType.BLOCK_CHANGE */ +export function isBlockChange(event: Abstract): event is BlockChange { + return event.type === EventType.BLOCK_CHANGE; +} + +/** @returns true iff event.type is EventType.BLOCK_FIELD_INTERMEDIATE_CHANGE */ +export function isBlockFieldIntermediateChange( + event: Abstract, +): event is BlockFieldIntermediateChange { + return event.type === EventType.BLOCK_FIELD_INTERMEDIATE_CHANGE; +} + +/** @returns true iff event.type is EventType.BLOCK_MOVE */ +export function isBlockMove(event: Abstract): event is BlockMove { + return event.type === EventType.BLOCK_MOVE; +} + +/** @returns true iff event.type is EventType.VAR_CREATE */ +export function isVarCreate(event: Abstract): event is VarCreate { + return event.type === EventType.VAR_CREATE; +} + +/** @returns true iff event.type is EventType.VAR_DELETE */ +export function isVarDelete(event: Abstract): event is VarDelete { + return event.type === EventType.VAR_DELETE; +} + +/** @returns true iff event.type is EventType.VAR_RENAME */ +export function isVarRename(event: Abstract): event is VarRename { + return event.type === EventType.VAR_RENAME; +} + +/** @returns true iff event.type is EventType.BLOCK_DRAG */ +export function isBlockDrag(event: Abstract): event is BlockDrag { + return event.type === EventType.BLOCK_DRAG; +} + +/** @returns true iff event.type is EventType.SELECTED */ +export function isSelected(event: Abstract): event is Selected { + return event.type === EventType.SELECTED; +} + +/** @returns true iff event.type is EventType.CLICK */ +export function isClick(event: Abstract): event is Click { + return event.type === EventType.CLICK; +} + +/** @returns true iff event.type is EventType.BUBBLE_OPEN */ +export function isBubbleOpen(event: Abstract): event is BubbleOpen { + return event.type === EventType.BUBBLE_OPEN; +} + +/** @returns true iff event.type is EventType.TRASHCAN_OPEN */ +export function isTrashcanOpen(event: Abstract): event is TrashcanOpen { + return event.type === EventType.TRASHCAN_OPEN; +} + +/** @returns true iff event.type is EventType.TOOLBOX_ITEM_SELECT */ +export function isToolboxItemSelect( + event: Abstract, +): event is ToolboxItemSelect { + return event.type === EventType.TOOLBOX_ITEM_SELECT; +} + +/** @returns true iff event.type is EventType.THEME_CHANGE */ +export function isThemeChange(event: Abstract): event is ThemeChange { + return event.type === EventType.THEME_CHANGE; +} + +/** @returns true iff event.type is EventType.VIEWPORT_CHANGE */ +export function isViewportChange(event: Abstract): event is ViewportChange { + return event.type === EventType.VIEWPORT_CHANGE; +} + +/** @returns true iff event.type is EventType.COMMENT_CREATE */ +export function isCommentCreate(event: Abstract): event is CommentCreate { + return event.type === EventType.COMMENT_CREATE; +} + +/** @returns true iff event.type is EventType.COMMENT_DELETE */ +export function isCommentDelete(event: Abstract): event is CommentDelete { + return event.type === EventType.COMMENT_DELETE; +} + +/** @returns true iff event.type is EventType.COMMENT_CHANGE */ +export function isCommentChange(event: Abstract): event is CommentChange { + return event.type === EventType.COMMENT_CHANGE; +} + +/** @returns true iff event.type is EventType.COMMENT_MOVE */ +export function isCommentMove(event: Abstract): event is CommentMove { + return event.type === EventType.COMMENT_MOVE; +} + +/** @returns true iff event.type is EventType.COMMENT_RESIZE */ +export function isCommentResize(event: Abstract): event is CommentResize { + return event.type === EventType.COMMENT_RESIZE; +} + +/** @returns true iff event.type is EventType.COMMENT_DRAG */ +export function isCommentDrag(event: Abstract): event is CommentDrag { + return event.type === EventType.COMMENT_DRAG; +} + +/** @returns true iff event.type is EventType.COMMENT_COLLAPSE */ +export function isCommentCollapse(event: Abstract): event is CommentCollapse { + return event.type === EventType.COMMENT_COLLAPSE; +} + +/** @returns true iff event.type is EventType.FINISHED_LOADING */ +export function isFinishedLoading(event: Abstract): event is FinishedLoading { + return event.type === EventType.FINISHED_LOADING; +} diff --git a/core/events/type.ts b/core/events/type.ts new file mode 100644 index 00000000000..0928b8ff077 --- /dev/null +++ b/core/events/type.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Enum of values for the .type property for event classes (concrete subclasses + * of Abstract). + */ +export enum EventType { + /** Type of event that creates a block. */ + BLOCK_CREATE = 'create', + /** Type of event that deletes a block. */ + BLOCK_DELETE = 'delete', + /** Type of event that changes a block. */ + BLOCK_CHANGE = 'change', + /** + * Type of event representing an in-progress change to a field of a + * block, which is expected to be followed by a block change event. + */ + BLOCK_FIELD_INTERMEDIATE_CHANGE = 'block_field_intermediate_change', + /** Type of event that moves a block. */ + BLOCK_MOVE = 'move', + /** Type of event that creates a variable. */ + VAR_CREATE = 'var_create', + /** Type of event that deletes a variable. */ + VAR_DELETE = 'var_delete', + /** Type of event that renames a variable. */ + VAR_RENAME = 'var_rename', + /** Type of event that changes the type of a variable. */ + VAR_TYPE_CHANGE = 'var_type_change', + /** + * Type of generic event that records a UI change. + * + * @deprecated Was only ever intended for internal use. + */ + UI = 'ui', + /** Type of event that drags a block. */ + BLOCK_DRAG = 'drag', + /** Type of event that records a change in selected element. */ + SELECTED = 'selected', + /** Type of event that records a click. */ + CLICK = 'click', + /** Type of event that records a marker move. */ + MARKER_MOVE = 'marker_move', + /** Type of event that records a bubble open. */ + BUBBLE_OPEN = 'bubble_open', + /** Type of event that records a trashcan open. */ + TRASHCAN_OPEN = 'trashcan_open', + /** Type of event that records a toolbox item select. */ + TOOLBOX_ITEM_SELECT = 'toolbox_item_select', + /** Type of event that records a theme change. */ + THEME_CHANGE = 'theme_change', + /** Type of event that records a viewport change. */ + VIEWPORT_CHANGE = 'viewport_change', + /** Type of event that creates a comment. */ + COMMENT_CREATE = 'comment_create', + /** Type of event that deletes a comment. */ + COMMENT_DELETE = 'comment_delete', + /** Type of event that changes a comment. */ + COMMENT_CHANGE = 'comment_change', + /** Type of event that moves a comment. */ + COMMENT_MOVE = 'comment_move', + /** Type of event that resizes a comment. */ + COMMENT_RESIZE = 'comment_resize', + /** Type of event that drags a comment. */ + COMMENT_DRAG = 'comment_drag', + /** Type of event that collapses a comment. */ + COMMENT_COLLAPSE = 'comment_collapse', + /** Type of event that records a workspace load. */ + FINISHED_LOADING = 'finished_loading', +} + +/** + * List of events that cause objects to be bumped back into the visible + * portion of the workspace. + * + * Not to be confused with bumping so that disconnected connections do not + * appear connected. + */ +export const BUMP_EVENTS: string[] = [ + EventType.BLOCK_CREATE, + EventType.BLOCK_MOVE, + EventType.COMMENT_CREATE, + EventType.COMMENT_MOVE, +]; diff --git a/core/events/utils.ts b/core/events/utils.ts new file mode 100644 index 00000000000..ac78c694273 --- /dev/null +++ b/core/events/utils.ts @@ -0,0 +1,455 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.Events.utils + +import type {Block} from '../block.js'; +import * as common from '../common.js'; +import * as registry from '../registry.js'; +import * as idGenerator from '../utils/idgenerator.js'; +import type {Workspace} from '../workspace.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import type {Abstract} from './events_abstract.js'; +import type {BlockCreate} from './events_block_create.js'; +import type {BlockMove} from './events_block_move.js'; +import type {CommentCreate} from './events_comment_create.js'; +import type {CommentMove} from './events_comment_move.js'; +import type {CommentResize} from './events_comment_resize.js'; +import { + isBlockChange, + isBlockCreate, + isBlockMove, + isBubbleOpen, + isClick, + isViewportChange, +} from './predicates.js'; + +/** Group ID for new events. Grouped events are indivisible. */ +let group = ''; + +/** Sets whether the next event should be added to the undo stack. */ +let recordUndo = true; + +/** + * Sets whether events should be added to the undo stack. + * + * @param newValue True if events should be added to the undo stack. + */ +export function setRecordUndo(newValue: boolean) { + recordUndo = newValue; +} + +/** + * Returns whether or not events will be added to the undo stack. + * + * @returns True if events will be added to the undo stack. + */ +export function getRecordUndo(): boolean { + return recordUndo; +} + +/** Allow change events to be created and fired. */ +let disabled = 0; + +/** + * The language-neutral ID for when the reason why a block is disabled is + * because the block is not descended from a root block. + */ +const ORPHANED_BLOCK_DISABLED_REASON = 'ORPHANED_BLOCK'; + +/** + * Type of events that cause objects to be bumped back into the visible + * portion of the workspace. + * + * Not to be confused with bumping so that disconnected connections do not + * appear connected. + */ +export type BumpEvent = + | BlockCreate + | BlockMove + | CommentCreate + | CommentMove + | CommentResize; + +/** List of events queued for firing. */ +const FIRE_QUEUE: Abstract[] = []; + +/** + * Enqueue an event to be dispatched to change listeners. + * + * Notes: + * + * - Events are enqueued until a timeout, generally after rendering is + * complete or at the end of the current microtask, if not running + * in a browser. + * - Queued events are subject to destructive modification by being + * combined with later-enqueued events, but only until they are + * fired. + * - Events are dispatched via the fireChangeListener method on the + * affected workspace. + * + * @param event Any Blockly event. + */ +export function fire(event: Abstract) { + TEST_ONLY.fireInternal(event); +} + +/** + * Private version of fireInternal for stubbing in tests. + */ +function fireInternal(event: Abstract) { + if (!isEnabled()) { + return; + } + if (!FIRE_QUEUE.length) { + // First event added; schedule a firing of the event queue. + try { + // If we are in a browser context, we want to make sure that the event + // fires after blocks have been rerendered this frame. + requestAnimationFrame(() => { + setTimeout(fireNow, 0); + }); + } catch { + // Otherwise we just want to delay so events can be coallesced. + // requestAnimationFrame will error triggering this. + setTimeout(fireNow, 0); + } + } + enqueueEvent(event); +} + +/** Dispatch all queued events. */ +function fireNow() { + const queue = filter(FIRE_QUEUE); + FIRE_QUEUE.length = 0; + for (const event of queue) { + if (!event.workspaceId) continue; + common.getWorkspaceById(event.workspaceId)?.fireChangeListener(event); + } +} + +/** + * Enqueue an event on FIRE_QUEUE. + * + * Normally this is equivalent to FIRE_QUEUE.push(event), but if the + * enqueued event is a BlockChange event and the most recent event(s) + * on the queue are BlockMove events that (re)connect other blocks to + * the changed block (and belong to the same event group) then the + * enqueued event will be enqueued before those events rather than + * after. + * + * This is a workaround for a problem caused by the fact that + * MutatorIcon.prototype.recomposeSourceBlock can only fire a + * BlockChange event after the mutating block's compose method + * returns, meaning that if the compose method reconnects child blocks + * the corresponding BlockMove events are emitted _before_ the + * BlockChange event, causing issues with undo, mirroring, etc.; see + * https://github.com/google/blockly/issues/8225#issuecomment-2195751783 + * (and following) for details. + */ +function enqueueEvent(event: Abstract) { + if (isBlockChange(event) && event.element === 'mutation') { + let i; + for (i = FIRE_QUEUE.length; i > 0; i--) { + const otherEvent = FIRE_QUEUE[i - 1]; + if ( + otherEvent.group !== event.group || + otherEvent.workspaceId !== event.workspaceId || + !isBlockMove(otherEvent) || + otherEvent.newParentId !== event.blockId + ) { + break; + } + } + FIRE_QUEUE.splice(i, 0, event); + return; + } + + FIRE_QUEUE.push(event); +} + +/** + * Filter the queued events by merging duplicates, removing null + * events and reording BlockChange events. + * + * History of this function: + * + * This function was originally added in commit cf257ea5 with the + * intention of dramatically reduing the total number of dispatched + * events. Initialy it affected only BlockMove events but others were + * added over time. + * + * Code was added to reorder BlockChange events added in commit + * 5578458, for uncertain reasons but most probably as part of an + * only-partially-successful attemp to fix problems with event + * ordering during block mutations. This code should probably have + * been added to the top of the function, before merging and + * null-removal, but was added at the bottom for now-forgotten + * reasons. See these bug investigations for a fuller discussion of + * the underlying issue and some of the failures that arose because of + * this incomplete/incorrect fix: + * + * https://github.com/google/blockly/issues/8225#issuecomment-2195751783 + * https://github.com/google/blockly/issues/2037#issuecomment-2209696351 + * + * Later, in PR #1205 the original O(n^2) implementation was replaced + * by a linear-time implementation, though additonal fixes were made + * subsequently. + * + * In August 2024 a number of significant simplifications were made: + * + * This function was previously called from Workspace.prototype.undo, + * but the mutation of events by this function was the cause of issue + * #7026 (note that events would combine differently in reverse order + * vs. forward order). The originally-chosen fix for this was the + * addition (in PR #7069) of code to fireNow to post-filter the + * .undoStack_ and .redoStack_ of any workspace that had just been + * involved in dispatching events; this apparently resolved the issue + * but added considerable additional complexity and made it difficult + * to reason about how events are processed for undo/redo, so both the + * call from undo and the post-processing code was removed, and + * forward=true was made the default while calling the function with + * forward=false was deprecated. + * + * At the same time, the buggy code to reorder BlockChange events was + * replaced by a less-buggy version of the same functionality in a new + * function, enqueueEvent, called from fireInternal, thus assuring + * that events will be in the correct order at the time filter is + * called. + * + * Additionally, the event merging code was modified so that only + * immediately adjacent events would be merged. This simplified the + * implementation while ensuring that the merging of events cannot + * cause them to be reordered. + * + * @param queue Array of events. + * @returns Array of filtered events. + */ +export function filter(queue: Abstract[]): Abstract[] { + const mergedQueue: Abstract[] = []; + // Merge duplicates. + for (const event of queue) { + const lastEvent = mergedQueue[mergedQueue.length - 1]; + if (event.isNull()) continue; + if ( + !lastEvent || + lastEvent.workspaceId !== event.workspaceId || + lastEvent.group !== event.group + ) { + mergedQueue.push(event); + continue; + } + if ( + isBlockMove(event) && + isBlockMove(lastEvent) && + event.blockId === lastEvent.blockId + ) { + // Merge move events. + lastEvent.newParentId = event.newParentId; + lastEvent.newInputName = event.newInputName; + lastEvent.newCoordinate = event.newCoordinate; + // Concatenate reasons without duplicates. + if (lastEvent.reason || event.reason) { + lastEvent.reason = Array.from( + new Set((lastEvent.reason ?? []).concat(event.reason ?? [])), + ); + } + } else if ( + isBlockChange(event) && + isBlockChange(lastEvent) && + event.blockId === lastEvent.blockId && + event.element === lastEvent.element && + event.name === lastEvent.name + ) { + // Merge change events. + lastEvent.newValue = event.newValue; + } else if (isViewportChange(event) && isViewportChange(lastEvent)) { + // Merge viewport change events. + lastEvent.viewTop = event.viewTop; + lastEvent.viewLeft = event.viewLeft; + lastEvent.scale = event.scale; + lastEvent.oldScale = event.oldScale; + } else if (isClick(event) && isBubbleOpen(lastEvent)) { + // Drop click events caused by opening/closing bubbles. + } else { + mergedQueue.push(event); + } + } + // Filter out any events that have become null due to merging. + queue = mergedQueue.filter((e) => !e.isNull()); + return queue; +} + +/** + * Modify pending undo events so that when they are fired they don't land + * in the undo stack. Called by Workspace.clearUndo. + */ +export function clearPendingUndo() { + for (let i = 0, event; (event = FIRE_QUEUE[i]); i++) { + event.recordUndo = false; + } +} + +/** + * Stop sending events. Every call to this function MUST also call enable. + */ +export function disable() { + disabled++; +} + +/** + * Start sending events. Unless events were already disabled when the + * corresponding call to disable was made. + */ +export function enable() { + disabled--; +} + +/** + * Returns whether events may be fired or not. + * + * @returns True if enabled. + */ +export function isEnabled(): boolean { + return disabled === 0; +} + +/** + * Current group. + * + * @returns ID string. + */ +export function getGroup(): string { + return group; +} + +/** + * Start or stop a group. + * + * @param state True to start new group, false to end group. + * String to set group explicitly. + */ +export function setGroup(state: boolean | string) { + TEST_ONLY.setGroupInternal(state); +} + +/** + * Private version of setGroup for stubbing in tests. + */ +function setGroupInternal(state: boolean | string) { + if (typeof state === 'boolean') { + group = state ? idGenerator.genUid() : ''; + } else { + group = state; + } +} + +/** + * Compute a list of the IDs of the specified block and all its descendants. + * + * @param block The root block. + * @returns List of block IDs. + * @internal + */ +export function getDescendantIds(block: Block): string[] { + const ids = []; + const descendants = block.getDescendants(false); + for (let i = 0, descendant; (descendant = descendants[i]); i++) { + ids[i] = descendant.id; + } + return ids; +} + +/** + * Decode the JSON into an event. + * + * @param json JSON representation. + * @param workspace Target workspace for event. + * @returns The event represented by the JSON. + * @throws {Error} if an event type is not found in the registry. + */ +export function fromJson( + json: AnyDuringMigration, + workspace: Workspace, +): Abstract { + const eventClass = get(json['type']); + if (!eventClass) throw Error('Unknown event type.'); + + return (eventClass as any).fromJson(json, workspace); +} + +/** + * Gets the class for a specific event type from the registry. + * + * @param eventType The type of the event to get. + * @returns The event class with the given type. + */ +export function get( + eventType: string, +): new (...p1: AnyDuringMigration[]) => Abstract { + const event = registry.getClass(registry.Type.EVENT, eventType); + if (!event) { + throw new Error(`Event type ${eventType} not found in registry.`); + } + return event; +} + +/** + * Set if a block is disabled depending on whether it is properly connected. + * Use this on applications where all blocks should be connected to a top block. + * + * @param event Custom data for event. + */ +export function disableOrphans(event: Abstract) { + if (isBlockMove(event) || isBlockCreate(event)) { + const blockEvent = event as BlockMove | BlockCreate; + if (!blockEvent.workspaceId) { + return; + } + const eventWorkspace = common.getWorkspaceById( + blockEvent.workspaceId, + ) as WorkspaceSvg; + if (!blockEvent.blockId) { + throw new Error('Encountered a blockEvent without a proper blockId'); + } + let block = eventWorkspace.getBlockById(blockEvent.blockId); + if (block) { + // Changing blocks as part of this event shouldn't be undoable. + const initialUndoFlag = recordUndo; + try { + recordUndo = false; + const parent = block.getParent(); + if ( + parent && + !parent.hasDisabledReason(ORPHANED_BLOCK_DISABLED_REASON) + ) { + const children = block.getDescendants(false); + for (let i = 0, child; (child = children[i]); i++) { + child.setDisabledReason(false, ORPHANED_BLOCK_DISABLED_REASON); + } + } else if ( + (block.outputConnection || block.previousConnection) && + !eventWorkspace.isDragging() + ) { + do { + block.setDisabledReason(true, ORPHANED_BLOCK_DISABLED_REASON); + block = block.getNextBlock(); + } while (block); + } + } finally { + recordUndo = initialUndoFlag; + } + } + } +} + +export const TEST_ONLY = { + FIRE_QUEUE, + enqueueEvent, + fireNow, + fireInternal, + setGroupInternal, +}; diff --git a/core/events/workspace_events.ts b/core/events/workspace_events.ts new file mode 100644 index 00000000000..1a2ff54735b --- /dev/null +++ b/core/events/workspace_events.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for a finished loading workspace event. + * + * @class + */ +// Former goog.module ID: Blockly.Events.FinishedLoading + +import * as registry from '../registry.js'; +import type {Workspace} from '../workspace.js'; +import {Abstract as AbstractEvent} from './events_abstract.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners when the workspace has finished deserializing from + * JSON/XML. + */ +export class FinishedLoading extends AbstractEvent { + override isBlank = true; + override recordUndo = false; + override type = EventType.FINISHED_LOADING; + + /** + * @param opt_workspace The workspace that has finished loading. Undefined + * for a blank event. + */ + constructor(opt_workspace?: Workspace) { + super(); + this.isBlank = !!opt_workspace; + + if (!opt_workspace) return; + + this.workspaceId = opt_workspace.id; + } +} + +registry.register( + registry.Type.EVENT, + EventType.FINISHED_LOADING, + FinishedLoading, +); diff --git a/core/extensions.js b/core/extensions.js deleted file mode 100644 index 66602688855..00000000000 --- a/core/extensions.js +++ /dev/null @@ -1,446 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Extensions are functions that help initialize blocks, usually - * adding dynamic behavior such as onchange handlers and mutators. These - * are applied using Block.applyExtension(), or the JSON "extensions" - * array attribute. - * @author Anm@anm.me (Andrew n marshall) - */ -'use strict'; - -/** - * @name Blockly.Extensions - * @namespace - **/ -goog.provide('Blockly.Extensions'); - - -/** - * The set of all registered extensions, keyed by extension name/id. - * @private - */ -Blockly.Extensions.ALL_ = {}; - -/** - * The set of properties on a block that may only be set by a mutator. - * @type {!Array.} - * @private - * @constant - */ -Blockly.Extensions.MUTATOR_PROPERTIES_ = - ['domToMutation', 'mutationToDom', 'compose', 'decompose']; - -/** - * Registers a new extension function. Extensions are functions that help - * initialize blocks, usually adding dynamic behavior such as onchange - * handlers and mutators. These are applied using Block.applyExtension(), or - * the JSON "extensions" array attribute. - * @param {string} name The name of this extension. - * @param {function} initFn The function to initialize an extended block. - * @throws {Error} if the extension name is empty, the extension is already - * registered, or extensionFn is not a function. - */ -Blockly.Extensions.register = function(name, initFn) { - if (!goog.isString(name) || goog.string.isEmptyOrWhitespace(name)) { - throw new Error('Error: Invalid extension name "' + name + '"'); - } - if (Blockly.Extensions.ALL_[name]) { - throw new Error('Error: Extension "' + name + '" is already registered.'); - } - if (!goog.isFunction(initFn)) { - throw new Error('Error: Extension "' + name + '" must be a function'); - } - Blockly.Extensions.ALL_[name] = initFn; -}; - -/** - * Registers a new extension function that adds all key/value of mixinObj. - * @param {string} name The name of this extension. - * @param {!Object} mixinObj The values to mix in. - * @throws {Error} if the extension name is empty or the extension is already - * registered. - */ -Blockly.Extensions.registerMixin = function(name, mixinObj) { - Blockly.Extensions.register(name, function() { - this.mixin(mixinObj); - }); -}; - -/** - * Registers a new extension function that adds a mutator to the block. - * At register time this performs some basic sanity checks on the mutator. - * The wrapper may also add a mutator dialog to the block, if both compose and - * decompose are defined on the mixin. - * @param {string} name The name of this mutator extension. - * @param {!Object} mixinObj The values to mix in. - * @param {function()=} opt_helperFn An optional function to apply after mixing - * in the object. - * @param {Array.=} opt_blockList A list of blocks to appear in the - * flyout of the mutator dialog. - * @throws {Error} if the mutation is invalid or can't be applied to the block. - */ -Blockly.Extensions.registerMutator = function(name, mixinObj, opt_helperFn, - opt_blockList) { - var errorPrefix = 'Error when registering mutator "' + name + '": '; - - // Sanity check the mixin object before registering it. - Blockly.Extensions.checkHasFunction_(errorPrefix, mixinObj, 'domToMutation'); - Blockly.Extensions.checkHasFunction_(errorPrefix, mixinObj, 'mutationToDom'); - - var hasMutatorDialog = Blockly.Extensions.checkMutatorDialog_(mixinObj, - errorPrefix); - - if (opt_helperFn && !goog.isFunction(opt_helperFn)) { - throw new Error('Extension "' + name + '" is not a function'); - } - - // Sanity checks passed. - Blockly.Extensions.register(name, function() { - if (hasMutatorDialog) { - this.setMutator(new Blockly.Mutator(opt_blockList)); - } - // Mixin the object. - this.mixin(mixinObj); - - if (opt_helperFn) { - opt_helperFn.apply(this); - } - }); -}; - -/** - * Applies an extension method to a block. This should only be called during - * block construction. - * @param {string} name The name of the extension. - * @param {!Blockly.Block} block The block to apply the named extension to. - * @param {boolean} isMutator True if this extension defines a mutator. - * @throws {Error} if the extension is not found. - */ -Blockly.Extensions.apply = function(name, block, isMutator) { - var extensionFn = Blockly.Extensions.ALL_[name]; - if (!goog.isFunction(extensionFn)) { - throw new Error('Error: Extension "' + name + '" not found.'); - } - if (isMutator) { - // Fail early if the block already has mutation properties. - Blockly.Extensions.checkNoMutatorProperties_(name, block); - } else { - // Record the old properties so we can make sure they don't change after - // applying the extension. - var mutatorProperties = Blockly.Extensions.getMutatorProperties_(block); - } - extensionFn.apply(block); - - if (isMutator) { - var errorPrefix = 'Error after applying mutator "' + name + '": '; - Blockly.Extensions.checkBlockHasMutatorProperties_(name, block, errorPrefix); - } else { - if (!Blockly.Extensions.mutatorPropertiesMatch_(mutatorProperties, block)) { - throw new Error('Error when applying extension "' + name + - '": mutation properties changed when applying a non-mutator extension.'); - } - } -}; - -/** - * Check that the given object has a property with the given name, and that the - * property is a function. - * @param {string} errorPrefix The string to prepend to any error message. - * @param {!Object} object The object to check. - * @param {string} propertyName Which property to check. - * @throws {Error} if the property does not exist or is not a function. - * @private - */ -Blockly.Extensions.checkHasFunction_ = function(errorPrefix, object, - propertyName) { - if (!object.hasOwnProperty(propertyName)) { - throw new Error(errorPrefix + - 'missing required property "' + propertyName + '"'); - } else if (typeof object[propertyName] !== "function") { - throw new Error(errorPrefix + - '" required property "' + propertyName + '" must be a function'); - } -}; - -/** - * Check that the given block does not have any of the four mutator properties - * defined on it. This function should be called before applying a mutator - * extension to a block, to make sure we are not overwriting properties. - * @param {string} mutationName The name of the mutation to reference in error - * messages. - * @param {!Blockly.Block} block The block to check. - * @throws {Error} if any of the properties already exist on the block. - * @private - */ -Blockly.Extensions.checkNoMutatorProperties_ = function(mutationName, block) { - for (var i = 0; i < Blockly.Extensions.MUTATOR_PROPERTIES_.length; i++) { - var propertyName = Blockly.Extensions.MUTATOR_PROPERTIES_[i]; - if (block.hasOwnProperty(propertyName)) { - throw new Error('Error: tried to apply mutation "' + mutationName + - '" to a block that already has a "' + propertyName + - '" function. Block id: ' + block.id); - } - } -}; - -/** - * Check that the given object has both or neither of the functions required - * to have a mutator dialog. - * These functions are 'compose' and 'decompose'. If a block has one, it must - * have both. - * @param {!Object} object The object to check. - * @param {string} errorPrefix The string to prepend to any error message. - * @return {boolean} True if the object has both functions. False if it has - * neither function. - * @throws {Error} if the object has only one of the functions. - * @private - */ -Blockly.Extensions.checkMutatorDialog_ = function(object, errorPrefix) { - var hasCompose = object.hasOwnProperty('compose'); - var hasDecompose = object.hasOwnProperty('decompose'); - - if (hasCompose && hasDecompose) { - if (typeof object['compose'] !== "function") { - throw new Error(errorPrefix + 'compose must be a function.'); - } else if (typeof object['decompose'] !== "function") { - throw new Error(errorPrefix + 'decompose must be a function.'); - } - return true; - } else if (!hasCompose && !hasDecompose) { - return false; - } else { - throw new Error(errorPrefix + - 'Must have both or neither of "compose" and "decompose"'); - } -}; - -/** - * Check that a block has required mutator properties. This should be called - * after applying a mutation extension. - * @param {string} errorPrefix The string to prepend to any error message. - * @param {!Blockly.Block} block The block to inspect. - * @private - */ -Blockly.Extensions.checkBlockHasMutatorProperties_ = function(errorPrefix, - block) { - if (!block.hasOwnProperty('domToMutation')) { - throw new Error(errorPrefix + 'Applying a mutator didn\'t add "domToMutation"'); - } - if (!block.hasOwnProperty('mutationToDom')) { - throw new Error(errorPrefix + 'Applying a mutator didn\'t add "mutationToDom"'); - } - - // A block with a mutator isn't required to have a mutation dialog, but - // it should still have both or neither of compose and decompose. - Blockly.Extensions.checkMutatorDialog_(block, errorPrefix); -}; - -/** - * Get a list of values of mutator properties on the given block. - * @param {!Blockly.Block} block The block to inspect. - * @return {!Array.} a list with all of the properties, which should be - * functions or undefined, but are not guaranteed to be. - * @private - */ -Blockly.Extensions.getMutatorProperties_ = function(block) { - var result = []; - for (var i = 0; i < Blockly.Extensions.MUTATOR_PROPERTIES_.length; i++) { - result.push(block[Blockly.Extensions.MUTATOR_PROPERTIES_[i]]); - } - return result; -}; - -/** - * Check that the current mutator properties match a list of old mutator - * properties. This should be called after applying a non-mutator extension, - * to verify that the extension didn't change properties it shouldn't. - * @param {!Array.} oldProperties The old values to compare to. - * @param {!Blockly.Block} block The block to inspect for new values. - * @return {boolean} True if the property lists match. - * @private - */ -Blockly.Extensions.mutatorPropertiesMatch_ = function(oldProperties, block) { - var match = true; - var newProperties = Blockly.Extensions.getMutatorProperties_(block); - if (newProperties.length != oldProperties.length) { - match = false; - } else { - for (var i = 0; i < newProperties.length; i++) { - if (oldProperties[i] != newProperties[i]) { - match = false; - } - } - } - - return match; -}; - -/** - * Builds an extension function that will map a dropdown value to a tooltip - * string. - * - * This method includes multiple checks to ensure tooltips, dropdown options, - * and message references are aligned. This aims to catch errors as early as - * possible, without requiring developers to manually test tooltips under each - * option. After the page is loaded, each tooltip text string will be checked - * for matching message keys in the internationalized string table. Deferring - * this until the page is loaded decouples loading dependencies. Later, upon - * loading the first block of any given type, the extension will validate every - * dropdown option has a matching tooltip in the lookupTable. Errors are - * reported as warnings in the console, and are never fatal. - * @param {string} dropdownName The name of the field whose value is the key - * to the lookup table. - * @param {!Object} lookupTable The table of field values to - * tooltip text. - * @return {Function} The extension function. - */ -Blockly.Extensions.buildTooltipForDropdown = function(dropdownName, lookupTable) { - // List of block types already validated, to minimize duplicate warnings. - var blockTypesChecked = []; - - // Check the tooltip string messages for invalid references. - // Wait for load, in case Blockly.Msg is not yet populated. - // runAfterPageLoad() does not run in a Node.js environment due to lack of - // document object, in which case skip the validation. - if (document) { // Relies on document.readyState - Blockly.utils.runAfterPageLoad(function() { - for (var key in lookupTable) { - // Will print warnings is reference is missing. - Blockly.utils.checkMessageReferences(lookupTable[key]); - } - }); - } - - /** - * The actual extension. - * @this {Blockly.Block} - */ - var extensionFn = function() { - if (this.type && blockTypesChecked.indexOf(this.type) === -1) { - Blockly.Extensions.checkDropdownOptionsInTable_( - this, dropdownName, lookupTable); - blockTypesChecked.push(this.type); - } - - this.setTooltip(function() { - var value = this.getFieldValue(dropdownName); - var tooltip = lookupTable[value]; - if (tooltip == null) { - if (blockTypesChecked.indexOf(this.type) === -1) { - // Warn for missing values on generated tooltips - var warning = 'No tooltip mapping for value ' + value + - ' of field ' + dropdownName; - if (this.type != null) { - warning += (' of block type ' + this.type); - } - console.warn(warning + '.'); - } - } else { - tooltip = Blockly.utils.replaceMessageReferences(tooltip); - } - return tooltip; - }.bind(this)); - }; - return extensionFn; -}; - -/** - * Checks all options keys are present in the provided string lookup table. - * Emits console warnings when they are not. - * @param {!Blockly.Block} block The block containing the dropdown - * @param {string} dropdownName The name of the dropdown - * @param {!Object} lookupTable The string lookup table - * @private - */ -Blockly.Extensions.checkDropdownOptionsInTable_ = - function(block, dropdownName, lookupTable) { - // Validate all dropdown options have values. - var dropdown = block.getField(dropdownName); - if (!dropdown.isOptionListDynamic()) { - var options = dropdown.getOptions(); - for (var i = 0; i < options.length; ++i) { - var optionKey = options[i][1]; // label, then value - if (lookupTable[optionKey] == null) { - console.warn('No tooltip mapping for value ' + optionKey + - ' of field ' + dropdownName + ' of block type ' + block.type); - } - } - } - }; - -/** - * Builds an extension function that will install a dynamic tooltip. The - * tooltip message should include the string '%1' and that string will be - * replaced with the value of the named field. - * @param {string} msgTemplate The template form to of the message text, with - * %1 placeholder. - * @param {string} fieldName The field with the replacement value. - * @returns {Function} The extension function. - */ -Blockly.Extensions.buildTooltipWithFieldValue = - function(msgTemplate, fieldName) { - // Check the tooltip string messages for invalid references. - // Wait for load, in case Blockly.Msg is not yet populated. - // runAfterPageLoad() does not run in a Node.js environment due to lack of - // document object, in which case skip the validation. - if (document) { // Relies on document.readyState - Blockly.utils.runAfterPageLoad(function() { - // Will print warnings is reference is missing. - Blockly.utils.checkMessageReferences(msgTemplate); - }); - } - - /** - * The actual extension. - * @this {Blockly.Block} - */ - var extensionFn = function() { - this.setTooltip(function() { - return Blockly.utils.replaceMessageReferences(msgTemplate) - .replace('%1', this.getFieldValue(fieldName)); - }.bind(this)); - }; - return extensionFn; - }; - -/** - * Configures the tooltip to mimic the parent block when connected. Otherwise, - * uses the tooltip text at the time this extension is initialized. This takes - * advantage of the fact that all other values from JSON are initialized before - * extensions. - * @this {Blockly.Block} - * @private - */ -Blockly.Extensions.extensionParentTooltip_ = function() { - this.tooltipWhenNotConnected_ = this.tooltip; - this.setTooltip(function() { - var parent = this.getParent(); - return (parent && - parent.getInputsInline() && - parent.tooltip) || - this.tooltipWhenNotConnected_; - }.bind(this)); -}; -Blockly.Extensions.register('parent_tooltip_when_inline', - Blockly.Extensions.extensionParentTooltip_); - - diff --git a/core/extensions.ts b/core/extensions.ts new file mode 100644 index 00000000000..59d218d17fa --- /dev/null +++ b/core/extensions.ts @@ -0,0 +1,497 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.Extensions + +import type {Block} from './block.js'; +import type {BlockSvg} from './block_svg.js'; +import {FieldDropdown} from './field_dropdown.js'; +import {MutatorIcon} from './icons/mutator_icon.js'; +import * as parsing from './utils/parsing.js'; + +/** The set of all registered extensions, keyed by extension name/id. */ +const allExtensions = Object.create(null); +export const TEST_ONLY = {allExtensions}; + +/** + * Registers a new extension function. Extensions are functions that help + * initialize blocks, usually adding dynamic behavior such as onchange + * handlers and mutators. These are applied using Block.applyExtension(), or + * the JSON "extensions" array attribute. + * + * @param name The name of this extension. + * @param initFn The function to initialize an extended block. + * @throws {Error} if the extension name is empty, the extension is already + * registered, or extensionFn is not a function. + */ +export function register( + name: string, + initFn: (this: T) => void, +) { + if (typeof name !== 'string' || name.trim() === '') { + throw Error('Error: Invalid extension name "' + name + '"'); + } + if (allExtensions[name]) { + throw Error('Error: Extension "' + name + '" is already registered.'); + } + if (typeof initFn !== 'function') { + throw Error('Error: Extension "' + name + '" must be a function'); + } + allExtensions[name] = initFn; +} + +/** + * Registers a new extension function that adds all key/value of mixinObj. + * + * @param name The name of this extension. + * @param mixinObj The values to mix in. + * @throws {Error} if the extension name is empty or the extension is already + * registered. + */ +export function registerMixin(name: string, mixinObj: AnyDuringMigration) { + if (!mixinObj || typeof mixinObj !== 'object') { + throw Error('Error: Mixin "' + name + '" must be a object'); + } + register(name, function (this: Block) { + this.mixin(mixinObj); + }); +} + +/** + * Registers a new extension function that adds a mutator to the block. + * At register time this performs some basic sanity checks on the mutator. + * The wrapper may also add a mutator dialog to the block, if both compose and + * decompose are defined on the mixin. + * + * @param name The name of this mutator extension. + * @param mixinObj The values to mix in. + * @param opt_helperFn An optional function to apply after mixing in the object. + * @param opt_blockList A list of blocks to appear in the flyout of the mutator + * dialog. + * @throws {Error} if the mutation is invalid or can't be applied to the block. + */ +export function registerMutator( + name: string, + mixinObj: AnyDuringMigration, + opt_helperFn?: () => AnyDuringMigration, + opt_blockList?: string[], +) { + const errorPrefix = 'Error when registering mutator "' + name + '": '; + + checkHasMutatorProperties(errorPrefix, mixinObj); + const hasMutatorDialog = checkMutatorDialog(mixinObj, errorPrefix); + + if (opt_helperFn && typeof opt_helperFn !== 'function') { + throw Error(errorPrefix + 'Extension "' + name + '" is not a function'); + } + + // Sanity checks passed. + register(name, function (this: Block) { + if (hasMutatorDialog) { + this.setMutator(new MutatorIcon(opt_blockList || [], this as BlockSvg)); + } + // Mixin the object. + this.mixin(mixinObj); + + if (opt_helperFn) { + opt_helperFn.apply(this); + } + }); +} + +/** + * Unregisters the extension registered with the given name. + * + * @param name The name of the extension to unregister. + */ +export function unregister(name: string) { + if (isRegistered(name)) { + delete allExtensions[name]; + } else { + console.warn( + 'No extension mapping for name "' + name + '" found to unregister', + ); + } +} + +/** + * Returns whether an extension is registered with the given name. + * + * @param name The name of the extension to check for. + * @returns True if the extension is registered. False if it is not registered. + */ +export function isRegistered(name: string): boolean { + return !!allExtensions[name]; +} + +/** + * Applies an extension method to a block. This should only be called during + * block construction. + * + * @param name The name of the extension. + * @param block The block to apply the named extension to. + * @param isMutator True if this extension defines a mutator. + * @throws {Error} if the extension is not found. + */ +export function apply(name: string, block: Block, isMutator: boolean) { + const extensionFn = allExtensions[name]; + if (typeof extensionFn !== 'function') { + throw Error('Error: Extension "' + name + '" not found.'); + } + let mutatorProperties; + if (isMutator) { + // Fail early if the block already has mutation properties. + checkNoMutatorProperties(name, block); + } else { + // Record the old properties so we can make sure they don't change after + // applying the extension. + mutatorProperties = getMutatorProperties(block); + } + extensionFn.apply(block); + + if (isMutator) { + const errorPrefix = 'Error after applying mutator "' + name + '": '; + checkHasMutatorProperties(errorPrefix, block); + } else { + if ( + !mutatorPropertiesMatch(mutatorProperties as AnyDuringMigration[], block) + ) { + throw Error( + 'Error when applying extension "' + + name + + '": ' + + 'mutation properties changed when applying a non-mutator extension.', + ); + } + } +} + +/** + * Check that the given block does not have any of the four mutator properties + * defined on it. This function should be called before applying a mutator + * extension to a block, to make sure we are not overwriting properties. + * + * @param mutationName The name of the mutation to reference in error messages. + * @param block The block to check. + * @throws {Error} if any of the properties already exist on the block. + */ +function checkNoMutatorProperties(mutationName: string, block: Block) { + const properties = getMutatorProperties(block); + if (properties.length) { + throw Error( + 'Error: tried to apply mutation "' + + mutationName + + '" to a block that already has mutator functions.' + + ' Block id: ' + + block.id, + ); + } +} + +/** + * Checks if the given object has both the 'mutationToDom' and 'domToMutation' + * functions. + * + * @param object The object to check. + * @param errorPrefix The string to prepend to any error message. + * @returns True if the object has both functions. False if it has neither + * function. + * @throws {Error} if the object has only one of the functions, or either is not + * actually a function. + */ +function checkXmlHooks( + object: AnyDuringMigration, + errorPrefix: string, +): boolean { + return checkHasFunctionPair( + object.mutationToDom, + object.domToMutation, + errorPrefix + ' mutationToDom/domToMutation', + ); +} +/** + * Checks if the given object has both the 'saveExtraState' and 'loadExtraState' + * functions. + * + * @param object The object to check. + * @param errorPrefix The string to prepend to any error message. + * @returns True if the object has both functions. False if it has neither + * function. + * @throws {Error} if the object has only one of the functions, or either is not + * actually a function. + */ +function checkJsonHooks( + object: AnyDuringMigration, + errorPrefix: string, +): boolean { + return checkHasFunctionPair( + object.saveExtraState, + object.loadExtraState, + errorPrefix + ' saveExtraState/loadExtraState', + ); +} + +/** + * Checks if the given object has both the 'compose' and 'decompose' functions. + * + * @param object The object to check. + * @param errorPrefix The string to prepend to any error message. + * @returns True if the object has both functions. False if it has neither + * function. + * @throws {Error} if the object has only one of the functions, or either is not + * actually a function. + */ +function checkMutatorDialog( + object: AnyDuringMigration, + errorPrefix: string, +): boolean { + return checkHasFunctionPair( + object.compose, + object.decompose, + errorPrefix + ' compose/decompose', + ); +} + +/** + * Checks that both or neither of the given functions exist and that they are + * indeed functions. + * + * @param func1 The first function in the pair. + * @param func2 The second function in the pair. + * @param errorPrefix The string to prepend to any error message. + * @returns True if the object has both functions. False if it has neither + * function. + * @throws {Error} If the object has only one of the functions, or either is not + * actually a function. + */ +function checkHasFunctionPair( + func1: AnyDuringMigration, + func2: AnyDuringMigration, + errorPrefix: string, +): boolean { + if (func1 && func2) { + if (typeof func1 !== 'function' || typeof func2 !== 'function') { + throw Error(errorPrefix + ' must be a function'); + } + return true; + } else if (!func1 && !func2) { + return false; + } + throw Error(errorPrefix + 'Must have both or neither functions'); +} + +/** + * Checks that the given object required mutator properties. + * + * @param errorPrefix The string to prepend to any error message. + * @param object The object to inspect. + */ +function checkHasMutatorProperties( + errorPrefix: string, + object: AnyDuringMigration, +) { + const hasXmlHooks = checkXmlHooks(object, errorPrefix); + const hasJsonHooks = checkJsonHooks(object, errorPrefix); + if (!hasXmlHooks && !hasJsonHooks) { + throw Error( + errorPrefix + + 'Mutations must contain either XML hooks, or JSON hooks, or both', + ); + } + // A block with a mutator isn't required to have a mutation dialog, but + // it should still have both or neither of compose and decompose. + checkMutatorDialog(object, errorPrefix); +} + +/** + * Get a list of values of mutator properties on the given block. + * + * @param block The block to inspect. + * @returns A list with all of the defined properties, which should be + * functions, but may be anything other than undefined. + */ +function getMutatorProperties(block: Block): AnyDuringMigration[] { + const result = []; + // List each function explicitly by reference to allow for renaming + // during compilation. + if (block.domToMutation !== undefined) { + result.push(block.domToMutation); + } + if (block.mutationToDom !== undefined) { + result.push(block.mutationToDom); + } + if (block.saveExtraState !== undefined) { + result.push(block.saveExtraState); + } + if (block.loadExtraState !== undefined) { + result.push(block.loadExtraState); + } + if (block.compose !== undefined) { + result.push(block.compose); + } + if (block.decompose !== undefined) { + result.push(block.decompose); + } + return result; +} + +/** + * Check that the current mutator properties match a list of old mutator + * properties. This should be called after applying a non-mutator extension, + * to verify that the extension didn't change properties it shouldn't. + * + * @param oldProperties The old values to compare to. + * @param block The block to inspect for new values. + * @returns True if the property lists match. + */ +function mutatorPropertiesMatch( + oldProperties: AnyDuringMigration[], + block: Block, +): boolean { + const newProperties = getMutatorProperties(block); + if (newProperties.length !== oldProperties.length) { + return false; + } + for (let i = 0; i < newProperties.length; i++) { + if (oldProperties[i] !== newProperties[i]) { + return false; + } + } + return true; +} + +/** + * Calls a function after the page has loaded, possibly immediately. + * + * @param fn Function to run. + * @throws Error Will throw if no global document can be found (e.g., Node.js). + * @internal + */ +export function runAfterPageLoad(fn: () => void) { + if (typeof document !== 'object') { + throw Error('runAfterPageLoad() requires browser document.'); + } + if (document.readyState === 'complete') { + fn(); // Page has already loaded. Call immediately. + } else { + // Poll readyState. + const readyStateCheckInterval = setInterval(function () { + if (document.readyState === 'complete') { + clearInterval(readyStateCheckInterval); + fn(); + } + }, 10); + } +} + +/** + * Builds an extension function that will map a dropdown value to a tooltip + * string. + * + * @param dropdownName The name of the field whose value is the key to the + * lookup table. + * @param lookupTable The table of field values to tooltip text. + * @returns The extension function. + */ +export function buildTooltipForDropdown( + dropdownName: string, + lookupTable: {[key: string]: string}, +): (this: Block) => void { + // List of block types already validated, to minimize duplicate warnings. + const blockTypesChecked: string[] = []; + + return function (this: Block) { + if (!blockTypesChecked.includes(this.type)) { + checkDropdownOptionsInTable(this, dropdownName, lookupTable); + blockTypesChecked.push(this.type); + } + + this.setTooltip( + function (this: Block) { + const value = String(this.getFieldValue(dropdownName)); + return parsing.replaceMessageReferences(lookupTable[value]); + }.bind(this), + ); + }; +} + +/** + * Checks all options keys are present in the provided string lookup table. + * Emits console warnings when they are not. + * + * @param block The block containing the dropdown + * @param dropdownName The name of the dropdown + * @param lookupTable The string lookup table + */ +function checkDropdownOptionsInTable( + block: Block, + dropdownName: string, + lookupTable: {[key: string]: string}, +) { + const dropdown = block.getField(dropdownName); + if (!(dropdown instanceof FieldDropdown) || dropdown.isOptionListDynamic()) { + return; + } + + const options = dropdown.getOptions(); + for (const option of options) { + if (option === FieldDropdown.SEPARATOR) continue; + + const [, key] = option; + if (lookupTable[key] === undefined) { + console.warn( + `No tooltip mapping for value ${key} of field ` + + `${dropdownName} of block type ${block.type}.`, + ); + } + } +} + +/** + * Builds an extension function that will install a dynamic tooltip. The + * tooltip message should include the string '%1' and that string will be + * replaced with the text of the named field. + * + * @param msgTemplate The template form to of the message text, with %1 + * placeholder. + * @param fieldName The field with the replacement text. + * @returns The extension function. + */ +export function buildTooltipWithFieldText( + msgTemplate: string, + fieldName: string, +): (this: Block) => void { + return function (this: Block) { + this.setTooltip( + function (this: Block) { + const field = this.getField(fieldName); + return parsing + .replaceMessageReferences(msgTemplate) + .replace('%1', field ? field.getText() : ''); + }.bind(this), + ); + }; +} + +/** + * Configures the tooltip to mimic the parent block when connected. Otherwise, + * uses the tooltip text at the time this extension is initialized. This takes + * advantage of the fact that all other values from JSON are initialized before + * extensions. + */ +function extensionParentTooltip(this: Block) { + const tooltipWhenNotConnected = this.tooltip; + this.setTooltip( + function (this: Block) { + const parent = this.getParent(); + return ( + (parent && parent.getInputsInline() && parent.tooltip) || + tooltipWhenNotConnected + ); + }.bind(this), + ); +} +register('parent_tooltip_when_inline', extensionParentTooltip); diff --git a/core/field.js b/core/field.js deleted file mode 100644 index 4060ac62b3a..00000000000 --- a/core/field.js +++ /dev/null @@ -1,537 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Field. Used for editable titles, variables, etc. - * This is an abstract class that defines the UI on the block. Actual - * instances would be Blockly.FieldTextInput, Blockly.FieldDropdown, etc. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Field'); - -goog.require('Blockly.Gesture'); - -goog.require('goog.asserts'); -goog.require('goog.dom'); -goog.require('goog.math.Size'); -goog.require('goog.style'); -goog.require('goog.userAgent'); - - -/** - * Abstract class for an editable field. - * @param {string} text The initial content of the field. - * @param {Function=} opt_validator An optional function that is called - * to validate any constraints on what the user entered. Takes the new - * text as an argument and returns either the accepted text, a replacement - * text, or null to abort the change. - * @constructor - */ -Blockly.Field = function(text, opt_validator) { - this.size_ = new goog.math.Size(0, Blockly.BlockSvg.MIN_BLOCK_Y); - this.setValue(text); - this.setValidator(opt_validator); -}; - -/** - * Temporary cache of text widths. - * @type {Object} - * @private - */ -Blockly.Field.cacheWidths_ = null; - -/** - * Number of current references to cache. - * @type {number} - * @private - */ -Blockly.Field.cacheReference_ = 0; - - -/** - * Name of field. Unique within each block. - * Static labels are usually unnamed. - * @type {string|undefined} - */ -Blockly.Field.prototype.name = undefined; - -/** - * Maximum characters of text to display before adding an ellipsis. - * @type {number} - */ -Blockly.Field.prototype.maxDisplayLength = 50; - -/** - * Visible text to display. - * @type {string} - * @private - */ -Blockly.Field.prototype.text_ = ''; - -/** - * Block this field is attached to. Starts as null, then in set in init. - * @type {Blockly.Block} - * @private - */ -Blockly.Field.prototype.sourceBlock_ = null; - -/** - * Is the field visible, or hidden due to the block being collapsed? - * @type {boolean} - * @private - */ -Blockly.Field.prototype.visible_ = true; - -/** - * Validation function called when user edits an editable field. - * @type {Function} - * @private - */ -Blockly.Field.prototype.validator_ = null; - -/** - * Non-breaking space. - * @const - */ -Blockly.Field.NBSP = '\u00A0'; - -/** - * Editable fields are saved by the XML renderer, non-editable fields are not. - */ -Blockly.Field.prototype.EDITABLE = true; - -/** - * Attach this field to a block. - * @param {!Blockly.Block} block The block containing this field. - */ -Blockly.Field.prototype.setSourceBlock = function(block) { - goog.asserts.assert(!this.sourceBlock_, 'Field already bound to a block.'); - this.sourceBlock_ = block; -}; - -/** - * Install this field on a block. - */ -Blockly.Field.prototype.init = function() { - if (this.fieldGroup_) { - // Field has already been initialized once. - return; - } - // Build the DOM. - this.fieldGroup_ = Blockly.utils.createSvgElement('g', {}, null); - if (!this.visible_) { - this.fieldGroup_.style.display = 'none'; - } - this.borderRect_ = Blockly.utils.createSvgElement('rect', - {'rx': 4, - 'ry': 4, - 'x': -Blockly.BlockSvg.SEP_SPACE_X / 2, - 'y': 0, - 'height': 16}, this.fieldGroup_, this.sourceBlock_.workspace); - /** @type {!Element} */ - this.textElement_ = Blockly.utils.createSvgElement('text', - {'class': 'blocklyText', 'y': this.size_.height - 12.5}, - this.fieldGroup_); - - this.updateEditable(); - this.sourceBlock_.getSvgRoot().appendChild(this.fieldGroup_); - this.mouseDownWrapper_ = - Blockly.bindEventWithChecks_(this.fieldGroup_, 'mousedown', this, - this.onMouseDown_); - // Force a render. - this.render_(); -}; - -/** - * Initializes the model of the field after it has been installed on a block. - * No-op by default. - */ -Blockly.Field.prototype.initModel = function() { -}; - -/** - * Dispose of all DOM objects belonging to this editable field. - */ -Blockly.Field.prototype.dispose = function() { - if (this.mouseDownWrapper_) { - Blockly.unbindEvent_(this.mouseDownWrapper_); - this.mouseDownWrapper_ = null; - } - this.sourceBlock_ = null; - goog.dom.removeNode(this.fieldGroup_); - this.fieldGroup_ = null; - this.textElement_ = null; - this.borderRect_ = null; - this.validator_ = null; -}; - -/** - * Add or remove the UI indicating if this field is editable or not. - */ -Blockly.Field.prototype.updateEditable = function() { - var group = this.fieldGroup_; - if (!this.EDITABLE || !group) { - return; - } - if (this.sourceBlock_.isEditable()) { - Blockly.utils.addClass(group, 'blocklyEditableText'); - Blockly.utils.removeClass(group, 'blocklyNonEditableText'); - this.fieldGroup_.style.cursor = this.CURSOR; - } else { - Blockly.utils.addClass(group, 'blocklyNonEditableText'); - Blockly.utils.removeClass(group, 'blocklyEditableText'); - this.fieldGroup_.style.cursor = ''; - } -}; - -/** - * Check whether this field is currently editable. Some fields are never - * editable (e.g. text labels). Those fields are not serialized to XML. Other - * fields may be editable, and therefore serialized, but may exist on - * non-editable blocks. - * @return {boolean} whether this field is editable and on an editable block - */ -Blockly.Field.prototype.isCurrentlyEditable = function() { - return this.EDITABLE && !!this.sourceBlock_ && this.sourceBlock_.isEditable(); -}; - -/** - * Gets whether this editable field is visible or not. - * @return {boolean} True if visible. - */ -Blockly.Field.prototype.isVisible = function() { - return this.visible_; -}; - -/** - * Sets whether this editable field is visible or not. - * @param {boolean} visible True if visible. - */ -Blockly.Field.prototype.setVisible = function(visible) { - if (this.visible_ == visible) { - return; - } - this.visible_ = visible; - var root = this.getSvgRoot(); - if (root) { - root.style.display = visible ? 'block' : 'none'; - this.render_(); - } -}; - -/** - * Sets a new validation function for editable fields. - * @param {Function} handler New validation function, or null. - */ -Blockly.Field.prototype.setValidator = function(handler) { - this.validator_ = handler; -}; - -/** - * Gets the validation function for editable fields. - * @return {Function} Validation function, or null. - */ -Blockly.Field.prototype.getValidator = function() { - return this.validator_; -}; - -/** - * Validates a change. Does nothing. Subclasses may override this. - * @param {string} text The user's text. - * @return {string} No change needed. - */ -Blockly.Field.prototype.classValidator = function(text) { - return text; -}; - -/** - * Calls the validation function for this field, as well as all the validation - * function for the field's class and its parents. - * @param {string} text Proposed text. - * @return {?string} Revised text, or null if invalid. - */ -Blockly.Field.prototype.callValidator = function(text) { - var classResult = this.classValidator(text); - if (classResult === null) { - // Class validator rejects value. Game over. - return null; - } else if (classResult !== undefined) { - text = classResult; - } - var userValidator = this.getValidator(); - if (userValidator) { - var userResult = userValidator.call(this, text); - if (userResult === null) { - // User validator rejects value. Game over. - return null; - } else if (userResult !== undefined) { - text = userResult; - } - } - return text; -}; - -/** - * Gets the group element for this editable field. - * Used for measuring the size and for positioning. - * @return {!Element} The group element. - */ -Blockly.Field.prototype.getSvgRoot = function() { - return /** @type {!Element} */ (this.fieldGroup_); -}; - -/** - * Draws the border with the correct width. - * Saves the computed width in a property. - * @private - */ -Blockly.Field.prototype.render_ = function() { - if (!this.visible_) { - this.size_.width = 0; - return; - } - - // Replace the text. - goog.dom.removeChildren(/** @type {!Element} */ (this.textElement_)); - var textNode = document.createTextNode(this.getDisplayText_()); - this.textElement_.appendChild(textNode); - - this.updateWidth(); -}; - -/** - * Updates thw width of the field. This calls getCachedWidth which won't cache - * the approximated width on IE/Edge when `getComputedTextLength` fails. Once - * it eventually does succeed, the result will be cached. - **/ -Blockly.Field.prototype.updateWidth = function() { - var width = Blockly.Field.getCachedWidth(this.textElement_); - if (this.borderRect_) { - this.borderRect_.setAttribute('width', - width + Blockly.BlockSvg.SEP_SPACE_X); - } - this.size_.width = width; -}; - -/** - * Gets the width of a text element, caching it in the process. - * @param {!Element} textElement An SVG 'text' element. - * @return {number} Width of element. - */ -Blockly.Field.getCachedWidth = function(textElement) { - var key = textElement.textContent + '\n' + textElement.className.baseVal; - var width; - - // Return the cached width if it exists. - if (Blockly.Field.cacheWidths_) { - width = Blockly.Field.cacheWidths_[key]; - if (width) { - return width; - } - } - - // Attempt to compute fetch the width of the SVG text element. - try { - width = textElement.getComputedTextLength(); - } catch (e) { - // MSIE 11 and Edge are known to throw "Unexpected call to method or - // property access." if the block is hidden. Instead, use an - // approximation and do not cache the result. At some later point in time - // when the block is inserted into the visible DOM, this method will be - // called again and, at that point in time, will not throw an exception. - return textElement.textContent.length * 8; - } - - // Cache the computed width and return. - if (Blockly.Field.cacheWidths_) { - Blockly.Field.cacheWidths_[key] = width; - } - return width; -}; - -/** - * Start caching field widths. Every call to this function MUST also call - * stopCache. Caches must not survive between execution threads. - */ -Blockly.Field.startCache = function() { - Blockly.Field.cacheReference_++; - if (!Blockly.Field.cacheWidths_) { - Blockly.Field.cacheWidths_ = {}; - } -}; - -/** - * Stop caching field widths. Unless caching was already on when the - * corresponding call to startCache was made. - */ -Blockly.Field.stopCache = function() { - Blockly.Field.cacheReference_--; - if (!Blockly.Field.cacheReference_) { - Blockly.Field.cacheWidths_ = null; - } -}; - -/** - * Returns the height and width of the field. - * @return {!goog.math.Size} Height and width. - */ -Blockly.Field.prototype.getSize = function() { - if (!this.size_.width) { - this.render_(); - } - return this.size_; -}; - -/** - * Returns the height and width of the field, - * accounting for the workspace scaling. - * @return {!goog.math.Size} Height and width. - * @private - */ -Blockly.Field.prototype.getScaledBBox_ = function() { - var bBox = this.borderRect_.getBBox(); - // Create new object, as getBBox can return an uneditable SVGRect in IE. - return new goog.math.Size(bBox.width * this.sourceBlock_.workspace.scale, - bBox.height * this.sourceBlock_.workspace.scale); -}; - -/** - * Get the text from this field as displayed on screen. May differ from getText - * due to ellipsis, and other formatting. - * @return {string} Currently displayed text. - * @private - */ -Blockly.Field.prototype.getDisplayText_ = function() { - var text = this.text_; - if (!text) { - // Prevent the field from disappearing if empty. - return Blockly.Field.NBSP; - } - if (text.length > this.maxDisplayLength) { - // Truncate displayed string and add an ellipsis ('...'). - text = text.substring(0, this.maxDisplayLength - 2) + '\u2026'; - } - // Replace whitespace with non-breaking spaces so the text doesn't collapse. - text = text.replace(/\s/g, Blockly.Field.NBSP); - if (this.sourceBlock_.RTL) { - // The SVG is LTR, force text to be RTL. - text += '\u200F'; - } - return text; -}; - -/** - * Get the text from this field. - * @return {string} Current text. - */ -Blockly.Field.prototype.getText = function() { - return this.text_; -}; - -/** - * Set the text in this field. Trigger a rerender of the source block. - * @param {*} newText New text. - */ -Blockly.Field.prototype.setText = function(newText) { - if (newText === null) { - // No change if null. - return; - } - newText = String(newText); - if (newText === this.text_) { - // No change. - return; - } - this.text_ = newText; - // Set width to 0 to force a rerender of this field. - this.size_.width = 0; - - if (this.sourceBlock_ && this.sourceBlock_.rendered) { - this.sourceBlock_.render(); - this.sourceBlock_.bumpNeighbours_(); - } -}; - -/** - * By default there is no difference between the human-readable text and - * the language-neutral values. Subclasses (such as dropdown) may define this. - * @return {string} Current value. - */ -Blockly.Field.prototype.getValue = function() { - return this.getText(); -}; - -/** - * By default there is no difference between the human-readable text and - * the language-neutral values. Subclasses (such as dropdown) may define this. - * @param {string} newValue New value. - */ -Blockly.Field.prototype.setValue = function(newValue) { - if (newValue === null) { - // No change if null. - return; - } - var oldValue = this.getValue(); - if (oldValue == newValue) { - return; - } - if (this.sourceBlock_ && Blockly.Events.isEnabled()) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this.sourceBlock_, 'field', this.name, oldValue, newValue)); - } - this.setText(newValue); -}; - -/** - * Handle a mouse down event on a field. - * @param {!Event} e Mouse down event. - * @private - */ -Blockly.Field.prototype.onMouseDown_ = function(e) { - if (!this.sourceBlock_ || !this.sourceBlock_.workspace) { - return; - } - var gesture = this.sourceBlock_.workspace.getGesture(e); - if (gesture) { - gesture.setStartField(this); - } -}; - - -/** - * Change the tooltip text for this field. - * @param {string|!Element} newTip Text for tooltip or a parent element to - * link to for its tooltip. - */ -Blockly.Field.prototype.setTooltip = function(newTip) { - // Non-abstract sub-classes may wish to implement this. See FieldLabel. -}; - -/** - * Return the absolute coordinates of the top-left corner of this field. - * The origin (0,0) is the top-left corner of the page body. - * @return {!goog.math.Coordinate} Object with .x and .y properties. - * @private - */ -Blockly.Field.prototype.getAbsoluteXY_ = function() { - return goog.style.getPageOffset(this.borderRect_); -}; diff --git a/core/field.ts b/core/field.ts new file mode 100644 index 00000000000..e025efab709 --- /dev/null +++ b/core/field.ts @@ -0,0 +1,1445 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Field. Used for editable titles, variables, etc. + * This is an abstract class that defines the UI on the block. Actual + * instances would be FieldTextInput, FieldDropdown, etc. + * + * @class + */ +// Former goog.module ID: Blockly.Field + +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_block_change.js'; + +import type {Block} from './block.js'; +import type {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; +import * as dropDownDiv from './dropdowndiv.js'; +import {EventType} from './events/type.js'; +import * as eventUtils from './events/utils.js'; +import type {Input} from './inputs/input.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import type {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js'; +import type {IRegistrable} from './interfaces/i_registrable.js'; +import {ISerializable} from './interfaces/i_serializable.js'; +import type {ConstantProvider} from './renderers/common/constants.js'; +import type {KeyboardShortcut} from './shortcut_registry.js'; +import * as Tooltip from './tooltip.js'; +import type {Coordinate} from './utils/coordinate.js'; +import * as dom from './utils/dom.js'; +import * as idGenerator from './utils/idgenerator.js'; +import * as parsing from './utils/parsing.js'; +import {Rect} from './utils/rect.js'; +import {Size} from './utils/size.js'; +import * as style from './utils/style.js'; +import {Svg} from './utils/svg.js'; +import * as userAgent from './utils/useragent.js'; +import * as utilsXml from './utils/xml.js'; +import * as WidgetDiv from './widgetdiv.js'; +import {WorkspaceSvg} from './workspace_svg.js'; + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldValidator = (newValue: T) => T | null | undefined; + +/** + * Abstract class for an editable field. + * + * @typeParam T - The value stored on the field. + */ +export abstract class Field + implements IKeyboardAccessible, IRegistrable, ISerializable, IFocusableNode +{ + /** + * To overwrite the default value which is set in **Field**, directly update + * the prototype. + * + * Example: + * `FieldImage.prototype.DEFAULT_VALUE = null;` + */ + DEFAULT_VALUE: T | null = null; + + /** Non-breaking space. */ + static readonly NBSP = '\u00A0'; + + /** + * A value used to signal when a field's constructor should *not* set the + * field's value or run configure_, and should allow a subclass to do that + * instead. + */ + static readonly SKIP_SETUP = Symbol('SKIP_SETUP'); + + /** + * Name of field. Unique within each block. + * Static labels are usually unnamed. + */ + name?: string = undefined; + protected value_: T | null; + + /** Validation function called when user edits an editable field. */ + protected validator_: FieldValidator | null = null; + + /** + * Used to cache the field's tooltip value if setTooltip is called when the + * field is not yet initialized. Is *not* guaranteed to be accurate. + */ + private tooltip: Tooltip.TipInfo | null = null; + + /** This field's dimensions. */ + private size: Size = new Size(0, 0); + + /** + * Gets the size of this field. Because getSize() and updateSize() have side + * effects, this acts as a shim for subclasses which wish to adjust field + * bounds when setting/getting the size without triggering unwanted rendering + * or other side effects. Note that subclasses must override *both* get and + * set if either is overridden; the implementation may just call directly + * through to super, but it must exist per the JS spec. + */ + protected get size_(): Size { + return this.size; + } + + protected set size_(newValue: Size) { + this.size = newValue; + } + + /** The rendered field's SVG group element. */ + protected fieldGroup_: SVGGElement | null = null; + + /** The rendered field's SVG border element. */ + protected borderRect_: SVGRectElement | null = null; + + /** The rendered field's SVG text element. */ + protected textElement_: SVGTextElement | null = null; + + /** The rendered field's text content element. */ + protected textContent_: Text | null = null; + + /** Mouse down event listener data. */ + private mouseDownWrapper: browserEvents.Data | null = null; + + /** Constants associated with the source block's renderer. */ + protected constants_: ConstantProvider | null = null; + + /** + * Has this field been disposed of? + * + * @internal + */ + disposed = false; + + /** Maximum characters of text to display before adding an ellipsis. */ + maxDisplayLength = 50; + + /** Block this field is attached to. Starts as null, then set in init. */ + protected sourceBlock_: Block | null = null; + + /** Does this block need to be re-rendered? */ + protected isDirty_ = true; + + /** Is the field visible, or hidden due to the block being collapsed? */ + protected visible_ = true; + + /** + * Can the field value be changed using the editor on an editable block? + */ + protected enabled_ = true; + + /** The element the click handler is bound to. */ + protected clickTarget_: Element | null = null; + + /** + * The prefix field. + * + * @internal + */ + prefixField: string | null = null; + + /** + * The suffix field. + * + * @internal + */ + suffixField: string | null = null; + + /** + * Editable fields usually show some sort of UI indicating they are + * editable. They will also be saved by the serializer. + */ + EDITABLE = true; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. This is not the + * case by default so that SERIALIZABLE is backwards compatible. + */ + SERIALIZABLE = false; + + /** The unique ID of this field. */ + private id_: string | null = null; + + /** + * @param value The initial value of the field. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field value + * after their own constructors have run). + * @param validator A function that is called to validate changes to the + * field's value. Takes in a value & returns a validated value, or null to + * abort the change. + * @param config A map of options used to configure the field. + * Refer to the individual field's documentation for a list of properties + * this parameter supports. + */ + constructor( + value: T | typeof Field.SKIP_SETUP, + validator?: FieldValidator | null, + config?: FieldConfig, + ) { + /** + * A generic value possessed by the field. + * Should generally be non-null, only null when the field is created. + */ + this.value_ = + 'DEFAULT_VALUE' in new.target.prototype + ? new.target.prototype.DEFAULT_VALUE + : this.DEFAULT_VALUE; + + /** The size of the area rendered by the field. */ + this.size_ = new Size(0, 0); + + if (value === Field.SKIP_SETUP) return; + if (config) { + this.configure_(config); + } + this.setValue(value); + if (validator) { + this.setValidator(validator); + } + } + + /** + * Process the configuration map passed to the field. + * + * @param config A map of options used to configure the field. See the + * individual field's documentation for a list of properties this + * parameter supports. + */ + protected configure_(config: FieldConfig) { + // TODO (#2884): Possibly add CSS class config option. + // TODO (#2885): Possibly add cursor config option. + if (config.tooltip) { + this.setTooltip(parsing.replaceMessageReferences(config.tooltip)); + } + } + + /** + * Attach this field to a block. + * + * @param block The block containing this field. + */ + setSourceBlock(block: Block) { + if (this.sourceBlock_) { + throw Error('Field already bound to a block'); + } + this.sourceBlock_ = block; + if (block.id.includes('_field')) { + throw new Error( + `Field ID indicator is contained in block ID. This may cause ` + + `problems with focus: ${block.id}.`, + ); + } + this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; + } + + /** + * Get the renderer constant provider. + * + * @returns The renderer constant provider. + */ + getConstants(): ConstantProvider | null { + if ( + !this.constants_ && + this.sourceBlock_ && + !this.sourceBlock_.isDeadOrDying() && + this.sourceBlock_.workspace.rendered + ) { + this.constants_ = (this.sourceBlock_.workspace as WorkspaceSvg) + .getRenderer() + .getConstants(); + } + return this.constants_; + } + + /** + * Get the block this field is attached to. + * + * @returns The block containing this field. + * @throws An error if the source block is not defined. + */ + getSourceBlock(): Block | null { + return this.sourceBlock_; + } + + /** + * Initialize everything to render this field. Override + * methods initModel and initView rather than this method. + * + * @sealed + * @internal + */ + init() { + if (this.fieldGroup_) { + // Field has already been initialized once. + return; + } + const id = this.id_; + if (!id) throw new Error('Expected ID to be defined prior to init.'); + this.fieldGroup_ = dom.createSvgElement(Svg.G, { + 'id': id, + }); + if (!this.isVisible()) { + this.fieldGroup_.style.display = 'none'; + } + const sourceBlockSvg = this.sourceBlock_ as BlockSvg; + sourceBlockSvg.getSvgRoot().appendChild(this.fieldGroup_); + this.initView(); + this.updateEditable(); + this.setTooltip(this.tooltip); + this.bindEvents_(); + this.initModel(); + this.applyColour(); + } + + /** + * Create the block UI for this field. + */ + protected initView() { + this.createBorderRect_(); + this.createTextElement_(); + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyField'); + } + } + + /** + * Initializes the model of the field after it has been installed on a block. + * No-op by default. + */ + initModel() {} + + /** + * Defines whether this field should take up the full block or not. + * + * Be cautious when overriding this function. It may not work as you expect / + * intend because the behavior was kind of hacked in. If you are thinking + * about overriding this function, post on the forum with your intended + * behavior to see if there's another approach. + * + * @internal + */ + isFullBlockField(): boolean { + return !this.borderRect_; + } + + /** + * Create a field border rect element. Not to be overridden by subclasses. + * Instead modify the result of the function inside initView, or create a + * separate function to call. + */ + protected createBorderRect_() { + this.borderRect_ = dom.createSvgElement( + Svg.RECT, + { + 'rx': this.getConstants()!.FIELD_BORDER_RECT_RADIUS, + 'ry': this.getConstants()!.FIELD_BORDER_RECT_RADIUS, + 'x': 0, + 'y': 0, + 'height': this.size_.height, + 'width': this.size_.width, + 'class': 'blocklyFieldRect', + }, + this.fieldGroup_, + ); + } + + /** + * Create a field text element. Not to be overridden by subclasses. Instead + * modify the result of the function inside initView, or create a separate + * function to call. + */ + protected createTextElement_() { + this.textElement_ = dom.createSvgElement( + Svg.TEXT, + { + 'class': 'blocklyText blocklyFieldText', + }, + this.fieldGroup_, + ); + if (this.getConstants()!.FIELD_TEXT_BASELINE_CENTER) { + this.textElement_.setAttribute('dominant-baseline', 'central'); + } + this.textContent_ = document.createTextNode(''); + this.textElement_.appendChild(this.textContent_); + } + + /** + * Bind events to the field. Can be overridden by subclasses if they need to + * do custom input handling. + */ + protected bindEvents_() { + const clickTarget = this.getClickTarget_(); + if (!clickTarget) throw new Error('A click target has not been set.'); + Tooltip.bindMouseEvents(clickTarget); + this.mouseDownWrapper = browserEvents.conditionalBind( + clickTarget, + 'pointerdown', + this, + this.onMouseDown_, + ); + } + + /** + * Sets the field's value based on the given XML element. Should only be + * called by Blockly.Xml. + * + * @param fieldElement The element containing info about the field's state. + */ + fromXml(fieldElement: Element) { + // Any because gremlins live here. No touchie! + this.setValue(fieldElement.textContent as any); + } + + /** + * Serializes this field's value to XML. Should only be called by Blockly.Xml. + * + * @param fieldElement The element to populate with info about the field's + * state. + * @returns The element containing info about the field's state. + */ + toXml(fieldElement: Element): Element { + // Any because gremlins live here. No touchie! + fieldElement.textContent = this.getValue() as any; + return fieldElement; + } + + /** + * Saves this fields value as something which can be serialized to JSON. + * Should only be called by the serialization system. + * + * @param _doFullSerialization If true, this signals to the field that if it + * normally just saves a reference to some state (eg variable fields) it + * should instead serialize the full state of the thing being referenced. + * See the + * {@link https://developers.devsite.google.com/blockly/guides/create-custom-blocks/fields/customizing-fields/creating#full_serialization_and_backing_data | field serialization docs} + * for more information. + * @returns JSON serializable state. + */ + saveState(_doFullSerialization?: boolean): AnyDuringMigration { + const legacyState = this.saveLegacyState(Field); + if (legacyState !== null) { + return legacyState; + } + return this.getValue(); + } + + /** + * Sets the field's state based on the given state value. Should only be + * called by the serialization system. + * + * @param state The state we want to apply to the field. + */ + loadState(state: AnyDuringMigration) { + if (this.loadLegacyState(Field, state)) { + return; + } + this.setValue(state); + } + + /** + * Returns a stringified version of the XML state, if it should be used. + * Otherwise this returns null, to signal the field should use its own + * serialization. + * + * @param callingClass The class calling this method. + * Used to see if `this` has overridden any relevant hooks. + * @returns The stringified version of the XML state, or null. + */ + protected saveLegacyState(callingClass: FieldProto): string | null { + if ( + callingClass.prototype.saveState === this.saveState && + callingClass.prototype.toXml !== this.toXml + ) { + const elem = utilsXml.createElement('field'); + elem.setAttribute('name', this.name || ''); + const text = utilsXml.domToText(this.toXml(elem)); + return text.replace( + ' xmlns="https://developers.google.com/blockly/xml"', + '', + ); + } + // Either they called this on purpose from their saveState, or they have + // no implementations of either hook. Just do our thing. + return null; + } + + /** + * Loads the given state using either the old XML hooks, if they should be + * used. Returns true to indicate loading has been handled, false otherwise. + * + * @param callingClass The class calling this method. + * Used to see if `this` has overridden any relevant hooks. + * @param state The state to apply to the field. + * @returns Whether the state was applied or not. + */ + loadLegacyState( + callingClass: FieldProto, + state: AnyDuringMigration, + ): boolean { + if ( + callingClass.prototype.loadState === this.loadState && + callingClass.prototype.fromXml !== this.fromXml + ) { + this.fromXml(utilsXml.textToDom(state as string)); + return true; + } + // Either they called this on purpose from their loadState, or they have + // no implementations of either hook. Just do our thing. + return false; + } + + /** + * Dispose of all DOM objects and events belonging to this editable field. + */ + dispose() { + dropDownDiv.hideIfOwner(this); + WidgetDiv.hideIfOwner(this); + + if (!this.getSourceBlock()?.isDeadOrDying()) { + dom.removeNode(this.fieldGroup_); + } + + this.disposed = true; + } + + /** Add or remove the UI indicating if this field is editable or not. */ + updateEditable() { + const group = this.fieldGroup_; + const block = this.getSourceBlock(); + if (!this.EDITABLE || !group || !block) { + return; + } + if (this.enabled_ && block.isEditable()) { + dom.addClass(group, 'blocklyEditableField'); + dom.removeClass(group, 'blocklyNonEditableField'); + } else { + dom.addClass(group, 'blocklyNonEditableField'); + dom.removeClass(group, 'blocklyEditableField'); + } + } + + /** + * Set whether this field's value can be changed using the editor when the + * source block is editable. + * + * @param enabled True if enabled. + */ + setEnabled(enabled: boolean) { + this.enabled_ = enabled; + this.updateEditable(); + } + + /** + * Check whether this field's value can be changed using the editor when the + * source block is editable. + * + * @returns Whether this field is enabled. + */ + isEnabled(): boolean { + return this.enabled_; + } + + /** + * Check whether this field defines the showEditor_ function. + * + * @returns Whether this field is clickable. + */ + isClickable(): boolean { + return ( + this.enabled_ && + !!this.sourceBlock_ && + this.sourceBlock_.isEditable() && + this.showEditor_ !== Field.prototype.showEditor_ + ); + } + + /** + * Check whether the field should be clickable while the block is in a flyout. + * The default is that fields are clickable in always-open flyouts such as the + * simple toolbox, but not in autoclosing flyouts such as the category toolbox. + * Subclasses may override this function to change this behavior. Note that + * `isClickable` must also return true for this to have any effect. + * + * @param autoClosingFlyout true if the containing flyout is an auto-closing one. + * @returns Whether the field should be clickable while the block is in a flyout. + */ + isClickableInFlyout(autoClosingFlyout: boolean): boolean { + return !autoClosingFlyout; + } + + /** + * Check whether this field is currently editable. Some fields are never + * EDITABLE (e.g. text labels). Other fields may be EDITABLE but may exist on + * non-editable blocks or be currently disabled. + * + * @returns Whether this field is currently enabled, editable and on an + * editable block. + */ + isCurrentlyEditable(): boolean { + return ( + this.enabled_ && + this.EDITABLE && + !!this.sourceBlock_ && + this.sourceBlock_.isEditable() + ); + } + + /** + * Check whether this field should be serialized by the XML renderer. + * Handles the logic for backwards compatibility and incongruous states. + * + * @returns Whether this field should be serialized or not. + */ + isSerializable(): boolean { + let isSerializable = false; + if (this.name) { + if (this.SERIALIZABLE) { + isSerializable = true; + } else if (this.EDITABLE) { + console.warn( + 'Detected an editable field that was not serializable.' + + ' Please define SERIALIZABLE property as true on all editable custom' + + ' fields. Proceeding with serialization.', + ); + isSerializable = true; + } + } + return isSerializable; + } + + /** + * Gets whether this editable field is visible or not. + * + * @returns True if visible. + */ + isVisible(): boolean { + return this.visible_; + } + + /** + * Sets whether this editable field is visible or not. Should only be called + * by input.setVisible. + * + * @param visible True if visible. + * @internal + */ + setVisible(visible: boolean) { + if (this.visible_ === visible) { + return; + } + this.visible_ = visible; + const root = this.fieldGroup_; + if (root) { + root.style.display = visible ? 'block' : 'none'; + } + } + + /** + * Sets a new validation function for editable fields, or clears a previously + * set validator. + * + * The validator function takes in the new field value, and returns + * validated value. The validated value could be the input value, a modified + * version of the input value, or null to abort the change. + * + * If the function does not return anything (or returns undefined) the new + * value is accepted as valid. This is to allow for fields using the + * validated function as a field-level change event notification. + * + * @param handler The validator function or null to clear a previous + * validator. + */ + setValidator(handler: FieldValidator) { + this.validator_ = handler; + } + + /** + * Gets the validation function for editable fields, or null if not set. + * + * @returns Validation function, or null. + */ + getValidator(): FieldValidator | null { + return this.validator_; + } + + /** + * Gets the group element for this editable field. + * Used for measuring the size and for positioning. + * + * @returns The group element. + */ + getSvgRoot(): SVGGElement | null { + return this.fieldGroup_; + } + + /** + * Gets the border rectangle element. + * + * @returns The border rectangle element. + * @throws An error if the border rectangle element is not defined. + */ + protected getBorderRect(): SVGRectElement { + if (!this.borderRect_) { + throw new Error(`The border rectangle is ${this.borderRect_}.`); + } + return this.borderRect_; + } + + /** + * Gets the text element. + * + * @returns The text element. + * @throws An error if the text element is not defined. + */ + protected getTextElement(): SVGTextElement { + if (!this.textElement_) { + throw new Error(`The text element is ${this.textElement_}.`); + } + return this.textElement_; + } + + /** + * Gets the text content. + * + * @returns The text content. + * @throws An error if the text content is not defined. + */ + protected getTextContent(): Text { + if (!this.textContent_) { + throw new Error(`The text content is ${this.textContent_}.`); + } + return this.textContent_; + } + + /** + * Updates the field to match the colour/style of the block. + * + * Non-abstract sub-classes may wish to implement this if the colour of the + * field depends on the colour of the block. It will automatically be called + * at relevant times, such as when the parent block or renderer changes. + * + * See {@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/customizing-fields/creating#matching_block_colours + * | the field documentation} for more information, or FieldDropdown for an + * example. + */ + applyColour() {} + + /** + * Used by getSize() to move/resize any DOM elements, and get the new size. + * + * All rendering that has an effect on the size/shape of the block should be + * done here, and should be triggered by getSize(). + */ + protected render_() { + if (this.textContent_) { + this.textContent_.nodeValue = this.getDisplayText_(); + } + this.updateSize_(); + } + + /** + * Calls showEditor_ when the field is clicked if the field is clickable. + * Do not override. + * + * @param e Optional mouse event that triggered the field to open, or + * undefined if triggered programmatically. + * @sealed + * @internal + */ + showEditor(e?: Event) { + if (this.isClickable()) { + this.showEditor_(e); + } + } + + /** + * A developer hook to create an editor for the field. This is no-op by + * default, and must be overriden to create an editor. + * + * @param _e Optional mouse event that triggered the field to open, or + * undefined if triggered programmatically. + */ + protected showEditor_(_e?: Event): void {} + // NOP + + /** + * A developer hook to reposition the WidgetDiv during a window resize. You + * need to define this hook if your field has a WidgetDiv that needs to + * reposition itself when the window is resized. For example, text input + * fields define this hook so that the input WidgetDiv can reposition itself + * on a window resize event. This is especially important when modal inputs + * have been disabled, as Android devices will fire a window resize event when + * the soft keyboard opens. + * + * If you want the WidgetDiv to hide itself instead of repositioning, return + * false. This is the default behavior. + * + * DropdownDivs already handle their own positioning logic, so you do not need + * to override this function if your field only has a DropdownDiv. + * + * @returns True if the field should be repositioned, + * false if the WidgetDiv should hide itself instead. + */ + repositionForWindowResize(): boolean { + return false; + } + + /** + * Updates the size of the field based on the text. + * + * @param margin margin to use when positioning the text element. + */ + protected updateSize_(margin?: number) { + const constants = this.getConstants(); + const xOffset = + margin !== undefined + ? margin + : !this.isFullBlockField() + ? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING + : 0; + let totalWidth = xOffset * 2; + let totalHeight = constants!.FIELD_TEXT_HEIGHT; + + let contentWidth = 0; + if (this.textElement_) { + contentWidth = dom.getTextWidth(this.textElement_); + totalWidth += contentWidth; + } + if (!this.isFullBlockField()) { + totalHeight = Math.max(totalHeight, constants!.FIELD_BORDER_RECT_HEIGHT); + } + + this.size_ = new Size(totalWidth, totalHeight); + + this.positionTextElement_(xOffset, contentWidth); + this.positionBorderRect_(); + } + + /** + * Position a field's text element after a size change. This handles both LTR + * and RTL positioning. + * + * @param xOffset x offset to use when positioning the text element. + * @param contentWidth The content width. + */ + protected positionTextElement_(xOffset: number, contentWidth: number) { + if (!this.textElement_) { + return; + } + const constants = this.getConstants(); + const halfHeight = this.size_.height / 2; + + this.textElement_.setAttribute( + 'x', + String( + this.getSourceBlock()?.RTL + ? this.size_.width - contentWidth - xOffset + : xOffset, + ), + ); + this.textElement_.setAttribute( + 'y', + String( + constants!.FIELD_TEXT_BASELINE_CENTER + ? halfHeight + : halfHeight - + constants!.FIELD_TEXT_HEIGHT / 2 + + constants!.FIELD_TEXT_BASELINE, + ), + ); + } + + /** Position a field's border rect after a size change. */ + protected positionBorderRect_() { + if (!this.borderRect_) { + return; + } + this.borderRect_.setAttribute('width', String(this.size_.width)); + this.borderRect_.setAttribute('height', String(this.size_.height)); + this.borderRect_.setAttribute( + 'rx', + String(this.getConstants()!.FIELD_BORDER_RECT_RADIUS), + ); + this.borderRect_.setAttribute( + 'ry', + String(this.getConstants()!.FIELD_BORDER_RECT_RADIUS), + ); + } + + /** + * Returns the height and width of the field. + * + * This should *in general* be the only place render_ gets called from. + * + * @returns Height and width. + */ + getSize(): Size { + if (!this.isVisible()) { + return new Size(0, 0); + } + + if (this.isDirty_) { + this.render_(); + this.isDirty_ = false; + } + return this.size_; + } + + /** + * Returns the bounding box of the rendered field, accounting for workspace + * scaling. + * + * @returns An object with top, bottom, left, and right in pixels relative to + * the top left corner of the page (window coordinates). + * @internal + */ + getScaledBBox(): Rect { + let scaledWidth; + let scaledHeight; + let xy; + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + + if (this.isFullBlockField()) { + // Browsers are inconsistent in what they return for a bounding box. + // - Webkit / Blink: fill-box / object bounding box + // - Gecko: stroke-box + const bBox = (this.sourceBlock_ as BlockSvg).getHeightWidth(); + const scale = (block.workspace as WorkspaceSvg).scale; + xy = this.getAbsoluteXY_(); + scaledWidth = (bBox.width + 1) * scale; + scaledHeight = (bBox.height + 1) * scale; + + if (userAgent.GECKO) { + xy.x += 1.5 * scale; + xy.y += 1.5 * scale; + } else { + xy.x -= 0.5 * scale; + xy.y -= 0.5 * scale; + } + } else { + const bBox = this.borderRect_!.getBoundingClientRect(); + xy = style.getPageOffset(this.borderRect_!); + scaledWidth = bBox.width; + scaledHeight = bBox.height; + } + return new Rect(xy.y, xy.y + scaledHeight, xy.x, xy.x + scaledWidth); + } + + /** + * Notifies the field that it has changed locations. + * + * @param _ The location of this field's block's top-start corner + * in workspace coordinates. + */ + onLocationChange(_: Coordinate) {} + + /** + * Get the text from this field to display on the block. May differ from + * `getText` due to ellipsis, and other formatting. + * + * @returns Text to display. + */ + protected getDisplayText_(): string { + let text = this.getText(); + if (text.length > this.maxDisplayLength) { + // Truncate displayed string and add an ellipsis ('...'). + text = text.substring(0, this.maxDisplayLength - 2) + '…'; + } + // Replace whitespace with non-breaking spaces so the text doesn't collapse. + text = text.replace(/\s/g, Field.NBSP); + if (this.sourceBlock_ && this.sourceBlock_.RTL) { + // The SVG is LTR, force text to be RTL by adding an RLM. + text += '\u200F'; + } + return text; + } + + /** + * Get the text from this field. + * Override getText_ to provide a different behavior than simply casting the + * value to a string. + * + * @returns Current text. + * @sealed + */ + getText(): string { + // this.getText_ was intended so that devs don't have to remember to call + // super when overriding how the text of the field is generated. (#2910) + const text = this.getText_(); + if (text !== null) { + return String(text); + } + return String(this.getValue()); + } + + /** + * A developer hook to override the returned text of this field. + * Override if the text representation of the value of this field + * is not just a string cast of its value. + * Return null to resort to a string cast. + * + * @returns Current text or null. + */ + protected getText_(): string | null { + return null; + } + + /** + * Force a rerender of the block that this field is installed on, which will + * rerender this field and adjust for any sizing changes. + * Other fields on the same block will not rerender, because their sizes have + * already been recorded. + * + * @internal + */ + markDirty() { + this.isDirty_ = true; + this.constants_ = null; + } + + /** + * Force a rerender of the block that this field is installed on, which will + * rerender this field and adjust for any sizing changes. + * Other fields on the same block will not rerender, because their sizes have + * already been recorded. + */ + forceRerender() { + this.isDirty_ = true; + if (this.sourceBlock_ && this.sourceBlock_.rendered) { + (this.sourceBlock_ as BlockSvg).queueRender(); + } + } + + /** + * Used to change the value of the field. Handles validation and events. + * Subclasses should override doClassValidation_ and doValueUpdate_ rather + * than this method. + * + * @param newValue New value. + * @param fireChangeEvent Whether to fire a change event. Defaults to true. + * Should usually be true unless the change will be reported some other + * way, e.g. an intermediate field change event. + * @sealed + */ + setValue(newValue: AnyDuringMigration, fireChangeEvent = true) { + const doLogging = false; + if (newValue === null) { + if (doLogging) console.log('null, return'); + // Not a valid value to check. + return; + } + + // Field validators are allowed to make changes to the workspace, which + // should get grouped with the field value change event. + const existingGroup = eventUtils.getGroup(); + if (!existingGroup) { + eventUtils.setGroup(true); + } + + try { + const classValidation = this.doClassValidation_(newValue); + const classValue = this.processValidation( + newValue, + classValidation, + fireChangeEvent, + ); + if (classValue instanceof Error) { + if (doLogging) console.log('invalid class validation, return'); + return; + } + + const localValidation = this.getValidator()?.call(this, classValue); + const localValue = this.processValidation( + classValue, + localValidation, + fireChangeEvent, + ); + if (localValue instanceof Error) { + if (doLogging) console.log('invalid local validation, return'); + return; + } + + const source = this.sourceBlock_; + if (source && source.disposed) { + if (doLogging) console.log('source disposed, return'); + return; + } + + const oldValue = this.getValue(); + if (oldValue === localValue) { + if (doLogging) console.log('same, doValueUpdate_, return'); + this.doValueUpdate_(localValue); + return; + } + + this.doValueUpdate_(localValue); + if (fireChangeEvent && source && eventUtils.isEnabled()) { + eventUtils.fire( + new (eventUtils.get(EventType.BLOCK_CHANGE))( + source, + 'field', + this.name || null, + oldValue, + localValue, + ), + ); + } + if (this.isDirty_) { + this.forceRerender(); + } + if (doLogging) console.log(this.value_); + } finally { + eventUtils.setGroup(existingGroup); + } + } + + /** + * Process the result of validation. + * + * @param newValue New value. + * @param validatedValue Validated value. + * @param fireChangeEvent Whether to fire a change event if the value changes. + * @returns New value, or an Error object. + */ + private processValidation( + newValue: AnyDuringMigration, + validatedValue: T | null | undefined, + fireChangeEvent: boolean, + ): T | Error { + if (validatedValue === null) { + this.doValueInvalid_(newValue, fireChangeEvent); + if (this.isDirty_) { + this.forceRerender(); + } + return Error(); + } + return validatedValue === undefined ? (newValue as T) : validatedValue; + } + + /** + * Get the current value of the field. + * + * @returns Current value. + */ + getValue(): T | null { + return this.value_; + } + + /** + * Validate the changes to a field's value before they are set. See + * **FieldDropdown** for an example of subclass implementation. + * + * **NOTE:** Validation returns one option between `T`, `null`, and + * `undefined`. **Field**'s implementation will never return `undefined`, but + * it is valid for a subclass to return `undefined` if the new value is + * compatible with `T`. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue - The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ + protected doClassValidation_(newValue: T): T | null | undefined; + protected doClassValidation_(newValue?: AnyDuringMigration): T | null; + protected doClassValidation_( + newValue?: T | AnyDuringMigration, + ): T | null | undefined { + if (newValue === null || newValue === undefined) { + return null; + } + + return newValue as T; + } + + /** + * Used to update the value of a field. Can be overridden by subclasses to do + * custom storage of values/updating of external things. + * + * @param newValue The value to be saved. + */ + protected doValueUpdate_(newValue: T) { + this.value_ = newValue; + this.isDirty_ = true; + } + + /** + * Used to notify the field an invalid value was input. Can be overridden by + * subclasses, see FieldTextInput. + * No-op by default. + * + * @param _invalidValue The input value that was determined to be invalid. + * @param _fireChangeEvent Whether to fire a change event if the value changes. + */ + protected doValueInvalid_( + _invalidValue: AnyDuringMigration, + _fireChangeEvent: boolean = true, + ) {} + // NOP + + /** + * Handle a pointerdown event on a field. + * + * @param e Pointer down event. + */ + protected onMouseDown_(e: PointerEvent) { + if (!this.sourceBlock_ || this.sourceBlock_.isDeadOrDying()) { + return; + } + const gesture = (this.sourceBlock_.workspace as WorkspaceSvg).getGesture(e); + if (gesture) { + gesture.setStartField(this); + } + } + + /** + * Sets the tooltip for this field. + * + * @param newTip The text for the tooltip, a function that returns the text + * for the tooltip, a parent object whose tooltip will be used, or null to + * display the tooltip of the parent block. To not display a tooltip pass + * the empty string. + */ + setTooltip(newTip: Tooltip.TipInfo | null) { + if (!newTip && newTip !== '') { + // If null or undefined. + newTip = this.sourceBlock_; + } + const clickTarget = this.getClickTarget_(); + if (clickTarget) { + (clickTarget as AnyDuringMigration).tooltip = newTip; + } else { + // Field has not been initialized yet. + this.tooltip = newTip; + } + } + + /** + * Returns the tooltip text for this field. + * + * @returns The tooltip text for this field. + */ + getTooltip(): string { + const clickTarget = this.getClickTarget_(); + if (clickTarget) { + return Tooltip.getTooltipOfObject(clickTarget); + } + // Field has not been initialized yet. Return stashed this.tooltip value. + return Tooltip.getTooltipOfObject({tooltip: this.tooltip}); + } + + /** + * The element to bind the click handler to. If not set explicitly, defaults + * to the SVG root of the field. When this element is + * clicked on an editable field, the editor will open. + * + * @returns Element to bind click handler to. + */ + protected getClickTarget_(): Element | null { + return this.clickTarget_ || this.getSvgRoot(); + } + + /** + * Return the absolute coordinates of the top-left corner of this field. + * The origin (0,0) is the top-left corner of the page body. + * + * @returns Object with .x and .y properties. + */ + protected getAbsoluteXY_(): Coordinate { + return style.getPageOffset(this.getClickTarget_() as SVGRectElement); + } + + /** + * Whether this field references any Blockly variables. If true it may need + * to be handled differently during serialization and deserialization. + * Subclasses may override this. + * + * @returns True if this field has any variable references. + */ + referencesVariables(): boolean { + return false; + } + + /** + * Refresh the variable name referenced by this field if this field references + * variables. + */ + refreshVariableName() {} + // NOP + + /** + * Search through the list of inputs and their fields in order to find the + * parent input of a field. + * + * @returns The input that the field belongs to. + * @internal + */ + getParentInput(): Input { + let parentInput = null; + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + const inputs = block.inputList; + + for (let idx = 0; idx < block.inputList.length; idx++) { + const input = inputs[idx]; + const fieldRows = input.fieldRow; + for (let j = 0; j < fieldRows.length; j++) { + if (fieldRows[j] === this) { + parentInput = input; + break; + } + } + } + return parentInput!; + } + + /** + * Returns whether or not we should flip the field in RTL. + * + * @returns True if we should flip in RTL. + */ + getFlipRtl(): boolean { + return false; + } + + /** + * Handles the given keyboard shortcut. + * + * @param _shortcut The shortcut to be handled. + * @returns True if the shortcut has been handled, false otherwise. + */ + onShortcut(_shortcut: KeyboardShortcut): boolean { + return false; + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + if (!this.fieldGroup_) { + throw Error('This field currently has no representative DOM element.'); + } + return this.fieldGroup_; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + return block.workspace as WorkspaceSvg; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + const block = this.getSourceBlock() as BlockSvg; + block.workspace.scrollBoundsIntoView( + block.getBoundingRectangleWithoutChildren(), + ); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } + + /** + * Subclasses should reimplement this method to construct their Field + * subclass from a JSON arg object. + * + * It is an error to attempt to register a field subclass in the + * FieldRegistry if that subclass has not overridden this method. + * + * @param _options JSON configuration object with properties needed + * to configure a specific field. + */ + static fromJson(_options: FieldConfig): Field { + throw new Error( + `Attempted to instantiate a field from the registry that hasn't defined a 'fromJson' method.`, + ); + } +} + +/** + * Extra configuration options for the base field. + */ +export interface FieldConfig { + tooltip?: string; +} + +/** + * Represents an object that has all the prototype properties of the `Field` + * class. This is necessary because constructors can change + * in descendants, though they should contain all of Field's prototype methods. + * + * This type should only be used in places where we directly access the prototype + * of a Field class or subclass. + */ +type FieldProto = Pick; + +/** + * Represents an error where the field is trying to access its block or + * information about its block before it has actually been attached to said + * block. + */ +export class UnattachedFieldError extends Error { + /** @internal */ + constructor() { + super( + 'The field has not yet been attached to its input. ' + + 'Call appendField to attach it.', + ); + } +} diff --git a/core/field_angle.js b/core/field_angle.js deleted file mode 100644 index 55b15d2634a..00000000000 --- a/core/field_angle.js +++ /dev/null @@ -1,320 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2013 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Angle input field. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.FieldAngle'); - -goog.require('Blockly.FieldTextInput'); -goog.require('goog.math'); -goog.require('goog.userAgent'); - - -/** - * Class for an editable angle field. - * @param {(string|number)=} opt_value The initial content of the field. The - * value should cast to a number, and if it does not, '0' will be used. - * @param {Function=} opt_validator An optional function that is called - * to validate any constraints on what the user entered. Takes the new - * text as an argument and returns the accepted text or null to abort - * the change. - * @extends {Blockly.FieldTextInput} - * @constructor - */ -Blockly.FieldAngle = function(opt_value, opt_validator) { - // Add degree symbol: '360°' (LTR) or '°360' (RTL) - this.symbol_ = Blockly.utils.createSvgElement('tspan', {}, null); - this.symbol_.appendChild(document.createTextNode('\u00B0')); - - opt_value = (opt_value && !isNaN(opt_value)) ? String(opt_value) : '0'; - Blockly.FieldAngle.superClass_.constructor.call( - this, opt_value, opt_validator); -}; -goog.inherits(Blockly.FieldAngle, Blockly.FieldTextInput); - -/** - * Round angles to the nearest 15 degrees when using mouse. - * Set to 0 to disable rounding. - */ -Blockly.FieldAngle.ROUND = 15; - -/** - * Half the width of protractor image. - */ -Blockly.FieldAngle.HALF = 100 / 2; - -/* The following two settings work together to set the behaviour of the angle - * picker. While many combinations are possible, two modes are typical: - * Math mode. - * 0 deg is right, 90 is up. This is the style used by protractors. - * Blockly.FieldAngle.CLOCKWISE = false; - * Blockly.FieldAngle.OFFSET = 0; - * Compass mode. - * 0 deg is up, 90 is right. This is the style used by maps. - * Blockly.FieldAngle.CLOCKWISE = true; - * Blockly.FieldAngle.OFFSET = 90; - */ - -/** - * Angle increases clockwise (true) or counterclockwise (false). - */ -Blockly.FieldAngle.CLOCKWISE = false; - -/** - * Offset the location of 0 degrees (and all angles) by a constant. - * Usually either 0 (0 = right) or 90 (0 = up). - */ -Blockly.FieldAngle.OFFSET = 0; - -/** - * Maximum allowed angle before wrapping. - * Usually either 360 (for 0 to 359.9) or 180 (for -179.9 to 180). - */ -Blockly.FieldAngle.WRAP = 360; - -/** - * Radius of protractor circle. Slightly smaller than protractor size since - * otherwise SVG crops off half the border at the edges. - */ -Blockly.FieldAngle.RADIUS = Blockly.FieldAngle.HALF - 1; - -/** - * Adds degree symbol and recalculates width. - * Saves the computed width in a property. - * @private - */ -Blockly.FieldAngle.prototype.render_ = function() { - if (!this.visible_) { - this.size_.width = 0; - return; - } - - // Update textElement. - this.textElement_.textContent = this.getDisplayText_(); - - // Insert degree symbol. - if (this.sourceBlock_.RTL) { - this.textElement_.insertBefore(this.symbol_, this.textElement_.firstChild); - } else { - this.textElement_.appendChild(this.symbol_); - } - this.updateWidth(); -}; - -/** - * Clean up this FieldAngle, as well as the inherited FieldTextInput. - * @return {!Function} Closure to call on destruction of the WidgetDiv. - * @private - */ -Blockly.FieldAngle.prototype.dispose_ = function() { - var thisField = this; - return function() { - Blockly.FieldAngle.superClass_.dispose_.call(thisField)(); - thisField.gauge_ = null; - if (thisField.clickWrapper_) { - Blockly.unbindEvent_(thisField.clickWrapper_); - } - if (thisField.moveWrapper1_) { - Blockly.unbindEvent_(thisField.moveWrapper1_); - } - if (thisField.moveWrapper2_) { - Blockly.unbindEvent_(thisField.moveWrapper2_); - } - }; -}; - -/** - * Show the inline free-text editor on top of the text. - * @private - */ -Blockly.FieldAngle.prototype.showEditor_ = function() { - var noFocus = - goog.userAgent.MOBILE || goog.userAgent.ANDROID || goog.userAgent.IPAD; - // Mobile browsers have issues with in-line textareas (focus & keyboards). - Blockly.FieldAngle.superClass_.showEditor_.call(this, noFocus); - var div = Blockly.WidgetDiv.DIV; - if (!div.firstChild) { - // Mobile interface uses Blockly.prompt. - return; - } - // Build the SVG DOM. - var svg = Blockly.utils.createSvgElement('svg', { - 'xmlns': 'http://www.w3.org/2000/svg', - 'xmlns:html': 'http://www.w3.org/1999/xhtml', - 'xmlns:xlink': 'http://www.w3.org/1999/xlink', - 'version': '1.1', - 'height': (Blockly.FieldAngle.HALF * 2) + 'px', - 'width': (Blockly.FieldAngle.HALF * 2) + 'px' - }, div); - var circle = Blockly.utils.createSvgElement('circle', { - 'cx': Blockly.FieldAngle.HALF, 'cy': Blockly.FieldAngle.HALF, - 'r': Blockly.FieldAngle.RADIUS, - 'class': 'blocklyAngleCircle' - }, svg); - this.gauge_ = Blockly.utils.createSvgElement('path', - {'class': 'blocklyAngleGauge'}, svg); - this.line_ = Blockly.utils.createSvgElement('line',{ - 'x1': Blockly.FieldAngle.HALF, - 'y1': Blockly.FieldAngle.HALF, - 'class': 'blocklyAngleLine', - }, svg); - // Draw markers around the edge. - for (var angle = 0; angle < 360; angle += 15) { - Blockly.utils.createSvgElement('line', { - 'x1': Blockly.FieldAngle.HALF + Blockly.FieldAngle.RADIUS, - 'y1': Blockly.FieldAngle.HALF, - 'x2': Blockly.FieldAngle.HALF + Blockly.FieldAngle.RADIUS - - (angle % 45 == 0 ? 10 : 5), - 'y2': Blockly.FieldAngle.HALF, - 'class': 'blocklyAngleMarks', - 'transform': 'rotate(' + angle + ',' + - Blockly.FieldAngle.HALF + ',' + Blockly.FieldAngle.HALF + ')' - }, svg); - } - svg.style.marginLeft = (15 - Blockly.FieldAngle.RADIUS) + 'px'; - - // The angle picker is different from other fields in that it updates on - // mousemove even if it's not in the middle of a drag. In future we may - // change this behavior. For now, using bindEvent_ instead of - // bindEventWithChecks_ allows it to work without a mousedown/touchstart. - this.clickWrapper_ = - Blockly.bindEvent_(svg, 'click', this, Blockly.WidgetDiv.hide); - this.moveWrapper1_ = - Blockly.bindEvent_(circle, 'mousemove', this, this.onMouseMove); - this.moveWrapper2_ = - Blockly.bindEvent_(this.gauge_, 'mousemove', this, - this.onMouseMove); - this.updateGraph_(); -}; - -/** - * Set the angle to match the mouse's position. - * @param {!Event} e Mouse move event. - */ -Blockly.FieldAngle.prototype.onMouseMove = function(e) { - var bBox = this.gauge_.ownerSVGElement.getBoundingClientRect(); - var dx = e.clientX - bBox.left - Blockly.FieldAngle.HALF; - var dy = e.clientY - bBox.top - Blockly.FieldAngle.HALF; - var angle = Math.atan(-dy / dx); - if (isNaN(angle)) { - // This shouldn't happen, but let's not let this error propagate further. - return; - } - angle = goog.math.toDegrees(angle); - // 0: East, 90: North, 180: West, 270: South. - if (dx < 0) { - angle += 180; - } else if (dy > 0) { - angle += 360; - } - if (Blockly.FieldAngle.CLOCKWISE) { - angle = Blockly.FieldAngle.OFFSET + 360 - angle; - } else { - angle -= Blockly.FieldAngle.OFFSET; - } - if (Blockly.FieldAngle.ROUND) { - angle = Math.round(angle / Blockly.FieldAngle.ROUND) * - Blockly.FieldAngle.ROUND; - } - angle = this.callValidator(angle); - Blockly.FieldTextInput.htmlInput_.value = angle; - this.setValue(angle); - this.validate_(); - this.resizeEditor_(); -}; - -/** - * Insert a degree symbol. - * @param {?string} text New text. - */ -Blockly.FieldAngle.prototype.setText = function(text) { - Blockly.FieldAngle.superClass_.setText.call(this, text); - if (!this.textElement_) { - // Not rendered yet. - return; - } - this.updateGraph_(); - // Cached width is obsolete. Clear it. - this.size_.width = 0; -}; - -/** - * Redraw the graph with the current angle. - * @private - */ -Blockly.FieldAngle.prototype.updateGraph_ = function() { - if (!this.gauge_) { - return; - } - var angleDegrees = Number(this.getText()) + Blockly.FieldAngle.OFFSET; - var angleRadians = goog.math.toRadians(angleDegrees); - var path = ['M ', Blockly.FieldAngle.HALF, ',', Blockly.FieldAngle.HALF]; - var x2 = Blockly.FieldAngle.HALF; - var y2 = Blockly.FieldAngle.HALF; - if (!isNaN(angleRadians)) { - var angle1 = goog.math.toRadians(Blockly.FieldAngle.OFFSET); - var x1 = Math.cos(angle1) * Blockly.FieldAngle.RADIUS; - var y1 = Math.sin(angle1) * -Blockly.FieldAngle.RADIUS; - if (Blockly.FieldAngle.CLOCKWISE) { - angleRadians = 2 * angle1 - angleRadians; - } - x2 += Math.cos(angleRadians) * Blockly.FieldAngle.RADIUS; - y2 -= Math.sin(angleRadians) * Blockly.FieldAngle.RADIUS; - // Don't ask how the flag calculations work. They just do. - var largeFlag = Math.abs(Math.floor((angleRadians - angle1) / Math.PI) % 2); - if (Blockly.FieldAngle.CLOCKWISE) { - largeFlag = 1 - largeFlag; - } - var sweepFlag = Number(Blockly.FieldAngle.CLOCKWISE); - path.push(' l ', x1, ',', y1, - ' A ', Blockly.FieldAngle.RADIUS, ',', Blockly.FieldAngle.RADIUS, - ' 0 ', largeFlag, ' ', sweepFlag, ' ', x2, ',', y2, ' z'); - } - this.gauge_.setAttribute('d', path.join('')); - this.line_.setAttribute('x2', x2); - this.line_.setAttribute('y2', y2); -}; - -/** - * Ensure that only an angle may be entered. - * @param {string} text The user's text. - * @return {?string} A string representing a valid angle, or null if invalid. - */ -Blockly.FieldAngle.prototype.classValidator = function(text) { - if (text === null) { - return null; - } - var n = parseFloat(text || 0); - if (isNaN(n)) { - return null; - } - n = n % 360; - if (n < 0) { - n += 360; - } - if (n > Blockly.FieldAngle.WRAP) { - n -= 360; - } - return String(n); -}; \ No newline at end of file diff --git a/core/field_checkbox.js b/core/field_checkbox.js deleted file mode 100644 index 0f2c78c6af6..00000000000 --- a/core/field_checkbox.js +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Checkbox field. Checked or not checked. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.FieldCheckbox'); - -goog.require('Blockly.Field'); - - -/** - * Class for a checkbox field. - * @param {string} state The initial state of the field ('TRUE' or 'FALSE'). - * @param {Function=} opt_validator A function that is executed when a new - * option is selected. Its sole argument is the new checkbox state. If - * it returns a value, this becomes the new checkbox state, unless the - * value is null, in which case the change is aborted. - * @extends {Blockly.Field} - * @constructor - */ -Blockly.FieldCheckbox = function(state, opt_validator) { - Blockly.FieldCheckbox.superClass_.constructor.call(this, '', opt_validator); - // Set the initial state. - this.setValue(state); -}; -goog.inherits(Blockly.FieldCheckbox, Blockly.Field); - -/** - * Character for the checkmark. - */ -Blockly.FieldCheckbox.CHECK_CHAR = '\u2713'; - -/** - * Mouse cursor style when over the hotspot that initiates editability. - */ -Blockly.FieldCheckbox.prototype.CURSOR = 'default'; - -/** - * Install this checkbox on a block. - */ -Blockly.FieldCheckbox.prototype.init = function() { - if (this.fieldGroup_) { - // Checkbox has already been initialized once. - return; - } - Blockly.FieldCheckbox.superClass_.init.call(this); - // The checkbox doesn't use the inherited text element. - // Instead it uses a custom checkmark element that is either visible or not. - this.checkElement_ = Blockly.utils.createSvgElement('text', - {'class': 'blocklyText blocklyCheckbox', 'x': -3, 'y': 14}, - this.fieldGroup_); - var textNode = document.createTextNode(Blockly.FieldCheckbox.CHECK_CHAR); - this.checkElement_.appendChild(textNode); - this.checkElement_.style.display = this.state_ ? 'block' : 'none'; -}; - -/** - * Return 'TRUE' if the checkbox is checked, 'FALSE' otherwise. - * @return {string} Current state. - */ -Blockly.FieldCheckbox.prototype.getValue = function() { - return String(this.state_).toUpperCase(); -}; - -/** - * Set the checkbox to be checked if newBool is 'TRUE' or true, - * unchecks otherwise. - * @param {string|boolean} newBool New state. - */ -Blockly.FieldCheckbox.prototype.setValue = function(newBool) { - var newState = (typeof newBool == 'string') ? - (newBool.toUpperCase() == 'TRUE') : !!newBool; - if (this.state_ !== newState) { - if (this.sourceBlock_ && Blockly.Events.isEnabled()) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this.sourceBlock_, 'field', this.name, this.state_, newState)); - } - this.state_ = newState; - if (this.checkElement_) { - this.checkElement_.style.display = newState ? 'block' : 'none'; - } - } -}; - -/** - * Toggle the state of the checkbox. - * @private - */ -Blockly.FieldCheckbox.prototype.showEditor_ = function() { - var newState = !this.state_; - if (this.sourceBlock_) { - // Call any validation function, and allow it to override. - newState = this.callValidator(newState); - } - if (newState !== null) { - this.setValue(String(newState).toUpperCase()); - } -}; diff --git a/core/field_checkbox.ts b/core/field_checkbox.ts new file mode 100644 index 00000000000..55ed42cbf4b --- /dev/null +++ b/core/field_checkbox.ts @@ -0,0 +1,266 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Checkbox field. Checked or not checked. + * + * @class + */ +// Former goog.module ID: Blockly.FieldCheckbox + +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_block_change.js'; + +import {Field, FieldConfig, FieldValidator} from './field.js'; +import * as fieldRegistry from './field_registry.js'; +import * as dom from './utils/dom.js'; + +type BoolString = 'TRUE' | 'FALSE'; +type CheckboxBool = BoolString | boolean; + +/** + * Class for a checkbox field. + */ +export class FieldCheckbox extends Field { + /** Default character for the checkmark. */ + static readonly CHECK_CHAR = '✓'; + private checkChar: string; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + */ + override SERIALIZABLE = true; + + /** + * NOTE: The default value is set in `Field`, so maintain that value instead + * of overwriting it here or in the constructor. + */ + override value_: boolean | null = this.value_; + + /** + * @param value The initial value of the field. Should either be 'TRUE', + * 'FALSE' or a boolean. Defaults to 'FALSE'. Also accepts + * Field.SKIP_SETUP if you wish to skip setup (only used by subclasses + * that want to handle configuration and setting the field value after + * their own constructors have run). + * @param validator A function that is called to validate changes to the + * field's value. Takes in a value ('TRUE' or 'FALSE') & returns a + * validated value ('TRUE' or 'FALSE'), or null to abort the change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/checkbox#creation} + * for a list of properties this parameter supports. + */ + constructor( + value?: CheckboxBool | typeof Field.SKIP_SETUP, + validator?: FieldCheckboxValidator, + config?: FieldCheckboxConfig, + ) { + super(Field.SKIP_SETUP); + + /** + * Character for the check mark. Used to apply a different check mark + * character to individual fields. + */ + this.checkChar = FieldCheckbox.CHECK_CHAR; + + if (value === Field.SKIP_SETUP) return; + if (config) { + this.configure_(config); + } + this.setValue(value); + if (validator) { + this.setValidator(validator); + } + } + + /** + * Configure the field based on the given map of options. + * + * @param config A map of options to configure the field based on. + */ + protected override configure_(config: FieldCheckboxConfig) { + super.configure_(config); + if (config.checkCharacter) this.checkChar = config.checkCharacter; + } + + /** + * Saves this field's value. + * + * @returns The boolean value held by this field. + * @internal + */ + override saveState(): AnyDuringMigration { + const legacyState = this.saveLegacyState(FieldCheckbox); + if (legacyState !== null) { + return legacyState; + } + return this.getValueBoolean(); + } + + /** + * Create the block UI for this checkbox. + */ + override initView() { + super.initView(); + + const textElement = this.getTextElement(); + dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField'); + textElement.style.display = this.value_ ? 'block' : 'none'; + } + + override render_() { + if (this.textContent_) { + this.textContent_.nodeValue = this.getDisplayText_(); + } + this.updateSize_(this.getConstants()!.FIELD_CHECKBOX_X_OFFSET); + } + + override getDisplayText_() { + return this.checkChar; + } + + /** + * Set the character used for the check mark. + * + * @param character The character to use for the check mark, or null to use + * the default. + */ + setCheckCharacter(character: string | null) { + this.checkChar = character || FieldCheckbox.CHECK_CHAR; + this.forceRerender(); + } + + /** Toggle the state of the checkbox on click. */ + protected override showEditor_() { + this.setValue(!this.value_); + } + + /** + * Ensure that the input value is valid ('TRUE' or 'FALSE'). + * + * @param newValue The input value. + * @returns A valid value ('TRUE' or 'FALSE), or null if invalid. + */ + protected override doClassValidation_( + newValue?: AnyDuringMigration, + ): BoolString | null { + if (newValue === true || newValue === 'TRUE') { + return 'TRUE'; + } + if (newValue === false || newValue === 'FALSE') { + return 'FALSE'; + } + return null; + } + + /** + * Update the value of the field, and update the checkElement. + * + * @param newValue The value to be saved. The default validator guarantees + * that this is a either 'TRUE' or 'FALSE'. + */ + protected override doValueUpdate_(newValue: BoolString) { + this.value_ = this.convertValueToBool(newValue); + // Update visual. + if (this.textElement_) { + this.textElement_.style.display = this.value_ ? 'block' : 'none'; + } + } + + /** + * Get the value of this field, either 'TRUE' or 'FALSE'. + * + * @returns The value of this field. + */ + override getValue(): BoolString { + return this.value_ ? 'TRUE' : 'FALSE'; + } + + /** + * Get the boolean value of this field. + * + * @returns The boolean value of this field. + */ + getValueBoolean(): boolean | null { + return this.value_; + } + + /** + * Get the text of this field. Used when the block is collapsed. + * + * @returns Text representing the value of this field ('true' or 'false'). + */ + override getText(): string { + return String(this.convertValueToBool(this.value_)); + } + + /** + * Convert a value into a pure boolean. + * + * Converts 'TRUE' to true and 'FALSE' to false correctly, everything else + * is cast to a boolean. + * + * @param value The value to convert. + * @returns The converted value. + */ + private convertValueToBool(value: CheckboxBool | null): boolean { + if (typeof value === 'string') return value === 'TRUE'; + return !!value; + } + + /** + * Construct a FieldCheckbox from a JSON arg object. + * + * @param options A JSON object with options (checked). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static override fromJson( + options: FieldCheckboxFromJsonConfig, + ): FieldCheckbox { + // `this` might be a subclass of FieldCheckbox if that class doesn't + // 'override' the static fromJson method. + return new this(options.checked, undefined, options); + } +} + +fieldRegistry.register('field_checkbox', FieldCheckbox); + +FieldCheckbox.prototype.DEFAULT_VALUE = false; + +/** + * Config options for the checkbox field. + */ +export interface FieldCheckboxConfig extends FieldConfig { + checkCharacter?: string; +} + +/** + * fromJson config options for the checkbox field. + */ +export interface FieldCheckboxFromJsonConfig extends FieldCheckboxConfig { + checked?: boolean; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldCheckboxValidator = FieldValidator; diff --git a/core/field_colour.js b/core/field_colour.js deleted file mode 100644 index 425e4a35c55..00000000000 --- a/core/field_colour.js +++ /dev/null @@ -1,235 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Colour input field. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.FieldColour'); - -goog.require('Blockly.Field'); -goog.require('goog.dom'); -goog.require('goog.events'); -goog.require('goog.style'); -goog.require('goog.ui.ColorPicker'); - - -/** - * Class for a colour input field. - * @param {string} colour The initial colour in '#rrggbb' format. - * @param {Function=} opt_validator A function that is executed when a new - * colour is selected. Its sole argument is the new colour value. Its - * return value becomes the selected colour, unless it is undefined, in - * which case the new colour stands, or it is null, in which case the change - * is aborted. - * @extends {Blockly.Field} - * @constructor - */ -Blockly.FieldColour = function(colour, opt_validator) { - Blockly.FieldColour.superClass_.constructor.call(this, colour, opt_validator); - this.setText(Blockly.Field.NBSP + Blockly.Field.NBSP + Blockly.Field.NBSP); -}; -goog.inherits(Blockly.FieldColour, Blockly.Field); - -/** - * By default use the global constants for colours. - * @type {Array.} - * @private - */ -Blockly.FieldColour.prototype.colours_ = null; - -/** - * By default use the global constants for columns. - * @type {number} - * @private - */ -Blockly.FieldColour.prototype.columns_ = 0; - -/** - * Install this field on a block. - */ -Blockly.FieldColour.prototype.init = function() { - Blockly.FieldColour.superClass_.init.call(this); - this.borderRect_.style['fillOpacity'] = 1; - this.setValue(this.getValue()); -}; - -/** - * Mouse cursor style when over the hotspot that initiates the editor. - */ -Blockly.FieldColour.prototype.CURSOR = 'default'; - -/** - * Close the colour picker if this input is being deleted. - */ -Blockly.FieldColour.prototype.dispose = function() { - Blockly.WidgetDiv.hideIfOwner(this); - Blockly.FieldColour.superClass_.dispose.call(this); -}; - -/** - * Return the current colour. - * @return {string} Current colour in '#rrggbb' format. - */ -Blockly.FieldColour.prototype.getValue = function() { - return this.colour_; -}; - -/** - * Set the colour. - * @param {string} colour The new colour in '#rrggbb' format. - */ -Blockly.FieldColour.prototype.setValue = function(colour) { - if (this.sourceBlock_ && Blockly.Events.isEnabled() && - this.colour_ != colour) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this.sourceBlock_, 'field', this.name, this.colour_, colour)); - } - this.colour_ = colour; - if (this.borderRect_) { - this.borderRect_.style.fill = colour; - } -}; - -/** - * Get the text from this field. Used when the block is collapsed. - * @return {string} Current text. - */ -Blockly.FieldColour.prototype.getText = function() { - var colour = this.colour_; - // Try to use #rgb format if possible, rather than #rrggbb. - var m = colour.match(/^#(.)\1(.)\2(.)\3$/); - if (m) { - colour = '#' + m[1] + m[2] + m[3]; - } - return colour; -}; - -/** - * An array of colour strings for the palette. - * See bottom of this page for the default: - * http://docs.closure-library.googlecode.com/git/closure_goog_ui_colorpicker.js.source.html - * @type {!Array.} - */ -Blockly.FieldColour.COLOURS = goog.ui.ColorPicker.SIMPLE_GRID_COLORS; - -/** - * Number of columns in the palette. - */ -Blockly.FieldColour.COLUMNS = 7; - -/** - * Set a custom colour grid for this field. - * @param {Array.} colours Array of colours for this block, - * or null to use default (Blockly.FieldColour.COLOURS). - * @return {!Blockly.FieldColour} Returns itself (for method chaining). - */ -Blockly.FieldColour.prototype.setColours = function(colours) { - this.colours_ = colours; - return this; -}; - -/** - * Set a custom grid size for this field. - * @param {number} columns Number of columns for this block, - * or 0 to use default (Blockly.FieldColour.COLUMNS). - * @return {!Blockly.FieldColour} Returns itself (for method chaining). - */ -Blockly.FieldColour.prototype.setColumns = function(columns) { - this.columns_ = columns; - return this; -}; - -/** - * Create a palette under the colour field. - * @private - */ -Blockly.FieldColour.prototype.showEditor_ = function() { - Blockly.WidgetDiv.show(this, this.sourceBlock_.RTL, - Blockly.FieldColour.widgetDispose_); - // Create the palette using Closure. - var picker = new goog.ui.ColorPicker(); - picker.setSize(this.columns_ || Blockly.FieldColour.COLUMNS); - picker.setColors(this.colours_ || Blockly.FieldColour.COLOURS); - - // Position the palette to line up with the field. - // Record windowSize and scrollOffset before adding the palette. - var windowSize = goog.dom.getViewportSize(); - var scrollOffset = goog.style.getViewportPageOffset(document); - var xy = this.getAbsoluteXY_(); - var borderBBox = this.getScaledBBox_(); - var div = Blockly.WidgetDiv.DIV; - picker.render(div); - picker.setSelectedColor(this.getValue()); - // Record paletteSize after adding the palette. - var paletteSize = goog.style.getSize(picker.getElement()); - - // Flip the palette vertically if off the bottom. - if (xy.y + paletteSize.height + borderBBox.height >= - windowSize.height + scrollOffset.y) { - xy.y -= paletteSize.height - 1; - } else { - xy.y += borderBBox.height - 1; - } - if (this.sourceBlock_.RTL) { - xy.x += borderBBox.width; - xy.x -= paletteSize.width; - // Don't go offscreen left. - if (xy.x < scrollOffset.x) { - xy.x = scrollOffset.x; - } - } else { - // Don't go offscreen right. - if (xy.x > windowSize.width + scrollOffset.x - paletteSize.width) { - xy.x = windowSize.width + scrollOffset.x - paletteSize.width; - } - } - Blockly.WidgetDiv.position(xy.x, xy.y, windowSize, scrollOffset, - this.sourceBlock_.RTL); - - // Configure event handler. - var thisField = this; - Blockly.FieldColour.changeEventKey_ = goog.events.listen(picker, - goog.ui.ColorPicker.EventType.CHANGE, - function(event) { - var colour = event.target.getSelectedColor() || '#000000'; - Blockly.WidgetDiv.hide(); - if (thisField.sourceBlock_) { - // Call any validation function, and allow it to override. - colour = thisField.callValidator(colour); - } - if (colour !== null) { - thisField.setValue(colour); - } - }); -}; - -/** - * Hide the colour palette. - * @private - */ -Blockly.FieldColour.widgetDispose_ = function() { - if (Blockly.FieldColour.changeEventKey_) { - goog.events.unlistenByKey(Blockly.FieldColour.changeEventKey_); - } - Blockly.Events.setGroup(false); -}; diff --git a/core/field_date.js b/core/field_date.js deleted file mode 100644 index 9e19c63acc8..00000000000 --- a/core/field_date.js +++ /dev/null @@ -1,347 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2015 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Date input field. - * @author pkendall64@gmail.com (Paul Kendall) - */ -'use strict'; - -goog.provide('Blockly.FieldDate'); - -goog.require('Blockly.Field'); -goog.require('goog.date'); -goog.require('goog.dom'); -goog.require('goog.events'); -goog.require('goog.i18n.DateTimeSymbols'); -goog.require('goog.i18n.DateTimeSymbols_he'); -goog.require('goog.style'); -goog.require('goog.ui.DatePicker'); - - -/** - * Class for a date input field. - * @param {string} date The initial date. - * @param {Function=} opt_validator A function that is executed when a new - * date is selected. Its sole argument is the new date value. Its - * return value becomes the selected date, unless it is undefined, in - * which case the new date stands, or it is null, in which case the change - * is aborted. - * @extends {Blockly.Field} - * @constructor - */ -Blockly.FieldDate = function(date, opt_validator) { - if (!date) { - date = new goog.date.Date().toIsoString(true); - } - Blockly.FieldDate.superClass_.constructor.call(this, date, opt_validator); - this.setValue(date); -}; -goog.inherits(Blockly.FieldDate, Blockly.Field); - -/** - * Mouse cursor style when over the hotspot that initiates the editor. - */ -Blockly.FieldDate.prototype.CURSOR = 'text'; - -/** - * Close the colour picker if this input is being deleted. - */ -Blockly.FieldDate.prototype.dispose = function() { - Blockly.WidgetDiv.hideIfOwner(this); - Blockly.FieldDate.superClass_.dispose.call(this); -}; - -/** - * Return the current date. - * @return {string} Current date. - */ -Blockly.FieldDate.prototype.getValue = function() { - return this.date_; -}; - -/** - * Set the date. - * @param {string} date The new date. - */ -Blockly.FieldDate.prototype.setValue = function(date) { - if (this.sourceBlock_) { - var validated = this.callValidator(date); - // If the new date is invalid, validation returns null. - // In this case we still want to display the illegal result. - if (validated !== null) { - date = validated; - } - } - this.date_ = date; - Blockly.Field.prototype.setText.call(this, date); -}; - -/** - * Create a date picker under the date field. - * @private - */ -Blockly.FieldDate.prototype.showEditor_ = function() { - Blockly.WidgetDiv.show(this, this.sourceBlock_.RTL, - Blockly.FieldDate.widgetDispose_); - // Create the date picker using Closure. - Blockly.FieldDate.loadLanguage_(); - var picker = new goog.ui.DatePicker(); - picker.setAllowNone(false); - picker.setShowWeekNum(false); - - // Position the picker to line up with the field. - // Record windowSize and scrollOffset before adding the picker. - var windowSize = goog.dom.getViewportSize(); - var scrollOffset = goog.style.getViewportPageOffset(document); - var xy = this.getAbsoluteXY_(); - var borderBBox = this.getScaledBBox_(); - var div = Blockly.WidgetDiv.DIV; - picker.render(div); - picker.setDate(goog.date.fromIsoString(this.getValue())); - // Record pickerSize after adding the date picker. - var pickerSize = goog.style.getSize(picker.getElement()); - - // Flip the picker vertically if off the bottom. - if (xy.y + pickerSize.height + borderBBox.height >= - windowSize.height + scrollOffset.y) { - xy.y -= pickerSize.height - 1; - } else { - xy.y += borderBBox.height - 1; - } - if (this.sourceBlock_.RTL) { - xy.x += borderBBox.width; - xy.x -= pickerSize.width; - // Don't go offscreen left. - if (xy.x < scrollOffset.x) { - xy.x = scrollOffset.x; - } - } else { - // Don't go offscreen right. - if (xy.x > windowSize.width + scrollOffset.x - pickerSize.width) { - xy.x = windowSize.width + scrollOffset.x - pickerSize.width; - } - } - Blockly.WidgetDiv.position(xy.x, xy.y, windowSize, scrollOffset, - this.sourceBlock_.RTL); - - // Configure event handler. - var thisField = this; - Blockly.FieldDate.changeEventKey_ = goog.events.listen(picker, - goog.ui.DatePicker.Events.CHANGE, - function(event) { - var date = event.date ? event.date.toIsoString(true) : ''; - Blockly.WidgetDiv.hide(); - if (thisField.sourceBlock_) { - // Call any validation function, and allow it to override. - date = thisField.callValidator(date); - } - thisField.setValue(date); - }); -}; - -/** - * Hide the date picker. - * @private - */ -Blockly.FieldDate.widgetDispose_ = function() { - if (Blockly.FieldDate.changeEventKey_) { - goog.events.unlistenByKey(Blockly.FieldDate.changeEventKey_); - } - Blockly.Events.setGroup(false); -}; - -/** - * Load the best language pack by scanning the Blockly.Msg object for a - * language that matches the available languages in Closure. - * @private - */ -Blockly.FieldDate.loadLanguage_ = function() { - var reg = /^DateTimeSymbols_(.+)$/; - for (var prop in goog.i18n) { - var m = prop.match(reg); - if (m) { - var lang = m[1].toLowerCase().replace('_', '.'); // E.g. 'pt.br' - if (goog.getObjectByName(lang, Blockly.Msg)) { - goog.i18n.DateTimeSymbols = goog.i18n[prop]; - } - } - } -}; - -/** - * CSS for date picker. See css.js for use. - */ -Blockly.FieldDate.CSS = [ - /* Copied from: goog/css/datepicker.css */ - /** - * 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. - * - * @author arv@google.com (Erik Arvidsson) - */ - - '.blocklyWidgetDiv .goog-date-picker,', - '.blocklyWidgetDiv .goog-date-picker th,', - '.blocklyWidgetDiv .goog-date-picker td {', - ' font: 13px Arial, sans-serif;', - '}', - - '.blocklyWidgetDiv .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;', - '}', - - '.blocklyWidgetDiv .goog-date-picker th {', - ' text-align: center;', - '}', - - '.blocklyWidgetDiv .goog-date-picker td {', - ' text-align: center;', - ' vertical-align: middle;', - ' padding: 1px 3px;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-menu {', - ' position: absolute;', - ' background: threedface;', - ' border: 1px solid gray;', - ' -moz-user-focus: normal;', - ' z-index: 1;', - ' outline: none;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-menu ul {', - ' list-style: none;', - ' margin: 0px;', - ' padding: 0px;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-menu ul li {', - ' cursor: default;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-menu-selected {', - ' background: #ccf;', - '}', - - '.blocklyWidgetDiv .goog-date-picker th {', - ' font-size: .9em;', - '}', - - '.blocklyWidgetDiv .goog-date-picker td div {', - ' float: left;', - '}', - - '.blocklyWidgetDiv .goog-date-picker button {', - ' padding: 0px;', - ' margin: 1px 0;', - ' border: 0;', - ' color: #20c;', - ' font-weight: bold;', - ' background: transparent;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-date {', - ' background: #fff;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-week,', - '.blocklyWidgetDiv .goog-date-picker-wday {', - ' padding: 1px 3px;', - ' border: 0;', - ' border-color: #a2bbdd;', - ' border-style: solid;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-week {', - ' border-right-width: 1px;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-wday {', - ' border-bottom-width: 1px;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-head td {', - ' text-align: center;', - '}', - - /** Use td.className instead of !important */ - '.blocklyWidgetDiv td.goog-date-picker-today-cont {', - ' text-align: center;', - '}', - - /** Use td.className instead of !important */ - '.blocklyWidgetDiv td.goog-date-picker-none-cont {', - ' text-align: center;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-month {', - ' min-width: 11ex;', - ' white-space: nowrap;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-year {', - ' min-width: 6ex;', - ' white-space: nowrap;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-monthyear {', - ' white-space: nowrap;', - '}', - - '.blocklyWidgetDiv .goog-date-picker table {', - ' border-collapse: collapse;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-other-month {', - ' color: #888;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-wkend-start,', - '.blocklyWidgetDiv .goog-date-picker-wkend-end {', - ' background: #eee;', - '}', - - /** Use td.className instead of !important */ - '.blocklyWidgetDiv td.goog-date-picker-selected {', - ' background: #c3d9ff;', - '}', - - '.blocklyWidgetDiv .goog-date-picker-today {', - ' background: #9ab;', - ' font-weight: bold !important;', - ' border-color: #246 #9bd #9bd #246;', - ' color: #fff;', - '}' -]; diff --git a/core/field_dropdown.js b/core/field_dropdown.js deleted file mode 100644 index 7ece4e8dc02..00000000000 --- a/core/field_dropdown.js +++ /dev/null @@ -1,422 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Dropdown input field. Used for editable titles and variables. - * In the interests of a consistent UI, the toolbox shares some functions and - * properties with the context menu. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.FieldDropdown'); - -goog.require('Blockly.Field'); -goog.require('goog.dom'); -goog.require('goog.events'); -goog.require('goog.style'); -goog.require('goog.ui.Menu'); -goog.require('goog.ui.MenuItem'); -goog.require('goog.userAgent'); - - -/** - * Class for an editable dropdown field. - * @param {(!Array.|!Function)} menuGenerator An array of options - * for a dropdown list, or a function which generates these options. - * @param {Function=} opt_validator A function that is executed when a new - * option is selected, with the newly selected value as its sole argument. - * If it returns a value, that value (which must be one of the options) will - * become selected in place of the newly selected option, unless the return - * value is null, in which case the change is aborted. - * @extends {Blockly.Field} - * @constructor - */ -Blockly.FieldDropdown = function(menuGenerator, opt_validator) { - this.menuGenerator_ = menuGenerator; - this.trimOptions_(); - var firstTuple = this.getOptions()[0]; - - // Call parent's constructor. - Blockly.FieldDropdown.superClass_.constructor.call(this, firstTuple[1], - opt_validator); -}; -goog.inherits(Blockly.FieldDropdown, Blockly.Field); - -/** - * Horizontal distance that a checkmark overhangs the dropdown. - */ -Blockly.FieldDropdown.CHECKMARK_OVERHANG = 25; - -/** - * Android can't (in 2014) display "▾", so use "▼" instead. - */ -Blockly.FieldDropdown.ARROW_CHAR = goog.userAgent.ANDROID ? '\u25BC' : '\u25BE'; - -/** - * Mouse cursor style when over the hotspot that initiates the editor. - */ -Blockly.FieldDropdown.prototype.CURSOR = 'default'; - -/** - * Language-neutral currently selected string or image object. - * @type {string|!Object} - * @private - */ -Blockly.FieldDropdown.prototype.value_ = ''; - -/** - * SVG image element if currently selected option is an image, or null. - * @type {SVGElement} - * @private - */ -Blockly.FieldDropdown.prototype.imageElement_ = null; - -/** - * Object with src, height, width, and alt attributes if currently selected - * option is an image, or null. - * @type {Object} - * @private - */ -Blockly.FieldDropdown.prototype.imageJson_ = null; - -/** - * Install this dropdown on a block. - */ -Blockly.FieldDropdown.prototype.init = function() { - if (this.fieldGroup_) { - // Dropdown has already been initialized once. - return; - } - // Add dropdown arrow: "option ▾" (LTR) or "▾ אופציה" (RTL) - this.arrow_ = Blockly.utils.createSvgElement('tspan', {}, null); - this.arrow_.appendChild(document.createTextNode(this.sourceBlock_.RTL ? - Blockly.FieldDropdown.ARROW_CHAR + ' ' : - ' ' + Blockly.FieldDropdown.ARROW_CHAR)); - - Blockly.FieldDropdown.superClass_.init.call(this); - // Force a reset of the text to add the arrow. - var text = this.text_; - this.text_ = null; - this.setText(text); -}; - -/** - * Create a dropdown menu under the text. - * @private - */ -Blockly.FieldDropdown.prototype.showEditor_ = function() { - Blockly.WidgetDiv.show(this, this.sourceBlock_.RTL, null); - var thisField = this; - - function callback(e) { - var menu = this; - var menuItem = e.target; - if (menuItem) { - thisField.onItemSelected(menu, menuItem); - } - Blockly.WidgetDiv.hideIfOwner(thisField); - Blockly.Events.setGroup(false); - } - - var menu = new goog.ui.Menu(); - menu.setRightToLeft(this.sourceBlock_.RTL); - var options = this.getOptions(); - for (var i = 0; i < options.length; i++) { - var content = options[i][0]; // Human-readable text or image. - var value = options[i][1]; // Language-neutral value. - if (typeof content == 'object') { - // An image, not text. - var image = new Image(content['width'], content['height']); - image.src = content['src']; - image.alt = content['alt'] || ''; - content = image; - } - var menuItem = new goog.ui.MenuItem(content); - menuItem.setRightToLeft(this.sourceBlock_.RTL); - menuItem.setValue(value); - menuItem.setCheckable(true); - menu.addChild(menuItem, true); - menuItem.setChecked(value == this.value_); - } - // Listen for mouse/keyboard events. - goog.events.listen(menu, goog.ui.Component.EventType.ACTION, callback); - // Listen for touch events (why doesn't Closure handle this already?). - function callbackTouchStart(e) { - var control = this.getOwnerControl(/** @type {Node} */ (e.target)); - // Highlight the menu item. - control.handleMouseDown(e); - } - function callbackTouchEnd(e) { - var control = this.getOwnerControl(/** @type {Node} */ (e.target)); - // Activate the menu item. - control.performActionInternal(e); - } - menu.getHandler().listen(menu.getElement(), goog.events.EventType.TOUCHSTART, - callbackTouchStart); - menu.getHandler().listen(menu.getElement(), goog.events.EventType.TOUCHEND, - callbackTouchEnd); - - // Record windowSize and scrollOffset before adding menu. - var windowSize = goog.dom.getViewportSize(); - var scrollOffset = goog.style.getViewportPageOffset(document); - var xy = this.getAbsoluteXY_(); - var borderBBox = this.getScaledBBox_(); - var div = Blockly.WidgetDiv.DIV; - menu.render(div); - var menuDom = menu.getElement(); - Blockly.utils.addClass(menuDom, 'blocklyDropdownMenu'); - // Record menuSize after adding menu. - var menuSize = goog.style.getSize(menuDom); - // Recalculate height for the total content, not only box height. - menuSize.height = menuDom.scrollHeight; - - // Position the menu. - // Flip menu vertically if off the bottom. - if (xy.y + menuSize.height + borderBBox.height >= - windowSize.height + scrollOffset.y) { - xy.y -= menuSize.height + 2; - } else { - xy.y += borderBBox.height; - } - if (this.sourceBlock_.RTL) { - xy.x += borderBBox.width; - xy.x += Blockly.FieldDropdown.CHECKMARK_OVERHANG; - // Don't go offscreen left. - if (xy.x < scrollOffset.x + menuSize.width) { - xy.x = scrollOffset.x + menuSize.width; - } - } else { - xy.x -= Blockly.FieldDropdown.CHECKMARK_OVERHANG; - // Don't go offscreen right. - if (xy.x > windowSize.width + scrollOffset.x - menuSize.width) { - xy.x = windowSize.width + scrollOffset.x - menuSize.width; - } - } - Blockly.WidgetDiv.position(xy.x, xy.y, windowSize, scrollOffset, - this.sourceBlock_.RTL); - menu.setAllowAutoFocus(true); - menuDom.focus(); -}; - -/** - * Handle the selection of an item in the dropdown menu. - * @param {!goog.ui.Menu} menu The Menu component clicked. - * @param {!goog.ui.MenuItem} menuItem The MenuItem selected within menu. - */ -Blockly.FieldDropdown.prototype.onItemSelected = function(menu, menuItem) { - var value = menuItem.getValue(); - if (this.sourceBlock_) { - // Call any validation function, and allow it to override. - value = this.callValidator(value); - } - if (value !== null) { - this.setValue(value); - } -}; - -/** - * Factor out common words in statically defined options. - * Create prefix and/or suffix labels. - * @private - */ -Blockly.FieldDropdown.prototype.trimOptions_ = function() { - this.prefixField = null; - this.suffixField = null; - var options = this.menuGenerator_; - if (!goog.isArray(options)) { - return; - } - var hasImages = false; - - // Localize label text and image alt text. - for (var i = 0; i < options.length; i++) { - var label = options[i][0]; - if (typeof label == 'string') { - options[i][0] = Blockly.utils.replaceMessageReferences(label); - } else { - if (label.alt != null) { - options[i][0].alt = Blockly.utils.replaceMessageReferences(label.alt); - } - hasImages = true; - } - } - if (hasImages || options.length < 2) { - return; // Do nothing if too few items or at least one label is an image. - } - var strings = []; - for (var i = 0; i < options.length; i++) { - strings.push(options[i][0]); - } - var shortest = Blockly.utils.shortestStringLength(strings); - var prefixLength = Blockly.utils.commonWordPrefix(strings, shortest); - var suffixLength = Blockly.utils.commonWordSuffix(strings, shortest); - if (!prefixLength && !suffixLength) { - return; - } - if (shortest <= prefixLength + suffixLength) { - // One or more strings will entirely vanish if we proceed. Abort. - return; - } - if (prefixLength) { - this.prefixField = strings[0].substring(0, prefixLength - 1); - } - if (suffixLength) { - this.suffixField = strings[0].substr(1 - suffixLength); - } - // Remove the prefix and suffix from the options. - var newOptions = []; - for (var i = 0; i < options.length; i++) { - var text = options[i][0]; - var value = options[i][1]; - text = text.substring(prefixLength, text.length - suffixLength); - newOptions[i] = [text, value]; - } - this.menuGenerator_ = newOptions; -}; - -/** - * @return {boolean} True if the option list is generated by a function. Otherwise false. - */ -Blockly.FieldDropdown.prototype.isOptionListDynamic = function() { - return goog.isFunction(this.menuGenerator_); -}; - -/** - * Return a list of the options for this dropdown. - * @return {!Array.} Array of option tuples: - * (human-readable text or image, language-neutral name). - */ -Blockly.FieldDropdown.prototype.getOptions = function() { - if (goog.isFunction(this.menuGenerator_)) { - return this.menuGenerator_.call(this); - } - return /** @type {!Array.>} */ (this.menuGenerator_); -}; - -/** - * Get the language-neutral value from this dropdown menu. - * @return {string} Current text. - */ -Blockly.FieldDropdown.prototype.getValue = function() { - return this.value_; -}; - -/** - * Set the language-neutral value for this dropdown menu. - * @param {string} newValue New value to set. - */ -Blockly.FieldDropdown.prototype.setValue = function(newValue) { - if (newValue === null || newValue === this.value_) { - return; // No change if null. - } - if (this.sourceBlock_ && Blockly.Events.isEnabled()) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this.sourceBlock_, 'field', this.name, this.value_, newValue)); - } - this.value_ = newValue; - // Look up and display the human-readable text. - var options = this.getOptions(); - for (var i = 0; i < options.length; i++) { - // Options are tuples of human-readable text and language-neutral values. - if (options[i][1] == newValue) { - var content = options[i][0]; - if (typeof content == 'object') { - this.imageJson_ = content; - this.setText(content.alt); - } else { - this.imageJson_ = null; - this.setText(content); - } - return; - } - } - // Value not found. Add it, maybe it will become valid once set - // (like variable names). - this.setText(newValue); -}; - -/** - * Draws the border with the correct width. - * @private - */ -Blockly.FieldDropdown.prototype.render_ = function() { - if (!this.visible_) { - this.size_.width = 0; - return; - } - if (this.sourceBlock_ && this.arrow_) { - // Update arrow's colour. - this.arrow_.style.fill = this.sourceBlock_.getColour(); - } - goog.dom.removeChildren(/** @type {!Element} */ (this.textElement_)); - goog.dom.removeNode(this.imageElement_); - this.imageElement_ = null; - - if (this.imageJson_) { - // Image option is selected. - this.imageElement_ = Blockly.utils.createSvgElement('image', - {'y': 5, - 'height': this.imageJson_.height + 'px', - 'width': this.imageJson_.width + 'px'}, this.fieldGroup_); - this.imageElement_.setAttributeNS('http://www.w3.org/1999/xlink', - 'xlink:href', this.imageJson_.src); - // Insert dropdown arrow. - this.textElement_.appendChild(this.arrow_); - var arrowWidth = Blockly.Field.getCachedWidth(this.arrow_); - this.size_.height = Number(this.imageJson_.height) + 19; - this.size_.width = Number(this.imageJson_.width) + arrowWidth; - if (this.sourceBlock_.RTL) { - this.imageElement_.setAttribute('x', arrowWidth); - this.textElement_.setAttribute('x', -1); - } else { - this.textElement_.setAttribute('text-anchor', 'end'); - this.textElement_.setAttribute('x', this.size_.width + 1); - } - - } else { - // Text option is selected. - // Replace the text. - var textNode = document.createTextNode(this.getDisplayText_()); - this.textElement_.appendChild(textNode); - // Insert dropdown arrow. - if (this.sourceBlock_.RTL) { - this.textElement_.insertBefore(this.arrow_, this.textElement_.firstChild); - } else { - this.textElement_.appendChild(this.arrow_); - } - this.textElement_.setAttribute('text-anchor', 'start'); - this.textElement_.setAttribute('x', 0); - - this.size_.height = Blockly.BlockSvg.MIN_BLOCK_Y; - this.size_.width = Blockly.Field.getCachedWidth(this.textElement_); - } - this.borderRect_.setAttribute('height', this.size_.height - 9); - this.borderRect_.setAttribute('width', - this.size_.width + Blockly.BlockSvg.SEP_SPACE_X); -}; - -/** - * Close the dropdown menu if this input is being deleted. - */ -Blockly.FieldDropdown.prototype.dispose = function() { - Blockly.WidgetDiv.hideIfOwner(this); - Blockly.FieldDropdown.superClass_.dispose.call(this); -}; diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts new file mode 100644 index 00000000000..3be5c94c3e3 --- /dev/null +++ b/core/field_dropdown.ts @@ -0,0 +1,907 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Dropdown input field. Used for editable titles and variables. + * In the interests of a consistent UI, the toolbox shares some functions and + * properties with the context menu. + * + * @class + */ +// Former goog.module ID: Blockly.FieldDropdown + +import type {BlockSvg} from './block_svg.js'; +import * as dropDownDiv from './dropdowndiv.js'; +import { + Field, + FieldConfig, + FieldValidator, + UnattachedFieldError, +} from './field.js'; +import * as fieldRegistry from './field_registry.js'; +import {Menu} from './menu.js'; +import {MenuSeparator} from './menu_separator.js'; +import {MenuItem} from './menuitem.js'; +import * as aria from './utils/aria.js'; +import {Coordinate} from './utils/coordinate.js'; +import * as dom from './utils/dom.js'; +import * as parsing from './utils/parsing.js'; +import {Size} from './utils/size.js'; +import * as utilsString from './utils/string.js'; +import {Svg} from './utils/svg.js'; + +/** + * Class for an editable dropdown field. + */ +export class FieldDropdown extends Field { + /** + * Magic constant used to represent a separator in a list of dropdown items. + */ + static readonly SEPARATOR = 'separator'; + + static ARROW_CHAR = '▾'; + + /** A reference to the currently selected menu item. */ + private selectedMenuItem: MenuItem | null = null; + + /** The dropdown menu. */ + protected menu_: Menu | null = null; + + /** + * SVG image element if currently selected option is an image, or null. + */ + private imageElement: SVGImageElement | null = null; + + /** Tspan based arrow element. */ + private arrow: SVGTSpanElement | null = null; + + /** SVG based arrow element. */ + private svgArrow: SVGElement | null = null; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + */ + override SERIALIZABLE = true; + + protected menuGenerator_?: MenuGenerator; + + /** A cache of the most recently generated options. */ + private generatedOptions: MenuOption[] | null = null; + + /** + * The prefix field label, of common words set after options are trimmed. + * + * @internal + */ + override prefixField: string | null = null; + + /** + * The suffix field label, of common words set after options are trimmed. + * + * @internal + */ + override suffixField: string | null = null; + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + private selectedOption!: MenuOption; + override clickTarget_: SVGElement | null = null; + + /** + * The y offset from the top of the field to the top of the image, if an image + * is selected. + */ + protected static IMAGE_Y_OFFSET = 5; + + /** The total vertical padding above and below an image. */ + protected static IMAGE_Y_PADDING = FieldDropdown.IMAGE_Y_OFFSET * 2; + + /** + * @param menuGenerator A non-empty array of options for a dropdown list, or a + * function which generates these options. Also accepts Field.SKIP_SETUP + * if you wish to skip setup (only used by subclasses that want to handle + * configuration and setting the field value after their own constructors + * have run). + * @param validator A function that is called to validate changes to the + * field's value. Takes in a language-neutral dropdown option & returns a + * validated language-neutral dropdown option, or null to abort the + * change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/dropdown#creation} + * for a list of properties this parameter supports. + * @throws {TypeError} If `menuGenerator` options are incorrectly structured. + */ + constructor( + menuGenerator: MenuGenerator, + validator?: FieldDropdownValidator, + config?: FieldDropdownConfig, + ); + constructor(menuGenerator: typeof Field.SKIP_SETUP); + constructor( + menuGenerator: MenuGenerator | typeof Field.SKIP_SETUP, + validator?: FieldDropdownValidator, + config?: FieldDropdownConfig, + ) { + super(Field.SKIP_SETUP); + + // If we pass SKIP_SETUP, don't do *anything* with the menu generator. + if (menuGenerator === Field.SKIP_SETUP) return; + + this.setOptions(menuGenerator); + + if (config) { + this.configure_(config); + } + if (validator) { + this.setValidator(validator); + } + } + + /** + * Sets the field's value based on the given XML element. Should only be + * called by Blockly.Xml. + * + * @param fieldElement The element containing info about the field's state. + * @internal + */ + override fromXml(fieldElement: Element) { + if (this.isOptionListDynamic()) { + this.getOptions(false); + } + this.setValue(fieldElement.textContent); + } + + /** + * Sets the field's value based on the given state. + * + * @param state The state to apply to the dropdown field. + * @internal + */ + override loadState(state: AnyDuringMigration) { + if (this.loadLegacyState(FieldDropdown, state)) { + return; + } + if (this.isOptionListDynamic()) { + this.getOptions(false); + } + this.setValue(state); + } + + /** + * Create the block UI for this dropdown. + */ + override initView() { + if (this.shouldAddBorderRect_()) { + this.createBorderRect_(); + } else { + this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot(); + } + this.createTextElement_(); + + this.imageElement = dom.createSvgElement(Svg.IMAGE, {}, this.fieldGroup_); + + if (this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW) { + this.createSVGArrow_(); + } else { + this.createTextArrow_(); + } + + if (this.borderRect_) { + dom.addClass(this.borderRect_, 'blocklyDropdownRect'); + } + + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyField'); + dom.addClass(this.fieldGroup_, 'blocklyDropdownField'); + } + } + + /** + * Whether or not the dropdown should add a border rect. + * + * @returns True if the dropdown field should add a border rect. + */ + protected shouldAddBorderRect_(): boolean { + return ( + !this.getConstants()!.FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW || + (this.getConstants()!.FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW && + !this.getSourceBlock()?.isShadow()) + ); + } + + /** Create a tspan based arrow. */ + protected createTextArrow_() { + this.arrow = dom.createSvgElement(Svg.TSPAN, {}, this.textElement_); + this.arrow!.appendChild( + document.createTextNode( + this.getSourceBlock()?.RTL + ? FieldDropdown.ARROW_CHAR + ' ' + : ' ' + FieldDropdown.ARROW_CHAR, + ), + ); + if (this.getConstants()!.FIELD_TEXT_BASELINE_CENTER) { + this.arrow.setAttribute('dominant-baseline', 'central'); + } + if (this.getSourceBlock()?.RTL) { + this.getTextElement().insertBefore(this.arrow, this.textContent_); + } else { + this.getTextElement().appendChild(this.arrow); + } + } + + /** Create an SVG based arrow. */ + protected createSVGArrow_() { + this.svgArrow = dom.createSvgElement( + Svg.IMAGE, + { + 'height': this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px', + 'width': this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px', + }, + this.fieldGroup_, + ); + this.svgArrow!.setAttributeNS( + dom.XLINK_NS, + 'xlink:href', + this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_DATAURI, + ); + } + + /** + * Create a dropdown menu under the text. + * + * @param e Optional mouse event that triggered the field to open, or + * undefined if triggered programmatically. + */ + protected override showEditor_(e?: MouseEvent) { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + this.dropdownCreate(); + if (!this.menu_) return; + + if (e && typeof e.clientX === 'number') { + this.menu_.openingCoords = new Coordinate(e.clientX, e.clientY); + } else { + this.menu_.openingCoords = null; + } + + // Remove any pre-existing elements in the dropdown. + dropDownDiv.clearContent(); + // Element gets created in render. + const menuElement = this.menu_.render(dropDownDiv.getContentDiv()); + dom.addClass(menuElement, 'blocklyDropdownMenu'); + + if (this.getConstants()!.FIELD_DROPDOWN_COLOURED_DIV) { + const primaryColour = block.getColour(); + const borderColour = (this.sourceBlock_ as BlockSvg).getColourTertiary(); + dropDownDiv.setColour(primaryColour, borderColour); + } + + dropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this)); + + dropDownDiv.getContentDiv().style.height = `${this.menu_.getSize().height}px`; + + // Focusing needs to be handled after the menu is rendered and positioned. + // Otherwise it will cause a page scroll to get the misplaced menu in + // view. See issue #1329. + this.menu_.focus(); + + if (this.selectedMenuItem) { + this.menu_.setHighlighted(this.selectedMenuItem); + } + + this.applyColour(); + } + + /** Create the dropdown editor. */ + private dropdownCreate() { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + const menu = new Menu(); + menu.setRole(aria.Role.LISTBOX); + this.menu_ = menu; + + const options = this.getOptions(false); + this.selectedMenuItem = null; + for (let i = 0; i < options.length; i++) { + const option = options[i]; + if (option === FieldDropdown.SEPARATOR) { + menu.addChild(new MenuSeparator()); + continue; + } + + const [label, value] = option; + const content = (() => { + if (isImageProperties(label)) { + // Convert ImageProperties to an HTMLImageElement. + const image = new Image(label.width, label.height); + image.src = label.src; + image.alt = label.alt; + return image; + } + return label; + })(); + const menuItem = new MenuItem(content, value); + menuItem.setRole(aria.Role.OPTION); + menuItem.setRightToLeft(block.RTL); + menuItem.setCheckable(true); + menu.addChild(menuItem); + menuItem.setChecked(value === this.value_); + if (value === this.value_) { + this.selectedMenuItem = menuItem; + } + menuItem.onAction(this.handleMenuActionEvent, this); + } + } + + /** + * Disposes of events and DOM-references belonging to the dropdown editor. + */ + protected dropdownDispose_() { + if (this.menu_) { + this.menu_.dispose(); + } + this.menu_ = null; + this.selectedMenuItem = null; + this.applyColour(); + } + + /** + * Handle an action in the dropdown menu. + * + * @param menuItem The MenuItem selected within menu. + */ + private handleMenuActionEvent(menuItem: MenuItem) { + dropDownDiv.hideIfOwner(this, true); + this.onItemSelected_(this.menu_ as Menu, menuItem); + } + + /** + * Handle the selection of an item in the dropdown menu. + * + * @param menu The Menu component clicked. + * @param menuItem The MenuItem selected within menu. + */ + protected onItemSelected_(menu: Menu, menuItem: MenuItem) { + this.setValue(menuItem.getValue()); + } + + /** + * @returns True if the option list is generated by a function. + * Otherwise false. + */ + isOptionListDynamic(): boolean { + return typeof this.menuGenerator_ === 'function'; + } + + /** + * Return a list of the options for this dropdown. + * + * @param useCache For dynamic options, whether or not to use the cached + * options or to re-generate them. + * @returns A non-empty array of option tuples: + * (human-readable text or image, language-neutral name). + * @throws {TypeError} If generated options are incorrectly structured. + */ + getOptions(useCache?: boolean): MenuOption[] { + if (!this.menuGenerator_) { + // A subclass improperly skipped setup without defining the menu + // generator. + throw TypeError('A menu generator was never defined.'); + } + if (Array.isArray(this.menuGenerator_)) return this.menuGenerator_; + if (useCache && this.generatedOptions) return this.generatedOptions; + + this.generatedOptions = this.menuGenerator_(); + this.validateOptions(this.generatedOptions); + return this.generatedOptions; + } + + /** + * Update the options on this dropdown. This will reset the selected item to + * the first item in the list. + * + * @param menuGenerator The array of options or a generator function. + */ + setOptions(menuGenerator: MenuGenerator) { + if (Array.isArray(menuGenerator)) { + this.validateOptions(menuGenerator); + const trimmed = this.trimOptions(menuGenerator); + this.menuGenerator_ = trimmed.options; + this.prefixField = trimmed.prefix || null; + this.suffixField = trimmed.suffix || null; + } else { + this.menuGenerator_ = menuGenerator; + } + // The currently selected option. The field is initialized with the + // first option selected. + this.selectedOption = this.getOptions(false)[0]; + this.setValue(this.selectedOption[1]); + } + + /** + * Ensure that the input value is a valid language-neutral option. + * + * @param newValue The input value. + * @returns A valid language-neutral option, or null if invalid. + */ + protected override doClassValidation_( + newValue: string, + ): string | null | undefined; + protected override doClassValidation_(newValue?: string): string | null; + protected override doClassValidation_( + newValue?: string, + ): string | null | undefined { + const options = this.getOptions(true); + const isValueValid = options.some((option) => option[1] === newValue); + + if (!isValueValid) { + if (this.sourceBlock_) { + console.warn( + "Cannot set the dropdown's value to an unavailable option." + + ' Block type: ' + + this.sourceBlock_.type + + ', Field name: ' + + this.name + + ', Value: ' + + newValue, + ); + } + return null; + } + return newValue; + } + + /** + * Update the value of this dropdown field. + * + * @param newValue The value to be saved. The default validator guarantees + * that this is one of the valid dropdown options. + */ + protected override doValueUpdate_(newValue: string) { + super.doValueUpdate_(newValue); + const options = this.getOptions(true); + for (let i = 0, option; (option = options[i]); i++) { + if (option[1] === this.value_) { + this.selectedOption = option; + } + } + } + + /** + * Updates the dropdown arrow to match the colour/style of the block. + */ + override applyColour() { + const sourceBlock = this.sourceBlock_ as BlockSvg; + if (this.borderRect_) { + this.borderRect_.setAttribute('stroke', sourceBlock.getColourTertiary()); + if (this.menu_) { + this.borderRect_.setAttribute('fill', sourceBlock.getColourTertiary()); + } else { + this.borderRect_.setAttribute('fill', 'transparent'); + } + } + // Update arrow's colour. + if (sourceBlock && this.arrow) { + if (sourceBlock.isShadow()) { + this.arrow.style.fill = sourceBlock.getColourSecondary(); + } else { + this.arrow.style.fill = sourceBlock.getColour(); + } + } + } + + /** Draws the border with the correct width. */ + protected override render_() { + // Hide both elements. + this.getTextContent().nodeValue = ''; + this.imageElement!.style.display = 'none'; + + // Show correct element. + const option = this.selectedOption && this.selectedOption[0]; + if (isImageProperties(option)) { + this.renderSelectedImage(option); + } else { + this.renderSelectedText(); + } + + this.positionBorderRect_(); + } + + /** + * Renders the selected option, which must be an image. + * + * @param imageJson Selected option that must be an image. + */ + private renderSelectedImage(imageJson: ImageProperties) { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + this.imageElement!.style.display = ''; + this.imageElement!.setAttributeNS( + dom.XLINK_NS, + 'xlink:href', + imageJson.src, + ); + this.imageElement!.setAttribute('height', String(imageJson.height)); + this.imageElement!.setAttribute('width', String(imageJson.width)); + + const imageHeight = Number(imageJson.height); + const imageWidth = Number(imageJson.width); + + // Height and width include the border rect. + const hasBorder = !!this.borderRect_; + const height = Math.max( + hasBorder ? this.getConstants()!.FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0, + imageHeight + FieldDropdown.IMAGE_Y_PADDING, + ); + const xPadding = hasBorder + ? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING + : 0; + let arrowWidth = 0; + if (this.svgArrow) { + arrowWidth = this.positionSVGArrow( + imageWidth + xPadding, + height / 2 - this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE / 2, + ); + } else { + arrowWidth = dom.getTextWidth(this.arrow as SVGTSpanElement); + } + this.size_ = new Size(imageWidth + arrowWidth + xPadding * 2, height); + + let arrowX = 0; + if (block.RTL) { + const imageX = xPadding + arrowWidth; + this.imageElement!.setAttribute('x', `${imageX}`); + } else { + arrowX = imageWidth + arrowWidth; + this.getTextElement().setAttribute('text-anchor', 'end'); + this.imageElement!.setAttribute('x', `${xPadding}`); + } + this.imageElement!.setAttribute('y', String(height / 2 - imageHeight / 2)); + + this.positionTextElement_(arrowX + xPadding, imageWidth + arrowWidth); + } + + /** Renders the selected option, which must be text. */ + private renderSelectedText() { + // Retrieves the selected option to display through getText_. + this.getTextContent().nodeValue = this.getDisplayText_(); + const textElement = this.getTextElement(); + dom.addClass(textElement, 'blocklyDropdownText'); + textElement.setAttribute('text-anchor', 'start'); + + // Height and width include the border rect. + const hasBorder = !!this.borderRect_; + const height = Math.max( + hasBorder ? this.getConstants()!.FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0, + this.getConstants()!.FIELD_TEXT_HEIGHT, + ); + const textWidth = dom.getTextWidth(this.getTextElement()); + const xPadding = hasBorder + ? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING + : 0; + let arrowWidth = 0; + if (this.svgArrow) { + arrowWidth = this.positionSVGArrow( + textWidth + xPadding, + height / 2 - this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE / 2, + ); + } + this.size_ = new Size(textWidth + arrowWidth + xPadding * 2, height); + + this.positionTextElement_(xPadding, textWidth); + } + + /** + * Position a drop-down arrow at the appropriate location at render-time. + * + * @param x X position the arrow is being rendered at, in px. + * @param y Y position the arrow is being rendered at, in px. + * @returns Amount of space the arrow is taking up, in px. + */ + private positionSVGArrow(x: number, y: number): number { + if (!this.svgArrow) { + return 0; + } + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + const hasBorder = !!this.borderRect_; + const xPadding = hasBorder + ? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING + : 0; + const textPadding = this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_PADDING; + const svgArrowSize = this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE; + const arrowX = block.RTL ? xPadding : x + textPadding; + this.svgArrow.setAttribute( + 'transform', + 'translate(' + arrowX + ',' + y + ')', + ); + return svgArrowSize + textPadding; + } + + /** + * Use the `getText_` developer hook to override the field's text + * representation. Get the selected option text. If the selected option is + * an image we return the image alt text. If the selected option is + * an HTMLElement, return the title, ariaLabel, or innerText of the + * element. + * + * If you use HTMLElement options in Node.js and call this function, + * ensure that you are supplying an implementation of HTMLElement, + * such as through jsdom-global. + * + * @returns Selected option text. + */ + protected override getText_(): string | null { + if (!this.selectedOption) { + return null; + } + const option = this.selectedOption[0]; + if (isImageProperties(option)) { + return option.alt; + } else if ( + typeof HTMLElement !== 'undefined' && + option instanceof HTMLElement + ) { + return option.title ?? option.ariaLabel ?? option.innerText; + } else if (typeof option === 'string') { + return option; + } + + console.warn( + "Can't get text for existing dropdown option. If " + + "you're using HTMLElement dropdown options in node, ensure you're " + + 'using jsdom-global or similar.', + ); + return null; + } + + /** + * Construct a FieldDropdown from a JSON arg object. + * + * @param options A JSON object with options (options). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static override fromJson( + options: FieldDropdownFromJsonConfig, + ): FieldDropdown { + if (!options.options) { + throw new Error( + 'options are required for the dropdown field. The ' + + 'options property must be assigned an array of ' + + '[humanReadableValue, languageNeutralValue] tuples.', + ); + } + // `this` might be a subclass of FieldDropdown if that class doesn't + // override the static fromJson method. + return new this(options.options, undefined, options); + } + + /** + * Factor out common words in statically defined options. + * Create prefix and/or suffix labels. + */ + protected trimOptions(options: MenuOption[]): { + options: MenuOption[]; + prefix?: string; + suffix?: string; + } { + let hasNonTextContent = false; + const trimmedOptions = options.map((option): MenuOption => { + if (option === FieldDropdown.SEPARATOR) { + hasNonTextContent = true; + return option; + } + + const [label, value] = option; + if (typeof label === 'string') { + return [parsing.replaceMessageReferences(label), value]; + } + + hasNonTextContent = true; + // Copy the image properties so they're not influenced by the original. + // NOTE: No need to deep copy since image properties are only 1 level deep. + const imageLabel = isImageProperties(label) + ? {...label, alt: parsing.replaceMessageReferences(label.alt)} + : label; + return [imageLabel, value]; + }); + + if (hasNonTextContent || options.length < 2) { + return {options: trimmedOptions}; + } + + const stringOptions = trimmedOptions as [string, string][]; + const stringLabels = stringOptions.map(([label]) => label); + + const shortest = utilsString.shortestStringLength(stringLabels); + const prefixLength = utilsString.commonWordPrefix(stringLabels, shortest); + const suffixLength = utilsString.commonWordSuffix(stringLabels, shortest); + + if ( + (!prefixLength && !suffixLength) || + shortest <= prefixLength + suffixLength + ) { + // One or more strings will entirely vanish if we proceed. Abort. + return {options: stringOptions}; + } + + const prefix = prefixLength + ? stringLabels[0].substring(0, prefixLength - 1) + : undefined; + const suffix = suffixLength + ? stringLabels[0].substr(1 - suffixLength) + : undefined; + return { + options: this.applyTrim(stringOptions, prefixLength, suffixLength), + prefix, + suffix, + }; + } + + /** + * Use the calculated prefix and suffix lengths to trim all of the options in + * the given array. + * + * @param options Array of option tuples: + * (human-readable text or image, language-neutral name). + * @param prefixLength The length of the common prefix. + * @param suffixLength The length of the common suffix + * @returns A new array with all of the option text trimmed. + */ + private applyTrim( + options: [string, string][], + prefixLength: number, + suffixLength: number, + ): MenuOption[] { + return options.map(([text, value]) => [ + text.substring(prefixLength, text.length - suffixLength), + value, + ]); + } + + /** + * Validates the data structure to be processed as an options list. + * + * @param options The proposed dropdown options. + * @throws {TypeError} If proposed options are incorrectly structured. + */ + protected validateOptions(options: MenuOption[]) { + if (!Array.isArray(options)) { + throw TypeError('FieldDropdown options must be an array.'); + } + if (!options.length) { + throw TypeError('FieldDropdown options must not be an empty array.'); + } + let foundError = false; + for (let i = 0; i < options.length; i++) { + const option = options[i]; + if (!Array.isArray(option) && option !== FieldDropdown.SEPARATOR) { + foundError = true; + console.error( + `Invalid option[${i}]: Each FieldDropdown option must be an array or + the string literal 'separator'. Found: ${option}`, + ); + } else if (typeof option[1] !== 'string') { + foundError = true; + console.error( + `Invalid option[${i}]: Each FieldDropdown option id must be a string. + Found ${option[1]} in: ${option}`, + ); + } else if ( + option[0] && + typeof option[0] !== 'string' && + !isImageProperties(option[0]) && + !( + typeof HTMLElement !== 'undefined' && option[0] instanceof HTMLElement + ) + ) { + foundError = true; + console.error( + `Invalid option[${i}]: Each FieldDropdown option must have a string + label, image description, or HTML element. Found ${option[0]} in: ${option}`, + ); + } + } + if (foundError) { + throw TypeError('Found invalid FieldDropdown options.'); + } + } +} + +/** + * Returns whether or not an object conforms to the ImageProperties interface. + * + * @param obj The object to test. + * @returns True if the object conforms to ImageProperties, otherwise false. + */ +function isImageProperties(obj: any): obj is ImageProperties { + return ( + obj && + typeof obj === 'object' && + 'src' in obj && + typeof obj.src === 'string' && + 'alt' in obj && + typeof obj.alt === 'string' && + 'width' in obj && + typeof obj.width === 'number' && + 'height' in obj && + typeof obj.height === 'number' + ); +} + +/** + * Definition of a human-readable image dropdown option. + */ +export interface ImageProperties { + src: string; + alt: string; + width: number; + height: number; +} + +/** + * An individual option in the dropdown menu. Can be either the string literal + * `separator` for a menu separator item, or an array for normal action menu + * items. In the latter case, the first element is the human-readable value + * (text, ImageProperties object, or HTML element), and the second element is + * the language-neutral value. + */ +export type MenuOption = + | [string | ImageProperties | HTMLElement, string] + | 'separator'; + +/** + * A function that generates an array of menu options for FieldDropdown + * or its descendants. + */ +export type MenuGeneratorFunction = (this: FieldDropdown) => MenuOption[]; + +/** + * Either an array of menu options or a function that generates an array of + * menu options for FieldDropdown or its descendants. + */ +export type MenuGenerator = MenuOption[] | MenuGeneratorFunction; + +/** + * Config options for the dropdown field. + */ +export type FieldDropdownConfig = FieldConfig; + +/** + * fromJson config for the dropdown field. + */ +export interface FieldDropdownFromJsonConfig extends FieldDropdownConfig { + options?: MenuOption[]; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldDropdownValidator = FieldValidator; + +fieldRegistry.register('field_dropdown', FieldDropdown); diff --git a/core/field_image.js b/core/field_image.js deleted file mode 100644 index ede3e2b92f9..00000000000 --- a/core/field_image.js +++ /dev/null @@ -1,178 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Image field. Used for pictures, icons, etc. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.FieldImage'); - -goog.require('Blockly.Field'); -goog.require('goog.dom'); -goog.require('goog.math.Size'); -goog.require('goog.userAgent'); - - -/** - * Class for an image on a block. - * @param {string} src The URL of the image. - * @param {number} width Width of the image. - * @param {number} height Height of the image. - * @param {string=} opt_alt Optional alt text for when block is collapsed. - * @param {function=} opt_onClick Optional function to be called when image is clicked - * @extends {Blockly.Field} - * @constructor - */ -Blockly.FieldImage = function(src, width, height, opt_alt, opt_onClick) { - this.sourceBlock_ = null; - - // Ensure height and width are numbers. Strings are bad at math. - this.height_ = Number(height); - this.width_ = Number(width); - this.size_ = new goog.math.Size(this.width_, - this.height_ + 2 * Blockly.BlockSvg.INLINE_PADDING_Y); - this.text_ = opt_alt || ''; - this.setValue(src); - - if (typeof opt_onClick === "function") { - this.clickHandler_ = opt_onClick; - } -}; -goog.inherits(Blockly.FieldImage, Blockly.Field); - -/** - * Editable fields are saved by the XML renderer, non-editable fields are not. - */ -Blockly.FieldImage.prototype.EDITABLE = false; - -/** - * Install this image on a block. - */ -Blockly.FieldImage.prototype.init = function() { - if (this.fieldGroup_) { - // Image has already been initialized once. - return; - } - // Build the DOM. - /** @type {SVGElement} */ - this.fieldGroup_ = Blockly.utils.createSvgElement('g', {}, null); - if (!this.visible_) { - this.fieldGroup_.style.display = 'none'; - } - /** @type {SVGElement} */ - this.imageElement_ = Blockly.utils.createSvgElement( - 'image', - { - 'height': this.height_ + 'px', - 'width': this.width_ + 'px' - }, - this.fieldGroup_); - this.setValue(this.src_); - this.sourceBlock_.getSvgRoot().appendChild(this.fieldGroup_); - - // Configure the field to be transparent with respect to tooltips. - this.setTooltip(this.sourceBlock_); - Blockly.Tooltip.bindMouseEvents(this.imageElement_); -}; - -/** - * Dispose of all DOM objects belonging to this text. - */ -Blockly.FieldImage.prototype.dispose = function() { - goog.dom.removeNode(this.fieldGroup_); - this.fieldGroup_ = null; - this.imageElement_ = null; -}; - -/** - * Change the tooltip text for this field. - * @param {string|!Element} newTip Text for tooltip or a parent element to - * link to for its tooltip. - */ -Blockly.FieldImage.prototype.setTooltip = function(newTip) { - this.imageElement_.tooltip = newTip; -}; - -/** - * Get the source URL of this image. - * @return {string} Current text. - * @override - */ -Blockly.FieldImage.prototype.getValue = function() { - return this.src_; -}; - -/** - * Set the source URL of this image. - * @param {?string} src New source. - * @override - */ -Blockly.FieldImage.prototype.setValue = function(src) { - if (src === null) { - // No change if null. - return; - } - this.src_ = src; - if (this.imageElement_) { - this.imageElement_.setAttributeNS('http://www.w3.org/1999/xlink', - 'xlink:href', src || ''); - } -}; - -/** - * Set the alt text of this image. - * @param {?string} alt New alt text. - * @override - */ -Blockly.FieldImage.prototype.setText = function(alt) { - if (alt === null) { - // No change if null. - return; - } - this.text_ = alt; -}; - -/** - * Images are fixed width, no need to render. - * @private - */ -Blockly.FieldImage.prototype.render_ = function() { - // NOP -}; - -/** - * Images are fixed width, no need to update. - * @private - */ -Blockly.FieldImage.prototype.updateWidth = function() { - // NOP -}; - -/** - * If field click is called, and click handler defined, - * call the handler. - */ - Blockly.FieldImage.prototype.showEditor = function() { - if (this.clickHandler_){ - this.clickHandler_(this); - } - }; diff --git a/core/field_image.ts b/core/field_image.ts new file mode 100644 index 00000000000..01133c20340 --- /dev/null +++ b/core/field_image.ts @@ -0,0 +1,307 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Image field. Used for pictures, icons, etc. + * + * @class + */ +// Former goog.module ID: Blockly.FieldImage + +import {Field, FieldConfig} from './field.js'; +import * as fieldRegistry from './field_registry.js'; +import * as dom from './utils/dom.js'; +import * as parsing from './utils/parsing.js'; +import {Size} from './utils/size.js'; +import {Svg} from './utils/svg.js'; + +/** + * Class for an image on a block. + */ +export class FieldImage extends Field { + /** + * Vertical padding below the image, which is included in the reported height + * of the field. + */ + private static readonly Y_PADDING = 1; + protected readonly imageHeight: number; + + /** The function to be called when this field is clicked. */ + private clickHandler: ((p1: FieldImage) => void) | null = null; + + /** The rendered field's image element. */ + protected imageElement: SVGImageElement | null = null; + + /** + * Editable fields usually show some sort of UI indicating they are + * editable. This field should not. + */ + override readonly EDITABLE = false; + + /** + * Used to tell if the field needs to be rendered the next time the block is + * rendered. Image fields are statically sized, and only need to be + * rendered at initialization. + */ + protected override isDirty_ = false; + + /** Whether to flip this image in RTL. */ + private flipRtl = false; + + /** Alt text of this image. */ + private altText = ''; + + /** + * @param src The URL of the image. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field value + * after their own constructors have run). + * @param width Width of the image. + * @param height Height of the image. + * @param alt Optional alt text for when block is collapsed. + * @param onClick Optional function to be called when the image is + * clicked. If onClick is defined, alt must also be defined. + * @param flipRtl Whether to flip the icon in RTL. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/image#creation} + * for a list of properties this parameter supports. + */ + constructor( + src: string | typeof Field.SKIP_SETUP, + width: string | number, + height: string | number, + alt?: string, + onClick?: (p1: FieldImage) => void, + flipRtl?: boolean, + config?: FieldImageConfig, + ) { + super(Field.SKIP_SETUP); + + const imageHeight = Number(parsing.replaceMessageReferences(height)); + const imageWidth = Number(parsing.replaceMessageReferences(width)); + if (isNaN(imageHeight) || isNaN(imageWidth)) { + throw Error( + 'Height and width values of an image field must cast to' + ' numbers.', + ); + } + if (imageHeight <= 0 || imageWidth <= 0) { + throw Error( + 'Height and width values of an image field must be greater' + + ' than 0.', + ); + } + + /** The size of the area rendered by the field. */ + this.size_ = new Size(imageWidth, imageHeight + FieldImage.Y_PADDING); + + /** + * Store the image height, since it is different from the field height. + */ + this.imageHeight = imageHeight; + + if (typeof onClick === 'function') { + this.clickHandler = onClick; + } + + if (src === Field.SKIP_SETUP) return; + + if (config) { + this.configure_(config); + } else { + this.flipRtl = !!flipRtl; + this.altText = parsing.replaceMessageReferences(alt) || ''; + } + this.setValue(parsing.replaceMessageReferences(src)); + } + + /** + * Configure the field based on the given map of options. + * + * @param config A map of options to configure the field based on. + */ + protected override configure_(config: FieldImageConfig) { + super.configure_(config); + if (config.flipRtl) this.flipRtl = config.flipRtl; + if (config.alt) { + this.altText = parsing.replaceMessageReferences(config.alt); + } + } + + /** + * Create the block UI for this image. + */ + override initView() { + this.imageElement = dom.createSvgElement( + Svg.IMAGE, + { + 'height': this.imageHeight + 'px', + 'width': this.size_.width + 'px', + 'alt': this.altText, + }, + this.fieldGroup_, + ); + this.imageElement.setAttributeNS( + dom.XLINK_NS, + 'xlink:href', + this.value_ as string, + ); + + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyImageField'); + } + + if (this.clickHandler) { + this.imageElement.style.cursor = 'pointer'; + } + } + + override updateSize_() {} + // NOP + + /** + * Ensure that the input value (the source URL) is a string. + * + * @param newValue The input value. + * @returns A string, or null if invalid. + */ + protected override doClassValidation_(newValue?: any): string | null { + if (typeof newValue !== 'string') { + return null; + } + return newValue; + } + + /** + * Update the value of this image field, and update the displayed image. + * + * @param newValue The value to be saved. The default validator guarantees + * that this is a string. + */ + protected override doValueUpdate_(newValue: string) { + this.value_ = newValue; + if (this.imageElement) { + this.imageElement.setAttributeNS(dom.XLINK_NS, 'xlink:href', this.value_); + } + } + + /** + * Get whether to flip this image in RTL + * + * @returns True if we should flip in RTL. + */ + override getFlipRtl(): boolean { + return this.flipRtl; + } + + /** + * Set the alt text of this image. + * + * @param alt New alt text. + */ + setAlt(alt: string | null) { + if (alt === this.altText) { + return; + } + this.altText = alt || ''; + if (this.imageElement) { + this.imageElement.setAttribute('alt', this.altText); + } + } + + /** + * Check whether this field should be clickable. + * + * @returns Whether this field is clickable. + */ + isClickable(): boolean { + // Images are only clickable if they have a click handler and fulfill the + // contract to be clickable: enabled and attached to an editable block. + return super.isClickable() && !!this.clickHandler; + } + + /** + * If field click is called, and click handler defined, + * call the handler. + */ + protected override showEditor_() { + if (this.clickHandler) { + this.clickHandler(this); + } + } + + /** + * Set the function that is called when this image is clicked. + * + * @param func The function that is called when the image is clicked, or null + * to remove. + */ + setOnClickHandler(func: ((p1: FieldImage) => void) | null) { + this.clickHandler = func; + } + + /** + * Use the `getText_` developer hook to override the field's text + * representation. + * Return the image alt text instead. + * + * @returns The image alt text. + */ + protected override getText_(): string | null { + return this.altText; + } + + /** + * Construct a FieldImage from a JSON arg object, + * dereferencing any string table references. + * + * @param options A JSON object with options (src, width, height, alt, and + * flipRtl). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static override fromJson(options: FieldImageFromJsonConfig): FieldImage { + if (!options.src || !options.width || !options.height) { + throw new Error( + 'src, width, and height values for an image field are' + + 'required. The width and height must be non-zero.', + ); + } + // `this` might be a subclass of FieldImage if that class doesn't override + // the static fromJson method. + return new this( + options.src, + options.width, + options.height, + undefined, + undefined, + undefined, + options, + ); + } +} + +fieldRegistry.register('field_image', FieldImage); + +FieldImage.prototype.DEFAULT_VALUE = ''; + +/** + * Config options for the image field. + */ +export interface FieldImageConfig extends FieldConfig { + flipRtl?: boolean; + alt?: string; +} + +/** + * fromJson config options for the image field. + */ +export interface FieldImageFromJsonConfig extends FieldImageConfig { + src?: string; + width?: number; + height?: number; +} diff --git a/core/field_input.ts b/core/field_input.ts new file mode 100644 index 00000000000..55383a4c1d2 --- /dev/null +++ b/core/field_input.ts @@ -0,0 +1,826 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Text input field. + * + * @class + */ +// Former goog.module ID: Blockly.FieldInput + +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_block_change.js'; + +import {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; +import * as bumpObjects from './bump_objects.js'; +import * as dialog from './dialog.js'; +import * as dropDownDiv from './dropdowndiv.js'; +import {EventType} from './events/type.js'; +import * as eventUtils from './events/utils.js'; +import { + Field, + FieldConfig, + FieldValidator, + UnattachedFieldError, +} from './field.js'; +import {getFocusManager} from './focus_manager.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import {Msg} from './msg.js'; +import * as renderManagement from './render_management.js'; +import * as aria from './utils/aria.js'; +import * as dom from './utils/dom.js'; +import {Size} from './utils/size.js'; +import * as userAgent from './utils/useragent.js'; +import * as WidgetDiv from './widgetdiv.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +/** + * Supported types for FieldInput subclasses. + * + * @internal + */ +type InputTypes = string | number; + +/** + * The minimum width of an input field. + */ +const MINIMUM_WIDTH = 14; + +/** + * Abstract class for an editable input field. + * + * @typeParam T - The value stored on the field. + * @internal + */ +export abstract class FieldInput extends Field< + string | T +> { + /** + * Pixel size of input border radius. + * Should match blocklyText's border-radius in CSS. + */ + static BORDERRADIUS = 4; + + /** Allow browser to spellcheck this field. */ + protected spellcheck_ = true; + + /** The HTML input element. */ + protected htmlInput_: HTMLInputElement | null = null; + + /** True if the field's value is currently being edited via the UI. */ + protected isBeingEdited_ = false; + + /** + * True if the value currently displayed in the field's editory UI is valid. + */ + protected isTextValid_ = false; + + /** + * The intial value of the field when the user opened an editor to change its + * value. When the editor is disposed, an event will be fired that uses this + * as the event's oldValue. + */ + protected valueWhenEditorWasOpened_: string | T | null = null; + + /** Key down event data. */ + private onKeyDownWrapper: browserEvents.Data | null = null; + + /** Input element input event data. */ + private onInputWrapper: browserEvents.Data | null = null; + + /** + * Whether the field should consider the whole parent block to be its click + * target. + */ + fullBlockClickTarget_: boolean = false; + + /** The workspace that this field belongs to. */ + protected workspace_: WorkspaceSvg | null = null; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + */ + override SERIALIZABLE = true; + + protected override set size_(newValue: Size) { + // Although this appears to be a no-op, it must exist since the getter is + // overridden below. + super.size_ = newValue; + } + + /** + * Returns the size of this field, with a minimum width of 14. + */ + protected override get size_() { + const s = super.size_; + if (s.width < MINIMUM_WIDTH) { + s.width = MINIMUM_WIDTH; + } + + return s; + } + + /** + * @param value The initial value of the field. Should cast to a string. + * Defaults to an empty string if null or undefined. Also accepts + * Field.SKIP_SETUP if you wish to skip setup (only used by subclasses + * that want to handle configuration and setting the field value after + * their own constructors have run). + * @param validator A function that is called to validate changes to the + * field's value. Takes in a string & returns a validated string, or null + * to abort the change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/text-input#creation} + * for a list of properties this parameter supports. + */ + constructor( + value?: string | typeof Field.SKIP_SETUP, + validator?: FieldInputValidator | null, + config?: FieldInputConfig, + ) { + super(Field.SKIP_SETUP); + + if (value === Field.SKIP_SETUP) return; + if (config) { + this.configure_(config); + } + this.setValue(value); + if (validator) { + this.setValidator(validator); + } + } + + protected override configure_(config: FieldInputConfig) { + super.configure_(config); + if (config.spellcheck !== undefined) { + this.spellcheck_ = config.spellcheck; + } + } + + override initView() { + const block = this.getSourceBlock(); + if (!block) throw new UnattachedFieldError(); + super.initView(); + + if (this.isFullBlockField()) { + this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot(); + } + + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyInputField'); + } + } + + override isFullBlockField(): boolean { + const block = this.getSourceBlock(); + if (!block) throw new UnattachedFieldError(); + + // Side effect for backwards compatibility. + this.fullBlockClickTarget_ = + !!this.getConstants()?.FULL_BLOCK_FIELDS && block.isSimpleReporter(); + return this.fullBlockClickTarget_; + } + + /** + * Called by setValue if the text input is not valid. If the field is + * currently being edited it reverts value of the field to the previous + * value while allowing the display text to be handled by the htmlInput_. + * + * @param _invalidValue The input value that was determined to be invalid. + * This is not used by the text input because its display value is stored + * on the htmlInput_. + * @param fireChangeEvent Whether to fire a change event if the value changes. + */ + protected override doValueInvalid_( + _invalidValue: AnyDuringMigration, + fireChangeEvent: boolean = true, + ) { + if (this.isBeingEdited_) { + this.isDirty_ = true; + this.isTextValid_ = false; + const oldValue = this.value_; + // Revert value when the text becomes invalid. + this.value_ = this.valueWhenEditorWasOpened_; + if ( + this.sourceBlock_ && + eventUtils.isEnabled() && + this.value_ !== oldValue && + fireChangeEvent + ) { + eventUtils.fire( + new (eventUtils.get(EventType.BLOCK_CHANGE))( + this.sourceBlock_, + 'field', + this.name || null, + oldValue, + this.value_, + ), + ); + } + } + } + + /** + * Called by setValue if the text input is valid. Updates the value of the + * field, and updates the text of the field if it is not currently being + * edited (i.e. handled by the htmlInput_). + * + * @param newValue The value to be saved. The default validator guarantees + * that this is a string. + */ + protected override doValueUpdate_(newValue: string | T) { + this.isDirty_ = true; + this.isTextValid_ = true; + this.value_ = newValue; + } + + /** + * Updates text field to match the colour/style of the block. + */ + override applyColour() { + const block = this.getSourceBlock() as BlockSvg | null; + if (!block) throw new UnattachedFieldError(); + + if (!this.getConstants()!.FULL_BLOCK_FIELDS) return; + if (!this.fieldGroup_) return; + + if (!this.isFullBlockField() && this.borderRect_) { + this.borderRect_!.style.display = 'block'; + this.borderRect_.setAttribute('stroke', block.getColourTertiary()); + } else { + this.borderRect_!.style.display = 'none'; + // In general, do *not* let fields control the color of blocks. Having the + // field control the color is unexpected, and could have performance + // impacts. + block.pathObject.svgPath.setAttribute( + 'fill', + this.getConstants()!.FIELD_BORDER_RECT_COLOUR, + ); + } + } + + /** + * Returns the height and width of the field. + * + * This should *in general* be the only place render_ gets called from. + * + * @returns Height and width. + */ + override getSize(): Size { + if (this.getConstants()?.FULL_BLOCK_FIELDS) { + // In general, do *not* let fields control the color of blocks. Having the + // field control the color is unexpected, and could have performance + // impacts. + // Full block fields have more control of the block than they should + // (i.e. updating fill colour). Whenever we get the size, the field may + // no longer be a full-block field, so we need to rerender. + this.render_(); + this.isDirty_ = false; + } + return super.getSize(); + } + + /** + * Notifies the field that it has changed locations. Moves the widget div to + * be in the correct place if it is open. + */ + onLocationChange(): void { + if (this.isBeingEdited_) this.resizeEditor_(); + } + + /** + * Updates the colour of the htmlInput given the current validity of the + * field's value. + * + * Also updates the colour of the block to reflect whether this is a full + * block field or not. + */ + protected override render_() { + super.render_(); + // This logic is done in render_ rather than doValueInvalid_ or + // doValueUpdate_ so that the code is more centralized. + if (this.isBeingEdited_) { + const htmlInput = this.htmlInput_ as HTMLElement; + if (!this.isTextValid_) { + dom.addClass(htmlInput, 'blocklyInvalidInput'); + aria.setState(htmlInput, aria.State.INVALID, true); + } else { + dom.removeClass(htmlInput, 'blocklyInvalidInput'); + aria.setState(htmlInput, aria.State.INVALID, false); + } + } + + const block = this.getSourceBlock() as BlockSvg | null; + if (!block) throw new UnattachedFieldError(); + // In general, do *not* let fields control the color of blocks. Having the + // field control the color is unexpected, and could have performance + // impacts. + // Whenever we render, the field may no longer be a full-block-field so + // we need to update the colour. + if (this.getConstants()!.FULL_BLOCK_FIELDS) block.applyColour(); + } + + /** + * Set whether this field is spellchecked by the browser. + * + * @param check True if checked. + */ + setSpellcheck(check: boolean) { + if (check === this.spellcheck_) { + return; + } + this.spellcheck_ = check; + if (this.htmlInput_) { + // AnyDuringMigration because: Argument of type 'boolean' is not + // assignable to parameter of type 'string'. + this.htmlInput_.setAttribute( + 'spellcheck', + this.spellcheck_ as AnyDuringMigration, + ); + } + } + + /** + * Show an editor for the field. + * Shows the inline free-text editor on top of the text by default. + * Shows a prompt editor for mobile browsers if the modalInputs option is + * enabled. + * + * @param _e Optional mouse event that triggered the field to open, or + * undefined if triggered programmatically. + * @param quietInput True if editor should be created without focus. + * Defaults to false. + * @param manageEphemeralFocus Whether ephemeral focus should be managed as + * part of the editor's inline editor (when the inline editor is shown). + * Callers must manage ephemeral focus themselves if this is false. + * Defaults to true. + */ + protected override showEditor_( + _e?: Event, + quietInput = false, + manageEphemeralFocus: boolean = true, + ) { + this.workspace_ = (this.sourceBlock_ as BlockSvg).workspace; + if ( + !quietInput && + this.workspace_.options.modalInputs && + (userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD) + ) { + this.showPromptEditor(); + } else { + this.showInlineEditor(quietInput, manageEphemeralFocus); + } + } + + /** + * Create and show a text input editor that is a prompt (usually a popup). + * Mobile browsers may have issues with in-line textareas (focus and + * keyboards). + */ + private showPromptEditor() { + dialog.prompt( + Msg['CHANGE_VALUE_TITLE'], + this.getText(), + (text: string | null) => { + // Text is null if user pressed cancel button. + if (text !== null) { + this.setValue(this.getValueFromEditorText_(text)); + } + this.onFinishEditing_(this.value_); + }, + ); + } + + /** + * Create and show a text input editor that sits directly over the text input. + * + * @param quietInput True if editor should be created without focus. + * @param manageEphemeralFocus Whether ephemeral focus should be managed as + * part of the field's inline editor (widget div). + */ + private showInlineEditor(quietInput: boolean, manageEphemeralFocus: boolean) { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + WidgetDiv.show( + this, + block.RTL, + this.widgetDispose_.bind(this), + this.workspace_, + manageEphemeralFocus, + ); + this.htmlInput_ = this.widgetCreate_() as HTMLInputElement; + this.isBeingEdited_ = true; + this.valueWhenEditorWasOpened_ = this.value_; + + if (!quietInput) { + (this.htmlInput_ as HTMLElement).focus({ + preventScroll: true, + }); + this.htmlInput_.select(); + } + } + + /** + * Create the text input editor widget. + * + * @returns The newly created text input editor. + */ + protected widgetCreate_(): HTMLInputElement | HTMLTextAreaElement { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + eventUtils.setGroup(true); + const div = WidgetDiv.getDiv(); + + const clickTarget = this.getClickTarget_(); + if (!clickTarget) throw new Error('A click target has not been set.'); + dom.addClass(clickTarget, 'blocklyEditing'); + + const htmlInput = document.createElement('input'); + htmlInput.className = 'blocklyHtmlInput'; + // AnyDuringMigration because: Argument of type 'boolean' is not assignable + // to parameter of type 'string'. + htmlInput.setAttribute( + 'spellcheck', + this.spellcheck_ as AnyDuringMigration, + ); + const scale = this.workspace_!.getAbsoluteScale(); + const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE * scale + 'pt'; + div!.style.fontSize = fontSize; + htmlInput.style.fontSize = fontSize; + let borderRadius = FieldInput.BORDERRADIUS * scale + 'px'; + + if (this.isFullBlockField()) { + const bBox = this.getScaledBBox(); + + // Override border radius. + borderRadius = (bBox.bottom - bBox.top) / 2 + 'px'; + // Pull stroke colour from the existing shadow block + const strokeColour = block.getParent() + ? (block.getParent() as BlockSvg).getColourTertiary() + : (this.sourceBlock_ as BlockSvg).getColourTertiary(); + htmlInput.style.border = 1 * scale + 'px solid ' + strokeColour; + div!.style.borderRadius = borderRadius; + div!.style.transition = 'box-shadow 0.25s ease 0s'; + if (this.getConstants()!.FIELD_TEXTINPUT_BOX_SHADOW) { + div!.style.boxShadow = + 'rgba(255, 255, 255, 0.3) 0 0 0 ' + 4 * scale + 'px'; + } + } + htmlInput.style.borderRadius = borderRadius; + + div!.appendChild(htmlInput); + + htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_); + htmlInput.setAttribute('data-untyped-default-value', String(this.value_)); + + this.resizeEditor_(); + + this.bindInputEvents_(htmlInput); + + return htmlInput; + } + + /** + * Closes the editor, saves the results, and disposes of any events or + * DOM-references belonging to the editor. + */ + protected widgetDispose_() { + // Non-disposal related things that we do when the editor closes. + this.isBeingEdited_ = false; + this.isTextValid_ = true; + // Make sure the field's node matches the field's internal value. + this.forceRerender(); + this.onFinishEditing_(this.value_); + + if ( + this.sourceBlock_ && + eventUtils.isEnabled() && + this.valueWhenEditorWasOpened_ !== null && + this.valueWhenEditorWasOpened_ !== this.value_ + ) { + // When closing a field input widget, fire an event indicating that the + // user has completed a sequence of changes. The value may have changed + // multiple times while the editor was open, but this will fire an event + // containing the value when the editor was opened as well as the new one. + eventUtils.fire( + new (eventUtils.get(EventType.BLOCK_CHANGE))( + this.sourceBlock_, + 'field', + this.name || null, + this.valueWhenEditorWasOpened_, + this.value_, + ), + ); + this.valueWhenEditorWasOpened_ = null; + } + + eventUtils.setGroup(false); + + // Actual disposal. + this.unbindInputEvents_(); + const style = WidgetDiv.getDiv()!.style; + style.width = 'auto'; + style.height = 'auto'; + style.fontSize = ''; + style.transition = ''; + style.boxShadow = ''; + this.htmlInput_ = null; + + const clickTarget = this.getClickTarget_(); + if (!clickTarget) throw new Error('A click target has not been set.'); + dom.removeClass(clickTarget, 'blocklyEditing'); + } + + /** + * A callback triggered when the user is done editing the field via the UI. + * + * @param _value The new value of the field. + */ + onFinishEditing_(_value: AnyDuringMigration) {} + + /** + * Bind handlers for user input on the text input field's editor. + * + * @param htmlInput The htmlInput to which event handlers will be bound. + */ + protected bindInputEvents_(htmlInput: HTMLElement) { + // Trap Enter without IME and Esc to hide. + this.onKeyDownWrapper = browserEvents.conditionalBind( + htmlInput, + 'keydown', + this, + this.onHtmlInputKeyDown_, + ); + // Resize after every input change. + this.onInputWrapper = browserEvents.conditionalBind( + htmlInput, + 'input', + this, + this.onHtmlInputChange, + ); + } + + /** Unbind handlers for user input and workspace size changes. */ + protected unbindInputEvents_() { + if (this.onKeyDownWrapper) { + browserEvents.unbind(this.onKeyDownWrapper); + this.onKeyDownWrapper = null; + } + if (this.onInputWrapper) { + browserEvents.unbind(this.onInputWrapper); + this.onInputWrapper = null; + } + } + + /** + * Handle key down to the editor. + * + * @param e Keyboard event. + */ + protected onHtmlInputKeyDown_(e: KeyboardEvent) { + if (e.key === 'Enter') { + WidgetDiv.hideIfOwner(this); + dropDownDiv.hideWithoutAnimation(); + } else if (e.key === 'Escape') { + this.setValue( + this.htmlInput_!.getAttribute('data-untyped-default-value'), + false, + ); + WidgetDiv.hideIfOwner(this); + dropDownDiv.hideWithoutAnimation(); + } else if (e.key === 'Tab') { + e.preventDefault(); + const cursor = this.workspace_?.getCursor(); + + const isValidDestination = (node: IFocusableNode | null) => + (node instanceof FieldInput || + (node instanceof BlockSvg && node.isSimpleReporter())) && + node !== this.getSourceBlock(); + + let target = e.shiftKey + ? cursor?.getPreviousNode(this, isValidDestination, false) + : cursor?.getNextNode(this, isValidDestination, false); + target = + target instanceof BlockSvg && target.isSimpleReporter() + ? target.getFields().next().value + : target; + + if (target instanceof FieldInput) { + WidgetDiv.hideIfOwner(this); + dropDownDiv.hideWithoutAnimation(); + const targetSourceBlock = target.getSourceBlock(); + if ( + target.isFullBlockField() && + targetSourceBlock && + targetSourceBlock instanceof BlockSvg + ) { + getFocusManager().focusNode(targetSourceBlock); + } else getFocusManager().focusNode(target); + target.showEditor(); + } + } + } + + /** + * Handle a change to the editor. + * + * @param _e InputEvent. + */ + private onHtmlInputChange(_e: Event) { + // Intermediate value changes from user input are not confirmed until the + // user closes the editor, and may be numerous. Inhibit reporting these as + // normal block change events, and instead report them as special + // intermediate changes that do not get recorded in undo history. + const oldValue = this.value_; + // Change the field's value without firing the normal change event. + this.setValue( + this.getValueFromEditorText_(this.htmlInput_!.value), + /* fireChangeEvent= */ false, + ); + if ( + this.sourceBlock_ && + eventUtils.isEnabled() && + this.value_ !== oldValue + ) { + // Fire a special event indicating that the value changed but the change + // isn't complete yet and normal field change listeners can wait. + eventUtils.fire( + new (eventUtils.get(EventType.BLOCK_FIELD_INTERMEDIATE_CHANGE))( + this.sourceBlock_, + this.name || null, + oldValue, + this.value_, + ), + ); + } + } + + /** + * Set the HTML input value and the field's internal value. The difference + * between this and `setValue` is that this also updates the HTML input + * value whilst editing. + * + * @param newValue New value. + * @param fireChangeEvent Whether to fire a change event. Defaults to true. + * Should usually be true unless the change will be reported some other + * way, e.g. an intermediate field change event. + */ + protected setEditorValue_( + newValue: AnyDuringMigration, + fireChangeEvent = true, + ) { + this.isDirty_ = true; + if (this.isBeingEdited_) { + // In the case this method is passed an invalid value, we still + // pass it through the transformation method `getEditorText` to deal + // with. Otherwise, the internal field's state will be inconsistent + // with what's shown to the user. + this.htmlInput_!.value = this.getEditorText_(newValue); + } + this.setValue(newValue, fireChangeEvent); + } + + /** Resize the editor to fit the text. */ + protected resizeEditor_() { + renderManagement.finishQueuedRenders().then(() => { + const block = this.getSourceBlock(); + if (!block) throw new UnattachedFieldError(); + const div = WidgetDiv.getDiv(); + const bBox = this.getScaledBBox(); + div!.style.width = bBox.right - bBox.left + 'px'; + div!.style.height = bBox.bottom - bBox.top + 'px'; + + // In RTL mode block fields and LTR input fields the left edge moves, + // whereas the right edge is fixed. Reposition the editor. + const x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left; + const y = bBox.top; + + div!.style.left = `${x}px`; + div!.style.top = `${y}px`; + }); + } + + /** + * Handles repositioning the WidgetDiv used for input fields when the + * workspace is resized. Will bump the block into the viewport and update the + * position of the text input if necessary. + * + * @returns True for rendered workspaces, as we never want to hide the widget + * div. + */ + override repositionForWindowResize(): boolean { + const block = this.getSourceBlock()?.getRootBlock(); + // This shouldn't be possible. We should never have a WidgetDiv if not using + // rendered blocks. + if (!(block instanceof BlockSvg)) return false; + + const bumped = bumpObjects.bumpIntoBounds( + this.workspace_!, + this.workspace_!.getMetricsManager().getViewMetrics(true), + block, + ); + + if (!bumped) this.resizeEditor_(); + + return true; + } + + /** + * Position a field's text element after a size change. This handles both LTR + * and RTL positioning. + * + * @param xMargin x offset to use when positioning the text element. + * @param contentWidth The content width. + */ + protected override positionTextElement_( + xMargin: number, + contentWidth: number, + ) { + const effectiveWidth = xMargin * 2 + contentWidth; + const delta = + effectiveWidth < MINIMUM_WIDTH ? (MINIMUM_WIDTH - effectiveWidth) / 2 : 0; + super.positionTextElement_(xMargin + delta, contentWidth); + } + + /** + * Use the `getText_` developer hook to override the field's text + * representation. When we're currently editing, return the current HTML value + * instead. Otherwise, return null which tells the field to use the default + * behaviour (which is a string cast of the field's value). + * + * @returns The HTML value if we're editing, otherwise null. + */ + protected override getText_(): string | null { + if (this.isBeingEdited_ && this.htmlInput_) { + // We are currently editing, return the HTML input value instead. + return this.htmlInput_.value; + } + return null; + } + + /** + * Transform the provided value into a text to show in the HTML input. + * Override this method if the field's HTML input representation is different + * than the field's value. This should be coupled with an override of + * `getValueFromEditorText_`. + * + * @param value The value stored in this field. + * @returns The text to show on the HTML input. + */ + protected getEditorText_(value: AnyDuringMigration): string { + return `${value}`; + } + + /** + * Transform the text received from the HTML input into a value to store + * in this field. + * Override this method if the field's HTML input representation is different + * than the field's value. This should be coupled with an override of + * `getEditorText_`. + * + * @param text Text received from the HTML input. + * @returns The value to store. + */ + protected getValueFromEditorText_(text: string): AnyDuringMigration { + return text; + } +} + +/** + * Config options for the input field. + * + * @internal + */ +export interface FieldInputConfig extends FieldConfig { + spellcheck?: boolean; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + * @internal + */ +export type FieldInputValidator = FieldValidator< + string | T +>; diff --git a/core/field_label.js b/core/field_label.js deleted file mode 100644 index f4bca6a24a5..00000000000 --- a/core/field_label.js +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Non-editable text field. Used for titles, labels, etc. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.FieldLabel'); - -goog.require('Blockly.Field'); -goog.require('Blockly.Tooltip'); -goog.require('goog.dom'); -goog.require('goog.math.Size'); - - -/** - * Class for a non-editable field. - * @param {string} text The initial content of the field. - * @param {string=} opt_class Optional CSS class for the field's text. - * @extends {Blockly.Field} - * @constructor - */ -Blockly.FieldLabel = function(text, opt_class) { - this.size_ = new goog.math.Size(0, 17.5); - this.class_ = opt_class; - this.setValue(text); -}; -goog.inherits(Blockly.FieldLabel, Blockly.Field); - -/** - * Editable fields are saved by the XML renderer, non-editable fields are not. - */ -Blockly.FieldLabel.prototype.EDITABLE = false; - -/** - * Install this text on a block. - */ -Blockly.FieldLabel.prototype.init = function() { - if (this.textElement_) { - // Text has already been initialized once. - return; - } - // Build the DOM. - this.textElement_ = Blockly.utils.createSvgElement('text', - {'class': 'blocklyText', 'y': this.size_.height - 5}, null); - if (this.class_) { - Blockly.utils.addClass(this.textElement_, this.class_); - } - if (!this.visible_) { - this.textElement_.style.display = 'none'; - } - this.sourceBlock_.getSvgRoot().appendChild(this.textElement_); - - // Configure the field to be transparent with respect to tooltips. - this.textElement_.tooltip = this.sourceBlock_; - Blockly.Tooltip.bindMouseEvents(this.textElement_); - // Force a render. - this.render_(); -}; - -/** - * Dispose of all DOM objects belonging to this text. - */ -Blockly.FieldLabel.prototype.dispose = function() { - goog.dom.removeNode(this.textElement_); - this.textElement_ = null; -}; - -/** - * Gets the group element for this field. - * Used for measuring the size and for positioning. - * @return {!Element} The group element. - */ -Blockly.FieldLabel.prototype.getSvgRoot = function() { - return /** @type {!Element} */ (this.textElement_); -}; - -/** - * Change the tooltip text for this field. - * @param {string|!Element} newTip Text for tooltip or a parent element to - * link to for its tooltip. - */ -Blockly.FieldLabel.prototype.setTooltip = function(newTip) { - this.textElement_.tooltip = newTip; -}; diff --git a/core/field_label.ts b/core/field_label.ts new file mode 100644 index 00000000000..236154cc7b1 --- /dev/null +++ b/core/field_label.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Non-editable, non-serializable text field. Used for titles, + * labels, etc. + * + * @class + */ +// Former goog.module ID: Blockly.FieldLabel + +import {Field, FieldConfig} from './field.js'; +import * as fieldRegistry from './field_registry.js'; +import * as dom from './utils/dom.js'; +import * as parsing from './utils/parsing.js'; + +/** + * Class for a non-editable, non-serializable text field. + */ +export class FieldLabel extends Field { + /** The HTML class name to use for this field. */ + private class: string | null = null; + + /** + * Editable fields usually show some sort of UI indicating they are + * editable. This field should not. + */ + override EDITABLE = false; + + /** Text labels should not truncate. */ + override maxDisplayLength = Infinity; + + /** + * @param value The initial value of the field. Should cast to a string. + * Defaults to an empty string if null or undefined. Also accepts + * Field.SKIP_SETUP if you wish to skip setup (only used by subclasses + * that want to handle configuration and setting the field value after + * their own constructors have run). + * @param textClass Optional CSS class for the field's text. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/label#creation} + * for a list of properties this parameter supports. + */ + constructor( + value?: string | typeof Field.SKIP_SETUP, + textClass?: string, + config?: FieldLabelConfig, + ) { + super(Field.SKIP_SETUP); + + if (value === Field.SKIP_SETUP) return; + if (config) { + this.configure_(config); + } else { + this.class = textClass || null; + } + this.setValue(value); + } + + protected override configure_(config: FieldLabelConfig) { + super.configure_(config); + if (config.class) this.class = config.class; + } + + /** + * Create block UI for this label. + */ + override initView() { + this.createTextElement_(); + if (this.class) { + dom.addClass(this.getTextElement(), this.class); + } + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyLabelField'); + } + } + + /** + * Ensure that the input value casts to a valid string. + * + * @param newValue The input value. + * @returns A valid string, or null if invalid. + */ + protected override doClassValidation_( + newValue?: AnyDuringMigration, + ): string | null { + if (newValue === null || newValue === undefined) { + return null; + } + return `${newValue}`; + } + + /** + * Set the CSS class applied to the field's textElement_. + * + * @param cssClass The new CSS class name, or null to remove. + */ + setClass(cssClass: string | null) { + if (this.textElement_) { + if (this.class) { + dom.removeClass(this.textElement_, this.class); + } + if (cssClass) { + dom.addClass(this.textElement_, cssClass); + } + } + this.class = cssClass; + } + + /** + * Construct a FieldLabel from a JSON arg object, + * dereferencing any string table references. + * + * @param options A JSON object with options (text, and class). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static override fromJson(options: FieldLabelFromJsonConfig): FieldLabel { + const text = parsing.replaceMessageReferences(options.text); + // `this` might be a subclass of FieldLabel if that class doesn't override + // the static fromJson method. + return new this(text, undefined, options); + } +} + +fieldRegistry.register('field_label', FieldLabel); + +FieldLabel.prototype.DEFAULT_VALUE = ''; + +// clang-format off +// Clang does not like the 'class' keyword being used as a property. +/** + * Config options for the label field. + */ +export interface FieldLabelConfig extends FieldConfig { + class?: string; +} +// clang-format on + +/** + * fromJson config options for the label field. + */ +export interface FieldLabelFromJsonConfig extends FieldLabelConfig { + text?: string; +} diff --git a/core/field_label_serializable.ts b/core/field_label_serializable.ts new file mode 100644 index 00000000000..b2783583a72 --- /dev/null +++ b/core/field_label_serializable.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Non-editable, serializable text field. Behaves like a + * normal label but is serialized to XML. It may only be + * edited programmatically. + * + * @class + */ +// Former goog.module ID: Blockly.FieldLabelSerializable + +import { + FieldLabel, + FieldLabelConfig, + FieldLabelFromJsonConfig, +} from './field_label.js'; +import * as fieldRegistry from './field_registry.js'; +import * as parsing from './utils/parsing.js'; + +/** + * Class for a non-editable, serializable text field. + */ +export class FieldLabelSerializable extends FieldLabel { + /** + * Editable fields usually show some sort of UI indicating they are + * editable. This field should not. + */ + override EDITABLE = false; + + /** + * Serializable fields are saved by the XML renderer, non-serializable + * fields are not. This field should be serialized, but only edited + * programmatically. + */ + override SERIALIZABLE = true; + + /** + * @param value The initial value of the field. Should cast to a string. + * Defaults to an empty string if null or undefined. + * @param textClass Optional CSS class for the field's text. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/label-serializable#creation} + * for a list of properties this parameter supports. + */ + constructor(value?: string, textClass?: string, config?: FieldLabelConfig) { + super(String(value ?? ''), textClass, config); + } + + /** + * Construct a FieldLabelSerializable from a JSON arg object, + * dereferencing any string table references. + * + * @param options A JSON object with options (text, and class). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static override fromJson( + options: FieldLabelFromJsonConfig, + ): FieldLabelSerializable { + const text = parsing.replaceMessageReferences(options.text); + // `this` might be a subclass of FieldLabelSerializable if that class + // doesn't override the static fromJson method. + return new this(text, undefined, options); + } +} + +fieldRegistry.register('field_label_serializable', FieldLabelSerializable); diff --git a/core/field_number.js b/core/field_number.js deleted file mode 100644 index 722b0eefa52..00000000000 --- a/core/field_number.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Number input field - * @author fenichel@google.com (Rachel Fenichel) - */ -'use strict'; - -goog.provide('Blockly.FieldNumber'); - -goog.require('Blockly.FieldTextInput'); -goog.require('goog.math'); - -/** - * Class for an editable number field. - * @param {(string|number)=} opt_value The initial content of the field. The value - * should cast to a number, and if it does not, '0' will be used. - * @param {(string|number)=} opt_min Minimum value. - * @param {(string|number)=} opt_max Maximum value. - * @param {(string|number)=} opt_precision Precision for value. - * @param {Function=} opt_validator An optional function that is called - * to validate any constraints on what the user entered. Takes the new - * text as an argument and returns either the accepted text, a replacement - * text, or null to abort the change. - * @extends {Blockly.FieldTextInput} - * @constructor - */ -Blockly.FieldNumber = function(opt_value, opt_min, opt_max, opt_precision, - opt_validator) { - opt_value = (opt_value && !isNaN(opt_value)) ? String(opt_value) : '0'; - Blockly.FieldNumber.superClass_.constructor.call( - this, opt_value, opt_validator); - this.setConstraints(opt_min, opt_max, opt_precision); -}; -goog.inherits(Blockly.FieldNumber, Blockly.FieldTextInput); - -/** - * Set the maximum, minimum and precision constraints on this field. - * Any of these properties may be undefiend or NaN to be disabled. - * Setting precision (usually a power of 10) enforces a minimum step between - * values. That is, the user's value will rounded to the closest multiple of - * precision. The least significant digit place is inferred from the precision. - * Integers values can be enforces by choosing an integer precision. - * @param {number|string|undefined} min Minimum value. - * @param {number|string|undefined} max Maximum value. - * @param {number|string|undefined} precision Precision for value. - */ -Blockly.FieldNumber.prototype.setConstraints = function(min, max, precision) { - precision = parseFloat(precision); - this.precision_ = isNaN(precision) ? 0 : precision; - min = parseFloat(min); - this.min_ = isNaN(min) ? -Infinity : min; - max = parseFloat(max); - this.max_ = isNaN(max) ? Infinity : max; - this.setValue(this.callValidator(this.getValue())); -}; - -/** - * Ensure that only a number in the correct range may be entered. - * @param {string} text The user's text. - * @return {?string} A string representing a valid number, or null if invalid. - */ -Blockly.FieldNumber.prototype.classValidator = function(text) { - if (text === null) { - return null; - } - text = String(text); - // TODO: Handle cases like 'ten', '1.203,14', etc. - // 'O' is sometimes mistaken for '0' by inexperienced users. - text = text.replace(/O/ig, '0'); - // Strip out thousands separators. - text = text.replace(/,/g, ''); - var n = parseFloat(text || 0); - if (isNaN(n)) { - // Invalid number. - return null; - } - // Round to nearest multiple of precision. - if (this.precision_ && isFinite(n)) { - n = Math.round(n / this.precision_) * this.precision_; - } - // Get the value in range. - n = goog.math.clamp(n, this.min_, this.max_); - return String(n); -}; diff --git a/core/field_number.ts b/core/field_number.ts new file mode 100644 index 00000000000..7e36591753e --- /dev/null +++ b/core/field_number.ts @@ -0,0 +1,381 @@ +/** + * @license + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Number input field + * + * @class + */ +// Former goog.module ID: Blockly.FieldNumber + +import {Field} from './field.js'; +import { + FieldInput, + FieldInputConfig, + FieldInputValidator, +} from './field_input.js'; +import * as fieldRegistry from './field_registry.js'; +import * as aria from './utils/aria.js'; +import * as dom from './utils/dom.js'; + +/** + * Class for an editable number field. + */ +export class FieldNumber extends FieldInput { + /** The minimum value this number field can contain. */ + protected min_ = -Infinity; + + /** The maximum value this number field can contain. */ + protected max_ = Infinity; + + /** The multiple to which this fields value is rounded. */ + protected precision_ = 0; + + /** + * The number of decimal places to allow, or null to allow any number of + * decimal digits. + */ + private decimalPlaces: number | null = null; + + /** Don't spellcheck numbers. Our validator does a better job. */ + protected override spellcheck_ = false; + + /** + * @param value The initial value of the field. Should cast to a number. + * Defaults to 0. Also accepts Field.SKIP_SETUP if you wish to skip setup + * (only used by subclasses that want to handle configuration and setting + * the field value after their own constructors have run). + * @param min Minimum value. Will only be used if config is not + * provided. + * @param max Maximum value. Will only be used if config is not + * provided. + * @param precision Precision for value. Will only be used if config + * is not provided. + * @param validator A function that is called to validate changes to the + * field's value. Takes in a number & returns a validated number, or null + * to abort the change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/number#creation} + * for a list of properties this parameter supports. + */ + constructor( + value?: string | number | typeof Field.SKIP_SETUP, + min?: string | number | null, + max?: string | number | null, + precision?: string | number | null, + validator?: FieldNumberValidator | null, + config?: FieldNumberConfig, + ) { + // Pass SENTINEL so that we can define properties before value validation. + super(Field.SKIP_SETUP); + + if (value === Field.SKIP_SETUP) return; + if (config) { + this.configure_(config); + } else { + this.setConstraints(min, max, precision); + } + this.setValue(value); + if (validator) { + this.setValidator(validator); + } + } + + /** + * Configure the field based on the given map of options. + * + * @param config A map of options to configure the field based on. + */ + protected override configure_(config: FieldNumberConfig) { + super.configure_(config); + this.setMinInternal(config.min); + this.setMaxInternal(config.max); + this.setPrecisionInternal(config.precision); + } + + /** + * Set the maximum, minimum and precision constraints on this field. + * Any of these properties may be undefined or NaN to be disabled. + * Setting precision (usually a power of 10) enforces a minimum step between + * values. That is, the user's value will rounded to the closest multiple of + * precision. The least significant digit place is inferred from the + * precision. Integers values can be enforces by choosing an integer + * precision. + * + * @param min Minimum value. + * @param max Maximum value. + * @param precision Precision for value. + */ + setConstraints( + min: number | string | undefined | null, + max: number | string | undefined | null, + precision: number | string | undefined | null, + ) { + this.setMinInternal(min); + this.setMaxInternal(max); + this.setPrecisionInternal(precision); + this.setValue(this.getValue()); + } + + /** + * Sets the minimum value this field can contain. Updates the value to + * reflect. + * + * @param min Minimum value. + */ + setMin(min: number | string | undefined | null) { + this.setMinInternal(min); + this.setValue(this.getValue()); + } + + /** + * Sets the minimum value this field can contain. Called internally to avoid + * value updates. + * + * @param min Minimum value. + */ + private setMinInternal(min: number | string | undefined | null) { + if (min == null) { + this.min_ = -Infinity; + } else { + min = Number(min); + if (!isNaN(min)) { + this.min_ = min; + } + } + } + + /** + * Returns the current minimum value this field can contain. Default is + * -Infinity. + * + * @returns The current minimum value this field can contain. + */ + getMin(): number { + return this.min_; + } + + /** + * Sets the maximum value this field can contain. Updates the value to + * reflect. + * + * @param max Maximum value. + */ + setMax(max: number | string | undefined | null) { + this.setMaxInternal(max); + this.setValue(this.getValue()); + } + + /** + * Sets the maximum value this field can contain. Called internally to avoid + * value updates. + * + * @param max Maximum value. + */ + private setMaxInternal(max: number | string | undefined | null) { + if (max == null) { + this.max_ = Infinity; + } else { + max = Number(max); + if (!isNaN(max)) { + this.max_ = max; + } + } + } + + /** + * Returns the current maximum value this field can contain. Default is + * Infinity. + * + * @returns The current maximum value this field can contain. + */ + getMax(): number { + return this.max_; + } + + /** + * Sets the precision of this field's value, i.e. the number to which the + * value is rounded. Updates the field to reflect. + * + * @param precision The number to which the field's value is rounded. + */ + setPrecision(precision: number | string | undefined | null) { + this.setPrecisionInternal(precision); + this.setValue(this.getValue()); + } + + /** + * Sets the precision of this field's value. Called internally to avoid + * value updates. + * + * @param precision The number to which the field's value is rounded. + */ + private setPrecisionInternal(precision: number | string | undefined | null) { + this.precision_ = Number(precision) || 0; + let precisionString = String(this.precision_); + if (precisionString.includes('e')) { + // String() is fast. But it turns .0000001 into '1e-7'. + // Use the much slower toLocaleString to access all the digits. + precisionString = this.precision_.toLocaleString('en-US', { + maximumFractionDigits: 20, + }); + } + const decimalIndex = precisionString.indexOf('.'); + if (decimalIndex === -1) { + // If the precision is 0 (float) allow any number of decimals, + // otherwise allow none. + this.decimalPlaces = precision ? 0 : null; + } else { + this.decimalPlaces = precisionString.length - decimalIndex - 1; + } + } + + /** + * Returns the current precision of this field. The precision being the + * number to which the field's value is rounded. A precision of 0 means that + * the value is not rounded. + * + * @returns The number to which this field's value is rounded. + */ + getPrecision(): number { + return this.precision_; + } + + /** + * Ensure that the input value is a valid number (must fulfill the + * constraints placed on the field). + * + * @param newValue The input value. + * @returns A valid number, or null if invalid. + */ + protected override doClassValidation_( + newValue?: AnyDuringMigration, + ): number | null { + if (newValue === null) { + return null; + } + + // Clean up text. + newValue = `${newValue}`; + // TODO: Handle cases like 'ten', '1.203,14', etc. + // 'O' is sometimes mistaken for '0' by inexperienced users. + newValue = newValue.replace(/O/gi, '0'); + // Strip out thousands separators. + newValue = newValue.replace(/,/g, ''); + // Ignore case of 'Infinity'. + newValue = newValue.replace(/infinity/i, 'Infinity'); + + // Clean up number. + let n = Number(newValue || 0); + if (isNaN(n)) { + // Invalid number. + return null; + } + // Get the value in range. + n = Math.min(Math.max(n, this.min_), this.max_); + // Round to nearest multiple of precision. + if (this.precision_ && isFinite(n)) { + n = Math.round(n / this.precision_) * this.precision_; + } + // Clean up floating point errors. + if (this.decimalPlaces !== null) { + n = Number(n.toFixed(this.decimalPlaces)); + } + return n; + } + + /** + * Create the number input editor widget. + * + * @returns The newly created number input editor. + */ + protected override widgetCreate_(): HTMLInputElement { + const htmlInput = super.widgetCreate_() as HTMLInputElement; + + // Set the accessibility state + if (this.min_ > -Infinity) { + htmlInput.min = `${this.min_}`; + aria.setState(htmlInput, aria.State.VALUEMIN, this.min_); + } + if (this.max_ < Infinity) { + htmlInput.max = `${this.max_}`; + aria.setState(htmlInput, aria.State.VALUEMAX, this.max_); + } + return htmlInput; + } + + /** + * Initialize the field's DOM. + * + * @override + */ + + public override initView() { + super.initView(); + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyNumberField'); + } + } + + /** + * Construct a FieldNumber from a JSON arg object. + * + * @param options A JSON object with options (value, min, max, and precision). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static override fromJson(options: FieldNumberFromJsonConfig): FieldNumber { + // `this` might be a subclass of FieldNumber if that class doesn't override + // the static fromJson method. + return new this( + options.value, + undefined, + undefined, + undefined, + undefined, + options, + ); + } +} + +fieldRegistry.register('field_number', FieldNumber); + +FieldNumber.prototype.DEFAULT_VALUE = 0; + +/** + * Config options for the number field. + */ +export interface FieldNumberConfig extends FieldInputConfig { + min?: number; + max?: number; + precision?: number; +} + +/** + * fromJson config options for the number field. + */ +export interface FieldNumberFromJsonConfig extends FieldNumberConfig { + value?: number; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldNumberValidator = FieldInputValidator; diff --git a/core/field_registry.ts b/core/field_registry.ts new file mode 100644 index 00000000000..e02ece75c96 --- /dev/null +++ b/core/field_registry.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.fieldRegistry + +import type {Field, FieldConfig} from './field.js'; +import * as registry from './registry.js'; + +/** + * When constructing a field from JSON using the registry, the + * `fromJson` method in this file is called with an options parameter + * object consisting of the "type" which is the name of the field, and + * other options that are part of the field's config object. + * + * These options are then passed to the field's static `fromJson` + * method. That method accepts an options parameter with a type that usually + * extends from FieldConfig, and may or may not have a "type" attribute (in + * fact, it shouldn't, because we'd overwrite it as described above!) + * + * Unfortunately the registry has no way of knowing the actual Field subclass + * that will be returned from passing in the name of the field. Therefore it + * also has no way of knowing that the options object not only implements + * `FieldConfig`, but it also should satisfy the Config that belongs to that + * specific class's `fromJson` method. + * + * Because of this uncertainty, we just give up on type checking the properties + * passed to the `fromJson` method, and allow arbitrary string keys with + * unknown types. + */ +type RegistryOptions = FieldConfig & { + // The name of the field, e.g. field_dropdown + type: string; + [key: string]: unknown; +}; + +/** + * Represents the static methods that must be defined on any + * field that is registered, i.e. the constructor and fromJson methods. + * + * Because we don't know which Field subclass will be registered, we + * are unable to typecheck the parameters of the constructor. + */ +export interface RegistrableField { + new (...args: any[]): Field; + fromJson(options: FieldConfig): Field; +} + +/** + * Registers a field type. + * fieldRegistry.fromJson uses this registry to + * find the appropriate field type. + * + * @param type The field type name as used in the JSON definition. + * @param fieldClass The field class containing a fromJson function that can + * construct an instance of the field. + * @throws {Error} if the type name is empty or the fieldClass is not an object + * containing a fromJson function. + */ +export function register(type: string, fieldClass: RegistrableField) { + registry.register(registry.Type.FIELD, type, fieldClass, true); +} + +/** + * Unregisters the field registered with the given type. + * + * @param type The field type name as used in the JSON definition. + */ +export function unregister(type: string) { + registry.unregister(registry.Type.FIELD, type); +} + +/** + * Construct a Field from a JSON arg object. + * Finds the appropriate registered field by the type name as registered using + * fieldRegistry.register. + * + * @param options A JSON object with a type and options specific to the field + * type. + * @returns The new field instance or null if a field wasn't found with the + * given type name + * @internal + */ +export function fromJson(options: RegistryOptions): Field | null { + return TEST_ONLY.fromJsonInternal(options); +} + +/** + * Private version of fromJson for stubbing in tests. + * + * @param options + */ +function fromJsonInternal(options: RegistryOptions): Field | null { + const fieldObject = registry.getObject( + registry.Type.FIELD, + options.type, + ) as unknown as RegistrableField; + if (!fieldObject) { + console.warn( + 'Blockly could not create a field of type ' + + options['type'] + + '. The field is probably not being registered. This could be because' + + ' the file is not loaded, the field does not register itself (Issue' + + ' #1584), or the registration is not being reached.', + ); + return null; + } + return fieldObject.fromJson(options); +} + +export const TEST_ONLY = { + fromJsonInternal, +}; diff --git a/core/field_textinput.js b/core/field_textinput.js deleted file mode 100644 index a8014a187d1..00000000000 --- a/core/field_textinput.js +++ /dev/null @@ -1,355 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Text input field. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.FieldTextInput'); - -goog.require('Blockly.Field'); -goog.require('Blockly.Msg'); -goog.require('goog.asserts'); -goog.require('goog.dom'); -goog.require('goog.dom.TagName'); -goog.require('goog.userAgent'); - - -/** - * Class for an editable text field. - * @param {string} text The initial content of the field. - * @param {Function=} opt_validator An optional function that is called - * to validate any constraints on what the user entered. Takes the new - * text as an argument and returns either the accepted text, a replacement - * text, or null to abort the change. - * @extends {Blockly.Field} - * @constructor - */ -Blockly.FieldTextInput = function(text, opt_validator) { - Blockly.FieldTextInput.superClass_.constructor.call(this, text, - opt_validator); -}; -goog.inherits(Blockly.FieldTextInput, Blockly.Field); - -/** - * Point size of text. Should match blocklyText's font-size in CSS. - */ -Blockly.FieldTextInput.FONTSIZE = 11; - -/** - * Mouse cursor style when over the hotspot that initiates the editor. - */ -Blockly.FieldTextInput.prototype.CURSOR = 'text'; - -/** - * Allow browser to spellcheck this field. - * @private - */ -Blockly.FieldTextInput.prototype.spellcheck_ = true; - -/** - * Close the input widget if this input is being deleted. - */ -Blockly.FieldTextInput.prototype.dispose = function() { - Blockly.WidgetDiv.hideIfOwner(this); - Blockly.FieldTextInput.superClass_.dispose.call(this); -}; - -/** - * Set the value of this field. - * @param {?string} newValue New value. - * @override - */ -Blockly.FieldTextInput.prototype.setValue = function(newValue) { - if (newValue === null) { - return; // No change if null. - } - if (this.sourceBlock_) { - var validated = this.callValidator(newValue); - // If the new value is invalid, validation returns null. - // In this case we still want to display the illegal result. - if (validated !== null) { - newValue = validated; - } - } - Blockly.Field.prototype.setValue.call(this, newValue); -}; - -/** - * Set the text in this field and fire a change event. - * @param {*} newText New text. - */ -Blockly.FieldTextInput.prototype.setText = function(newText) { - if (newText === null) { - // No change if null. - return; - } - newText = String(newText); - if (newText === this.text_) { - // No change. - return; - } - if (this.sourceBlock_ && Blockly.Events.isEnabled()) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this.sourceBlock_, 'field', this.name, this.text_, newText)); - } - Blockly.Field.prototype.setText.call(this, newText); -}; - -/** - * Set whether this field is spellchecked by the browser. - * @param {boolean} check True if checked. - */ -Blockly.FieldTextInput.prototype.setSpellcheck = function(check) { - this.spellcheck_ = check; -}; - -/** - * Show the inline free-text editor on top of the text. - * @param {boolean=} opt_quietInput True if editor should be created without - * focus. Defaults to false. - * @private - */ -Blockly.FieldTextInput.prototype.showEditor_ = function(opt_quietInput) { - this.workspace_ = this.sourceBlock_.workspace; - var quietInput = opt_quietInput || false; - if (!quietInput && (goog.userAgent.MOBILE || goog.userAgent.ANDROID || - goog.userAgent.IPAD)) { - // Mobile browsers have issues with in-line textareas (focus & keyboards). - var fieldText = this; - Blockly.prompt(Blockly.Msg.CHANGE_VALUE_TITLE, this.text_, - function(newValue) { - if (fieldText.sourceBlock_) { - newValue = fieldText.callValidator(newValue); - } - fieldText.setValue(newValue); - }); - return; - } - - Blockly.WidgetDiv.show(this, this.sourceBlock_.RTL, this.widgetDispose_()); - var div = Blockly.WidgetDiv.DIV; - // Create the input. - var htmlInput = - goog.dom.createDom(goog.dom.TagName.INPUT, 'blocklyHtmlInput'); - htmlInput.setAttribute('spellcheck', this.spellcheck_); - var fontSize = - (Blockly.FieldTextInput.FONTSIZE * this.workspace_.scale) + 'pt'; - div.style.fontSize = fontSize; - htmlInput.style.fontSize = fontSize; - /** @type {!HTMLInputElement} */ - Blockly.FieldTextInput.htmlInput_ = htmlInput; - div.appendChild(htmlInput); - - htmlInput.value = htmlInput.defaultValue = this.text_; - htmlInput.oldValue_ = null; - this.validate_(); - this.resizeEditor_(); - if (!quietInput) { - htmlInput.focus(); - htmlInput.select(); - } - - // Bind to keydown -- trap Enter without IME and Esc to hide. - htmlInput.onKeyDownWrapper_ = - Blockly.bindEventWithChecks_(htmlInput, 'keydown', this, - this.onHtmlInputKeyDown_); - // Bind to keyup -- trap Enter; resize after every keystroke. - htmlInput.onKeyUpWrapper_ = - Blockly.bindEventWithChecks_(htmlInput, 'keyup', this, - this.onHtmlInputChange_); - // Bind to keyPress -- repeatedly resize when holding down a key. - htmlInput.onKeyPressWrapper_ = - Blockly.bindEventWithChecks_(htmlInput, 'keypress', this, - this.onHtmlInputChange_); - htmlInput.onWorkspaceChangeWrapper_ = this.resizeEditor_.bind(this); - this.workspace_.addChangeListener(htmlInput.onWorkspaceChangeWrapper_); -}; - -/** - * Handle key down to the editor. - * @param {!Event} e Keyboard event. - * @private - */ -Blockly.FieldTextInput.prototype.onHtmlInputKeyDown_ = function(e) { - var htmlInput = Blockly.FieldTextInput.htmlInput_; - var tabKey = 9, enterKey = 13, escKey = 27; - if (e.keyCode == enterKey) { - Blockly.WidgetDiv.hide(); - } else if (e.keyCode == escKey) { - htmlInput.value = htmlInput.defaultValue; - Blockly.WidgetDiv.hide(); - } else if (e.keyCode == tabKey) { - Blockly.WidgetDiv.hide(); - this.sourceBlock_.tab(this, !e.shiftKey); - e.preventDefault(); - } -}; - -/** - * Handle a change to the editor. - * @param {!Event} e Keyboard event. - * @private - */ -Blockly.FieldTextInput.prototype.onHtmlInputChange_ = function(e) { - var htmlInput = Blockly.FieldTextInput.htmlInput_; - // Update source block. - var text = htmlInput.value; - if (text !== htmlInput.oldValue_) { - htmlInput.oldValue_ = text; - this.setValue(text); - this.validate_(); - } else if (goog.userAgent.WEBKIT) { - // Cursor key. Render the source block to show the caret moving. - // Chrome only (version 26, OS X). - this.sourceBlock_.render(); - } - this.resizeEditor_(); - Blockly.svgResize(this.sourceBlock_.workspace); -}; - -/** - * Check to see if the contents of the editor validates. - * Style the editor accordingly. - * @private - */ -Blockly.FieldTextInput.prototype.validate_ = function() { - var valid = true; - goog.asserts.assertObject(Blockly.FieldTextInput.htmlInput_); - var htmlInput = Blockly.FieldTextInput.htmlInput_; - if (this.sourceBlock_) { - valid = this.callValidator(htmlInput.value); - } - if (valid === null) { - Blockly.utils.addClass(htmlInput, 'blocklyInvalidInput'); - } else { - Blockly.utils.removeClass(htmlInput, 'blocklyInvalidInput'); - } -}; - -/** - * Resize the editor and the underlying block to fit the text. - * @private - */ -Blockly.FieldTextInput.prototype.resizeEditor_ = function() { - var div = Blockly.WidgetDiv.DIV; - var bBox = this.fieldGroup_.getBBox(); - div.style.width = bBox.width * this.workspace_.scale + 'px'; - div.style.height = bBox.height * this.workspace_.scale + 'px'; - var xy = this.getAbsoluteXY_(); - // In RTL mode block fields and LTR input fields the left edge moves, - // whereas the right edge is fixed. Reposition the editor. - if (this.sourceBlock_.RTL) { - var borderBBox = this.getScaledBBox_(); - xy.x += borderBBox.width; - xy.x -= div.offsetWidth; - } - // Shift by a few pixels to line up exactly. - xy.y += 1; - if (goog.userAgent.GECKO && Blockly.WidgetDiv.DIV.style.top) { - // Firefox mis-reports the location of the border by a pixel - // once the WidgetDiv is moved into position. - xy.x -= 1; - xy.y -= 1; - } - if (goog.userAgent.WEBKIT) { - xy.y -= 3; - } - div.style.left = xy.x + 'px'; - div.style.top = xy.y + 'px'; -}; - -/** - * Close the editor, save the results, and dispose of the editable - * text field's elements. - * @return {!Function} Closure to call on destruction of the WidgetDiv. - * @private - */ -Blockly.FieldTextInput.prototype.widgetDispose_ = function() { - var thisField = this; - return function() { - var htmlInput = Blockly.FieldTextInput.htmlInput_; - // Save the edit (if it validates). - var text = htmlInput.value; - if (thisField.sourceBlock_) { - var text1 = thisField.callValidator(text); - if (text1 === null) { - // Invalid edit. - text = htmlInput.defaultValue; - } else { - // Validation function has changed the text. - text = text1; - if (thisField.onFinishEditing_) { - thisField.onFinishEditing_(text); - } - } - } - thisField.setText(text); - thisField.sourceBlock_.rendered && thisField.sourceBlock_.render(); - Blockly.unbindEvent_(htmlInput.onKeyDownWrapper_); - Blockly.unbindEvent_(htmlInput.onKeyUpWrapper_); - Blockly.unbindEvent_(htmlInput.onKeyPressWrapper_); - thisField.workspace_.removeChangeListener( - htmlInput.onWorkspaceChangeWrapper_); - Blockly.FieldTextInput.htmlInput_ = null; - Blockly.Events.setGroup(false); - // Delete style properties. - var style = Blockly.WidgetDiv.DIV.style; - style.width = 'auto'; - style.height = 'auto'; - style.fontSize = ''; - }; -}; - -/** - * Ensure that only a number may be entered. - * @param {string} text The user's text. - * @return {?string} A string representing a valid number, or null if invalid. - */ -Blockly.FieldTextInput.numberValidator = function(text) { - console.warn('Blockly.FieldTextInput.numberValidator is deprecated. ' + - 'Use Blockly.FieldNumber instead.'); - if (text === null) { - return null; - } - text = String(text); - // TODO: Handle cases like 'ten', '1.203,14', etc. - // 'O' is sometimes mistaken for '0' by inexperienced users. - text = text.replace(/O/ig, '0'); - // Strip out thousands separators. - text = text.replace(/,/g, ''); - var n = parseFloat(text || 0); - return isNaN(n) ? null : String(n); -}; - -/** - * Ensure that only a nonnegative integer may be entered. - * @param {string} text The user's text. - * @return {?string} A string representing a valid int, or null if invalid. - */ -Blockly.FieldTextInput.nonnegativeIntegerValidator = function(text) { - var n = Blockly.FieldTextInput.numberValidator(text); - if (n) { - n = String(Math.max(0, Math.floor(n))); - } - return n; -}; diff --git a/core/field_textinput.ts b/core/field_textinput.ts new file mode 100644 index 00000000000..2b896ad47be --- /dev/null +++ b/core/field_textinput.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Text input field. + * + * @class + */ +// Former goog.module ID: Blockly.FieldTextInput + +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_block_change.js'; + +import {Field} from './field.js'; +import { + FieldInput, + FieldInputConfig, + FieldInputValidator, +} from './field_input.js'; +import * as fieldRegistry from './field_registry.js'; +import * as dom from './utils/dom.js'; +import * as parsing from './utils/parsing.js'; + +/** + * Class for an editable text field. + */ +export class FieldTextInput extends FieldInput { + /** + * @param value The initial value of the field. Should cast to a string. + * Defaults to an empty string if null or undefined. Also accepts + * Field.SKIP_SETUP if you wish to skip setup (only used by subclasses + * that want to handle configuration and setting the field value after + * their own constructors have run). + * @param validator A function that is called to validate changes to the + * field's value. Takes in a string & returns a validated string, or null + * to abort the change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/text-input#creation} + * for a list of properties this parameter supports. + */ + constructor( + value?: string | typeof Field.SKIP_SETUP, + validator?: FieldTextInputValidator | null, + config?: FieldTextInputConfig, + ) { + super(value, validator, config); + } + + override initView() { + super.initView(); + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyTextInputField'); + } + } + + /** + * Ensure that the input value casts to a valid string. + * + * @param newValue The input value. + * @returns A valid string, or null if invalid. + */ + protected override doClassValidation_( + newValue?: AnyDuringMigration, + ): string | null { + if (newValue === undefined) { + return null; + } + return `${newValue}`; + } + + /** + * Construct a FieldTextInput from a JSON arg object, + * dereferencing any string table references. + * + * @param options A JSON object with options (text, and spellcheck). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static override fromJson( + options: FieldTextInputFromJsonConfig, + ): FieldTextInput { + const text = parsing.replaceMessageReferences(options.text); + // `this` might be a subclass of FieldTextInput if that class doesn't + // override the static fromJson method. + return new this(text, undefined, options); + } +} + +fieldRegistry.register('field_input', FieldTextInput); + +FieldTextInput.prototype.DEFAULT_VALUE = ''; + +/** + * Config options for the text input field. + */ +export type FieldTextInputConfig = FieldInputConfig; + +/** + * fromJson config options for the text input field. + */ +export interface FieldTextInputFromJsonConfig extends FieldTextInputConfig { + text?: string; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldTextInputValidator = FieldInputValidator; diff --git a/core/field_variable.js b/core/field_variable.js deleted file mode 100644 index 7996260d62a..00000000000 --- a/core/field_variable.js +++ /dev/null @@ -1,221 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Variable input field. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.FieldVariable'); - -goog.require('Blockly.FieldDropdown'); -goog.require('Blockly.Msg'); -goog.require('Blockly.VariableModel'); -goog.require('Blockly.Variables'); -goog.require('Blockly.VariableModel'); -goog.require('goog.asserts'); -goog.require('goog.string'); - - -/** - * Class for a variable's dropdown field. - * @param {?string} varname The default name for the variable. If null, - * a unique variable name will be generated. - * @param {Function=} opt_validator A function that is executed when a new - * option is selected. Its sole argument is the new option value. - * @extends {Blockly.FieldDropdown} - * @constructor - */ -Blockly.FieldVariable = function(varname, opt_validator) { - Blockly.FieldVariable.superClass_.constructor.call(this, - Blockly.FieldVariable.dropdownCreate, opt_validator); - this.setValue(varname || ''); -}; -goog.inherits(Blockly.FieldVariable, Blockly.FieldDropdown); - -/** - * Install this dropdown on a block. - */ -Blockly.FieldVariable.prototype.init = function() { - if (this.fieldGroup_) { - // Dropdown has already been initialized once. - return; - } - Blockly.FieldVariable.superClass_.init.call(this); - - // TODO (1010): Change from init/initModel to initView/initModel - this.initModel(); -}; - -Blockly.FieldVariable.prototype.initModel = function() { - if (!this.getValue()) { - // Variables without names get uniquely named for this workspace. - var workspace = - this.sourceBlock_.isInFlyout ? - this.sourceBlock_.workspace.targetWorkspace : - this.sourceBlock_.workspace; - this.setValue(Blockly.Variables.generateUniqueName(workspace)); - } - // If the selected variable doesn't exist yet, create it. - // For instance, some blocks in the toolbox have variable dropdowns filled - // in by default. - if (!this.sourceBlock_.isInFlyout) { - this.sourceBlock_.workspace.createVariable(this.getValue()); - } -}; - -/** - * Attach this field to a block. - * @param {!Blockly.Block} block The block containing this field. - */ -Blockly.FieldVariable.prototype.setSourceBlock = function(block) { - goog.asserts.assert(!block.isShadow(), - 'Variable fields are not allowed to exist on shadow blocks.'); - Blockly.FieldVariable.superClass_.setSourceBlock.call(this, block); -}; - -/** - * Get the variable's name (use a variableDB to convert into a real name). - * Unline a regular dropdown, variables are literal and have no neutral value. - * @return {string} Current text. - */ -Blockly.FieldVariable.prototype.getValue = function() { - return this.getText(); -}; - -/** - * Set the variable name. - * @param {string} value New text. - */ -Blockly.FieldVariable.prototype.setValue = function(value) { - var newValue = value; - var newText = value; - - if (this.sourceBlock_) { - var variable = this.sourceBlock_.workspace.getVariableById(value); - if (variable) { - newText = variable.name; - } - // TODO(marisaleung): Remove name lookup after converting all Field Variable - // instances to use id instead of name. - else if (variable = this.sourceBlock_.workspace.getVariable(value)) { - newValue = variable.getId(); - } - if (Blockly.Events.isEnabled()) { - Blockly.Events.fire(new Blockly.Events.BlockChange( - this.sourceBlock_, 'field', this.name, this.value_, newValue)); - } - } - this.value_ = newValue; - this.setText(newText); -}; - -/** - * Return a sorted list of variable names for variable dropdown menus. - * Include a special option at the end for creating a new variable name. - * @return {!Array.} Array of variable names. - * @this {Blockly.FieldVariable} - */ -Blockly.FieldVariable.dropdownCreate = function() { - var variableModelList = []; - var name = this.getText(); - // Don't create a new variable if there is nothing selected. - var createSelectedVariable = name ? true : false; - var workspace = null; - if (this.sourceBlock_) { - workspace = this.sourceBlock_.workspace; - } - - if (workspace) { - // Get a copy of the list, so that adding rename and new variable options - // doesn't modify the workspace's list. - variableModelList = workspace.getVariablesOfType(''); - for (var i = 0; i < variableModelList.length; i++){ - if (createSelectedVariable && - goog.string.caseInsensitiveEquals(variableModelList[i].name, name)) { - createSelectedVariable = false; - break; - } - } - } - // Ensure that the currently selected variable is an option. - if (createSelectedVariable && workspace) { - var newVar = workspace.createVariable(name); - variableModelList.push(newVar); - } - variableModelList.sort(Blockly.VariableModel.compareByName); - var options = []; - for (var i = 0; i < variableModelList.length; i++) { - // Set the uuid as the internal representation of the variable. - options[i] = [variableModelList[i].name, variableModelList[i].getId()]; - } - options.push([Blockly.Msg.RENAME_VARIABLE, Blockly.RENAME_VARIABLE_ID]); - if (Blockly.Msg.DELETE_VARIABLE) { - options.push([Blockly.Msg.DELETE_VARIABLE.replace('%1', name), - Blockly.DELETE_VARIABLE_ID]); - } - return options; -}; - -/** - * Handle the selection of an item in the variable dropdown menu. - * Special case the 'Rename variable...' and 'Delete variable...' options. - * In the rename case, prompt the user for a new name. - * @param {!goog.ui.Menu} menu The Menu component clicked. - * @param {!goog.ui.MenuItem} menuItem The MenuItem selected within menu. - */ -Blockly.FieldVariable.prototype.onItemSelected = function(menu, menuItem) { - var id = menuItem.getValue(); - // TODO(marisaleung): change setValue() to take in an id as the parameter. - // Then remove itemText. - var itemText; - if (this.sourceBlock_ && this.sourceBlock_.workspace) { - var workspace = this.sourceBlock_.workspace; - var variable = workspace.getVariableById(id); - // If the item selected is a variable, set itemText to the variable name. - if (variable) { - itemText = variable.name; - } - else if (id == Blockly.RENAME_VARIABLE_ID) { - // Rename variable. - var oldName = this.getText(); - Blockly.hideChaff(); - Blockly.Variables.promptName( - Blockly.Msg.RENAME_VARIABLE_TITLE.replace('%1', oldName), oldName, - function(newName) { - if (newName) { - workspace.renameVariable(oldName, newName); - } - }); - return; - } else if (id == Blockly.DELETE_VARIABLE_ID) { - // Delete variable. - workspace.deleteVariable(this.getText()); - return; - } - - // Call any validation function, and allow it to override. - itemText = this.callValidator(itemText); - } - if (itemText !== null) { - this.setValue(itemText); - } -}; diff --git a/core/field_variable.ts b/core/field_variable.ts new file mode 100644 index 00000000000..aa4fdfe310f --- /dev/null +++ b/core/field_variable.ts @@ -0,0 +1,654 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Variable input field. + * + * @class + */ +// Former goog.module ID: Blockly.FieldVariable + +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_block_change.js'; + +import type {Block} from './block.js'; +import {Field, FieldConfig, UnattachedFieldError} from './field.js'; +import { + FieldDropdown, + FieldDropdownValidator, + MenuGenerator, + MenuOption, +} from './field_dropdown.js'; +import * as fieldRegistry from './field_registry.js'; +import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; +import * as internalConstants from './internal_constants.js'; +import type {Menu} from './menu.js'; +import type {MenuItem} from './menuitem.js'; +import {Msg} from './msg.js'; +import * as dom from './utils/dom.js'; +import * as parsing from './utils/parsing.js'; +import {Size} from './utils/size.js'; +import * as Variables from './variables.js'; +import * as Xml from './xml.js'; + +/** + * Class for a variable's dropdown field. + */ +export class FieldVariable extends FieldDropdown { + protected override menuGenerator_: MenuGenerator | undefined; + defaultVariableName: string; + + /** The type of the default variable for this field. */ + private defaultType = ''; + + /** + * All of the types of variables that will be available in this field's + * dropdown. + */ + variableTypes: string[] | null = []; + + /** The variable model associated with this field. */ + private variable: IVariableModel | null = null; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + */ + override SERIALIZABLE = true; + + /** + * @param varName The default name for the variable. + * If null, a unique variable name will be generated. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field value + * after their own constructors have run). + * @param validator A function that is called to validate changes to the + * field's value. Takes in a variable ID & returns a validated variable + * ID, or null to abort the change. + * @param variableTypes A list of the types of variables to include in the + * dropdown. Pass `null` to include all types that exist on the + * workspace. Will only be used if config is not provided. + * @param defaultType The type of variable to create if this field's value + * is not explicitly set. Defaults to ''. Will only be used if config + * is not provided. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/variable#creation} + * for a list of properties this parameter supports. + */ + constructor( + varName: string | null | typeof Field.SKIP_SETUP, + validator?: FieldVariableValidator, + variableTypes?: string[] | null, + defaultType?: string, + config?: FieldVariableConfig, + ) { + super(Field.SKIP_SETUP); + + /** + * An array of options for a dropdown list, + * or a function which generates these options. + */ + this.menuGenerator_ = FieldVariable.dropdownCreate as MenuGenerator; + + /** + * The initial variable name passed to this field's constructor, or an + * empty string if a name wasn't provided. Used to create the initial + * variable. + */ + this.defaultVariableName = typeof varName === 'string' ? varName : ''; + + /** The size of the area rendered by the field. */ + this.size_ = new Size(0, 0); + + if (varName === Field.SKIP_SETUP) return; + + if (config) { + this.configure_(config); + } else { + this.setTypes(variableTypes, defaultType); + } + if (validator) { + this.setValidator(validator); + } + } + + /** + * Configure the field based on the given map of options. + * + * @param config A map of options to configure the field based on. + */ + protected override configure_(config: FieldVariableConfig) { + super.configure_(config); + this.setTypes(config.variableTypes, config.defaultType); + } + + /** + * Initialize the model for this field if it has not already been initialized. + * If the value has not been set to a variable by the first render, we make up + * a variable rather than let the value be invalid. + */ + override initModel() { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + if (this.variable) { + return; // Initialization already happened. + } + const variable = Variables.getOrCreateVariablePackage( + block.workspace, + null, + this.defaultVariableName, + this.defaultType, + ); + // Don't call setValue because we don't want to cause a rerender. + this.doValueUpdate_(variable.getId()); + } + + override initView() { + super.initView(); + dom.addClass(this.fieldGroup_!, 'blocklyVariableField'); + } + + override shouldAddBorderRect_() { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + return ( + super.shouldAddBorderRect_() && + (!this.getConstants()!.FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW || + block.type !== 'variables_get') + ); + } + + /** + * Initialize this field based on the given XML. + * + * @param fieldElement The element containing information about the variable + * field's state. + */ + override fromXml(fieldElement: Element) { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + const id = fieldElement.getAttribute('id'); + const variableName = fieldElement.textContent; + // 'variabletype' should be lowercase, but until July 2019 it was sometimes + // recorded as 'variableType'. Thus we need to check for both. + const variableType = + fieldElement.getAttribute('variabletype') || + fieldElement.getAttribute('variableType') || + ''; + + // AnyDuringMigration because: Argument of type 'string | null' is not + // assignable to parameter of type 'string | undefined'. + const variable = Variables.getOrCreateVariablePackage( + block.workspace, + id, + variableName as AnyDuringMigration, + variableType, + ); + + // This should never happen :) + if (variableType !== null && variableType !== variable.getType()) { + throw Error( + "Serialized variable type with id '" + + variable.getId() + + "' had type " + + variable.getType() + + ', and ' + + 'does not match variable field that references it: ' + + Xml.domToText(fieldElement) + + '.', + ); + } + + this.setValue(variable.getId()); + } + + /** + * Serialize this field to XML. + * + * @param fieldElement The element to populate with info about the field's + * state. + * @returns The element containing info about the field's state. + */ + override toXml(fieldElement: Element): Element { + // Make sure the variable is initialized. + this.initModel(); + + fieldElement.id = this.variable!.getId(); + fieldElement.textContent = this.variable!.getName(); + if (this.variable!.getType()) { + fieldElement.setAttribute('variabletype', this.variable!.getType()); + } + return fieldElement; + } + + /** + * Saves this field's value. + * + * @param doFullSerialization If true, the variable field will serialize the + * full state of the field being referenced (ie ID, name, and type) rather + * than just a reference to it (ie ID). + * @returns The state of the variable field. + * @internal + */ + override saveState(doFullSerialization?: boolean): AnyDuringMigration { + const legacyState = this.saveLegacyState(FieldVariable); + if (legacyState !== null) { + return legacyState; + } + // Make sure the variable is initialized. + this.initModel(); + const state = {'id': this.variable!.getId()}; + if (doFullSerialization) { + (state as AnyDuringMigration)['name'] = this.variable!.getName(); + (state as AnyDuringMigration)['type'] = this.variable!.getType(); + } + return state; + } + + /** + * Sets the field's value based on the given state. + * + * @param state The state of the variable to assign to this variable field. + * @internal + */ + override loadState(state: AnyDuringMigration) { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + if (this.loadLegacyState(FieldVariable, state)) { + return; + } + // This is necessary so that blocks in the flyout can have custom var names. + const variable = Variables.getOrCreateVariablePackage( + block.workspace, + state['id'] || null, + state['name'], + state['type'] || '', + ); + this.setValue(variable.getId()); + } + + /** + * Attach this field to a block. + * + * @param block The block containing this field. + */ + override setSourceBlock(block: Block) { + if (block.isShadow()) { + throw Error('Variable fields are not allowed to exist on shadow blocks.'); + } + super.setSourceBlock(block); + } + + /** + * Get the variable's ID. + * + * @returns Current variable's ID. + */ + override getValue(): string | null { + return this.variable ? this.variable.getId() : null; + } + + /** + * Get the text from this field, which is the selected variable's name. + * + * @returns The selected variable's name, or the empty string if no variable + * is selected. + */ + override getText(): string { + return this.variable ? this.variable.getName() : ''; + } + + /** + * Get the variable model for the selected variable. + * Not guaranteed to be in the variable map on the workspace (e.g. if accessed + * after the variable has been deleted). + * + * @returns The selected variable, or null if none was selected. + * @internal + */ + getVariable(): IVariableModel | null { + return this.variable; + } + + /** + * Gets the type of this field's default variable. + * + * @returns The default type for this variable field. + */ + protected getDefaultType(): string { + return this.defaultType; + } + + /** + * Gets the validation function for this field, or null if not set. + * Returns null if the variable is not set, because validators should not + * run on the initial setValue call, because the field won't be attached to + * a block and workspace at that point. + * + * @returns Validation function, or null. + */ + override getValidator(): FieldVariableValidator | null { + // Validators shouldn't operate on the initial setValue call. + // Normally this is achieved by calling setValidator after setValue, but + // this is not a possibility with variable fields. + if (this.variable) { + return this.validator_; + } + return null; + } + + /** + * Ensure that the ID belongs to a valid variable of an allowed type. + * + * @param newValue The ID of the new variable to set. + * @returns The validated ID, or null if invalid. + */ + protected override doClassValidation_( + newValue?: AnyDuringMigration, + ): string | null { + if (newValue === null) { + return null; + } + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + const newId = newValue as string; + const variable = Variables.getVariable(block.workspace, newId); + if (!variable) { + console.warn( + "Variable id doesn't point to a real variable! " + 'ID was ' + newId, + ); + return null; + } + // Type Checks. + const type = variable.getType(); + if (!this.typeIsAllowed(type)) { + console.warn("Variable type doesn't match this field! Type was " + type); + return null; + } + return newId; + } + + /** + * Update the value of this variable field, as well as its variable and text. + * + * The variable ID should be valid at this point, but if a variable field + * validator returns a bad ID, this could break. + * + * @param newId The value to be saved. + */ + protected override doValueUpdate_(newId: string) { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + this.variable = Variables.getVariable(block.workspace, newId as string); + super.doValueUpdate_(newId); + } + + /** + * Check whether the given variable type is allowed on this field. + * + * @param type The type to check. + * @returns True if the type is in the list of allowed types. + */ + private typeIsAllowed(type: string): boolean { + const typeList = this.getVariableTypes(); + if (!typeList) { + return true; // If it's null, all types are valid. + } + for (let i = 0; i < typeList.length; i++) { + if (type === typeList[i]) { + return true; + } + } + return false; + } + + /** + * Return a list of variable types to include in the dropdown. + * + * @returns Array of variable types. + */ + private getVariableTypes(): string[] { + if (this.variableTypes) return this.variableTypes; + + if (!this.sourceBlock_ || this.sourceBlock_.isDeadOrDying()) { + // We should include all types in the block's workspace, + // but the block is dead so just give up. + return ['']; + } + + // If variableTypes is null, return all variable types in the workspace. + let allTypes = this.sourceBlock_.workspace.getVariableMap().getTypes(); + if (this.sourceBlock_.isInFlyout) { + // If this block is in a flyout, we also need to check the potential variables + const potentialMap = + this.sourceBlock_.workspace.getPotentialVariableMap(); + if (!potentialMap) return allTypes; + allTypes = Array.from(new Set([...allTypes, ...potentialMap.getTypes()])); + } + + return allTypes; + } + + /** + * Parse the optional arguments representing the allowed variable types and + * the default variable type. + * + * @param variableTypes A list of the types of variables to include in the + * dropdown. If null or undefined, variables of all types will be + * displayed in the dropdown. + * @param defaultType The type of the variable to create if this field's + * value is not explicitly set. Defaults to ''. + */ + private setTypes(variableTypes: string[] | null = null, defaultType = '') { + const name = this.getText(); + if (Array.isArray(variableTypes)) { + if (variableTypes.length === 0) { + // Throw an error if variableTypes is an empty list. + throw Error( + `'variableTypes' of field variable ${name} was an empty list. If you want to include all variable types, pass 'null' instead.`, + ); + } + + // Make sure the default type is valid. + let isInArray = false; + for (let i = 0; i < variableTypes.length; i++) { + if (variableTypes[i] === defaultType) { + isInArray = true; + } + } + if (!isInArray) { + throw Error( + "Invalid default type '" + + defaultType + + "' in " + + 'the definition of a FieldVariable', + ); + } + } else if (variableTypes !== null) { + throw Error( + `'variableTypes' was not an array or null in the definition of FieldVariable ${name}`, + ); + } + // Only update the field once all checks pass. + this.defaultType = defaultType; + this.variableTypes = variableTypes; + } + + /** + * Refreshes the name of the variable by grabbing the name of the model. + * Used when a variable gets renamed, but the ID stays the same. Should only + * be called by the block. + * + * @internal + */ + override refreshVariableName() { + this.forceRerender(); + } + + /** + * Handle the selection of an item in the variable dropdown menu. + * Special case the 'Rename variable...' and 'Delete variable...' options. + * In the rename case, prompt the user for a new name. + * + * @param menu The Menu component clicked. + * @param menuItem The MenuItem selected within menu. + */ + protected override onItemSelected_(menu: Menu, menuItem: MenuItem) { + const id = menuItem.getValue(); + // Handle special cases. + if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) { + if (id === internalConstants.RENAME_VARIABLE_ID && this.variable) { + // Rename variable. + Variables.renameVariable(this.sourceBlock_.workspace, this.variable); + return; + } else if (id === internalConstants.DELETE_VARIABLE_ID && this.variable) { + // Delete variable. + const workspace = this.variable.getWorkspace(); + Variables.deleteVariable(workspace, this.variable, this.sourceBlock_); + return; + } + } + // Handle unspecial case. + this.setValue(id); + } + + /** + * Overrides referencesVariables(), indicating this field refers to a + * variable. + * + * @returns True. + * @internal + */ + override referencesVariables(): boolean { + return true; + } + + /** + * Construct a FieldVariable from a JSON arg object, + * dereferencing any string table references. + * + * @param options A JSON object with options (variable, variableTypes, and + * defaultType). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static override fromJson( + options: FieldVariableFromJsonConfig, + ): FieldVariable { + const varName = parsing.replaceMessageReferences(options.variable); + // `this` might be a subclass of FieldVariable if that class doesn't + // override the static fromJson method. + return new this(varName, undefined, undefined, undefined, options); + } + + /** + * Return a sorted list of variable names for variable dropdown menus. + * Include a special option at the end for creating a new variable name. + * + * @returns Array of variable names/id tuples. + */ + static dropdownCreate(this: FieldVariable): MenuOption[] { + if (!this.variable) { + throw Error( + 'Tried to call dropdownCreate on a variable field with no' + + ' variable selected.', + ); + } + const name = this.getText(); + let variableModelList: IVariableModel[] = []; + const sourceBlock = this.getSourceBlock(); + if (sourceBlock && !sourceBlock.isDeadOrDying()) { + const workspace = sourceBlock.workspace; + const variableTypes = this.getVariableTypes(); + // Get a copy of the list, so that adding rename and new variable options + // doesn't modify the workspace's list. + for (let i = 0; i < variableTypes.length; i++) { + const variableType = variableTypes[i]; + const variables = workspace + .getVariableMap() + .getVariablesOfType(variableType); + variableModelList = variableModelList.concat(variables); + if (workspace.isFlyout) { + variableModelList = variableModelList.concat( + workspace + .getPotentialVariableMap() + ?.getVariablesOfType(variableType) ?? [], + ); + } + } + } + variableModelList.sort(Variables.compareByName); + + const options: [string, string][] = []; + for (let i = 0; i < variableModelList.length; i++) { + // Set the UUID as the internal representation of the variable. + options[i] = [ + variableModelList[i].getName(), + variableModelList[i].getId(), + ]; + } + options.push([ + Msg['RENAME_VARIABLE'], + internalConstants.RENAME_VARIABLE_ID, + ]); + if (Msg['DELETE_VARIABLE']) { + options.push([ + Msg['DELETE_VARIABLE'].replace('%1', name), + internalConstants.DELETE_VARIABLE_ID, + ]); + } + + return options; + } +} + +fieldRegistry.register('field_variable', FieldVariable); + +/** + * Config options for the variable field. + */ +export interface FieldVariableConfig extends FieldConfig { + variableTypes?: string[]; + defaultType?: string; +} + +/** + * fromJson config options for the variable field. + */ +export interface FieldVariableFromJsonConfig extends FieldVariableConfig { + variable?: string; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldVariableValidator = FieldDropdownValidator; diff --git a/core/flyout_base.js b/core/flyout_base.js deleted file mode 100644 index 18e99d88e5e..00000000000 --- a/core/flyout_base.js +++ /dev/null @@ -1,754 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2011 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Flyout tray containing blocks which may be created. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Flyout'); - -goog.require('Blockly.Block'); -goog.require('Blockly.Events'); -goog.require('Blockly.FlyoutButton'); -goog.require('Blockly.Gesture'); -goog.require('Blockly.Touch'); -goog.require('Blockly.WorkspaceSvg'); -goog.require('goog.dom'); -goog.require('goog.events'); -goog.require('goog.math.Rect'); -goog.require('goog.userAgent'); - - -/** - * Class for a flyout. - * @param {!Object} workspaceOptions Dictionary of options for the workspace. - * @constructor - */ -Blockly.Flyout = function(workspaceOptions) { - workspaceOptions.getMetrics = this.getMetrics_.bind(this); - workspaceOptions.setMetrics = this.setMetrics_.bind(this); - - /** - * @type {!Blockly.Workspace} - * @private - */ - this.workspace_ = new Blockly.WorkspaceSvg(workspaceOptions); - this.workspace_.isFlyout = true; - - /** - * Is RTL vs LTR. - * @type {boolean} - */ - this.RTL = !!workspaceOptions.RTL; - - /** - * Position of the toolbox and flyout relative to the workspace. - * @type {number} - * @private - */ - this.toolboxPosition_ = workspaceOptions.toolboxPosition; - - /** - * Opaque data that can be passed to Blockly.unbindEvent_. - * @type {!Array.} - * @private - */ - this.eventWrappers_ = []; - - /** - * List of background buttons that lurk behind each block to catch clicks - * landing in the blocks' lakes and bays. - * @type {!Array.} - * @private - */ - this.backgroundButtons_ = []; - - /** - * List of visible buttons. - * @type {!Array.} - * @private - */ - this.buttons_ = []; - - /** - * List of event listeners. - * @type {!Array.} - * @private - */ - this.listeners_ = []; - - /** - * List of blocks that should always be disabled. - * @type {!Array.} - * @private - */ - this.permanentlyDisabled_ = []; -}; - -/** - * Does the flyout automatically close when a block is created? - * @type {boolean} - */ -Blockly.Flyout.prototype.autoClose = true; - -/** - * Whether the flyout is visible. - * @type {boolean} - * @private - */ -Blockly.Flyout.prototype.isVisible_ = false; - -/** - * Whether the workspace containing this flyout is visible. - * @type {boolean} - * @private - */ -Blockly.Flyout.prototype.containerVisible_ = true; - -/** - * Corner radius of the flyout background. - * @type {number} - * @const - */ -Blockly.Flyout.prototype.CORNER_RADIUS = 8; - -/** - * Margin around the edges of the blocks in the flyout. - * @type {number} - * @const - */ -Blockly.Flyout.prototype.MARGIN = Blockly.Flyout.prototype.CORNER_RADIUS; - -/** - * TODO: Move GAP_X and GAP_Y to their appropriate files. - * Gap between items in horizontal flyouts. Can be overridden with the "sep" - * element. - * @const {number} - */ -Blockly.Flyout.prototype.GAP_X = Blockly.Flyout.prototype.MARGIN * 3; - -/** - * Gap between items in vertical flyouts. Can be overridden with the "sep" - * element. - * @const {number} - */ -Blockly.Flyout.prototype.GAP_Y = Blockly.Flyout.prototype.MARGIN * 3; - -/** - * Top/bottom padding between scrollbar and edge of flyout background. - * @type {number} - * @const - */ -Blockly.Flyout.prototype.SCROLLBAR_PADDING = 2; - -/** - * Width of flyout. - * @type {number} - * @private - */ -Blockly.Flyout.prototype.width_ = 0; - -/** - * Height of flyout. - * @type {number} - * @private - */ -Blockly.Flyout.prototype.height_ = 0; - -/** - * Range of a drag angle from a flyout considered "dragging toward workspace". - * Drags that are within the bounds of this many degrees from the orthogonal - * line to the flyout edge are considered to be "drags toward the workspace". - * Example: - * Flyout Edge Workspace - * [block] / <-within this angle, drags "toward workspace" | - * [block] ---- orthogonal to flyout boundary ---- | - * [block] \ | - * The angle is given in degrees from the orthogonal. - * - * This is used to know when to create a new block and when to scroll the - * flyout. Setting it to 360 means that all drags create a new block. - * @type {number} - * @private -*/ -Blockly.Flyout.prototype.dragAngleRange_ = 70; - -/** - * Creates the flyout's DOM. Only needs to be called once. The flyout can - * either exist as its own svg element or be a g element nested inside a - * separate svg element. - * @param {string} tagName The type of tag to put the flyout in. This - * should be or . - * @return {!Element} The flyout's SVG group. - */ -Blockly.Flyout.prototype.createDom = function(tagName) { - /* - - - - - */ - // Setting style to display:none to start. The toolbox and flyout - // hide/show code will set up proper visibility and size later. - this.svgGroup_ = Blockly.utils.createSvgElement(tagName, - {'class': 'blocklyFlyout', 'style': 'display: none'}, null); - this.svgBackground_ = Blockly.utils.createSvgElement('path', - {'class': 'blocklyFlyoutBackground'}, this.svgGroup_); - this.svgGroup_.appendChild(this.workspace_.createDom()); - return this.svgGroup_; -}; - -/** - * Initializes the flyout. - * @param {!Blockly.Workspace} targetWorkspace The workspace in which to create - * new blocks. - */ -Blockly.Flyout.prototype.init = function(targetWorkspace) { - this.targetWorkspace_ = targetWorkspace; - this.workspace_.targetWorkspace = targetWorkspace; - // Add scrollbar. - this.scrollbar_ = new Blockly.Scrollbar(this.workspace_, - this.horizontalLayout_, false, 'blocklyFlyoutScrollbar'); - - this.hide(); - - Array.prototype.push.apply(this.eventWrappers_, - Blockly.bindEventWithChecks_(this.svgGroup_, 'wheel', this, this.wheel_)); - if (!this.autoClose) { - this.filterWrapper_ = this.filterForCapacity_.bind(this); - this.targetWorkspace_.addChangeListener(this.filterWrapper_); - } - - // Dragging the flyout up and down. - Array.prototype.push.apply(this.eventWrappers_, - Blockly.bindEventWithChecks_(this.svgBackground_, 'mousedown', this, - this.onMouseDown_)); - - // A flyout connected to a workspace doesn't have its own current gesture. - this.workspace_.getGesture = - this.targetWorkspace_.getGesture.bind(this.targetWorkspace_); - - // Get variables from the main workspace rather than the target workspace. - this.workspace_.getVariable = - this.targetWorkspace_.getVariable.bind(this.targetWorkspace_); - - this.workspace_.getVariableById = - this.targetWorkspace_.getVariableById.bind(this.targetWorkspace_); - - this.workspace_.getVariablesOfType = - this.targetWorkspace_.getVariablesOfType.bind(this.targetWorkspace_); - - this.workspace_.deleteVariable = - this.targetWorkspace_.deleteVariable.bind(this.targetWorkspace_); - - this.workspace_.deleteVariableById = - this.targetWorkspace_.deleteVariableById.bind(this.targetWorkspace_); - - this.workspace_.renameVariable = - this.targetWorkspace_.renameVariable.bind(this.targetWorkspace_); - - this.workspace_.renameVariableById = - this.targetWorkspace_.renameVariableById.bind(this.targetWorkspace_); -}; - -/** - * Dispose of this flyout. - * Unlink from all DOM elements to prevent memory leaks. - */ -Blockly.Flyout.prototype.dispose = function() { - this.hide(); - Blockly.unbindEvent_(this.eventWrappers_); - if (this.filterWrapper_) { - this.targetWorkspace_.removeChangeListener(this.filterWrapper_); - this.filterWrapper_ = null; - } - if (this.scrollbar_) { - this.scrollbar_.dispose(); - this.scrollbar_ = null; - } - if (this.workspace_) { - this.workspace_.targetWorkspace = null; - this.workspace_.dispose(); - this.workspace_ = null; - } - if (this.svgGroup_) { - goog.dom.removeNode(this.svgGroup_); - this.svgGroup_ = null; - } - this.svgBackground_ = null; - this.targetWorkspace_ = null; -}; - -/** - * Get the width of the flyout. - * @return {number} The width of the flyout. - */ -Blockly.Flyout.prototype.getWidth = function() { - return this.width_; -}; - -/** - * Get the height of the flyout. - * @return {number} The width of the flyout. - */ -Blockly.Flyout.prototype.getHeight = function() { - return this.height_; -}; - -/** - * Get the workspace inside the flyout. - * @return {!Blockly.WorkspaceSvg} The workspace inside the flyout. - * @package - */ -Blockly.Flyout.prototype.getWorkspace = function() { - return this.workspace_; -}; - -/** - * Is the flyout visible? - * @return {boolean} True if visible. - */ -Blockly.Flyout.prototype.isVisible = function() { - return this.isVisible_; -}; - - /** - * Set whether the flyout is visible. A value of true does not necessarily mean - * that the flyout is shown. It could be hidden because its container is hidden. - * @param {boolean} visible True if visible. - */ -Blockly.Flyout.prototype.setVisible = function(visible) { - var visibilityChanged = (visible != this.isVisible()); - - this.isVisible_ = visible; - if (visibilityChanged) { - this.updateDisplay_(); - } -}; - -/** - * Set whether this flyout's container is visible. - * @param {boolean} visible Whether the container is visible. - */ -Blockly.Flyout.prototype.setContainerVisible = function(visible) { - var visibilityChanged = (visible != this.containerVisible_); - this.containerVisible_ = visible; - if (visibilityChanged) { - this.updateDisplay_(); - } -}; - -/** - * Update the display property of the flyout based whether it thinks it should - * be visible and whether its containing workspace is visible. - * @private - */ -Blockly.Flyout.prototype.updateDisplay_ = function() { - var show = true; - if (!this.containerVisible_) { - show = false; - } else { - show = this.isVisible(); - } - this.svgGroup_.style.display = show ? 'block' : 'none'; - // Update the scrollbar's visiblity too since it should mimic the - // flyout's visibility. - this.scrollbar_.setContainerVisible(show); -}; - -/** - * Update the view based on coordinates calculated in position(). - * @param {number} width The computed width of the flyout's SVG group - * @param {number} height The computed height of the flyout's SVG group. - * @param {number} x The computed x origin of the flyout's SVG group. - * @param {number} y The computed y origin of the flyout's SVG group. - * @private - */ -Blockly.Flyout.prototype.positionAt_ = function(width, height, x, y) { - this.svgGroup_.setAttribute("width", width); - this.svgGroup_.setAttribute("height", height); - var transform = 'translate(' + x + 'px,' + y + 'px)'; - Blockly.utils.setCssTransform(this.svgGroup_, transform); - - // Update the scrollbar (if one exists). - if (this.scrollbar_) { - // Set the scrollbars origin to be the top left of the flyout. - this.scrollbar_.setOrigin(x, y); - this.scrollbar_.resize(); - } -}; - -/** - * Hide and empty the flyout. - */ -Blockly.Flyout.prototype.hide = function() { - if (!this.isVisible()) { - return; - } - this.setVisible(false); - // Delete all the event listeners. - for (var x = 0, listen; listen = this.listeners_[x]; x++) { - Blockly.unbindEvent_(listen); - } - this.listeners_.length = 0; - if (this.reflowWrapper_) { - this.workspace_.removeChangeListener(this.reflowWrapper_); - this.reflowWrapper_ = null; - } - // Do NOT delete the blocks here. Wait until Flyout.show. - // https://neil.fraser.name/news/2014/08/09/ -}; - -/** - * Show and populate the flyout. - * @param {!Array|string} xmlList List of blocks to show. - * Variables and procedures have a custom set of blocks. - */ -Blockly.Flyout.prototype.show = function(xmlList) { - this.workspace_.setResizesEnabled(false); - this.hide(); - this.clearOldBlocks_(); - - // Handle dynamic categories, represented by a name instead of a list of XML. - // Look up the correct category generation function and call that to get a - // valid XML list. - if (typeof xmlList == 'string') { - var fnToApply = this.workspace_.targetWorkspace.getToolboxCategoryCallback( - xmlList); - goog.asserts.assert(goog.isFunction(fnToApply), - 'Couldn\'t find a callback function when opening a toolbox category.'); - xmlList = fnToApply(this.workspace_.targetWorkspace); - goog.asserts.assert(goog.isArray(xmlList), - 'The result of a toolbox category callback must be an array.'); - } - - this.setVisible(true); - // Create the blocks to be shown in this flyout. - var contents = []; - var gaps = []; - this.permanentlyDisabled_.length = 0; - for (var i = 0, xml; xml = xmlList[i]; i++) { - if (xml.tagName) { - var tagName = xml.tagName.toUpperCase(); - var default_gap = this.horizontalLayout_ ? this.GAP_X : this.GAP_Y; - if (tagName == 'BLOCK') { - var curBlock = Blockly.Xml.domToBlock(xml, this.workspace_); - if (curBlock.disabled) { - // Record blocks that were initially disabled. - // Do not enable these blocks as a result of capacity filtering. - this.permanentlyDisabled_.push(curBlock); - } - contents.push({type: 'block', block: curBlock}); - var gap = parseInt(xml.getAttribute('gap'), 10); - gaps.push(isNaN(gap) ? default_gap : gap); - } else if (xml.tagName.toUpperCase() == 'SEP') { - // Change the gap between two blocks. - // - // The default gap is 24, can be set larger or smaller. - // This overwrites the gap attribute on the previous block. - // Note that a deprecated method is to add a gap to a block. - // - var newGap = parseInt(xml.getAttribute('gap'), 10); - // Ignore gaps before the first block. - if (!isNaN(newGap) && gaps.length > 0) { - gaps[gaps.length - 1] = newGap; - } else { - gaps.push(default_gap); - } - } else if (tagName == 'BUTTON' || tagName == 'LABEL') { - // Labels behave the same as buttons, but are styled differently. - var isLabel = tagName == 'LABEL'; - var curButton = new Blockly.FlyoutButton(this.workspace_, - this.targetWorkspace_, xml, isLabel); - contents.push({type: 'button', button: curButton}); - gaps.push(default_gap); - } - } - } - - this.layout_(contents, gaps); - - // IE 11 is an incompetent browser that fails to fire mouseout events. - // When the mouse is over the background, deselect all blocks. - var deselectAll = function() { - var topBlocks = this.workspace_.getTopBlocks(false); - for (var i = 0, block; block = topBlocks[i]; i++) { - block.removeSelect(); - } - }; - - this.listeners_.push(Blockly.bindEventWithChecks_(this.svgBackground_, - 'mouseover', this, deselectAll)); - - if (this.horizontalLayout_) { - this.height_ = 0; - } else { - this.width_ = 0; - } - this.workspace_.setResizesEnabled(true); - this.reflow(); - - this.filterForCapacity_(); - - // Correctly position the flyout's scrollbar when it opens. - this.position(); - - this.reflowWrapper_ = this.reflow.bind(this); - this.workspace_.addChangeListener(this.reflowWrapper_); -}; - -/** - * Delete blocks and background buttons from a previous showing of the flyout. - * @private - */ -Blockly.Flyout.prototype.clearOldBlocks_ = function() { - // Delete any blocks from a previous showing. - var oldBlocks = this.workspace_.getTopBlocks(false); - for (var i = 0, block; block = oldBlocks[i]; i++) { - if (block.workspace == this.workspace_) { - block.dispose(false, false); - } - } - // Delete any background buttons from a previous showing. - for (var j = 0, rect; rect = this.backgroundButtons_[j]; j++) { - goog.dom.removeNode(rect); - } - this.backgroundButtons_.length = 0; - - for (var i = 0, button; button = this.buttons_[i]; i++) { - button.dispose(); - } - this.buttons_.length = 0; -}; - -/** - * Add listeners to a block that has been added to the flyout. - * @param {!Element} root The root node of the SVG group the block is in. - * @param {!Blockly.Block} block The block to add listeners for. - * @param {!Element} rect The invisible rectangle under the block that acts as - * a button for that block. - * @private - */ -Blockly.Flyout.prototype.addBlockListeners_ = function(root, block, rect) { - this.listeners_.push(Blockly.bindEventWithChecks_(root, 'mousedown', null, - this.blockMouseDown_(block))); - this.listeners_.push(Blockly.bindEventWithChecks_(rect, 'mousedown', null, - this.blockMouseDown_(block))); - this.listeners_.push(Blockly.bindEvent_(root, 'mouseover', block, - block.addSelect)); - this.listeners_.push(Blockly.bindEvent_(root, 'mouseout', block, - block.removeSelect)); - this.listeners_.push(Blockly.bindEvent_(rect, 'mouseover', block, - block.addSelect)); - this.listeners_.push(Blockly.bindEvent_(rect, 'mouseout', block, - block.removeSelect)); -}; - -/** - * Handle a mouse-down on an SVG block in a non-closing flyout. - * @param {!Blockly.Block} block The flyout block to copy. - * @return {!Function} Function to call when block is clicked. - * @private - */ -Blockly.Flyout.prototype.blockMouseDown_ = function(block) { - var flyout = this; - return function(e) { - var gesture = flyout.targetWorkspace_.getGesture(e); - if (gesture) { - gesture.setStartBlock(block); - gesture.handleFlyoutStart(e, flyout); - } - }; -}; - -/** - * Mouse down on the flyout background. Start a vertical scroll drag. - * @param {!Event} e Mouse down event. - * @private - */ -Blockly.Flyout.prototype.onMouseDown_ = function(e) { - var gesture = this.targetWorkspace_.getGesture(e); - if (gesture) { - gesture.handleFlyoutStart(e, this); - } -}; - -/** - * Create a copy of this block on the workspace. - * @param {!Blockly.BlockSvg} originalBlock The block to copy from the flyout. - * @return {Blockly.BlockSvg} The newly created block, or null if something - * went wrong with deserialization. - * @package - */ -Blockly.Flyout.prototype.createBlock = function(originalBlock) { - var newBlock = null; - Blockly.Events.disable(); - this.targetWorkspace_.setResizesEnabled(false); - try { - newBlock = this.placeNewBlock_(originalBlock); - //Force a render on IE and Edge to get around the issue described in - //Blockly.Field.getCachedWidth - if (goog.userAgent.IE || goog.userAgent.EDGE) { - var blocks = newBlock.getDescendants(); - for (var i = blocks.length - 1; i >= 0; i--) { - blocks[i].render(false); - } - } - // Close the flyout. - Blockly.hideChaff(); - } finally { - Blockly.Events.enable(); - } - - if (Blockly.Events.isEnabled()) { - Blockly.Events.setGroup(true); - Blockly.Events.fire(new Blockly.Events.Create(newBlock)); - } - if (this.autoClose) { - this.hide(); - } else { - this.filterForCapacity_(); - } - return newBlock; -}; - -/** - * Initialize the given button: move it to the correct location, - * add listeners, etc. - * @param {!Blockly.FlyoutButton} button The button to initialize and place. - * @param {number} x The x position of the cursor during this layout pass. - * @param {number} y The y position of the cursor during this layout pass. - * @private - */ -Blockly.Flyout.prototype.initFlyoutButton_ = function(button, x, y) { - var buttonSvg = button.createDom(); - button.moveTo(x, y); - button.show(); - // Clicking on a flyout button or label is a lot like clicking on the - // flyout background. - this.listeners_.push(Blockly.bindEventWithChecks_(buttonSvg, 'mousedown', - this, this.onMouseDown_)); - - this.buttons_.push(button); -}; - -/** - * Create and place a rectangle corresponding to the given block. - * @param {!Blockly.Block} block The block to associate the rect to. - * @param {number} x The x position of the cursor during this layout pass. - * @param {number} y The y position of the cursor during this layout pass. - * @param {!{height: number, width: number}} blockHW The height and width of the - * block. - * @param {number} index The index into the background buttons list where this - * rect should be placed. - * @return {!SVGElement} Newly created SVG element for the rectangle behind the - * block. - * @private - */ -Blockly.Flyout.prototype.createRect_ = function(block, x, y, blockHW, index) { - // Create an invisible rectangle under the block to act as a button. Just - // using the block as a button is poor, since blocks have holes in them. - var rect = Blockly.utils.createSvgElement('rect', - { - 'fill-opacity': 0, - 'x': x, - 'y': y, - 'height': blockHW.height, - 'width': blockHW.width - }, null); - rect.tooltip = block; - Blockly.Tooltip.bindMouseEvents(rect); - // Add the rectangles under the blocks, so that the blocks' tooltips work. - this.workspace_.getCanvas().insertBefore(rect, block.getSvgRoot()); - - block.flyoutRect_ = rect; - this.backgroundButtons_[index] = rect; - return rect; -}; - -/** - * Move a rectangle to sit exactly behind a block, taking into account tabs, - * hats, and any other protrusions we invent. - * @param {!SVGElement} rect The rectangle to move directly behind the block. - * @param {!Blockly.BlockSvg} block The block the rectangle should be behind. - * @private - */ -Blockly.Flyout.prototype.moveRectToBlock_ = function(rect, block) { - var blockHW = block.getHeightWidth(); - rect.setAttribute('width', blockHW.width); - rect.setAttribute('height', blockHW.height); - - // For hat blocks we want to shift them down by the hat height - // since the y coordinate is the corner, not the top of the hat. - var hatOffset = - block.startHat_ ? Blockly.BlockSvg.START_HAT_HEIGHT : 0; - if (hatOffset) { - block.moveBy(0, hatOffset); - } - - // Blocks with output tabs are shifted a bit. - var tab = block.outputConnection ? Blockly.BlockSvg.TAB_WIDTH : 0; - var blockXY = block.getRelativeToSurfaceXY(); - rect.setAttribute('y', blockXY.y); - rect.setAttribute('x', - this.RTL ? blockXY.x - blockHW.width + tab : blockXY.x - tab); -}; - -/** - * Filter the blocks on the flyout to disable the ones that are above the - * capacity limit. For instance, if the user may only place two more blocks on - * the workspace, an "a + b" block that has two shadow blocks would be disabled. - * @private - */ -Blockly.Flyout.prototype.filterForCapacity_ = function() { - var remainingCapacity = this.targetWorkspace_.remainingCapacity(); - var blocks = this.workspace_.getTopBlocks(false); - for (var i = 0, block; block = blocks[i]; i++) { - if (this.permanentlyDisabled_.indexOf(block) == -1) { - var allBlocks = block.getDescendants(); - block.setDisabled(allBlocks.length > remainingCapacity); - } - } -}; - -/** - * Reflow blocks and their buttons. - */ -Blockly.Flyout.prototype.reflow = function() { - if (this.reflowWrapper_) { - this.workspace_.removeChangeListener(this.reflowWrapper_); - } - var blocks = this.workspace_.getTopBlocks(false); - this.reflowInternal_(blocks); - if (this.reflowWrapper_) { - this.workspace_.addChangeListener(this.reflowWrapper_); - } -}; - -/** - * @return {boolean} True if this flyout may be scrolled with a scrollbar or by - * dragging. - * @package - */ -Blockly.Flyout.prototype.isScrollable = function() { - return this.scrollbar_ ? this.scrollbar_.isVisible() : false; -}; diff --git a/core/flyout_base.ts b/core/flyout_base.ts new file mode 100644 index 00000000000..492d3341762 --- /dev/null +++ b/core/flyout_base.ts @@ -0,0 +1,1050 @@ +/** + * @license + * Copyright 2011 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Flyout tray containing blocks which may be created. + * + * @class + */ +// Former goog.module ID: Blockly.Flyout + +import {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; +import {ComponentManager} from './component_manager.js'; +import {DeleteArea} from './delete_area.js'; +import type {Abstract as AbstractEvent} from './events/events_abstract.js'; +import {EventType} from './events/type.js'; +import * as eventUtils from './events/utils.js'; +import {FlyoutItem} from './flyout_item.js'; +import {FlyoutMetricsManager} from './flyout_metrics_manager.js'; +import {FlyoutNavigator} from './flyout_navigator.js'; +import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js'; +import {IAutoHideable} from './interfaces/i_autohideable.js'; +import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import type {Options} from './options.js'; +import * as registry from './registry.js'; +import * as renderManagement from './render_management.js'; +import {ScrollbarPair} from './scrollbar_pair.js'; +import {SEPARATOR_TYPE} from './separator_flyout_inflater.js'; +import * as blocks from './serialization/blocks.js'; +import {Coordinate} from './utils/coordinate.js'; +import * as dom from './utils/dom.js'; +import * as idGenerator from './utils/idgenerator.js'; +import {Svg} from './utils/svg.js'; +import * as toolbox from './utils/toolbox.js'; +import * as Variables from './variables.js'; +import {WorkspaceSvg} from './workspace_svg.js'; + +/** + * Class for a flyout. + */ +export abstract class Flyout + extends DeleteArea + implements IAutoHideable, IFlyout, IFocusableNode +{ + /** + * Position the flyout. + */ + abstract position(): void; + + /** + * Determine if a drag delta is toward the workspace, based on the position + * and orientation of the flyout. This is used in determineDragIntention_ to + * determine if a new block should be created or if the flyout should scroll. + * + * @param currentDragDeltaXY How far the pointer has + * moved from the position at mouse down, in pixel units. + * @returns True if the drag is toward the workspace. + */ + abstract isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean; + + /** + * Sets the translation of the flyout to match the scrollbars. + * + * @param xyRatio Contains a y property which is a float + * between 0 and 1 specifying the degree of scrolling and a + * similar x property. + */ + protected abstract setMetrics_(xyRatio: {x?: number; y?: number}): void; + + /** + * Lay out the elements in the flyout. + * + * @param contents The flyout elements to lay out. + */ + protected abstract layout_(contents: FlyoutItem[]): void; + + /** + * Scroll the flyout. + * + * @param e Mouse wheel scroll event. + */ + protected abstract wheel_(e: WheelEvent): void; + + /** + * Compute bounds of flyout. + * For RTL: Lay out the elements right-aligned. + */ + protected abstract reflowInternal_(): void; + + /** + * Calculates the x coordinate for the flyout position. + * + * @returns X coordinate. + */ + abstract getX(): number; + + /** + * Calculates the y coordinate for the flyout position. + * + * @returns Y coordinate. + */ + abstract getY(): number; + + /** + * Scroll the flyout to the beginning of its contents. + */ + abstract scrollToStart(): void; + + protected workspace_: WorkspaceSvg; + RTL: boolean; + /** + * Whether the flyout should be laid out horizontally or not. + * + * @internal + */ + horizontalLayout = false; + protected toolboxPosition_: number; + + /** + * Array holding info needed to unbind events. + * Used for disposing. + * Ex: [[node, name, func], [node, name, func]]. + */ + private boundEvents: browserEvents.Data[] = []; + + /** + * Function that will be registered as a change listener on the workspace + * to reflow when elements in the flyout workspace change. + */ + private reflowWrapper: ((e: AbstractEvent) => void) | null = null; + + /** + * List of flyout elements. + */ + protected contents: FlyoutItem[] = []; + + protected readonly tabWidth_: number; + + /** + * The target workspace. + * + * @internal + */ + targetWorkspace!: WorkspaceSvg; + + /** + * Does the flyout automatically close when a block is created? + */ + autoClose = true; + + /** + * Whether the flyout is visible. + */ + private visible = false; + + /** + * Whether the workspace containing this flyout is visible. + */ + private containerVisible = true; + + /** + * Corner radius of the flyout background. + */ + readonly CORNER_RADIUS: number = 8; + readonly MARGIN: number; + readonly GAP_X: number; + readonly GAP_Y: number; + + /** + * Top/bottom padding between scrollbar and edge of flyout background. + */ + readonly SCROLLBAR_MARGIN: number = 2.5; + + /** + * Width of flyout. + */ + protected width_ = 0; + + /** + * Height of flyout. + */ + protected height_ = 0; + // clang-format off + /** + * Range of a drag angle from a flyout considered "dragging toward + * workspace". Drags that are within the bounds of this many degrees from + * the orthogonal line to the flyout edge are considered to be "drags toward + * the workspace". + * + * @example + * + * ``` + * Flyout Edge Workspace + * [block] / <-within this angle, drags "toward workspace" | + * [block] ---- orthogonal to flyout boundary ---- | + * [block] \ | + * ``` + * + * The angle is given in degrees from the orthogonal. + * + * This is used to know when to create a new block and when to scroll the + * flyout. Setting it to 360 means that all drags create a new block. + */ + // clang-format on + protected dragAngleRange_ = 70; + + /** + * The path around the background of the flyout, which will be filled with a + * background colour. + */ + protected svgBackground_: SVGPathElement | null = null; + + /** + * The root SVG group for the button or label. + */ + protected svgGroup_: SVGGElement | null = null; + + /** + * Map from flyout content type to the corresponding inflater class + * responsible for creating concrete instances of the content type. + */ + protected inflaters = new Map(); + + /** + * @param workspaceOptions Dictionary of options for the + * workspace. + */ + constructor(workspaceOptions: Options) { + super(); + workspaceOptions.setMetrics = this.setMetrics_.bind(this); + + this.workspace_ = new WorkspaceSvg(workspaceOptions); + this.workspace_.setMetricsManager( + new FlyoutMetricsManager(this.workspace_, this), + ); + + this.workspace_.internalIsFlyout = true; + // Keep the workspace visibility consistent with the flyout's visibility. + this.workspace_.setVisible(this.visible); + this.workspace_.setNavigator(new FlyoutNavigator(this)); + + /** + * The unique id for this component that is used to register with the + * ComponentManager. + */ + this.id = idGenerator.genUid(); + + /** + * Is RTL vs LTR. + */ + this.RTL = !!workspaceOptions.RTL; + + /** + * Position of the toolbox and flyout relative to the workspace. + */ + this.toolboxPosition_ = workspaceOptions.toolboxPosition; + + /** + * Width of output tab. + */ + this.tabWidth_ = this.workspace_.getRenderer().getConstants().TAB_WIDTH; + + /** + * Margin around the edges of the elements in the flyout. + */ + this.MARGIN = this.CORNER_RADIUS; + + // TODO: Move GAP_X and GAP_Y to their appropriate files. + /** + * Gap between items in horizontal flyouts. Can be overridden with the "sep" + * element. + */ + this.GAP_X = this.MARGIN * 3; + + /** + * Gap between items in vertical flyouts. Can be overridden with the "sep" + * element. + */ + this.GAP_Y = this.MARGIN * 3; + } + + /** + * Creates the flyout's DOM. Only needs to be called once. The flyout can + * either exist as its own SVG element or be a g element nested inside a + * separate SVG element. + * + * @param tagName The type of tag to + * put the flyout in. This should be or . + * @returns The flyout's SVG group. + */ + createDom( + tagName: string | Svg | Svg, + ): SVGElement { + /* + + + + + */ + // Setting style to display:none to start. The toolbox and flyout + // hide/show code will set up proper visibility and size later. + this.svgGroup_ = dom.createSvgElement(tagName, { + 'class': 'blocklyFlyout', + }); + this.svgGroup_.style.display = 'none'; + this.svgBackground_ = dom.createSvgElement( + Svg.PATH, + {'class': 'blocklyFlyoutBackground'}, + this.svgGroup_, + ); + this.svgGroup_.appendChild(this.workspace_.createDom()); + this.workspace_ + .getThemeManager() + .subscribe(this.svgBackground_, 'flyoutBackgroundColour', 'fill'); + this.workspace_ + .getThemeManager() + .subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity'); + + return this.svgGroup_; + } + + /** + * Initializes the flyout. + * + * @param targetWorkspace The workspace in which to + * create new blocks. + */ + init(targetWorkspace: WorkspaceSvg) { + this.targetWorkspace = targetWorkspace; + this.workspace_.targetWorkspace = targetWorkspace; + + this.workspace_.scrollbar = new ScrollbarPair( + this.workspace_, + this.horizontalLayout, + !this.horizontalLayout, + 'blocklyFlyoutScrollbar', + this.SCROLLBAR_MARGIN, + ); + + this.hide(); + + this.boundEvents.push( + browserEvents.conditionalBind( + this.svgGroup_ as SVGGElement, + 'wheel', + this, + this.wheel_, + ), + ); + + // Dragging the flyout up and down. + this.boundEvents.push( + browserEvents.conditionalBind( + this.svgBackground_ as SVGPathElement, + 'pointerdown', + this, + this.onMouseDown, + ), + ); + + // A flyout connected to a workspace doesn't have its own current gesture. + this.workspace_.getGesture = this.targetWorkspace.getGesture.bind( + this.targetWorkspace, + ); + + // Get variables from the main workspace rather than the target workspace. + this.workspace_.setVariableMap(this.targetWorkspace.getVariableMap()); + + this.workspace_.createPotentialVariableMap(); + + targetWorkspace.getComponentManager().addComponent({ + component: this, + weight: ComponentManager.ComponentWeight.FLYOUT_WEIGHT, + capabilities: [ + ComponentManager.Capability.AUTOHIDEABLE, + ComponentManager.Capability.DELETE_AREA, + ComponentManager.Capability.DRAG_TARGET, + ], + }); + } + + /** + * Dispose of this flyout. + * Unlink from all DOM elements to prevent memory leaks. + */ + dispose() { + this.hide(); + this.targetWorkspace.getComponentManager().removeComponent(this.id); + for (const event of this.boundEvents) { + browserEvents.unbind(event); + } + this.boundEvents.length = 0; + if (this.workspace_) { + this.workspace_.getThemeManager().unsubscribe(this.svgBackground_!); + this.workspace_.dispose(); + } + if (this.svgGroup_) { + dom.removeNode(this.svgGroup_); + } + } + + /** + * Get the width of the flyout. + * + * @returns The width of the flyout. + */ + getWidth(): number { + return this.width_; + } + + /** + * Get the height of the flyout. + * + * @returns The width of the flyout. + */ + getHeight(): number { + return this.height_; + } + + /** + * Get the scale (zoom level) of the flyout. By default, + * this matches the target workspace scale, but this can be overridden. + * + * @returns Flyout workspace scale. + */ + getFlyoutScale(): number { + return this.targetWorkspace.scale; + } + + /** + * Get the workspace inside the flyout. + * + * @returns The workspace inside the flyout. + */ + getWorkspace(): WorkspaceSvg { + return this.workspace_; + } + + /** + * Sets whether this flyout automatically closes when blocks are dragged out, + * the workspace is clicked, etc, or not. + */ + setAutoClose(autoClose: boolean) { + this.autoClose = autoClose; + this.targetWorkspace.recordDragTargets(); + this.targetWorkspace.resizeContents(); + } + + /** Automatically hides the flyout if it is an autoclosing flyout. */ + autoHide(onlyClosePopups: boolean): void { + if ( + !onlyClosePopups && + this.targetWorkspace.getFlyout(true) === this && + this.autoClose + ) + this.hide(); + } + + /** + * Get the target workspace inside the flyout. + * + * @returns The target workspace inside the flyout. + */ + getTargetWorkspace(): WorkspaceSvg { + return this.targetWorkspace; + } + + /** + * Is the flyout visible? + * + * @returns True if visible. + */ + isVisible(): boolean { + return this.visible; + } + + /** + * Set whether the flyout is visible. A value of true does not necessarily + * mean that the flyout is shown. It could be hidden because its container is + * hidden. + * + * @param visible True if visible. + */ + setVisible(visible: boolean) { + const visibilityChanged = visible !== this.isVisible(); + + this.visible = visible; + if (visibilityChanged) { + if (!this.autoClose) { + // Auto-close flyouts are ignored as drag targets, so only non + // auto-close flyouts need to have their drag target updated. + this.targetWorkspace.recordDragTargets(); + } + this.updateDisplay(); + } + } + + /** + * Set whether this flyout's container is visible. + * + * @param visible Whether the container is visible. + */ + setContainerVisible(visible: boolean) { + const visibilityChanged = visible !== this.containerVisible; + this.containerVisible = visible; + if (visibilityChanged) { + this.updateDisplay(); + } + } + + /** + * Get the list of elements of the current flyout. + * + * @returns The array of flyout elements. + */ + getContents(): FlyoutItem[] { + return this.contents; + } + + /** + * Store the list of elements on the flyout. + * + * @param contents - The array of items for the flyout. + */ + setContents(contents: FlyoutItem[]): void { + this.contents = contents; + } + /** + * Update the display property of the flyout based whether it thinks it should + * be visible and whether its containing workspace is visible. + */ + private updateDisplay() { + let show = true; + if (!this.containerVisible) { + show = false; + } else { + show = this.isVisible(); + } + if (this.svgGroup_) { + this.svgGroup_.style.display = show ? 'block' : 'none'; + } + // Update the scrollbar's visibility too since it should mimic the + // flyout's visibility. + this.workspace_.scrollbar?.setContainerVisible(show); + } + + /** + * Update the view based on coordinates calculated in position(). + * + * @param width The computed width of the flyout's SVG group + * @param height The computed height of the flyout's SVG group. + * @param x The computed x origin of the flyout's SVG group. + * @param y The computed y origin of the flyout's SVG group. + */ + protected positionAt_(width: number, height: number, x: number, y: number) { + this.svgGroup_?.setAttribute('width', `${width}`); + this.svgGroup_?.setAttribute('height', `${height}`); + this.workspace_.setCachedParentSvgSize(width, height); + + if (this.svgGroup_) { + const transform = 'translate(' + x + 'px,' + y + 'px)'; + dom.setCssTransform(this.svgGroup_, transform); + } + + // Update the scrollbar (if one exists). + const scrollbar = this.workspace_.scrollbar; + if (scrollbar) { + // Set the scrollbars origin to be the top left of the flyout. + scrollbar.setOrigin(x, y); + scrollbar.resize(); + // If origin changed and metrics haven't changed enough to trigger + // reposition in resize, we need to call setPosition. See issue #4692. + if (scrollbar.hScroll) { + scrollbar.hScroll.setPosition( + scrollbar.hScroll.position.x, + scrollbar.hScroll.position.y, + ); + } + if (scrollbar.vScroll) { + scrollbar.vScroll.setPosition( + scrollbar.vScroll.position.x, + scrollbar.vScroll.position.y, + ); + } + } + } + + /** + * Hide and empty the flyout. + */ + hide() { + if (!this.isVisible()) { + return; + } + this.setVisible(false); + if (this.reflowWrapper) { + this.workspace_.removeChangeListener(this.reflowWrapper); + this.reflowWrapper = null; + } + // Do NOT delete the flyout contents here. Wait until Flyout.show. + // https://neil.fraser.name/news/2014/08/09/ + } + + /** + * Show and populate the flyout. + * + * @param flyoutDef Contents to display + * in the flyout. This is either an array of Nodes, a NodeList, a + * toolbox definition, or a string with the name of the dynamic category. + */ + show(flyoutDef: toolbox.FlyoutDefinition | string) { + this.workspace_.setResizesEnabled(false); + this.hide(); + this.clearOldBlocks(); + + // Handle dynamic categories, represented by a name instead of a list. + if (typeof flyoutDef === 'string') { + flyoutDef = this.getDynamicCategoryContents(flyoutDef); + } + this.setVisible(true); + + // Parse the Array, Node or NodeList into a a list of flyout items. + const parsedContent = toolbox.convertFlyoutDefToJsonArray(flyoutDef); + const flyoutInfo = this.createFlyoutInfo(parsedContent); + + renderManagement.triggerQueuedRenders(this.workspace_); + + this.setContents(flyoutInfo); + + this.layout_(flyoutInfo); + + if (this.horizontalLayout) { + this.height_ = 0; + } else { + this.width_ = 0; + } + this.reflow(); + this.workspace_.setResizesEnabled(true); + + // Listen for block change events, and reflow the flyout in response. This + // accommodates e.g. resizing a non-autoclosing flyout in response to the + // user typing long strings into fields on the blocks in the flyout. + this.reflowWrapper = (event) => { + if ( + event.type === EventType.BLOCK_CHANGE || + event.type === EventType.BLOCK_FIELD_INTERMEDIATE_CHANGE + ) { + this.reflow(); + } + }; + this.workspace_.addChangeListener(this.reflowWrapper); + } + + /** + * Create the contents array and gaps array necessary to create the layout for + * the flyout. + * + * @param parsedContent The array + * of objects to show in the flyout. + * @returns The list of contents needed to lay out the flyout. + */ + private createFlyoutInfo( + parsedContent: toolbox.FlyoutItemInfoArray, + ): FlyoutItem[] { + const contents: FlyoutItem[] = []; + const defaultGap = this.horizontalLayout ? this.GAP_X : this.GAP_Y; + for (const info of parsedContent) { + if ('custom' in info) { + const customInfo = info as toolbox.DynamicCategoryInfo; + const categoryName = customInfo['custom']; + const flyoutDef = this.getDynamicCategoryContents(categoryName); + const parsedDynamicContent = + toolbox.convertFlyoutDefToJsonArray(flyoutDef); + contents.push(...this.createFlyoutInfo(parsedDynamicContent)); + } + + const type = info['kind'].toLowerCase(); + const inflater = this.getInflaterForType(type); + if (inflater) { + contents.push(inflater.load(info, this)); + const gap = inflater.gapForItem(info, defaultGap); + if (gap) { + contents.push( + new FlyoutItem( + new FlyoutSeparator( + gap, + this.horizontalLayout ? SeparatorAxis.X : SeparatorAxis.Y, + ), + SEPARATOR_TYPE, + ), + ); + } + } + } + + return this.normalizeSeparators(contents); + } + + /** + * Updates and returns the provided list of flyout contents to flatten + * separators as needed. + * + * When multiple separators occur one after another, the value of the last one + * takes precedence and the earlier separators in the group are removed. + * + * @param contents The list of flyout contents to flatten separators in. + * @returns An updated list of flyout contents with only one separator between + * each non-separator item. + */ + protected normalizeSeparators(contents: FlyoutItem[]): FlyoutItem[] { + for (let i = contents.length - 1; i > 0; i--) { + const elementType = contents[i].getType().toLowerCase(); + const previousElementType = contents[i - 1].getType().toLowerCase(); + if ( + elementType === SEPARATOR_TYPE && + previousElementType === SEPARATOR_TYPE + ) { + // Remove previousElement from the array, shifting the current element + // forward as a result. This preserves the behavior where explicit + // separator elements override the value of prior implicit (or explicit) + // separator elements. + contents.splice(i - 1, 1); + } + } + + return contents; + } + + /** + * Gets the flyout definition for the dynamic category. + * + * @param categoryName The name of the dynamic category. + * @returns The definition of the + * flyout in one of its many forms. + */ + private getDynamicCategoryContents( + categoryName: string, + ): toolbox.FlyoutDefinition { + // Look up the correct category generation function and call that to get a + // valid XML list. + const fnToApply = + this.workspace_.targetWorkspace!.getToolboxCategoryCallback(categoryName); + if (typeof fnToApply !== 'function') { + throw TypeError( + "Couldn't find a callback function when opening" + + ' a toolbox category.', + ); + } + return fnToApply(this.workspace_.targetWorkspace!); + } + + /** + * Delete elements from a previous showing of the flyout. + */ + private clearOldBlocks() { + this.getContents().forEach((item) => { + const inflater = this.getInflaterForType(item.getType()); + inflater?.disposeItem(item); + }); + + // Clear potential variables from the previous showing. + this.workspace_.getPotentialVariableMap()?.clear(); + } + + /** + * Pointer down on the flyout background. Start a vertical scroll drag. + * + * @param e Pointer down event. + */ + private onMouseDown(e: PointerEvent) { + const gesture = this.targetWorkspace.getGesture(e); + if (gesture) { + gesture.handleFlyoutStart(e, this); + } + } + + /** + * Does this flyout allow you to create a new instance of the given block? + * Used for deciding if a block can be "dragged out of" the flyout. + * + * @param block The block to copy from the flyout. + * @returns True if you can create a new instance of the block, false + * otherwise. + * @internal + */ + isBlockCreatable(block: BlockSvg): boolean { + return block.isEnabled() && !this.getTargetWorkspace().isReadOnly(); + } + + /** + * Create a copy of this block on the workspace. + * + * @param originalBlock The block to copy from the flyout. + * @returns The newly created block. + * @throws {Error} if something went wrong with deserialization. + * @internal + */ + createBlock(originalBlock: BlockSvg): BlockSvg { + let newBlock = null; + eventUtils.disable(); + const variablesBeforeCreation = this.targetWorkspace.getAllVariables(); + this.targetWorkspace.setResizesEnabled(false); + try { + newBlock = this.placeNewBlock(originalBlock); + } finally { + eventUtils.enable(); + } + + // Close the flyout. + this.targetWorkspace.hideChaff(); + + const newVariables = Variables.getAddedVariables( + this.targetWorkspace, + variablesBeforeCreation, + ); + + if (eventUtils.isEnabled()) { + eventUtils.setGroup(true); + // Fire a VarCreate event for each (if any) new variable created. + for (let i = 0; i < newVariables.length; i++) { + const thisVariable = newVariables[i]; + eventUtils.fire( + new (eventUtils.get(EventType.VAR_CREATE))(thisVariable), + ); + } + + // Block events come after var events, in case they refer to newly created + // variables. + eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(newBlock)); + } + if (this.autoClose) { + this.hide(); + } + return newBlock; + } + + /** + * Reflow flyout contents. + */ + reflow() { + if (this.reflowWrapper) { + this.workspace_.removeChangeListener(this.reflowWrapper); + } + this.reflowInternal_(); + if (this.reflowWrapper) { + this.workspace_.addChangeListener(this.reflowWrapper); + } + } + + /** + * @returns True if this flyout may be scrolled with a scrollbar or + * by dragging. + * @internal + */ + isScrollable(): boolean { + return this.workspace_.scrollbar + ? this.workspace_.scrollbar.isVisible() + : false; + } + + /** + * Copy a block from the flyout to the workspace and position it correctly. + * + * @param oldBlock The flyout block to copy. + * @returns The new block in the main workspace. + */ + private placeNewBlock(oldBlock: BlockSvg): BlockSvg { + const targetWorkspace = this.targetWorkspace; + const svgRootOld = oldBlock.getSvgRoot(); + if (!svgRootOld) { + throw Error('oldBlock is not rendered'); + } + + // Clone the block. + const json = this.serializeBlock(oldBlock); + // Normally this resizes leading to weird jumps. Save it for terminateDrag. + targetWorkspace.setResizesEnabled(false); + const block = blocks.append(json, targetWorkspace) as BlockSvg; + + this.positionNewBlock(oldBlock, block); + + return block; + } + + /** + * Serialize a block to JSON. + * + * @param block The block to serialize. + * @returns A serialized representation of the block. + */ + protected serializeBlock(block: BlockSvg): blocks.State { + return blocks.save(block) as blocks.State; + } + + /** + * Positions a block on the target workspace. + * + * @param oldBlock The flyout block being copied. + * @param block The block to posiiton. + */ + private positionNewBlock(oldBlock: BlockSvg, block: BlockSvg) { + const targetWorkspace = this.targetWorkspace; + + // The offset in pixels between the main workspace's origin and the upper + // left corner of the injection div. + const mainOffsetPixels = targetWorkspace.getOriginOffsetInPixels(); + + // The offset in pixels between the flyout workspace's origin and the upper + // left corner of the injection div. + const flyoutOffsetPixels = this.workspace_.getOriginOffsetInPixels(); + + // The position of the old block in flyout workspace coordinates. + const oldBlockPos = oldBlock.getRelativeToSurfaceXY(); + // The position of the old block in pixels relative to the flyout + // workspace's origin. + oldBlockPos.scale(this.workspace_.scale); + + // The position of the old block in pixels relative to the upper left corner + // of the injection div. + const oldBlockOffsetPixels = Coordinate.sum( + flyoutOffsetPixels, + oldBlockPos, + ); + + // The position of the old block in pixels relative to the origin of the + // main workspace. + const finalOffset = Coordinate.difference( + oldBlockOffsetPixels, + mainOffsetPixels, + ); + // The position of the old block in main workspace coordinates. + finalOffset.scale(1 / targetWorkspace.scale); + + // No 'reason' provided since events are disabled. + block.moveTo(new Coordinate(finalOffset.x, finalOffset.y)); + } + + /** + * Returns the inflater responsible for constructing items of the given type. + * + * @param type The type of flyout content item to provide an inflater for. + * @returns An inflater object for the given type, or null if no inflater + * is registered for that type. + */ + protected getInflaterForType(type: string): IFlyoutInflater | null { + if (this.inflaters.has(type)) { + return this.inflaters.get(type) ?? null; + } + + const InflaterClass = registry.getClass( + registry.Type.FLYOUT_INFLATER, + type, + ); + if (InflaterClass) { + const inflater = new InflaterClass(); + this.inflaters.set(type, inflater); + return inflater; + } + + return null; + } + + /** + * See IFocusableNode.getFocusableElement. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ + getFocusableElement(): HTMLElement | SVGElement { + throw new Error('Flyouts are not directly focusable.'); + } + + /** + * See IFocusableNode.getFocusableTree. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ + getFocusableTree(): IFocusableTree { + throw new Error('Flyouts are not directly focusable.'); + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return false; + } + + /** + * See IFocusableNode.getRootFocusableNode. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ + getRootFocusableNode(): IFocusableNode { + throw new Error('Flyouts are not directly focusable.'); + } + + /** + * See IFocusableNode.getRestoredFocusableNode. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ + getRestoredFocusableNode( + _previousNode: IFocusableNode | null, + ): IFocusableNode | null { + throw new Error('Flyouts are not directly focusable.'); + } + + /** + * See IFocusableNode.getNestedTrees. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ + getNestedTrees(): Array { + throw new Error('Flyouts are not directly focusable.'); + } + + /** + * See IFocusableNode.lookUpFocusableNode. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ + lookUpFocusableNode(_id: string): IFocusableNode | null { + throw new Error('Flyouts are not directly focusable.'); + } + + /** See IFocusableTree.onTreeFocus. */ + onTreeFocus( + _node: IFocusableNode, + _previousTree: IFocusableTree | null, + ): void {} + + /** + * See IFocusableNode.onTreeBlur. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ + onTreeBlur(_nextTree: IFocusableTree | null): void { + throw new Error('Flyouts are not directly focusable.'); + } +} diff --git a/core/flyout_button.js b/core/flyout_button.js deleted file mode 100644 index 965ce8ef32f..00000000000 --- a/core/flyout_button.js +++ /dev/null @@ -1,246 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Class for a button in the flyout. - * @author fenichel@google.com (Rachel Fenichel) - */ -'use strict'; - -goog.provide('Blockly.FlyoutButton'); - -goog.require('goog.dom'); -goog.require('goog.math.Coordinate'); - - -/** - * Class for a button in the flyout. - * @param {!Blockly.WorkspaceSvg} workspace The workspace in which to place this - * button. - * @param {!Blockly.WorkspaceSvg} targetWorkspace The flyout's target workspace. - * @param {!Element} xml The XML specifying the label/button. - * @param {boolean} isLabel Whether this button should be styled as a label. - * @constructor - */ -Blockly.FlyoutButton = function(workspace, targetWorkspace, xml, isLabel) { - // Labels behave the same as buttons, but are styled differently. - - /** - * @type {!Blockly.WorkspaceSvg} - * @private - */ - this.workspace_ = workspace; - - /** - * @type {!Blockly.Workspace} - * @private - */ - this.targetWorkspace_ = targetWorkspace; - - /** - * @type {string} - * @private - */ - this.text_ = xml.getAttribute('text'); - - /** - * @type {!goog.math.Coordinate} - * @private - */ - this.position_ = new goog.math.Coordinate(0, 0); - - /** - * Whether this button should be styled as a label. - * @type {boolean} - * @private - */ - this.isLabel_ = isLabel; - - /** - * Function to call when this button is clicked. - * @type {function(!Blockly.FlyoutButton)} - * @private - */ - this.callback_ = null; - - var callbackKey = xml.getAttribute('callbackKey'); - if (this.isLabel_ && callbackKey) { - console.warn('Labels should not have callbacks. Label text: ' + this.text_); - } else if (!this.isLabel_ && - !(callbackKey && targetWorkspace.getButtonCallback(callbackKey))) { - console.warn('Buttons should have callbacks. Button text: ' + this.text_); - } else { - this.callback_ = targetWorkspace.getButtonCallback(callbackKey); - } - - /** - * If specified, a CSS class to add to this button. - * @type {?string} - * @private - */ - this.cssClass_ = xml.getAttribute('web-class') || null; -}; - -/** - * The margin around the text in the button. - */ -Blockly.FlyoutButton.MARGIN = 5; - -/** - * The width of the button's rect. - * @type {number} - */ -Blockly.FlyoutButton.prototype.width = 0; - -/** - * The height of the button's rect. - * @type {number} - */ -Blockly.FlyoutButton.prototype.height = 0; - -/** - * Opaque data that can be passed to Blockly.unbindEvent_. - * @type {Array.} - * @private - */ -Blockly.FlyoutButton.prototype.onMouseUpWrapper_ = null; - -/** - * Create the button elements. - * @return {!Element} The button's SVG group. - */ -Blockly.FlyoutButton.prototype.createDom = function() { - var cssClass = this.isLabel_ ? 'blocklyFlyoutLabel' : 'blocklyFlyoutButton'; - if (this.cssClass_) { - cssClass += ' ' + this.cssClass_; - } - - this.svgGroup_ = Blockly.utils.createSvgElement('g', {'class': cssClass}, - this.workspace_.getCanvas()); - - if (!this.isLabel_) { - // Shadow rectangle (light source does not mirror in RTL). - var shadow = Blockly.utils.createSvgElement('rect', - {'class': 'blocklyFlyoutButtonShadow', - 'rx': 4, 'ry': 4, 'x': 1, 'y': 1}, - this.svgGroup_); - } - // Background rectangle. - var rect = Blockly.utils.createSvgElement('rect', - {'class': this.isLabel_ ? - 'blocklyFlyoutLabelBackground' : 'blocklyFlyoutButtonBackground', - 'rx': 4, 'ry': 4}, - this.svgGroup_); - - var svgText = Blockly.utils.createSvgElement('text', - {'class': this.isLabel_ ? 'blocklyFlyoutLabelText' : 'blocklyText', - 'x': 0, 'y': 0, 'text-anchor': 'middle'}, - this.svgGroup_); - svgText.textContent = this.text_; - - this.width = svgText.getComputedTextLength() + - 2 * Blockly.FlyoutButton.MARGIN; - this.height = 20; // Can't compute it :( - - if (!this.isLabel_) { - shadow.setAttribute('width', this.width); - shadow.setAttribute('height', this.height); - } - rect.setAttribute('width', this.width); - rect.setAttribute('height', this.height); - - svgText.setAttribute('x', this.width / 2); - svgText.setAttribute('y', this.height - Blockly.FlyoutButton.MARGIN); - - this.updateTransform_(); - - this.mouseUpWrapper_ = Blockly.bindEventWithChecks_(this.svgGroup_, 'mouseup', - this, this.onMouseUp_); - return this.svgGroup_; -}; - -/** - * Correctly position the flyout button and make it visible. - */ -Blockly.FlyoutButton.prototype.show = function() { - this.updateTransform_(); - this.svgGroup_.setAttribute('display', 'block'); -}; - -/** - * Update svg attributes to match internal state. - * @private - */ -Blockly.FlyoutButton.prototype.updateTransform_ = function() { - this.svgGroup_.setAttribute('transform', - 'translate(' + this.position_.x + ',' + this.position_.y + ')'); -}; - -/** - * Move the button to the given x, y coordinates. - * @param {number} x The new x coordinate. - * @param {number} y The new y coordinate. - */ -Blockly.FlyoutButton.prototype.moveTo = function(x, y) { - this.position_.x = x; - this.position_.y = y; - this.updateTransform_(); -}; - -/** - * Get the button's target workspace. - * @return {!Blockly.WorkspaceSvg} The target workspace of the flyout where this - * button resides. - */ -Blockly.FlyoutButton.prototype.getTargetWorkspace = function() { - return this.targetWorkspace_; -}; - -/** - * Dispose of this button. - */ -Blockly.FlyoutButton.prototype.dispose = function() { - if (this.onMouseUpWrapper_) { - Blockly.unbindEvent_(this.onMouseUpWrapper_); - } - if (this.svgGroup_) { - goog.dom.removeNode(this.svgGroup_); - this.svgGroup_ = null; - } - this.workspace_ = null; - this.targetWorkspace_ = null; -}; - -/** - * Do something when the button is clicked. - * @param {!Event} e Mouse up event. - * @private - */ -Blockly.FlyoutButton.prototype.onMouseUp_ = function(e) { - var gesture = this.targetWorkspace_.getGesture(e); - if (gesture) { - gesture.cancel(); - } - - // Call the callback registered to this button. - if (this.callback_) { - this.callback_(this); - } -}; diff --git a/core/flyout_button.ts b/core/flyout_button.ts new file mode 100644 index 00000000000..971fc4fee9f --- /dev/null +++ b/core/flyout_button.ts @@ -0,0 +1,441 @@ +/** + * @license + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for a button in the flyout. + * + * @class + */ +// Former goog.module ID: Blockly.FlyoutButton + +import * as browserEvents from './browser_events.js'; +import * as Css from './css.js'; +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import type {IRenderedElement} from './interfaces/i_rendered_element.js'; +import {idGenerator} from './utils.js'; +import {Coordinate} from './utils/coordinate.js'; +import * as dom from './utils/dom.js'; +import * as parsing from './utils/parsing.js'; +import {Rect} from './utils/rect.js'; +import * as style from './utils/style.js'; +import {Svg} from './utils/svg.js'; +import type * as toolbox from './utils/toolbox.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +/** + * Class for a button or label in the flyout. + */ +export class FlyoutButton + implements IBoundedElement, IRenderedElement, IFocusableNode +{ + /** The horizontal margin around the text in the button. */ + static TEXT_MARGIN_X = 5; + + /** The vertical margin around the text in the button. */ + static TEXT_MARGIN_Y = 2; + + /** The radius of the flyout button's borders. */ + static BORDER_RADIUS = 4; + + /** The key to the function called when this button is activated. */ + readonly callbackKey: string; + + private readonly text: string; + private readonly position: Coordinate; + private readonly cssClass: string | null; + + /** Mouse up event data. */ + private onMouseDownWrapper: browserEvents.Data; + private onMouseUpWrapper: browserEvents.Data; + info: toolbox.ButtonOrLabelInfo; + + /** The width of the button's rect. */ + width = 0; + + /** The height of the button's rect. */ + height = 0; + + /** The root SVG group for the button or label. */ + private svgGroup: SVGGElement; + + /** The SVG element with the text of the label or button. */ + private svgText: SVGTextElement | null = null; + + /** + * Holds the cursors svg element when the cursor is attached to the button. + * This is null if there is no cursor on the button. + */ + cursorSvg: SVGElement | null = null; + + /** The unique ID for this FlyoutButton. */ + private id: string; + + /** + * @param workspace The workspace in which to place this button. + * @param targetWorkspace The flyout's target workspace. + * @param json The JSON specifying the label/button. + * @param isFlyoutLabel Whether this button should be styled as a label. + * @internal + */ + constructor( + private readonly workspace: WorkspaceSvg, + private readonly targetWorkspace: WorkspaceSvg, + json: toolbox.ButtonOrLabelInfo, + private readonly isFlyoutLabel: boolean, + ) { + this.text = json['text']; + + this.position = new Coordinate(0, 0); + + /** + * The key to the function called when this button is activated. + * Check both the uppercase and lowercase version, because the docs + * say `callbackKey` but the type says `callbackkey`. + */ + this.callbackKey = + (json as AnyDuringMigration)['callbackKey'] || + (json as AnyDuringMigration)['callbackkey']; + + /** If specified, a CSS class to add to this button. */ + this.cssClass = (json as AnyDuringMigration)['web-class'] || null; + + /** The JSON specifying the label / button. */ + this.info = json; + let cssClass = this.isFlyoutLabel + ? 'blocklyFlyoutLabel' + : 'blocklyFlyoutButton'; + if (this.cssClass) { + cssClass += ' ' + this.cssClass; + } + + this.id = idGenerator.getNextUniqueId(); + this.svgGroup = dom.createSvgElement( + Svg.G, + {'id': this.id, 'class': cssClass}, + this.workspace.getCanvas(), + ); + + let shadow; + if (!this.isFlyoutLabel) { + // Shadow rectangle (light source does not mirror in RTL). + shadow = dom.createSvgElement( + Svg.RECT, + { + 'class': 'blocklyFlyoutButtonShadow', + 'rx': FlyoutButton.BORDER_RADIUS, + 'ry': FlyoutButton.BORDER_RADIUS, + 'x': 1, + 'y': 1, + }, + this.svgGroup!, + ); + } + // Background rectangle. + const rect = dom.createSvgElement( + Svg.RECT, + { + 'class': this.isFlyoutLabel + ? 'blocklyFlyoutLabelBackground' + : 'blocklyFlyoutButtonBackground', + 'rx': FlyoutButton.BORDER_RADIUS, + 'ry': FlyoutButton.BORDER_RADIUS, + }, + this.svgGroup!, + ); + + const svgText = dom.createSvgElement( + Svg.TEXT, + { + 'class': this.isFlyoutLabel ? 'blocklyFlyoutLabelText' : 'blocklyText', + 'x': 0, + 'y': 0, + 'text-anchor': 'middle', + }, + this.svgGroup!, + ); + let text = parsing.replaceMessageReferences(this.text); + if (this.workspace.RTL) { + // Force text to be RTL by adding an RLM. + text += '\u200F'; + } + svgText.textContent = text; + if (this.isFlyoutLabel) { + this.svgText = svgText; + this.workspace + .getThemeManager() + .subscribe(this.svgText, 'flyoutForegroundColour', 'fill'); + } + + const fontSize = style.getComputedStyle(svgText, 'fontSize'); + const fontWeight = style.getComputedStyle(svgText, 'fontWeight'); + const fontFamily = style.getComputedStyle(svgText, 'fontFamily'); + this.width = dom.getFastTextWidthWithSizeString( + svgText, + fontSize, + fontWeight, + fontFamily, + ); + const fontMetrics = dom.measureFontMetrics( + text, + fontSize, + fontWeight, + fontFamily, + ); + this.height = this.height || fontMetrics.height; + + if (!this.isFlyoutLabel) { + this.width += 2 * FlyoutButton.TEXT_MARGIN_X; + this.height += 2 * FlyoutButton.TEXT_MARGIN_Y; + shadow?.setAttribute('width', String(this.width)); + shadow?.setAttribute('height', String(this.height)); + } + rect.setAttribute('width', String(this.width)); + rect.setAttribute('height', String(this.height)); + + svgText.setAttribute('x', String(this.width / 2)); + svgText.setAttribute( + 'y', + String(this.height / 2 - fontMetrics.height / 2 + fontMetrics.baseline), + ); + + this.updateTransform(); + + this.onMouseDownWrapper = browserEvents.conditionalBind( + this.svgGroup, + 'pointerdown', + this, + this.onMouseDown, + ); + this.onMouseUpWrapper = browserEvents.conditionalBind( + this.svgGroup, + 'pointerup', + this, + this.onMouseUp, + ); + } + + createDom(): SVGElement { + // No-op, now handled in constructor. Will be removed in followup refactor + // PR that updates the flyout classes to use inflaters. + return this.svgGroup; + } + + /** Correctly position the flyout button and make it visible. */ + show() { + this.updateTransform(); + this.svgGroup!.setAttribute('display', 'block'); + } + + /** Update SVG attributes to match internal state. */ + private updateTransform() { + this.svgGroup!.setAttribute( + 'transform', + 'translate(' + this.position.x + ',' + this.position.y + ')', + ); + } + + /** + * Move the button to the given x, y coordinates. + * + * @param x The new x coordinate. + * @param y The new y coordinate. + */ + moveTo(x: number, y: number) { + this.position.x = x; + this.position.y = y; + this.updateTransform(); + } + + /** + * Move the element by a relative offset. + * + * @param dx Horizontal offset in workspace units. + * @param dy Vertical offset in workspace units. + * @param _reason Why is this move happening? 'user', 'bump', 'snap'... + */ + moveBy(dx: number, dy: number, _reason?: string[]) { + this.moveTo(this.position.x + dx, this.position.y + dy); + } + + /** @returns Whether or not the button is a label. */ + isLabel(): boolean { + return this.isFlyoutLabel; + } + + /** + * Location of the button. + * + * @returns x, y coordinates. + * @internal + */ + getPosition(): Coordinate { + return this.position; + } + + /** + * Returns the coordinates of a bounded element describing the dimensions of + * the element. Coordinate system: workspace coordinates. + * + * @returns Object with coordinates of the bounded element. + */ + getBoundingRectangle() { + return new Rect( + this.position.y, + this.position.y + this.height, + this.position.x, + this.position.x + this.width, + ); + } + + /** @returns Text of the button. */ + getButtonText(): string { + return this.text; + } + + /** + * Get the button's target workspace. + * + * @returns The target workspace of the flyout where this button resides. + */ + getTargetWorkspace(): WorkspaceSvg { + return this.targetWorkspace; + } + + /** + * Get the button's workspace. + * + * @returns The workspace in which to place this button. + */ + getWorkspace(): WorkspaceSvg { + return this.workspace; + } + + /** Dispose of this button. */ + dispose() { + browserEvents.unbind(this.onMouseDownWrapper); + browserEvents.unbind(this.onMouseUpWrapper); + if (this.svgGroup) { + dom.removeNode(this.svgGroup); + } + if (this.svgText) { + this.workspace.getThemeManager().unsubscribe(this.svgText); + } + } + + /** + * Add the cursor SVG to this buttons's SVG group. + * + * @param cursorSvg The SVG root of the cursor to be added to the button SVG + * group. + */ + setCursorSvg(cursorSvg: SVGElement) { + if (!cursorSvg) { + this.cursorSvg = null; + return; + } + if (this.svgGroup) { + this.svgGroup.appendChild(cursorSvg); + this.cursorSvg = cursorSvg; + } + } + + /** + * Do something when the button is clicked. + * + * @param e Pointer up event. + */ + private onMouseUp(e: PointerEvent) { + const gesture = this.targetWorkspace.getGesture(e); + if (gesture) { + gesture.cancel(); + } + + if (this.isFlyoutLabel && this.callbackKey) { + console.warn( + 'Labels should not have callbacks. Label text: ' + this.text, + ); + } else if ( + !this.isFlyoutLabel && + !( + this.callbackKey && + this.targetWorkspace.getButtonCallback(this.callbackKey) + ) + ) { + console.warn('Buttons should have callbacks. Button text: ' + this.text); + } else if (!this.isFlyoutLabel) { + const callback = this.targetWorkspace.getButtonCallback(this.callbackKey); + if (callback) { + callback(this); + } + } + } + + private onMouseDown(e: PointerEvent) { + const gesture = this.targetWorkspace.getGesture(e); + const flyout = this.targetWorkspace.getFlyout(); + if (gesture && flyout) { + gesture.handleFlyoutStart(e, flyout); + } + } + + /** + * @returns The root SVG element of this rendered element. + */ + getSvgRoot() { + return this.svgGroup; + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + return this.svgGroup; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.workspace; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + const xy = this.getPosition(); + const bounds = new Rect(xy.y, xy.y + this.height, xy.x, xy.x + this.width); + this.workspace.scrollBoundsIntoView(bounds); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } +} + +/** CSS for buttons and labels. See css.js for use. */ +Css.register(` +.blocklyFlyoutButton { + fill: #888; + cursor: default; +} + +.blocklyFlyoutButtonShadow { + fill: #666; +} + +.blocklyFlyoutButton:hover { + fill: #aaa; +} + +.blocklyFlyoutLabel { + cursor: default; +} + +.blocklyFlyoutLabelBackground { + opacity: 0; +} +`); diff --git a/core/flyout_dragger.js b/core/flyout_dragger.js deleted file mode 100644 index c3909eaf2f3..00000000000 --- a/core/flyout_dragger.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Methods for dragging a flyout visually. - * @author fenichel@google.com (Rachel Fenichel) - */ -'use strict'; - -goog.provide('Blockly.FlyoutDragger'); - -goog.require('Blockly.WorkspaceDragger'); - -goog.require('goog.asserts'); -goog.require('goog.math.Coordinate'); - - -/** - * Class for a flyout dragger. It moves a flyout workspace around when it is - * being dragged by a mouse or touch. - * Note that the workspace itself manages whether or not it has a drag surface - * and how to do translations based on that. This simply passes the right - * commands based on events. - * @param {!Blockly.Flyout} flyout The flyout to drag. - * @constructor - */ -Blockly.FlyoutDragger = function(flyout) { - Blockly.FlyoutDragger.superClass_.constructor.call(this, - flyout.getWorkspace()); - - /** - * The scrollbar to update to move the flyout. - * Unlike the main workspace, the flyout has only one scrollbar, in either the - * horizontal or the vertical direction. - * @type {!Blockly.Scrollbar} - * @private - */ - this.scrollbar_ = flyout.scrollbar_; - - /** - * Whether the flyout scrolls horizontally. If false, the flyout scrolls - * vertically. - * @type {boolean} - * @private - */ - this.horizontalLayout_ = flyout.horizontalLayout_; -}; -goog.inherits(Blockly.FlyoutDragger, Blockly.WorkspaceDragger); - -/** - * Move the appropriate scrollbar to drag the flyout. - * Since flyouts only scroll in one direction at a time, this will discard one - * of the calculated values. - * x and y are in pixels. - * @param {number} x The new x position to move the scrollbar to. - * @param {number} y The new y position to move the scrollbar to. - * @private - */ -Blockly.FlyoutDragger.prototype.updateScroll_ = function(x, y) { - // Move the scrollbar and the flyout will scroll automatically. - if (this.horizontalLayout_) { - this.scrollbar_.set(x); - } else { - this.scrollbar_.set(y); - } -}; diff --git a/core/flyout_horizontal.js b/core/flyout_horizontal.js deleted file mode 100644 index 0c1c8e52cb2..00000000000 --- a/core/flyout_horizontal.js +++ /dev/null @@ -1,456 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Horizontal flyout tray containing blocks which may be created. - * @author fenichel@google.com (Rachel Fenichel) - */ -'use strict'; - -goog.provide('Blockly.HorizontalFlyout'); - -goog.require('Blockly.Block'); -goog.require('Blockly.Events'); -goog.require('Blockly.FlyoutButton'); -goog.require('Blockly.Flyout'); -goog.require('Blockly.WorkspaceSvg'); -goog.require('goog.dom'); -goog.require('goog.events'); -goog.require('goog.math.Rect'); -goog.require('goog.userAgent'); - - -/** - * Class for a flyout. - * @param {!Object} workspaceOptions Dictionary of options for the workspace. - * @extends {Blockly.Flyout} - * @constructor - */ -Blockly.HorizontalFlyout = function(workspaceOptions) { - workspaceOptions.getMetrics = this.getMetrics_.bind(this); - workspaceOptions.setMetrics = this.setMetrics_.bind(this); - - Blockly.HorizontalFlyout.superClass_.constructor.call(this, workspaceOptions); - /** - * Flyout should be laid out horizontally. - * @type {boolean} - * @private - */ - this.horizontalLayout_ = true; -}; -goog.inherits(Blockly.HorizontalFlyout, Blockly.Flyout); - -/** - * Return an object with all the metrics required to size scrollbars for the - * flyout. The following properties are computed: - * .viewHeight: Height of the visible rectangle, - * .viewWidth: Width of the visible rectangle, - * .contentHeight: Height of the contents, - * .contentWidth: Width of the contents, - * .viewTop: Offset of top edge of visible rectangle from parent, - * .contentTop: Offset of the top-most content from the y=0 coordinate, - * .absoluteTop: Top-edge of view. - * .viewLeft: Offset of the left edge of visible rectangle from parent, - * .contentLeft: Offset of the left-most content from the x=0 coordinate, - * .absoluteLeft: Left-edge of view. - * @return {Object} Contains size and position metrics of the flyout. - * @private - */ -Blockly.HorizontalFlyout.prototype.getMetrics_ = function() { - if (!this.isVisible()) { - // Flyout is hidden. - return null; - } - - try { - var optionBox = this.workspace_.getCanvas().getBBox(); - } catch (e) { - // Firefox has trouble with hidden elements (Bug 528969). - var optionBox = {height: 0, y: 0, width: 0, x: 0}; - } - - var absoluteTop = this.SCROLLBAR_PADDING; - var absoluteLeft = this.SCROLLBAR_PADDING; - if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_BOTTOM) { - absoluteTop = 0; - } - var viewHeight = this.height_; - if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_TOP) { - viewHeight -= this.SCROLLBAR_PADDING; - } - var viewWidth = this.width_ - 2 * this.SCROLLBAR_PADDING; - - var metrics = { - viewHeight: viewHeight, - viewWidth: viewWidth, - contentHeight: (optionBox.height + 2 * this.MARGIN) * this.workspace_.scale, - contentWidth: (optionBox.width + 2 * this.MARGIN) * this.workspace_.scale, - viewTop: -this.workspace_.scrollY, - viewLeft: -this.workspace_.scrollX, - contentTop: optionBox.y, - contentLeft: optionBox.x, - absoluteTop: absoluteTop, - absoluteLeft: absoluteLeft - }; - return metrics; -}; - -/** - * Sets the translation of the flyout to match the scrollbars. - * @param {!Object} xyRatio Contains a y property which is a float - * between 0 and 1 specifying the degree of scrolling and a - * similar x property. - * @private - */ -Blockly.HorizontalFlyout.prototype.setMetrics_ = function(xyRatio) { - var metrics = this.getMetrics_(); - // This is a fix to an apparent race condition. - if (!metrics) { - return; - } - - if (goog.isNumber(xyRatio.x)) { - this.workspace_.scrollX = -metrics.contentWidth * xyRatio.x; - } - - this.workspace_.translate(this.workspace_.scrollX + metrics.absoluteLeft, - this.workspace_.scrollY + metrics.absoluteTop); -}; - -/** - * Move the flyout to the edge of the workspace. - */ -Blockly.HorizontalFlyout.prototype.position = function() { - if (!this.isVisible()) { - return; - } - var targetWorkspaceMetrics = this.targetWorkspace_.getMetrics(); - if (!targetWorkspaceMetrics) { - // Hidden components will return null. - return; - } - // Record the width for Blockly.Flyout.getMetrics_. - this.width_ = targetWorkspaceMetrics.viewWidth; - - var edgeWidth = targetWorkspaceMetrics.viewWidth - 2 * this.CORNER_RADIUS; - var edgeHeight = this.height_ - this.CORNER_RADIUS; - this.setBackgroundPath_(edgeWidth, edgeHeight); - - var x = targetWorkspaceMetrics.absoluteLeft; - var y = targetWorkspaceMetrics.absoluteTop; - if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_BOTTOM) { - y += (targetWorkspaceMetrics.viewHeight - this.height_); - } - this.positionAt_(this.width_, this.height_, x, y); -}; - -/** - * Create and set the path for the visible boundaries of the flyout. - * @param {number} width The width of the flyout, not including the - * rounded corners. - * @param {number} height The height of the flyout, not including - * rounded corners. - * @private - */ -Blockly.HorizontalFlyout.prototype.setBackgroundPath_ = function(width, - height) { - var atTop = this.toolboxPosition_ == Blockly.TOOLBOX_AT_TOP; - // Start at top left. - var path = ['M 0,' + (atTop ? 0 : this.CORNER_RADIUS)]; - - if (atTop) { - // Top. - path.push('h', width + 2 * this.CORNER_RADIUS); - // Right. - path.push('v', height); - // Bottom. - path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, - -this.CORNER_RADIUS, this.CORNER_RADIUS); - path.push('h', -1 * width); - // Left. - path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, - -this.CORNER_RADIUS, -this.CORNER_RADIUS); - path.push('z'); - } else { - // Top. - path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, - this.CORNER_RADIUS, -this.CORNER_RADIUS); - path.push('h', width); - // Right. - path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, - this.CORNER_RADIUS, this.CORNER_RADIUS); - path.push('v', height); - // Bottom. - path.push('h', -width - 2 * this.CORNER_RADIUS); - // Left. - path.push('z'); - } - this.svgBackground_.setAttribute('d', path.join(' ')); -}; - -/** - * Scroll the flyout to the top. - */ -Blockly.HorizontalFlyout.prototype.scrollToStart = function() { - this.scrollbar_.set(this.RTL ? Infinity : 0); -}; - -/** - * Scroll the flyout. - * @param {!Event} e Mouse wheel scroll event. - * @private - */ -Blockly.HorizontalFlyout.prototype.wheel_ = function(e) { - var delta = e.deltaX; - - if (delta) { - if (goog.userAgent.GECKO) { - // Firefox's deltas are a tenth that of Chrome/Safari. - delta *= 10; - } - // TODO: #1093 - var metrics = this.getMetrics_(); - var pos = metrics.viewLeft + delta; - var limit = metrics.contentWidth - metrics.viewWidth; - pos = Math.min(pos, limit); - pos = Math.max(pos, 0); - this.scrollbar_.set(pos); - // When the flyout moves from a wheel event, hide WidgetDiv. - Blockly.WidgetDiv.hide(); - } - - // Don't scroll the page. - e.preventDefault(); - // Don't propagate mousewheel event (zooming). - e.stopPropagation(); -}; - -/** - * Lay out the blocks in the flyout. - * @param {!Array.} contents The blocks and buttons to lay out. - * @param {!Array.} gaps The visible gaps between blocks. - * @private - */ -Blockly.HorizontalFlyout.prototype.layout_ = function(contents, gaps) { - this.workspace_.scale = this.targetWorkspace_.scale; - var margin = this.MARGIN; - var cursorX = this.RTL ? margin : margin + Blockly.BlockSvg.TAB_WIDTH; - var cursorY = margin; - if (this.RTL) { - contents = contents.reverse(); - } - - for (var i = 0, item; item = contents[i]; i++) { - if (item.type == 'block') { - var block = item.block; - var allBlocks = block.getDescendants(); - for (var j = 0, child; child = allBlocks[j]; j++) { - // Mark blocks as being inside a flyout. This is used to detect and - // prevent the closure of the flyout if the user right-clicks on such a - // block. - child.isInFlyout = true; - } - block.render(); - var root = block.getSvgRoot(); - var blockHW = block.getHeightWidth(); - - // Figure out where to place the block. - var tab = block.outputConnection ? Blockly.BlockSvg.TAB_WIDTH : 0; - if (this.RTL) { - var moveX = cursorX + blockHW.width; - } else { - var moveX = cursorX + tab; - } - block.moveBy(moveX, cursorY); - - var rect = this.createRect_(block, moveX, cursorY, blockHW, i); - cursorX += (blockHW.width + gaps[i]); - - this.addBlockListeners_(root, block, rect); - } else if (item.type == 'button') { - this.initFlyoutButton_(item.button, cursorX, cursorY); - cursorX += (item.button.width + gaps[i]); - } - } -}; - -/** - * Determine if a drag delta is toward the workspace, based on the position - * and orientation of the flyout. This is used in determineDragIntention_ to - * determine if a new block should be created or if the flyout should scroll. - * @param {!goog.math.Coordinate} currentDragDeltaXY How far the pointer has - * moved from the position at mouse down, in pixel units. - * @return {boolean} true if the drag is toward the workspace. - * @package - */ -Blockly.HorizontalFlyout.prototype.isDragTowardWorkspace = function( - currentDragDeltaXY) { - var dx = currentDragDeltaXY.x; - var dy = currentDragDeltaXY.y; - // Direction goes from -180 to 180, with 0 toward the right and 90 on top. - var dragDirection = Math.atan2(dy, dx) / Math.PI * 180; - - var range = this.dragAngleRange_; - if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_TOP) { - // Horizontal at top. - if (dragDirection < 90 + range && dragDirection > 90 - range) { - return true; - } - } else { - // Horizontal at bottom. - if (dragDirection > -90 - range && dragDirection < -90 + range) { - return true; - } - } - return false; -}; - -/** - * Copy a block from the flyout to the workspace and position it correctly. - * @param {!Blockly.Block} originBlock The flyout block to copy.. - * @return {!Blockly.Block} The new block in the main workspace. - * @private - */ -Blockly.HorizontalFlyout.prototype.placeNewBlock_ = function(originBlock) { - var targetWorkspace = this.targetWorkspace_; - var svgRootOld = originBlock.getSvgRoot(); - if (!svgRootOld) { - throw 'originBlock is not rendered.'; - } - // Figure out where the original block is on the screen, relative to the upper - // left corner of the main workspace. - if (targetWorkspace.isMutator) { - var xyOld = this.workspace_.getSvgXY(/** @type {!Element} */ (svgRootOld)); - } else { - var xyOld = Blockly.utils.getInjectionDivXY_(svgRootOld); - } - - // Take into account that the flyout might have been scrolled horizontally - // (separately from the main workspace). - // Generally a no-op in vertical mode but likely to happen in horizontal - // mode. - var scrollX = this.workspace_.scrollX; - var scale = this.workspace_.scale; - xyOld.x += scrollX / scale - scrollX; - - // Take into account that the flyout might have been scrolled vertically - // (separately from the main workspace). - // Generally a no-op in horizontal mode but likely to happen in vertical - // mode. - var scrollY = this.workspace_.scrollY; - scale = this.workspace_.scale; - xyOld.y += scrollY / scale - scrollY; - // If the flyout is on the bottom, (0, 0) in the flyout is offset to be below - // (0, 0) in the main workspace. Add an offset to take that into account. - if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_BOTTOM) { - scrollY = targetWorkspace.getMetrics().viewHeight - this.height_; - scale = targetWorkspace.scale; - xyOld.y += scrollY / scale - scrollY; - } - - // Create the new block by cloning the block in the flyout (via XML). - var xml = Blockly.Xml.blockToDom(originBlock); - var block = Blockly.Xml.domToBlock(xml, targetWorkspace); - var svgRootNew = block.getSvgRoot(); - if (!svgRootNew) { - throw 'block is not rendered.'; - } - // Figure out where the new block got placed on the screen, relative to the - // upper left corner of the workspace. This may not be the same as the - // original block because the flyout's origin may not be the same as the - // main workspace's origin. - if (targetWorkspace.isMutator) { - var xyNew = targetWorkspace.getSvgXY(/* @type {!Element} */(svgRootNew)); - } else { - var xyNew = Blockly.utils.getInjectionDivXY_(svgRootNew); - } - - // Scale the scroll (getSvgXY_ did not do this). - xyNew.x += - targetWorkspace.scrollX / targetWorkspace.scale - targetWorkspace.scrollX; - xyNew.y += - targetWorkspace.scrollY / targetWorkspace.scale - targetWorkspace.scrollY; - // If the flyout is collapsible and the workspace can't be scrolled. - if (targetWorkspace.toolbox_ && !targetWorkspace.scrollbar) { - xyNew.x += targetWorkspace.toolbox_.getWidth() / targetWorkspace.scale; - xyNew.y += targetWorkspace.toolbox_.getHeight() / targetWorkspace.scale; - } - - // Move the new block to where the old block is. - block.moveBy(xyOld.x - xyNew.x, xyOld.y - xyNew.y); - return block; -}; - -/** - * Return the deletion rectangle for this flyout in viewport coordinates. - * @return {goog.math.Rect} Rectangle in which to delete. - */ -Blockly.HorizontalFlyout.prototype.getClientRect = function() { - if (!this.svgGroup_) { - return null; - } - - var flyoutRect = this.svgGroup_.getBoundingClientRect(); - // BIG_NUM is offscreen padding so that blocks dragged beyond the shown flyout - // area are still deleted. Must be larger than the largest screen size, - // but be smaller than half Number.MAX_SAFE_INTEGER (not available on IE). - var BIG_NUM = 1000000000; - var y = flyoutRect.top; - var height = flyoutRect.height; - - if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_TOP) { - return new goog.math.Rect(-BIG_NUM, y - BIG_NUM, BIG_NUM * 2, - BIG_NUM + height); - } else if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_BOTTOM) { - return new goog.math.Rect(-BIG_NUM, y, BIG_NUM * 2, - BIG_NUM + height); - } - // TODO: Else throw error (should never happen). -}; - -/** - * Compute height of flyout. Position button under each block. - * For RTL: Lay out the blocks right-aligned. - * @param {!Array} blocks The blocks to reflow. - * @private - */ -Blockly.HorizontalFlyout.prototype.reflowInternal_ = function(blocks) { - this.workspace_.scale = this.targetWorkspace_.scale; - var flyoutHeight = 0; - for (var i = 0, block; block = blocks[i]; i++) { - flyoutHeight = Math.max(flyoutHeight, block.getHeightWidth().height); - } - flyoutHeight += this.MARGIN * 1.5; - flyoutHeight *= this.workspace_.scale; - flyoutHeight += Blockly.Scrollbar.scrollbarThickness; - - if (this.height_ != flyoutHeight) { - for (var i = 0, block; block = blocks[i]; i++) { - if (block.flyoutRect_) { - this.moveRectToBlock_(block.flyoutRect_, block); - } - } - // Record the height for .getMetrics_ and .position. - this.height_ = flyoutHeight; - // Call this since it is possible the trash and zoom buttons need - // to move. e.g. on a bottom positioned flyout when zoom is clicked. - this.targetWorkspace_.resize(); - } -}; diff --git a/core/flyout_horizontal.ts b/core/flyout_horizontal.ts new file mode 100644 index 00000000000..47b7ab06abd --- /dev/null +++ b/core/flyout_horizontal.ts @@ -0,0 +1,375 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Horizontal flyout tray containing blocks which may be created. + * + * @class + */ +// Former goog.module ID: Blockly.HorizontalFlyout + +import * as browserEvents from './browser_events.js'; +import * as dropDownDiv from './dropdowndiv.js'; +import {Flyout} from './flyout_base.js'; +import type {FlyoutItem} from './flyout_item.js'; +import type {Options} from './options.js'; +import * as registry from './registry.js'; +import {Scrollbar} from './scrollbar.js'; +import type {Coordinate} from './utils/coordinate.js'; +import {Rect} from './utils/rect.js'; +import * as toolbox from './utils/toolbox.js'; +import * as WidgetDiv from './widgetdiv.js'; + +/** + * Class for a flyout. + */ +export class HorizontalFlyout extends Flyout { + override horizontalLayout = true; + + /** @param workspaceOptions Dictionary of options for the workspace. */ + constructor(workspaceOptions: Options) { + super(workspaceOptions); + } + + /** + * Sets the translation of the flyout to match the scrollbars. + * + * @param xyRatio Contains a y property which is a float between 0 and 1 + * specifying the degree of scrolling and a similar x property. + */ + protected override setMetrics_(xyRatio: {x: number; y: number}) { + if (!this.isVisible()) { + return; + } + + const metricsManager = this.workspace_.getMetricsManager(); + const scrollMetrics = metricsManager.getScrollMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + const absoluteMetrics = metricsManager.getAbsoluteMetrics(); + + if (typeof xyRatio.x === 'number') { + this.workspace_.scrollX = -( + scrollMetrics.left + + (scrollMetrics.width - viewMetrics.width) * xyRatio.x + ); + } + + this.workspace_.translate( + this.workspace_.scrollX + absoluteMetrics.left, + this.workspace_.scrollY + absoluteMetrics.top, + ); + } + + /** + * Calculates the x coordinate for the flyout position. + * + * @returns X coordinate. + */ + override getX(): number { + // X is always 0 since this is a horizontal flyout. + return 0; + } + + /** + * Calculates the y coordinate for the flyout position. + * + * @returns Y coordinate. + */ + override getY(): number { + if (!this.isVisible()) { + return 0; + } + const metricsManager = this.targetWorkspace!.getMetricsManager(); + const absoluteMetrics = metricsManager.getAbsoluteMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + const toolboxMetrics = metricsManager.getToolboxMetrics(); + + let y = 0; + const atTop = this.toolboxPosition_ === toolbox.Position.TOP; + // If this flyout is not the trashcan flyout (e.g. toolbox or mutator). + // Trashcan flyout is opposite the main flyout. + if (this.targetWorkspace!.toolboxPosition === this.toolboxPosition_) { + // If there is a category toolbox. + // Simple (flyout-only) toolbox. + if (this.targetWorkspace!.getToolbox()) { + if (atTop) { + y = toolboxMetrics.height; + } else { + y = viewMetrics.height - this.getHeight(); + } + } else { + if (atTop) { + y = 0; + } else { + // The simple flyout does not cover the workspace. + y = viewMetrics.height; + } + } + } else { + if (atTop) { + y = 0; + } else { + // Because the anchor point of the flyout is on the top, but we want + // to align the bottom edge of the flyout with the bottom edge of the + // blocklyDiv, we calculate the full height of the div minus the height + // of the flyout. + y = viewMetrics.height + absoluteMetrics.top - this.getHeight(); + } + } + + return y; + } + + /** Move the flyout to the edge of the workspace. */ + override position() { + if (!this.isVisible() || !this.targetWorkspace!.isVisible()) { + return; + } + const metricsManager = this.targetWorkspace!.getMetricsManager(); + const targetWorkspaceViewMetrics = metricsManager.getViewMetrics(); + this.width_ = targetWorkspaceViewMetrics.width; + + const edgeWidth = targetWorkspaceViewMetrics.width - 2 * this.CORNER_RADIUS; + const edgeHeight = this.getHeight() - this.CORNER_RADIUS; + this.setBackgroundPath(edgeWidth, edgeHeight); + + const x = this.getX(); + const y = this.getY(); + + this.positionAt_(this.getWidth(), this.getHeight(), x, y); + } + + /** + * Create and set the path for the visible boundaries of the flyout. + * + * @param width The width of the flyout, not including the rounded corners. + * @param height The height of the flyout, not including rounded corners. + */ + private setBackgroundPath(width: number, height: number) { + const atTop = this.toolboxPosition_ === toolbox.Position.TOP; + // Start at top left. + const path: (string | number)[] = [ + 'M 0,' + (atTop ? 0 : this.CORNER_RADIUS), + ]; + + if (atTop) { + // Top. + path.push('h', width + 2 * this.CORNER_RADIUS); + // Right. + path.push('v', height); + // Bottom. + path.push( + 'a', + this.CORNER_RADIUS, + this.CORNER_RADIUS, + 0, + 0, + 1, + -this.CORNER_RADIUS, + this.CORNER_RADIUS, + ); + path.push('h', -width); + // Left. + path.push( + 'a', + this.CORNER_RADIUS, + this.CORNER_RADIUS, + 0, + 0, + 1, + -this.CORNER_RADIUS, + -this.CORNER_RADIUS, + ); + path.push('z'); + } else { + // Top. + path.push( + 'a', + this.CORNER_RADIUS, + this.CORNER_RADIUS, + 0, + 0, + 1, + this.CORNER_RADIUS, + -this.CORNER_RADIUS, + ); + path.push('h', width); + // Right. + path.push( + 'a', + this.CORNER_RADIUS, + this.CORNER_RADIUS, + 0, + 0, + 1, + this.CORNER_RADIUS, + this.CORNER_RADIUS, + ); + path.push('v', height); + // Bottom. + path.push('h', -width - 2 * this.CORNER_RADIUS); + // Left. + path.push('z'); + } + this.svgBackground_!.setAttribute('d', path.join(' ')); + } + + /** Scroll the flyout to the top. */ + override scrollToStart() { + this.workspace_.scrollbar?.setX(this.RTL ? Infinity : 0); + } + + /** + * Scroll the flyout. + * + * @param e Mouse wheel scroll event. + */ + protected override wheel_(e: WheelEvent) { + const scrollDelta = browserEvents.getScrollDeltaPixels(e); + const delta = scrollDelta.x || scrollDelta.y; + + if (delta) { + const metricsManager = this.workspace_.getMetricsManager(); + const scrollMetrics = metricsManager.getScrollMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + + const pos = viewMetrics.left - scrollMetrics.left + delta; + this.workspace_.scrollbar?.setX(pos); + // When the flyout moves from a wheel event, hide WidgetDiv and + // dropDownDiv. + WidgetDiv.hideIfOwnerIsInWorkspace(this.workspace_); + dropDownDiv.hideWithoutAnimation(); + } + // Don't scroll the page. + e.preventDefault(); + // Don't propagate mousewheel event (zooming). + e.stopPropagation(); + } + + /** + * Lay out the blocks in the flyout. + * + * @param contents The flyout items to lay out. + */ + protected override layout_(contents: FlyoutItem[]) { + this.workspace_.scale = this.targetWorkspace!.scale; + const margin = this.MARGIN; + let cursorX = margin + this.tabWidth_; + const cursorY = margin; + if (this.RTL) { + contents = contents.reverse(); + } + + for (const item of contents) { + const rect = item.getElement().getBoundingRectangle(); + const moveX = this.RTL ? cursorX + rect.getWidth() : cursorX; + item.getElement().moveBy(moveX, cursorY); + cursorX += item.getElement().getBoundingRectangle().getWidth(); + } + } + + /** + * Determine if a drag delta is toward the workspace, based on the position + * and orientation of the flyout. This is used in determineDragIntention_ to + * determine if a new block should be created or if the flyout should scroll. + * + * @param currentDragDeltaXY How far the pointer has moved from the position + * at mouse down, in pixel units. + * @returns True if the drag is toward the workspace. + */ + override isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean { + const dx = currentDragDeltaXY.x; + const dy = currentDragDeltaXY.y; + // Direction goes from -180 to 180, with 0 toward the right and 90 on top. + const dragDirection = (Math.atan2(dy, dx) / Math.PI) * 180; + + const range = this.dragAngleRange_; + // Check for up or down dragging. + if ( + (dragDirection < 90 + range && dragDirection > 90 - range) || + (dragDirection > -90 - range && dragDirection < -90 + range) + ) { + return true; + } + return false; + } + + /** + * Returns the bounding rectangle of the drag target area in pixel units + * relative to viewport. + * + * @returns The component's bounding box. Null if drag target area should be + * ignored. + */ + override getClientRect(): Rect | null { + if (!this.svgGroup_ || this.autoClose || !this.isVisible()) { + // The bounding rectangle won't compute correctly if the flyout is closed + // and auto-close flyouts aren't valid drag targets (or delete areas). + return null; + } + + const flyoutRect = this.svgGroup_.getBoundingClientRect(); + // BIG_NUM is offscreen padding so that blocks dragged beyond the shown + // flyout area are still deleted. Must be larger than the largest screen + // size, but be smaller than half Number.MAX_SAFE_INTEGER (not available on + // IE). + const BIG_NUM = 1000000000; + const top = flyoutRect.top; + + if (this.toolboxPosition_ === toolbox.Position.TOP) { + const height = flyoutRect.height; + return new Rect(-BIG_NUM, top + height, -BIG_NUM, BIG_NUM); + } else { + // Bottom. + return new Rect(top, BIG_NUM, -BIG_NUM, BIG_NUM); + } + } + + /** + * Compute height of flyout. toolbox.Position mat under each block. + * For RTL: Lay out the blocks right-aligned. + */ + protected override reflowInternal_() { + this.workspace_.scale = this.getFlyoutScale(); + let flyoutHeight = this.getContents().reduce((maxHeightSoFar, item) => { + return Math.max( + maxHeightSoFar, + item.getElement().getBoundingRectangle().getHeight(), + ); + }, 0); + flyoutHeight += this.MARGIN * 1.5; + flyoutHeight *= this.workspace_.scale; + flyoutHeight += Scrollbar.scrollbarThickness; + + if (this.getHeight() !== flyoutHeight) { + // TODO(#7689): Remove this. + // Workspace with no scrollbars where this is permanently open on the top. + // If scrollbars exist they properly update the metrics. + if ( + !this.targetWorkspace.scrollbar && + !this.autoClose && + this.targetWorkspace.getFlyout() === this && + this.toolboxPosition_ === toolbox.Position.TOP + ) { + this.targetWorkspace.translate( + this.targetWorkspace.scrollX, + this.targetWorkspace.scrollY + flyoutHeight, + ); + } + + this.height_ = flyoutHeight; + this.position(); + this.targetWorkspace.resizeContents(); + this.targetWorkspace.recordDragTargets(); + } + } +} + +registry.register( + registry.Type.FLYOUTS_HORIZONTAL_TOOLBOX, + registry.DEFAULT, + HorizontalFlyout, +); diff --git a/core/flyout_item.ts b/core/flyout_item.ts new file mode 100644 index 00000000000..26be0ed12e2 --- /dev/null +++ b/core/flyout_item.ts @@ -0,0 +1,33 @@ +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; + +/** + * Representation of an item displayed in a flyout. + */ +export class FlyoutItem { + /** + * Creates a new FlyoutItem. + * + * @param element The element that will be displayed in the flyout. + * @param type The type of element. Should correspond to the type of the + * flyout inflater that created this object. + */ + constructor( + private element: IBoundedElement & IFocusableNode, + private type: string, + ) {} + + /** + * Returns the element displayed in the flyout. + */ + getElement() { + return this.element; + } + + /** + * Returns the type of flyout element this item represents. + */ + getType() { + return this.type; + } +} diff --git a/core/flyout_metrics_manager.ts b/core/flyout_metrics_manager.ts new file mode 100644 index 00000000000..00f675caafa --- /dev/null +++ b/core/flyout_metrics_manager.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Calculates and reports flyout workspace metrics. + * + * @class + */ +// Former goog.module ID: Blockly.FlyoutMetricsManager + +import type {IFlyout} from './interfaces/i_flyout.js'; +import {ContainerRegion, MetricsManager} from './metrics_manager.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +/** + * Calculates metrics for a flyout's workspace. + * The metrics are mainly used to size scrollbars for the flyout. + */ +export class FlyoutMetricsManager extends MetricsManager { + /** The flyout that owns the workspace to calculate metrics for. */ + protected flyout_: IFlyout; + + /** + * @param workspace The flyout's workspace. + * @param flyout The flyout. + */ + constructor(workspace: WorkspaceSvg, flyout: IFlyout) { + super(workspace); + this.flyout_ = flyout; + } + + /** + * Gets the bounding box of the blocks on the flyout's workspace. + * This is in workspace coordinates. + * + * @returns The bounding box of the blocks on the workspace. + */ + private getBoundingBox(): + | SVGRect + | {height: number; y: number; width: number; x: number} { + let blockBoundingBox; + try { + blockBoundingBox = this.workspace_.getCanvas().getBBox(); + } catch { + // Firefox has trouble with hidden elements (Bug 528969). + // 2021 Update: It looks like this was fixed around Firefox 77 released in + // 2020. + blockBoundingBox = {height: 0, y: 0, width: 0, x: 0}; + } + return blockBoundingBox; + } + + override getContentMetrics(opt_getWorkspaceCoordinates?: boolean) { + // The bounding box is in workspace coordinates. + const blockBoundingBox = this.getBoundingBox(); + const scale = opt_getWorkspaceCoordinates ? 1 : this.workspace_.scale; + + return { + height: blockBoundingBox.height * scale, + width: blockBoundingBox.width * scale, + top: blockBoundingBox.y * scale, + left: blockBoundingBox.x * scale, + }; + } + + override getScrollMetrics( + opt_getWorkspaceCoordinates?: boolean, + opt_viewMetrics?: ContainerRegion, + opt_contentMetrics?: ContainerRegion, + ) { + const contentMetrics = opt_contentMetrics || this.getContentMetrics(); + const margin = this.flyout_.MARGIN * this.workspace_.scale; + const scale = opt_getWorkspaceCoordinates ? this.workspace_.scale : 1; + + // The left padding isn't just the margin. Some blocks are also offset by + // tabWidth so that value and statement blocks line up. + // The contentMetrics.left value is equivalent to the variable left padding. + const leftPadding = contentMetrics.left; + + return { + height: (contentMetrics.height + 2 * margin) / scale, + width: (contentMetrics.width + leftPadding + margin) / scale, + top: 0, + left: 0, + }; + } +} diff --git a/core/flyout_navigator.ts b/core/flyout_navigator.ts new file mode 100644 index 00000000000..a102ce81765 --- /dev/null +++ b/core/flyout_navigator.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFlyout} from './interfaces/i_flyout.js'; +import {FlyoutButtonNavigationPolicy} from './keyboard_nav/flyout_button_navigation_policy.js'; +import {FlyoutNavigationPolicy} from './keyboard_nav/flyout_navigation_policy.js'; +import {FlyoutSeparatorNavigationPolicy} from './keyboard_nav/flyout_separator_navigation_policy.js'; +import {Navigator} from './navigator.js'; + +export class FlyoutNavigator extends Navigator { + constructor(flyout: IFlyout) { + super(); + this.rules.push( + new FlyoutButtonNavigationPolicy(), + new FlyoutSeparatorNavigationPolicy(), + ); + this.rules = this.rules.map( + (rule) => new FlyoutNavigationPolicy(rule, flyout), + ); + } +} diff --git a/core/flyout_separator.ts b/core/flyout_separator.ts new file mode 100644 index 00000000000..e9ace428ec9 --- /dev/null +++ b/core/flyout_separator.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import {Rect} from './utils/rect.js'; + +/** + * Representation of a gap between elements in a flyout. + */ +export class FlyoutSeparator implements IBoundedElement, IFocusableNode { + private x = 0; + private y = 0; + + /** + * Creates a new separator. + * + * @param gap The amount of space this separator should occupy. + * @param axis The axis along which this separator occupies space. + */ + constructor( + private gap: number, + private axis: SeparatorAxis, + ) {} + + /** + * Returns the bounding box of this separator. + * + * @returns The bounding box of this separator. + */ + getBoundingRectangle(): Rect { + switch (this.axis) { + case SeparatorAxis.X: + return new Rect(this.y, this.y, this.x, this.x + this.gap); + case SeparatorAxis.Y: + return new Rect(this.y, this.y + this.gap, this.x, this.x); + } + } + + /** + * Repositions this separator. + * + * @param dx The distance to move this separator on the X axis. + * @param dy The distance to move this separator on the Y axis. + * @param _reason The reason this move was initiated. + */ + moveBy(dx: number, dy: number, _reason?: string[]) { + this.x += dx; + this.y += dy; + } + + /** + * Returns false to prevent this separator from being navigated to by the + * keyboard. + * + * @returns False. + */ + isNavigable() { + return false; + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + throw new Error('Cannot be focused'); + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + throw new Error('Cannot be focused'); + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return false; + } +} + +/** + * Representation of an axis along which a separator occupies space. + */ +export const enum SeparatorAxis { + X = 'x', + Y = 'y', +} diff --git a/core/flyout_vertical.js b/core/flyout_vertical.js deleted file mode 100644 index f1595a0a33a..00000000000 --- a/core/flyout_vertical.js +++ /dev/null @@ -1,452 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Layout code for a vertical variant of the flyout. - * @author fenichel@google.com (Rachel Fenichel) - */ -'use strict'; - -goog.provide('Blockly.VerticalFlyout'); - -goog.require('Blockly.Block'); -goog.require('Blockly.Events'); -goog.require('Blockly.Flyout'); -goog.require('Blockly.FlyoutButton'); -goog.require('Blockly.utils'); -goog.require('Blockly.WorkspaceSvg'); -goog.require('goog.dom'); -goog.require('goog.events'); -goog.require('goog.math.Rect'); -goog.require('goog.userAgent'); - - -/** - * Class for a flyout. - * @param {!Object} workspaceOptions Dictionary of options for the workspace. - * @extends {Blockly.Flyout} - * @constructor - */ -Blockly.VerticalFlyout = function(workspaceOptions) { - workspaceOptions.getMetrics = this.getMetrics_.bind(this); - workspaceOptions.setMetrics = this.setMetrics_.bind(this); - - Blockly.VerticalFlyout.superClass_.constructor.call(this, workspaceOptions); - /** - * Flyout should be laid out vertically. - * @type {boolean} - * @private - */ - this.horizontalLayout_ = false; -}; -goog.inherits(Blockly.VerticalFlyout, Blockly.Flyout); - -/** - * Return an object with all the metrics required to size scrollbars for the - * flyout. The following properties are computed: - * .viewHeight: Height of the visible rectangle, - * .viewWidth: Width of the visible rectangle, - * .contentHeight: Height of the contents, - * .contentWidth: Width of the contents, - * .viewTop: Offset of top edge of visible rectangle from parent, - * .contentTop: Offset of the top-most content from the y=0 coordinate, - * .absoluteTop: Top-edge of view. - * .viewLeft: Offset of the left edge of visible rectangle from parent, - * .contentLeft: Offset of the left-most content from the x=0 coordinate, - * .absoluteLeft: Left-edge of view. - * @return {Object} Contains size and position metrics of the flyout. - * @private - */ -Blockly.VerticalFlyout.prototype.getMetrics_ = function() { - if (!this.isVisible()) { - // Flyout is hidden. - return null; - } - - try { - var optionBox = this.workspace_.getCanvas().getBBox(); - } catch (e) { - // Firefox has trouble with hidden elements (Bug 528969). - var optionBox = {height: 0, y: 0, width: 0, x: 0}; - } - - // Padding for the end of the scrollbar. - var absoluteTop = this.SCROLLBAR_PADDING; - var absoluteLeft = 0; - - var viewHeight = this.height_ - 2 * this.SCROLLBAR_PADDING; - var viewWidth = this.width_; - if (!this.RTL) { - viewWidth -= this.SCROLLBAR_PADDING; - } - - var metrics = { - viewHeight: viewHeight, - viewWidth: viewWidth, - contentHeight: optionBox.height * this.workspace_.scale + 2 * this.MARGIN, - contentWidth: optionBox.width * this.workspace_.scale + 2 * this.MARGIN, - viewTop: -this.workspace_.scrollY + optionBox.y, - viewLeft: -this.workspace_.scrollX, - contentTop: optionBox.y, - contentLeft: optionBox.x, - absoluteTop: absoluteTop, - absoluteLeft: absoluteLeft - }; - return metrics; -}; - -/** - * Sets the translation of the flyout to match the scrollbars. - * @param {!Object} xyRatio Contains a y property which is a float - * between 0 and 1 specifying the degree of scrolling and a - * similar x property. - * @private - */ -Blockly.VerticalFlyout.prototype.setMetrics_ = function(xyRatio) { - var metrics = this.getMetrics_(); - // This is a fix to an apparent race condition. - if (!metrics) { - return; - } - if (goog.isNumber(xyRatio.y)) { - this.workspace_.scrollY = -metrics.contentHeight * xyRatio.y; - } - this.workspace_.translate(this.workspace_.scrollX + metrics.absoluteLeft, - this.workspace_.scrollY + metrics.absoluteTop); -}; - -/** - * Move the flyout to the edge of the workspace. - */ -Blockly.VerticalFlyout.prototype.position = function() { - if (!this.isVisible()) { - return; - } - var targetWorkspaceMetrics = this.targetWorkspace_.getMetrics(); - if (!targetWorkspaceMetrics) { - // Hidden components will return null. - return; - } - // Record the height for Blockly.Flyout.getMetrics_ - this.height_ = targetWorkspaceMetrics.viewHeight; - - var edgeWidth = this.width_ - this.CORNER_RADIUS; - var edgeHeight = targetWorkspaceMetrics.viewHeight - 2 * this.CORNER_RADIUS; - this.setBackgroundPath_(edgeWidth, edgeHeight); - - var y = targetWorkspaceMetrics.absoluteTop; - var x = targetWorkspaceMetrics.absoluteLeft; - if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_RIGHT) { - x += (targetWorkspaceMetrics.viewWidth - this.width_); - } - this.positionAt_(this.width_, this.height_, x, y); -}; - -/** - * Create and set the path for the visible boundaries of the flyout. - * @param {number} width The width of the flyout, not including the - * rounded corners. - * @param {number} height The height of the flyout, not including - * rounded corners. - * @private - */ -Blockly.VerticalFlyout.prototype.setBackgroundPath_ = function(width, height) { - var atRight = this.toolboxPosition_ == Blockly.TOOLBOX_AT_RIGHT; - var totalWidth = width + this.CORNER_RADIUS; - - // Decide whether to start on the left or right. - var path = ['M ' + (atRight ? totalWidth : 0) + ',0']; - // Top. - path.push('h', atRight ? -width : width); - // Rounded corner. - path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, - atRight ? 0 : 1, - atRight ? -this.CORNER_RADIUS : this.CORNER_RADIUS, - this.CORNER_RADIUS); - // Side closest to workspace. - path.push('v', Math.max(0, height)); - // Rounded corner. - path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, - atRight ? 0 : 1, - atRight ? this.CORNER_RADIUS : -this.CORNER_RADIUS, - this.CORNER_RADIUS); - // Bottom. - path.push('h', atRight ? width : -width); - path.push('z'); - this.svgBackground_.setAttribute('d', path.join(' ')); -}; - -/** - * Scroll the flyout to the top. - */ -Blockly.VerticalFlyout.prototype.scrollToStart = function() { - this.scrollbar_.set(0); -}; - -/** - * Scroll the flyout. - * @param {!Event} e Mouse wheel scroll event. - * @private - */ -Blockly.VerticalFlyout.prototype.wheel_ = function(e) { - var delta = e.deltaY; - - if (delta) { - if (goog.userAgent.GECKO) { - // Firefox's deltas are a tenth that of Chrome/Safari. - delta *= 10; - } - var metrics = this.getMetrics_(); - var pos = metrics.viewTop + delta; - var limit = metrics.contentHeight - metrics.viewHeight; - pos = Math.min(pos, limit); - pos = Math.max(pos, 0); - this.scrollbar_.set(pos); - // When the flyout moves from a wheel event, hide WidgetDiv. - Blockly.WidgetDiv.hide(); - } - - // Don't scroll the page. - e.preventDefault(); - // Don't propagate mousewheel event (zooming). - e.stopPropagation(); -}; - -/** - * Lay out the blocks in the flyout. - * @param {!Array.} contents The blocks and buttons to lay out. - * @param {!Array.} gaps The visible gaps between blocks. - * @private - */ -Blockly.VerticalFlyout.prototype.layout_ = function(contents, gaps) { - this.workspace_.scale = this.targetWorkspace_.scale; - var margin = this.MARGIN; - var cursorX = this.RTL ? margin : margin + Blockly.BlockSvg.TAB_WIDTH; - var cursorY = margin; - - for (var i = 0, item; item = contents[i]; i++) { - if (item.type == 'block') { - var block = item.block; - var allBlocks = block.getDescendants(); - for (var j = 0, child; child = allBlocks[j]; j++) { - // Mark blocks as being inside a flyout. This is used to detect and - // prevent the closure of the flyout if the user right-clicks on such a - // block. - child.isInFlyout = true; - } - block.render(); - var root = block.getSvgRoot(); - var blockHW = block.getHeightWidth(); - block.moveBy(cursorX, cursorY); - - var rect = this.createRect_(block, - this.RTL ? cursorX - blockHW.width : cursorX, cursorY, blockHW, i); - - this.addBlockListeners_(root, block, rect); - - cursorY += blockHW.height + gaps[i]; - } else if (item.type == 'button') { - this.initFlyoutButton_(item.button, cursorX, cursorY); - cursorY += item.button.height + gaps[i]; - } - } -}; - -/** - * Determine if a drag delta is toward the workspace, based on the position - * and orientation of the flyout. This is used in determineDragIntention_ to - * determine if a new block should be created or if the flyout should scroll. - * @param {!goog.math.Coordinate} currentDragDeltaXY How far the pointer has - * moved from the position at mouse down, in pixel units. - * @return {boolean} true if the drag is toward the workspace. - * @package - */ -Blockly.VerticalFlyout.prototype.isDragTowardWorkspace = function( - currentDragDeltaXY) { - var dx = currentDragDeltaXY.x; - var dy = currentDragDeltaXY.y; - // Direction goes from -180 to 180, with 0 toward the right and 90 on top. - var dragDirection = Math.atan2(dy, dx) / Math.PI * 180; - - var range = this.dragAngleRange_; - if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_LEFT) { - // Vertical at left. - if (dragDirection < range && dragDirection > -range) { - return true; - } - } else { - // Vertical at right. - if (dragDirection < -180 + range || dragDirection > 180 - range) { - return true; - } - } - return false; -}; - -/** - * Copy a block from the flyout to the workspace and position it correctly. - * @param {!Blockly.Block} originBlock The flyout block to copy. - * @return {!Blockly.Block} The new block in the main workspace. - * @private - */ -Blockly.VerticalFlyout.prototype.placeNewBlock_ = function(originBlock) { - var targetWorkspace = this.targetWorkspace_; - var svgRootOld = originBlock.getSvgRoot(); - if (!svgRootOld) { - throw 'originBlock is not rendered.'; - } - // Figure out where the original block is on the screen, relative to the upper - // left corner of the main workspace. - if (targetWorkspace.isMutator) { - var xyOld = this.workspace_.getSvgXY(/** @type {!Element} */ (svgRootOld)); - } else { - var xyOld = Blockly.utils.getInjectionDivXY_(svgRootOld); - } - - // Take into account that the flyout might have been scrolled horizontally - // (separately from the main workspace). - // Generally a no-op in vertical mode but likely to happen in horizontal - // mode. - var scrollX = this.workspace_.scrollX; - var scale = this.workspace_.scale; - xyOld.x += scrollX / scale - scrollX; - - var targetMetrics = targetWorkspace.getMetrics(); - - // If the flyout is on the right side, (0, 0) in the flyout is offset to - // the right of (0, 0) in the main workspace. Add an offset to take that - // into account. - var scrollX = 0; - if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_RIGHT) { - scrollX = targetMetrics.viewWidth - this.width_; - // Scale the scroll (getSvgXY_ did not do this). - xyOld.x += scrollX / scale - scrollX; - } - - // Take into account that the flyout might have been scrolled vertically - // (separately from the main workspace). - // Generally a no-op in horizontal mode but likely to happen in vertical - // mode. - var scrollY = this.workspace_.scrollY; - scale = this.workspace_.scale; - xyOld.y += scrollY / scale - scrollY; - - // Create the new block by cloning the block in the flyout (via XML). - var xml = Blockly.Xml.blockToDom(originBlock); - var block = Blockly.Xml.domToBlock(xml, targetWorkspace); - var svgRootNew = block.getSvgRoot(); - if (!svgRootNew) { - throw 'block is not rendered.'; - } - // Figure out where the new block got placed on the screen, relative to the - // upper left corner of the workspace. This may not be the same as the - // original block because the flyout's origin may not be the same as the - // main workspace's origin. - if (targetWorkspace.isMutator) { - var xyNew = targetWorkspace.getSvgXY(/* @type {!Element} */(svgRootNew)); - } else { - var xyNew = Blockly.utils.getInjectionDivXY_(svgRootNew); - } - - // Scale the scroll (getSvgXY_ did not do this). - xyNew.x += - targetWorkspace.scrollX / targetWorkspace.scale - targetWorkspace.scrollX; - xyNew.y += - targetWorkspace.scrollY / targetWorkspace.scale - targetWorkspace.scrollY; - - // If the flyout is collapsible and the workspace can't be scrolled. - if (targetWorkspace.toolbox_ && !targetWorkspace.scrollbar) { - xyNew.x += targetWorkspace.toolbox_.getWidth() / targetWorkspace.scale; - xyNew.y += targetWorkspace.toolbox_.getHeight() / targetWorkspace.scale; - } - - // Move the new block to where the old block is. - block.moveBy(xyOld.x - xyNew.x, xyOld.y - xyNew.y); - return block; -}; - -/** - * Return the deletion rectangle for this flyout in viewport coordinates. - * @return {goog.math.Rect} Rectangle in which to delete. - */ -Blockly.VerticalFlyout.prototype.getClientRect = function() { - if (!this.svgGroup_) { - return null; - } - - var flyoutRect = this.svgGroup_.getBoundingClientRect(); - // BIG_NUM is offscreen padding so that blocks dragged beyond the shown flyout - // area are still deleted. Must be larger than the largest screen size, - // but be smaller than half Number.MAX_SAFE_INTEGER (not available on IE). - var BIG_NUM = 1000000000; - var x = flyoutRect.left; - var width = flyoutRect.width; - - if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_LEFT) { - return new goog.math.Rect(x - BIG_NUM, -BIG_NUM, BIG_NUM + width, - BIG_NUM * 2); - } else { // Right - return new goog.math.Rect(x, -BIG_NUM, BIG_NUM + width, BIG_NUM * 2); - } -}; - -/** - * Compute width of flyout. Position button under each block. - * For RTL: Lay out the blocks right-aligned. - * @param {!Array} blocks The blocks to reflow. - * @private - */ -Blockly.VerticalFlyout.prototype.reflowInternal_ = function(blocks) { - this.workspace_.scale = this.targetWorkspace_.scale; - var flyoutWidth = 0; - for (var i = 0, block; block = blocks[i]; i++) { - var width = block.getHeightWidth().width; - if (block.outputConnection) { - width -= Blockly.BlockSvg.TAB_WIDTH; - } - flyoutWidth = Math.max(flyoutWidth, width); - } - for (var i = 0, button; button = this.buttons_[i]; i++) { - flyoutWidth = Math.max(flyoutWidth, button.width); - } - flyoutWidth += this.MARGIN * 1.5 + Blockly.BlockSvg.TAB_WIDTH; - flyoutWidth *= this.workspace_.scale; - flyoutWidth += Blockly.Scrollbar.scrollbarThickness; - - if (this.width_ != flyoutWidth) { - for (var i = 0, block; block = blocks[i]; i++) { - if (this.RTL) { - // With the flyoutWidth known, right-align the blocks. - var oldX = block.getRelativeToSurfaceXY().x; - var newX = flyoutWidth / this.workspace_.scale - this.MARGIN; - newX -= Blockly.BlockSvg.TAB_WIDTH; - block.moveBy(newX - oldX, 0); - } - if (block.flyoutRect_) { - this.moveRectToBlock_(block.flyoutRect_, block); - } - } - // Record the width for .getMetrics_ and .position. - this.width_ = flyoutWidth; - // Call this since it is possible the trash and zoom buttons need - // to move. e.g. on a bottom positioned flyout when zoom is clicked. - this.targetWorkspace_.resize(); - } -}; diff --git a/core/flyout_vertical.ts b/core/flyout_vertical.ts new file mode 100644 index 00000000000..968b7c02458 --- /dev/null +++ b/core/flyout_vertical.ts @@ -0,0 +1,354 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Layout code for a vertical variant of the flyout. + * + * @class + */ +// Former goog.module ID: Blockly.VerticalFlyout + +import * as browserEvents from './browser_events.js'; +import * as dropDownDiv from './dropdowndiv.js'; +import {Flyout} from './flyout_base.js'; +import type {FlyoutItem} from './flyout_item.js'; +import type {Options} from './options.js'; +import * as registry from './registry.js'; +import {Scrollbar} from './scrollbar.js'; +import type {Coordinate} from './utils/coordinate.js'; +import {Rect} from './utils/rect.js'; +import * as toolbox from './utils/toolbox.js'; +import * as WidgetDiv from './widgetdiv.js'; + +/** + * Class for a flyout. + */ +export class VerticalFlyout extends Flyout { + /** The name of the vertical flyout in the registry. */ + static registryName = 'verticalFlyout'; + + /** @param workspaceOptions Dictionary of options for the workspace. */ + constructor(workspaceOptions: Options) { + super(workspaceOptions); + } + + /** + * Sets the translation of the flyout to match the scrollbars. + * + * @param xyRatio Contains a y property which is a float between 0 and 1 + * specifying the degree of scrolling and a similar x property. + */ + protected override setMetrics_(xyRatio: {x: number; y: number}) { + if (!this.isVisible()) { + return; + } + const metricsManager = this.workspace_.getMetricsManager(); + const scrollMetrics = metricsManager.getScrollMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + const absoluteMetrics = metricsManager.getAbsoluteMetrics(); + + if (typeof xyRatio.y === 'number') { + this.workspace_.scrollY = -( + scrollMetrics.top + + (scrollMetrics.height - viewMetrics.height) * xyRatio.y + ); + } + this.workspace_.translate( + this.workspace_.scrollX + absoluteMetrics.left, + this.workspace_.scrollY + absoluteMetrics.top, + ); + } + + /** + * Calculates the x coordinate for the flyout position. + * + * @returns X coordinate. + */ + override getX(): number { + if (!this.isVisible()) { + return 0; + } + const metricsManager = this.targetWorkspace!.getMetricsManager(); + const absoluteMetrics = metricsManager.getAbsoluteMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + const toolboxMetrics = metricsManager.getToolboxMetrics(); + let x = 0; + + // If this flyout is not the trashcan flyout (e.g. toolbox or mutator). + // Trashcan flyout is opposite the main flyout. + if (this.targetWorkspace!.toolboxPosition === this.toolboxPosition_) { + // If there is a category toolbox. + // Simple (flyout-only) toolbox. + if (this.targetWorkspace!.getToolbox()) { + if (this.toolboxPosition_ === toolbox.Position.LEFT) { + x = toolboxMetrics.width; + } else { + x = viewMetrics.width - this.getWidth(); + } + } else { + if (this.toolboxPosition_ === toolbox.Position.LEFT) { + x = 0; + } else { + // The simple flyout does not cover the workspace. + x = viewMetrics.width; + } + } + } else { + if (this.toolboxPosition_ === toolbox.Position.LEFT) { + x = 0; + } else { + // Because the anchor point of the flyout is on the left, but we want + // to align the right edge of the flyout with the right edge of the + // blocklyDiv, we calculate the full width of the div minus the width + // of the flyout. + x = viewMetrics.width + absoluteMetrics.left - this.getWidth(); + } + } + + return x; + } + + /** + * Calculates the y coordinate for the flyout position. + * + * @returns Y coordinate. + */ + override getY(): number { + // Y is always 0 since this is a vertical flyout. + return 0; + } + + /** Move the flyout to the edge of the workspace. */ + override position() { + if (!this.isVisible() || !this.targetWorkspace!.isVisible()) { + return; + } + const metricsManager = this.targetWorkspace!.getMetricsManager(); + const targetWorkspaceViewMetrics = metricsManager.getViewMetrics(); + this.height_ = targetWorkspaceViewMetrics.height; + + const edgeWidth = this.getWidth() - this.CORNER_RADIUS; + const edgeHeight = + targetWorkspaceViewMetrics.height - 2 * this.CORNER_RADIUS; + this.setBackgroundPath(edgeWidth, edgeHeight); + + const x = this.getX(); + const y = this.getY(); + + this.positionAt_(this.getWidth(), this.getHeight(), x, y); + } + + /** + * Create and set the path for the visible boundaries of the flyout. + * + * @param width The width of the flyout, not including the rounded corners. + * @param height The height of the flyout, not including rounded corners. + */ + private setBackgroundPath(width: number, height: number) { + const atRight = this.toolboxPosition_ === toolbox.Position.RIGHT; + const totalWidth = width + this.CORNER_RADIUS; + + // Decide whether to start on the left or right. + const path: Array = [ + 'M ' + (atRight ? totalWidth : 0) + ',0', + ]; + // Top. + path.push('h', atRight ? -width : width); + // Rounded corner. + path.push( + 'a', + this.CORNER_RADIUS, + this.CORNER_RADIUS, + 0, + 0, + atRight ? 0 : 1, + atRight ? -this.CORNER_RADIUS : this.CORNER_RADIUS, + this.CORNER_RADIUS, + ); + // Side closest to workspace. + path.push('v', Math.max(0, height)); + // Rounded corner. + path.push( + 'a', + this.CORNER_RADIUS, + this.CORNER_RADIUS, + 0, + 0, + atRight ? 0 : 1, + atRight ? this.CORNER_RADIUS : -this.CORNER_RADIUS, + this.CORNER_RADIUS, + ); + // Bottom. + path.push('h', atRight ? width : -width); + path.push('z'); + this.svgBackground_!.setAttribute('d', path.join(' ')); + } + + /** Scroll the flyout to the top. */ + override scrollToStart() { + this.workspace_.scrollbar?.setY(0); + } + + /** + * Scroll the flyout. + * + * @param e Mouse wheel scroll event. + */ + protected override wheel_(e: WheelEvent) { + const scrollDelta = browserEvents.getScrollDeltaPixels(e); + + if (scrollDelta.y) { + const metricsManager = this.workspace_.getMetricsManager(); + const scrollMetrics = metricsManager.getScrollMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + const pos = viewMetrics.top - scrollMetrics.top + scrollDelta.y; + + this.workspace_.scrollbar?.setY(pos); + // When the flyout moves from a wheel event, hide WidgetDiv and + // dropDownDiv. + WidgetDiv.hideIfOwnerIsInWorkspace(this.workspace_); + dropDownDiv.hideWithoutAnimation(); + } + // Don't scroll the page. + e.preventDefault(); + // Don't propagate mousewheel event (zooming). + e.stopPropagation(); + } + + /** + * Lay out the blocks in the flyout. + * + * @param contents The flyout items to lay out. + */ + protected override layout_(contents: FlyoutItem[]) { + this.workspace_.scale = this.targetWorkspace!.scale; + const margin = this.MARGIN; + const cursorX = this.RTL ? margin : margin + this.tabWidth_; + let cursorY = margin; + + for (const item of contents) { + item.getElement().moveBy(cursorX, cursorY); + cursorY += item.getElement().getBoundingRectangle().getHeight(); + } + } + + /** + * Determine if a drag delta is toward the workspace, based on the position + * and orientation of the flyout. This is used in determineDragIntention_ to + * determine if a new block should be created or if the flyout should scroll. + * + * @param currentDragDeltaXY How far the pointer has moved from the position + * at mouse down, in pixel units. + * @returns True if the drag is toward the workspace. + */ + override isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean { + const dx = currentDragDeltaXY.x; + const dy = currentDragDeltaXY.y; + // Direction goes from -180 to 180, with 0 toward the right and 90 on top. + const dragDirection = (Math.atan2(dy, dx) / Math.PI) * 180; + + const range = this.dragAngleRange_; + // Check for left or right dragging. + if ( + (dragDirection < range && dragDirection > -range) || + dragDirection < -180 + range || + dragDirection > 180 - range + ) { + return true; + } + return false; + } + + /** + * Returns the bounding rectangle of the drag target area in pixel units + * relative to viewport. + * + * @returns The component's bounding box. Null if drag target area should be + * ignored. + */ + override getClientRect(): Rect | null { + if (!this.svgGroup_ || this.autoClose || !this.isVisible()) { + // The bounding rectangle won't compute correctly if the flyout is closed + // and auto-close flyouts aren't valid drag targets (or delete areas). + return null; + } + + const flyoutRect = this.svgGroup_.getBoundingClientRect(); + // BIG_NUM is offscreen padding so that blocks dragged beyond the shown + // flyout area are still deleted. Must be larger than the largest screen + // size, but be smaller than half Number.MAX_SAFE_INTEGER (not available on + // IE). + const BIG_NUM = 1000000000; + const left = flyoutRect.left; + + if (this.toolboxPosition_ === toolbox.Position.LEFT) { + const width = flyoutRect.width; + return new Rect(-BIG_NUM, BIG_NUM, -BIG_NUM, left + width); + } else { + // Right + return new Rect(-BIG_NUM, BIG_NUM, left, BIG_NUM); + } + } + + /** + * Compute width of flyout. + * For RTL: Lay out the blocks and buttons to be right-aligned. + */ + protected override reflowInternal_() { + this.workspace_.scale = this.getFlyoutScale(); + let flyoutWidth = this.getContents().reduce((maxWidthSoFar, item) => { + return Math.max( + maxWidthSoFar, + item.getElement().getBoundingRectangle().getWidth(), + ); + }, 0); + flyoutWidth += this.MARGIN * 1.5 + this.tabWidth_; + flyoutWidth *= this.workspace_.scale; + flyoutWidth += Scrollbar.scrollbarThickness; + + if (this.getWidth() !== flyoutWidth) { + if (this.RTL) { + // With the flyoutWidth known, right-align the flyout contents. + for (const item of this.getContents()) { + const oldX = item.getElement().getBoundingRectangle().left; + const newX = + flyoutWidth / this.workspace_.scale - + item.getElement().getBoundingRectangle().getWidth() - + this.MARGIN - + this.tabWidth_; + item.getElement().moveBy(newX - oldX, 0); + } + } + + // TODO(#7689): Remove this. + // Workspace with no scrollbars where this is permanently + // open on the left. + // If scrollbars exist they properly update the metrics. + if ( + !this.targetWorkspace.scrollbar && + !this.autoClose && + this.targetWorkspace.getFlyout() === this && + this.toolboxPosition_ === toolbox.Position.LEFT + ) { + this.targetWorkspace.translate( + this.targetWorkspace.scrollX + flyoutWidth, + this.targetWorkspace.scrollY, + ); + } + + this.width_ = flyoutWidth; + this.position(); + this.targetWorkspace.resizeContents(); + this.targetWorkspace.recordDragTargets(); + } + } +} + +registry.register( + registry.Type.FLYOUTS_VERTICAL_TOOLBOX, + registry.DEFAULT, + VerticalFlyout, +); diff --git a/core/focus_manager.ts b/core/focus_manager.ts new file mode 100644 index 00000000000..47e4324540d --- /dev/null +++ b/core/focus_manager.ts @@ -0,0 +1,675 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import * as dom from './utils/dom.js'; +import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; + +/** + * Type declaration for returning focus to FocusManager upon completing an + * ephemeral UI flow (such as a dialog). + * + * See FocusManager.takeEphemeralFocus for more details. + */ +export type ReturnEphemeralFocus = () => void; + +/** + * Represents an IFocusableTree that has been registered for focus management in + * FocusManager. + */ +class TreeRegistration { + /** + * Constructs a new TreeRegistration. + * + * @param tree The tree being registered. + * @param rootShouldBeAutoTabbable Whether the tree should have automatic + * top-level tab management. + */ + constructor( + readonly tree: IFocusableTree, + readonly rootShouldBeAutoTabbable: boolean, + ) {} +} + +/** + * A per-page singleton that manages Blockly focus across one or more + * IFocusableTrees, and bidirectionally synchronizes this focus with the DOM. + * + * Callers that wish to explicitly change input focus for select Blockly + * components on the page should use the focus functions in this manager. + * + * The manager is responsible for handling focus events from the DOM (which may + * may arise from users clicking on page elements) and ensuring that + * corresponding IFocusableNodes are clearly marked as actively/passively + * highlighted in the same way that this would be represented with calls to + * focusNode(). + */ +export class FocusManager { + /** + * The CSS class assigned to IFocusableNode elements that presently have + * active DOM and Blockly focus. + * + * This should never be used directly. Instead, rely on FocusManager to ensure + * nodes have active focus (either automatically through DOM focus or manually + * through the various focus* methods provided by this class). + * + * It's recommended to not query using this class name, either. Instead, use + * FocusableTreeTraverser or IFocusableTree's methods to find a specific node. + */ + static readonly ACTIVE_FOCUS_NODE_CSS_CLASS_NAME = 'blocklyActiveFocus'; + + /** + * The CSS class assigned to IFocusableNode elements that presently have + * passive focus (that is, they were the most recent node in their relative + * tree to have active focus--see ACTIVE_FOCUS_NODE_CSS_CLASS_NAME--and will + * receive active focus again if their surrounding tree is requested to become + * focused, i.e. using focusTree below). + * + * See ACTIVE_FOCUS_NODE_CSS_CLASS_NAME for caveats and limitations around + * using this constant directly (generally it never should need to be used). + */ + static readonly PASSIVE_FOCUS_NODE_CSS_CLASS_NAME = 'blocklyPassiveFocus'; + + private focusedNode: IFocusableNode | null = null; + private previouslyFocusedNode: IFocusableNode | null = null; + private registeredTrees: Array = []; + + private currentlyHoldsEphemeralFocus: boolean = false; + private lockFocusStateChanges: boolean = false; + private recentlyLostAllFocus: boolean = false; + private isUpdatingFocusedNode: boolean = false; + + constructor( + addGlobalEventListener: (type: string, listener: EventListener) => void, + ) { + // Note that 'element' here is the element *gaining* focus. + const maybeFocus = (element: Element | EventTarget | null) => { + // Skip processing the event if the focused node is currently updating. + if (this.isUpdatingFocusedNode) return; + + this.recentlyLostAllFocus = !element; + let newNode: IFocusableNode | null | undefined = null; + if (element instanceof HTMLElement || element instanceof SVGElement) { + // If the target losing or gaining focus maps to any tree, then it + // should be updated. Per the contract of findFocusableNodeFor only one + // tree should claim the element, so the search can be exited early. + for (const reg of this.registeredTrees) { + const tree = reg.tree; + newNode = FocusableTreeTraverser.findFocusableNodeFor(element, tree); + if (newNode) break; + } + } + + if (newNode && newNode.canBeFocused()) { + const newTree = newNode.getFocusableTree(); + const oldTree = this.focusedNode?.getFocusableTree(); + if (newNode === newTree.getRootFocusableNode() && newTree !== oldTree) { + // If the root of the tree is the one taking focus (such as due to + // being tabbed), try to focus the whole tree explicitly to ensure the + // correct node re-receives focus. + this.focusTree(newTree); + } else { + this.focusNode(newNode); + } + } else { + this.defocusCurrentFocusedNode(); + } + }; + + // Register root document focus listeners for tracking when focus leaves all + // tracked focusable trees. Note that focusin and focusout can be somewhat + // overlapping in the information that they provide. This is fine because + // they both aim to check for focus changes on the element gaining or having + // received focus, and maybeFocus should behave relatively deterministic. + addGlobalEventListener('focusin', (event) => { + if (!(event instanceof FocusEvent)) return; + + // When something receives focus, always use the current active element as + // the source of truth. + maybeFocus(document.activeElement); + }); + addGlobalEventListener('focusout', (event) => { + if (!(event instanceof FocusEvent)) return; + + // When something loses focus, it seems that document.activeElement may + // not necessarily be correct. Instead, use relatedTarget. + maybeFocus(event.relatedTarget); + }); + } + + /** + * Registers a new IFocusableTree for automatic focus management. + * + * If the tree currently has an element with DOM focus, it will not affect the + * internal state in this manager until the focus changes to a new, + * now-monitored element/node. + * + * This function throws if the provided tree is already currently registered + * in this manager. Use isRegistered to check in cases when it can't be + * certain whether the tree has been registered. + * + * The tree's registration can be customized to configure automatic tab stops. + * This specifically provides capability for the user to be able to tab + * navigate to the root of the tree but only when the tree doesn't hold active + * focus. If this functionality is disabled then the tree's root will + * automatically be made focusable (but not tabbable) when it is first focused + * in the same way as any other focusable node. + * + * @param tree The IFocusableTree to register. + * @param rootShouldBeAutoTabbable Whether the root of this tree should be + * added as a top-level page tab stop when it doesn't hold active focus. + */ + registerTree( + tree: IFocusableTree, + rootShouldBeAutoTabbable: boolean = false, + ): void { + this.ensureManagerIsUnlocked(); + if (this.isRegistered(tree)) { + throw Error(`Attempted to re-register already registered tree: ${tree}.`); + } + this.registeredTrees.push( + new TreeRegistration(tree, rootShouldBeAutoTabbable), + ); + const rootElement = tree.getRootFocusableNode().getFocusableElement(); + if (!rootElement.id || rootElement.id === 'null') { + throw Error( + `Attempting to register a tree with a root element that has an ` + + `invalid ID: ${tree}.`, + ); + } + if (rootShouldBeAutoTabbable) { + rootElement.tabIndex = 0; + } + } + + /** + * Returns whether the specified tree has already been registered in this + * manager using registerTree and hasn't yet been unregistered using + * unregisterTree. + */ + isRegistered(tree: IFocusableTree): boolean { + return !!this.lookUpRegistration(tree); + } + + /** + * Returns the TreeRegistration for the specified tree, or null if the tree is + * not currently registered. + */ + private lookUpRegistration(tree: IFocusableTree): TreeRegistration | null { + return this.registeredTrees.find((reg) => reg.tree === tree) ?? null; + } + + /** + * Unregisters a IFocusableTree from automatic focus management. + * + * If the tree had a previous focused node, it will have its highlight + * removed. This function does NOT change DOM focus. + * + * This function throws if the provided tree is not currently registered in + * this manager. + * + * This function will reset the tree's root element tabindex if the tree was + * registered with automatic tab management. + */ + unregisterTree(tree: IFocusableTree): void { + this.ensureManagerIsUnlocked(); + if (!this.isRegistered(tree)) { + throw Error(`Attempted to unregister not registered tree: ${tree}.`); + } + const treeIndex = this.registeredTrees.findIndex( + (reg) => reg.tree === tree, + ); + const registration = this.registeredTrees[treeIndex]; + this.registeredTrees.splice(treeIndex, 1); + + const focusedNode = FocusableTreeTraverser.findFocusedNode(tree); + const root = tree.getRootFocusableNode(); + if (focusedNode) this.removeHighlight(focusedNode); + if (this.focusedNode === focusedNode || this.focusedNode === root) { + this.updateFocusedNode(null); + } + this.removeHighlight(root); + + if (registration.rootShouldBeAutoTabbable) { + tree + .getRootFocusableNode() + .getFocusableElement() + .removeAttribute('tabindex'); + } + } + + /** + * Returns the current IFocusableTree that has focus, or null if none + * currently do. + * + * Note also that if ephemeral focus is currently captured (e.g. using + * takeEphemeralFocus) then the returned tree here may not currently have DOM + * focus. + */ + getFocusedTree(): IFocusableTree | null { + return this.focusedNode?.getFocusableTree() ?? null; + } + + /** + * Returns the current IFocusableNode with focus (which is always tied to a + * focused IFocusableTree), or null if there isn't one. + * + * Note that this function will maintain parity with + * IFocusableTree.getFocusedNode(). That is, if a tree itself has focus but + * none of its non-root children do, this will return null but + * getFocusedTree() will not. + * + * Note also that if ephemeral focus is currently captured (e.g. using + * takeEphemeralFocus) then the returned node here may not currently have DOM + * focus. + */ + getFocusedNode(): IFocusableNode | null { + return this.focusedNode; + } + + /** + * Focuses the specific IFocusableTree. This either means restoring active + * focus to the tree's passively focused node, or focusing the tree's root + * node. + * + * Note that if the specified tree already has a focused node then this will + * not change any existing focus (unless that node has passive focus, then it + * will be restored to active focus). + * + * See getFocusedNode for details on how other nodes are affected. + * + * @param focusableTree The tree that should receive active + * focus. + */ + focusTree(focusableTree: IFocusableTree): void { + this.ensureManagerIsUnlocked(); + if (!this.isRegistered(focusableTree)) { + throw Error(`Attempted to focus unregistered tree: ${focusableTree}.`); + } + const currNode = FocusableTreeTraverser.findFocusedNode(focusableTree); + const nodeToRestore = focusableTree.getRestoredFocusableNode(currNode); + const rootFallback = focusableTree.getRootFocusableNode(); + this.focusNode(nodeToRestore ?? currNode ?? rootFallback); + } + + /** + * Focuses DOM input on the specified node, and marks it as actively focused. + * + * Any previously focused node will be updated to be passively highlighted (if + * it's in a different focusable tree) or blurred (if it's in the same one). + * + * **Important**: If the provided node is not able to be focused (e.g. its + * canBeFocused() method returns false), it will be ignored and any existing + * focus state will remain unchanged. + * + * Note that this may update the specified node's element's tabindex to ensure + * that it can be properly read out by screenreaders while focused. + * + * The focused node will not be automatically scrolled into view. + * + * @param focusableNode The node that should receive active focus. + */ + focusNode(focusableNode: IFocusableNode): void { + this.ensureManagerIsUnlocked(); + const mustRestoreUpdatingNode = !this.currentlyHoldsEphemeralFocus; + if (mustRestoreUpdatingNode) { + // Disable state syncing from DOM events since possible calls to focus() + // below will loop a call back to focusNode(). + this.isUpdatingFocusedNode = true; + } + + // Double check that state wasn't desynchronized in the background. See: + // https://github.com/google/blockly-keyboard-experimentation/issues/87. + // This is only done for the case where the same node is being focused twice + // since other cases should automatically correct (due to the rest of the + // routine running as normal). + const prevFocusedElement = this.focusedNode?.getFocusableElement(); + const hasDesyncedState = prevFocusedElement !== document.activeElement; + if (this.focusedNode === focusableNode && !hasDesyncedState) { + if (mustRestoreUpdatingNode) { + // Reenable state syncing from DOM events. + this.isUpdatingFocusedNode = false; + } + return; // State is unchanged. + } + + if (!focusableNode.canBeFocused()) { + // This node can't be focused. + console.warn("Trying to focus a node that can't be focused."); + + if (mustRestoreUpdatingNode) { + // Reenable state syncing from DOM events. + this.isUpdatingFocusedNode = false; + } + return; + } + + const nextTree = focusableNode.getFocusableTree(); + if (!this.isRegistered(nextTree)) { + throw Error(`Attempted to focus unregistered node: ${focusableNode}.`); + } + + const focusableNodeElement = focusableNode.getFocusableElement(); + if (!focusableNodeElement.id || focusableNodeElement.id === 'null') { + // Warn that the ID is invalid, but continue execution since an invalid ID + // will result in an unmatched (null) node. Since a request to focus + // something was initiated, the code below will attempt to find the next + // best thing to focus, instead. + console.warn('Trying to focus a node that has an invalid ID.'); + } + + // Safety check for ensuring focusNode() doesn't get called for a node that + // isn't actually hooked up to its parent tree correctly. This usually + // happens when calls to focusNode() interleave with asynchronous clean-up + // operations (which can happen due to ephemeral focus and in other cases). + // Fall back to a reasonable default since there's no valid node to focus. + const matchedNode = FocusableTreeTraverser.findFocusableNodeFor( + focusableNodeElement, + nextTree, + ); + const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree); + let nodeToFocus = focusableNode; + if (matchedNode !== focusableNode) { + const nodeToRestore = nextTree.getRestoredFocusableNode(prevNodeNextTree); + const rootFallback = nextTree.getRootFocusableNode(); + nodeToFocus = nodeToRestore ?? prevNodeNextTree ?? rootFallback; + } + + const prevNode = this.focusedNode; + const prevTree = prevNode?.getFocusableTree(); + if (prevNode) { + this.passivelyFocusNode(prevNode, nextTree); + } + + // If there's a focused node in the new node's tree, ensure it's reset. + const nextTreeRoot = nextTree.getRootFocusableNode(); + if (prevNodeNextTree) { + this.removeHighlight(prevNodeNextTree); + } + // For caution, ensure that the root is always reset since getFocusedNode() + // is expected to return null if the root was highlighted, if the root is + // not the node now being set to active. + if (nextTreeRoot !== nodeToFocus) { + this.removeHighlight(nextTreeRoot); + } + + if (!this.currentlyHoldsEphemeralFocus) { + // Only change the actively focused node if ephemeral state isn't held. + this.activelyFocusNode(nodeToFocus, prevTree ?? null); + } + this.updateFocusedNode(nodeToFocus); + if (mustRestoreUpdatingNode) { + // Reenable state syncing from DOM events. + this.isUpdatingFocusedNode = false; + } + } + + /** + * Ephemerally captures focus for a specific element until the returned lambda + * is called. This is expected to be especially useful for ephemeral UI flows + * like dialogs. + * + * IMPORTANT: the returned lambda *must* be called, otherwise automatic focus + * will no longer work anywhere on the page. It is highly recommended to tie + * the lambda call to the closure of the corresponding UI so that if input is + * manually changed to an element outside of the ephemeral UI, the UI should + * close and automatic input restored. Note that this lambda must be called + * exactly once and that subsequent calls will throw an error. + * + * Note that the manager will continue to track DOM input signals even when + * ephemeral focus is active, but it won't actually change node state until + * the returned lambda is called. Additionally, only 1 ephemeral focus context + * can be active at any given time (attempting to activate more than one + * simultaneously will result in an error being thrown). + * + * This method does not scroll the ephemerally focused element into view. + */ + takeEphemeralFocus( + focusableElement: HTMLElement | SVGElement, + ): ReturnEphemeralFocus { + this.ensureManagerIsUnlocked(); + if (this.currentlyHoldsEphemeralFocus) { + throw Error( + `Attempted to take ephemeral focus when it's already held, ` + + `with new element: ${focusableElement}.`, + ); + } + this.currentlyHoldsEphemeralFocus = true; + + if (this.focusedNode) { + this.passivelyFocusNode(this.focusedNode, null); + } + focusableElement.focus({preventScroll: true}); + + let hasFinishedEphemeralFocus = false; + return () => { + if (hasFinishedEphemeralFocus) { + throw Error( + `Attempted to finish ephemeral focus twice for element: ` + + `${focusableElement}.`, + ); + } + hasFinishedEphemeralFocus = true; + this.currentlyHoldsEphemeralFocus = false; + + if (this.focusedNode) { + this.activelyFocusNode(this.focusedNode, null); + + // Even though focus was restored, check if it's lost again. It's + // possible for the browser to force focus away from all elements once + // the ephemeral element disappears. This ensures focus is restored. + const capturedNode = this.focusedNode; + setTimeout(() => { + // These checks are set up to minimize the risk that a legitimate + // focus change occurred within the delay that this would override. + if ( + !this.focusedNode && + this.previouslyFocusedNode === capturedNode && + this.recentlyLostAllFocus + ) { + this.focusNode(capturedNode); + } + }, 0); + } + }; + } + + /** + * @returns whether something is currently holding ephemeral focus + */ + ephemeralFocusTaken(): boolean { + return this.currentlyHoldsEphemeralFocus; + } + + /** + * Ensures that the manager is currently allowing operations that change its + * internal focus state (such as via focusNode()). + * + * If the manager is currently not allowing state changes, an exception is + * thrown. + */ + private ensureManagerIsUnlocked(): void { + if (this.lockFocusStateChanges) { + throw Error( + 'FocusManager state changes cannot happen in a tree/node focus/blur ' + + 'callback.', + ); + } + } + + /** + * Updates the internally tracked focused node to the specified node, or null + * if focus is being lost. This also updates previous focus tracking. + * + * @param newFocusedNode The new node to set as focused. + */ + private updateFocusedNode(newFocusedNode: IFocusableNode | null) { + this.previouslyFocusedNode = this.focusedNode; + this.focusedNode = newFocusedNode; + } + + /** + * Defocuses the current actively focused node tracked by the manager, iff + * there's a node being tracked and the manager doesn't have ephemeral focus. + */ + private defocusCurrentFocusedNode(): void { + // The current node will likely be defocused while ephemeral focus is held, + // but internal manager state shouldn't change since the node should be + // restored upon exiting ephemeral focus mode. + if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) { + this.passivelyFocusNode(this.focusedNode, null); + this.updateFocusedNode(null); + } + } + + /** + * Marks the specified node as actively focused, also calling related + * lifecycle callback methods for both the node and its parent tree. This + * ensures that the node is properly styled to indicate its active focus. + * + * This does not change the manager's currently tracked node, nor does it + * change any other nodes. + * + * @param node The node to be actively focused. + * @param prevTree The tree of the previously actively focused node, or null + * if there wasn't a previously actively focused node. + */ + private activelyFocusNode( + node: IFocusableNode, + prevTree: IFocusableTree | null, + ): void { + // Note that order matters here. Focus callbacks are allowed to change + // element visibility which can influence focusability, including for a + // node's focusable element (which *is* allowed to be invisible until the + // node needs to be focused). + this.lockFocusStateChanges = true; + const tree = node.getFocusableTree(); + const elem = node.getFocusableElement(); + const nextTreeReg = this.lookUpRegistration(tree); + const treeIsTabManaged = nextTreeReg?.rootShouldBeAutoTabbable; + if (tree !== prevTree) { + tree.onTreeFocus(node, prevTree); + + if (treeIsTabManaged) { + // If this node's tree has its tab auto-managed, ensure that it's no + // longer tabbable now that it holds active focus. + tree.getRootFocusableNode().getFocusableElement().tabIndex = -1; + } + } + node.onNodeFocus(); + this.lockFocusStateChanges = false; + + // The tab index should be set in all cases where: + // - It doesn't overwrite an pre-set tab index for the node. + // - The node is part of a tree whose tab index is unmanaged. + // OR + // - The node is part of a managed tree but this isn't the root. Managed + // roots are ignored since they are always overwritten to have a tab index + // of -1 with active focus so that they cannot be tab navigated. + // + // Setting the tab index ensures that the node's focusable element can + // actually receive DOM focus. + if (!treeIsTabManaged || node !== tree.getRootFocusableNode()) { + if (!elem.hasAttribute('tabindex')) elem.tabIndex = -1; + } + + this.setNodeToVisualActiveFocus(node); + elem.focus({preventScroll: true}); + } + + /** + * Marks the specified node as passively focused, also calling related + * lifecycle callback methods for both the node and its parent tree. This + * ensures that the node is properly styled to indicate its passive focus. + * + * This does not change the manager's currently tracked node, nor does it + * change any other nodes. + * + * @param node The node to be passively focused. + * @param nextTree The tree of the node receiving active focus, or null if no + * node will be actively focused. + */ + private passivelyFocusNode( + node: IFocusableNode, + nextTree: IFocusableTree | null, + ): void { + this.lockFocusStateChanges = true; + const tree = node.getFocusableTree(); + if (tree !== nextTree) { + tree.onTreeBlur(nextTree); + + const reg = this.lookUpRegistration(tree); + if (reg?.rootShouldBeAutoTabbable) { + // If this node's tree has its tab auto-managed, ensure that it's now + // tabbable since it no longer holds active focus. + tree.getRootFocusableNode().getFocusableElement().tabIndex = 0; + } + } + node.onNodeBlur(); + this.lockFocusStateChanges = false; + + if (tree !== nextTree) { + this.setNodeToVisualPassiveFocus(node); + } + } + + /** + * Updates the node's styling to indicate that it should have an active focus + * indicator. + * + * @param node The node to be styled for active focus. + */ + private setNodeToVisualActiveFocus(node: IFocusableNode): void { + const element = node.getFocusableElement(); + dom.addClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + } + + /** + * Updates the node's styling to indicate that it should have a passive focus + * indicator. + * + * @param node The node to be styled for passive focus. + */ + private setNodeToVisualPassiveFocus(node: IFocusableNode): void { + const element = node.getFocusableElement(); + dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + dom.addClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + } + + /** + * Removes any active/passive indicators for the specified node. + * + * @param node The node which should have neither passive nor active focus + * indication. + */ + private removeHighlight(node: IFocusableNode): void { + const element = node.getFocusableElement(); + dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + } + + private static focusManager: FocusManager | null = null; + + /** + * Returns the page-global FocusManager. + * + * The returned instance is guaranteed to not change across function calls, + * but may change across page loads. + */ + static getFocusManager(): FocusManager { + if (!FocusManager.focusManager) { + FocusManager.focusManager = new FocusManager(document.addEventListener); + } + return FocusManager.focusManager; + } +} + +/** Convenience function for FocusManager.getFocusManager. */ +export function getFocusManager(): FocusManager { + return FocusManager.getFocusManager(); +} diff --git a/core/generator.js b/core/generator.js deleted file mode 100644 index cba266f2787..00000000000 --- a/core/generator.js +++ /dev/null @@ -1,415 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Utility functions for generating executable code from - * Blockly code. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Generator'); - -goog.require('Blockly.Block'); -goog.require('goog.asserts'); - - -/** - * Class for a code generator that translates the blocks into a language. - * @param {string} name Language name of this generator. - * @constructor - */ -Blockly.Generator = function(name) { - this.name_ = name; - this.FUNCTION_NAME_PLACEHOLDER_REGEXP_ = - new RegExp(this.FUNCTION_NAME_PLACEHOLDER_, 'g'); -}; - -/** - * Category to separate generated function names from variables and procedures. - */ -Blockly.Generator.NAME_TYPE = 'generated_function'; - -/** - * Arbitrary code to inject into locations that risk causing infinite loops. - * Any instances of '%1' will be replaced by the block ID that failed. - * E.g. ' checkTimeout(%1);\n' - * @type {?string} - */ -Blockly.Generator.prototype.INFINITE_LOOP_TRAP = null; - -/** - * Arbitrary code to inject before every statement. - * Any instances of '%1' will be replaced by the block ID of the statement. - * E.g. 'highlight(%1);\n' - * @type {?string} - */ -Blockly.Generator.prototype.STATEMENT_PREFIX = null; - -/** - * The method of indenting. Defaults to two spaces, but language generators - * may override this to increase indent or change to tabs. - * @type {string} - */ -Blockly.Generator.prototype.INDENT = ' '; - -/** - * Maximum length for a comment before wrapping. Does not account for - * indenting level. - * @type {number} - */ -Blockly.Generator.prototype.COMMENT_WRAP = 60; - -/** - * List of outer-inner pairings that do NOT require parentheses. - * @type {!Array.>} - */ -Blockly.Generator.prototype.ORDER_OVERRIDES = []; - -/** - * Generate code for all blocks in the workspace to the specified language. - * @param {Blockly.Workspace} workspace Workspace to generate code from. - * @return {string} Generated code. - */ -Blockly.Generator.prototype.workspaceToCode = function(workspace) { - if (!workspace) { - // Backwards compatibility from before there could be multiple workspaces. - console.warn('No workspace specified in workspaceToCode call. Guessing.'); - workspace = Blockly.getMainWorkspace(); - } - var code = []; - this.init(workspace); - var blocks = workspace.getTopBlocks(true); - for (var x = 0, block; block = blocks[x]; x++) { - var line = this.blockToCode(block); - if (goog.isArray(line)) { - // Value blocks return tuples of code and operator order. - // Top-level blocks don't care about operator order. - line = line[0]; - } - if (line) { - if (block.outputConnection && this.scrubNakedValue) { - // This block is a naked value. Ask the language's code generator if - // it wants to append a semicolon, or something. - line = this.scrubNakedValue(line); - } - code.push(line); - } - } - code = code.join('\n'); // Blank line between each section. - code = this.finish(code); - // Final scrubbing of whitespace. - code = code.replace(/^\s+\n/, ''); - code = code.replace(/\n\s+$/, '\n'); - code = code.replace(/[ \t]+\n/g, '\n'); - return code; -}; - -// The following are some helpful functions which can be used by multiple -// languages. - -/** - * Prepend a common prefix onto each line of code. - * @param {string} text The lines of code. - * @param {string} prefix The common prefix. - * @return {string} The prefixed lines of code. - */ -Blockly.Generator.prototype.prefixLines = function(text, prefix) { - return prefix + text.replace(/(?!\n$)\n/g, '\n' + prefix); -}; - -/** - * Recursively spider a tree of blocks, returning all their comments. - * @param {!Blockly.Block} block The block from which to start spidering. - * @return {string} Concatenated list of comments. - */ -Blockly.Generator.prototype.allNestedComments = function(block) { - var comments = []; - var blocks = block.getDescendants(); - for (var i = 0; i < blocks.length; i++) { - var comment = blocks[i].getCommentText(); - if (comment) { - comments.push(comment); - } - } - // Append an empty string to create a trailing line break when joined. - if (comments.length) { - comments.push(''); - } - return comments.join('\n'); -}; - -/** - * Generate code for the specified block (and attached blocks). - * @param {Blockly.Block} block The block to generate code for. - * @return {string|!Array} For statement blocks, the generated code. - * For value blocks, an array containing the generated code and an - * operator order value. Returns '' if block is null. - */ -Blockly.Generator.prototype.blockToCode = function(block) { - if (!block) { - return ''; - } - if (block.disabled) { - // Skip past this block if it is disabled. - return this.blockToCode(block.getNextBlock()); - } - - var func = this[block.type]; - goog.asserts.assertFunction(func, - 'Language "%s" does not know how to generate code for block type "%s".', - this.name_, block.type); - // First argument to func.call is the value of 'this' in the generator. - // Prior to 24 September 2013 'this' was the only way to access the block. - // The current prefered method of accessing the block is through the second - // argument to func.call, which becomes the first parameter to the generator. - var code = func.call(block, block); - if (goog.isArray(code)) { - // Value blocks return tuples of code and operator order. - goog.asserts.assert(block.outputConnection, - 'Expecting string from statement block "%s".', block.type); - return [this.scrub_(block, code[0]), code[1]]; - } else if (goog.isString(code)) { - var id = block.id.replace(/\$/g, '$$$$'); // Issue 251. - if (this.STATEMENT_PREFIX) { - code = this.STATEMENT_PREFIX.replace(/%1/g, '\'' + id + '\'') + - code; - } - return this.scrub_(block, code); - } else if (code === null) { - // Block has handled code generation itself. - return ''; - } else { - goog.asserts.fail('Invalid code generated: %s', code); - } -}; - -/** - * Generate code representing the specified value input. - * @param {!Blockly.Block} block The block containing the input. - * @param {string} name The name of the input. - * @param {number} outerOrder The maximum binding strength (minimum order value) - * of any operators adjacent to "block". - * @return {string} Generated code or '' if no blocks are connected or the - * specified input does not exist. - */ -Blockly.Generator.prototype.valueToCode = function(block, name, outerOrder) { - if (isNaN(outerOrder)) { - goog.asserts.fail('Expecting valid order from block "%s".', block.type); - } - var targetBlock = block.getInputTargetBlock(name); - if (!targetBlock) { - return ''; - } - var tuple = this.blockToCode(targetBlock); - if (tuple === '') { - // Disabled block. - return ''; - } - // Value blocks must return code and order of operations info. - // Statement blocks must only return code. - goog.asserts.assertArray(tuple, 'Expecting tuple from value block "%s".', - targetBlock.type); - var code = tuple[0]; - var innerOrder = tuple[1]; - if (isNaN(innerOrder)) { - goog.asserts.fail('Expecting valid order from value block "%s".', - targetBlock.type); - } - if (!code) { - return ''; - } - - // Add parentheses if needed. - var parensNeeded = false; - var outerOrderClass = Math.floor(outerOrder); - var innerOrderClass = Math.floor(innerOrder); - if (outerOrderClass <= innerOrderClass) { - if (outerOrderClass == innerOrderClass && - (outerOrderClass == 0 || outerOrderClass == 99)) { - // Don't generate parens around NONE-NONE and ATOMIC-ATOMIC pairs. - // 0 is the atomic order, 99 is the none order. No parentheses needed. - // In all known languages multiple such code blocks are not order - // sensitive. In fact in Python ('a' 'b') 'c' would fail. - } else { - // The operators outside this code are stronger than the operators - // inside this code. To prevent the code from being pulled apart, - // wrap the code in parentheses. - parensNeeded = true; - // Check for special exceptions. - for (var i = 0; i < this.ORDER_OVERRIDES.length; i++) { - if (this.ORDER_OVERRIDES[i][0] == outerOrder && - this.ORDER_OVERRIDES[i][1] == innerOrder) { - parensNeeded = false; - break; - } - } - } - } - if (parensNeeded) { - // Technically, this should be handled on a language-by-language basis. - // However all known (sane) languages use parentheses for grouping. - code = '(' + code + ')'; - } - return code; -}; - -/** - * Generate code representing the statement. Indent the code. - * @param {!Blockly.Block} block The block containing the input. - * @param {string} name The name of the input. - * @return {string} Generated code or '' if no blocks are connected. - */ -Blockly.Generator.prototype.statementToCode = function(block, name) { - var targetBlock = block.getInputTargetBlock(name); - var code = this.blockToCode(targetBlock); - // Value blocks must return code and order of operations info. - // Statement blocks must only return code. - goog.asserts.assertString(code, 'Expecting code from statement block "%s".', - targetBlock && targetBlock.type); - if (code) { - code = this.prefixLines(/** @type {string} */ (code), this.INDENT); - } - return code; -}; - -/** - * Add an infinite loop trap to the contents of a loop. - * If loop is empty, add a statment prefix for the loop block. - * @param {string} branch Code for loop contents. - * @param {string} id ID of enclosing block. - * @return {string} Loop contents, with infinite loop trap added. - */ -Blockly.Generator.prototype.addLoopTrap = function(branch, id) { - id = id.replace(/\$/g, '$$$$'); // Issue 251. - if (this.INFINITE_LOOP_TRAP) { - branch = this.INFINITE_LOOP_TRAP.replace(/%1/g, '\'' + id + '\'') + branch; - } - if (this.STATEMENT_PREFIX) { - branch += this.prefixLines(this.STATEMENT_PREFIX.replace(/%1/g, - '\'' + id + '\''), this.INDENT); - } - return branch; -}; - -/** - * Comma-separated list of reserved words. - * @type {string} - * @private - */ -Blockly.Generator.prototype.RESERVED_WORDS_ = ''; - -/** - * Add one or more words to the list of reserved words for this language. - * @param {string} words Comma-separated list of words to add to the list. - * No spaces. Duplicates are ok. - */ -Blockly.Generator.prototype.addReservedWords = function(words) { - this.RESERVED_WORDS_ += words + ','; -}; - -/** - * This is used as a placeholder in functions defined using - * Blockly.Generator.provideFunction_. It must not be legal code that could - * legitimately appear in a function definition (or comment), and it must - * not confuse the regular expression parser. - * @type {string} - * @private - */ -Blockly.Generator.prototype.FUNCTION_NAME_PLACEHOLDER_ = '{leCUI8hutHZI4480Dc}'; - -/** - * Define a function to be included in the generated code. - * The first time this is called with a given desiredName, the code is - * saved and an actual name is generated. Subsequent calls with the - * same desiredName have no effect but have the same return value. - * - * It is up to the caller to make sure the same desiredName is not - * used for different code values. - * - * The code gets output when Blockly.Generator.finish() is called. - * - * @param {string} desiredName The desired name of the function (e.g., isPrime). - * @param {!Array.} code A list of statements. Use ' ' for indents. - * @return {string} The actual name of the new function. This may differ - * from desiredName if the former has already been taken by the user. - * @private - */ -Blockly.Generator.prototype.provideFunction_ = function(desiredName, code) { - if (!this.definitions_[desiredName]) { - var functionName = this.variableDB_.getDistinctName(desiredName, - Blockly.Procedures.NAME_TYPE); - this.functionNames_[desiredName] = functionName; - var codeText = code.join('\n').replace( - this.FUNCTION_NAME_PLACEHOLDER_REGEXP_, functionName); - // Change all ' ' indents into the desired indent. - // To avoid an infinite loop of replacements, change all indents to '\0' - // character first, then replace them all with the indent. - // We are assuming that no provided functions contain a literal null char. - var oldCodeText; - while (oldCodeText != codeText) { - oldCodeText = codeText; - codeText = codeText.replace(/^(( )*) /gm, '$1\0'); - } - codeText = codeText.replace(/\0/g, this.INDENT); - this.definitions_[desiredName] = codeText; - } - return this.functionNames_[desiredName]; -}; - -/** - * Hook for code to run before code generation starts. - * Subclasses may override this, e.g. to initialise the database of variable - * names. - * @param {!Blockly.Workspace} workspace Workspace to generate code from. - */ -Blockly.Generator.prototype.init = undefined; - -/** - * Common tasks for generating code from blocks. This is called from - * blockToCode and is called on every block, not just top level blocks. - * Subclasses may override this, e.g. to generate code for statements following - * the block, or to handle comments for the specified block and any connected - * value blocks. - * @param {!Blockly.Block} block The current block. - * @param {string} code The JavaScript code created for this block. - * @return {string} JavaScript code with comments and subsequent blocks added. - * @private - */ -Blockly.Generator.prototype.scrub_ = undefined; - -/** - * Hook for code to run at end of code generation. - * Subclasses may override this, e.g. to prepend the generated code with the - * variable definitions. - * @param {string} code Generated code. - * @return {string} Completed code. - */ -Blockly.Generator.prototype.finish = undefined; - -/** - * Naked values are top-level blocks with outputs that aren't plugged into - * anything. - * Subclasses may override this, e.g. if their language does not allow - * naked values. - * @param {string} line Line of generated code. - * @return {string} Legal line of code. - */ -Blockly.Generator.prototype.scrubNakedValue = undefined; diff --git a/core/generator.ts b/core/generator.ts new file mode 100644 index 00000000000..24510fd5b3a --- /dev/null +++ b/core/generator.ts @@ -0,0 +1,611 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Utility functions for generating executable code from + * Blockly code. + * + * @class + */ +// Former goog.module ID: Blockly.CodeGenerator + +import type {Block} from './block.js'; +import * as common from './common.js'; +import {Names, NameType} from './names.js'; +import type {Workspace} from './workspace.js'; + +/** + * Deprecated, no-longer used type declaration for per-block-type generator + * functions. + * + * @deprecated + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/generating-code} + * @param block The Block instance to generate code for. + * @param generator The CodeGenerator calling the function. + * @returns A string containing the generated code (for statement blocks), + * or a [code, precedence] tuple (for value/expression blocks), or + * null if no code should be emitted for block. + */ +export type BlockGenerator = ( + block: Block, + generator: CodeGenerator, +) => [string, number] | string | null; + +/** + * Class for a code generator that translates the blocks into a language. + */ +export class CodeGenerator { + name_: string; + + /** + * A dictionary of block generator functions, keyed by block type. + * Each block generator function takes two parameters: + * + * - the Block to generate code for, and + * - the calling CodeGenerator (or subclass) instance, so the + * function can call methods defined below (e.g. blockToCode) or + * on the relevant subclass (e.g. JavascripGenerator), + * + * and returns: + * + * - a [code, precedence] tuple (for value/expression blocks), or + * - a string containing the generated code (for statement blocks), or + * - null if no code should be emitted for block. + */ + forBlock: Record< + string, + (block: Block, generator: this) => [string, number] | string | null + > = Object.create(null); + + /** + * This is used as a placeholder in functions defined using + * CodeGenerator.provideFunction_. It must not be legal code that could + * legitimately appear in a function definition (or comment), and it must + * not confuse the regular expression parser. + */ + FUNCTION_NAME_PLACEHOLDER_ = '{leCUI8hutHZI4480Dc}'; + FUNCTION_NAME_PLACEHOLDER_REGEXP_: RegExp; + + /** + * Arbitrary code to inject into locations that risk causing infinite loops. + * Any instances of '%1' will be replaced by the block ID that failed. + * E.g. ` checkTimeout(%1);\n` + */ + INFINITE_LOOP_TRAP: string | null = null; + + /** + * Arbitrary code to inject before every statement. + * Any instances of '%1' will be replaced by the block ID of the statement. + * E.g. `highlight(%1);\n` + */ + STATEMENT_PREFIX: string | null = null; + + /** + * Arbitrary code to inject after every statement. + * Any instances of '%1' will be replaced by the block ID of the statement. + * E.g. `highlight(%1);\n` + */ + STATEMENT_SUFFIX: string | null = null; + + /** + * The method of indenting. Defaults to two spaces, but language generators + * may override this to increase indent or change to tabs. + */ + INDENT = ' '; + + /** + * Maximum length for a comment before wrapping. Does not account for + * indenting level. + */ + COMMENT_WRAP = 60; + + /** List of outer-inner pairings that do NOT require parentheses. */ + ORDER_OVERRIDES: number[][] = []; + + /** + * Whether the init method has been called. + * Generators that set this flag to false after creation and true in init + * will cause blockToCode to emit a warning if the generator has not been + * initialized. If this flag is untouched, it will have no effect. + */ + isInitialized: boolean | null = null; + + /** Comma-separated list of reserved words. */ + protected RESERVED_WORDS_ = ''; + + /** A dictionary of definitions to be printed before the code. */ + protected definitions_: {[key: string]: string} = Object.create(null); + + /** + * A dictionary mapping desired function names in definitions_ to actual + * function names (to avoid collisions with user functions). + */ + protected functionNames_: {[key: string]: string} = Object.create(null); + + /** A database of variable and procedure names. */ + nameDB_?: Names = undefined; + + /** @param name Language name of this generator. */ + constructor(name: string) { + this.name_ = name; + + this.FUNCTION_NAME_PLACEHOLDER_REGEXP_ = new RegExp( + this.FUNCTION_NAME_PLACEHOLDER_, + 'g', + ); + } + + /** + * Generate code for all blocks in the workspace to the specified language. + * + * @param workspace Workspace to generate code from. + * @returns Generated code. + */ + workspaceToCode(workspace?: Workspace): string { + if (!workspace) { + // Backwards compatibility from before there could be multiple workspaces. + console.warn( + 'No workspace specified in workspaceToCode call. Guessing.', + ); + workspace = common.getMainWorkspace(); + } + const code = []; + this.init(workspace); + const blocks = workspace.getTopBlocks(true); + for (let i = 0, block; (block = blocks[i]); i++) { + let line = this.blockToCode(block); + if (Array.isArray(line)) { + // Value blocks return tuples of code and operator order. + // Top-level blocks don't care about operator order. + line = line[0]; + } + if (line) { + if (block.outputConnection) { + // This block is a naked value. Ask the language's code generator if + // it wants to append a semicolon, or something. + line = this.scrubNakedValue(line); + if (this.STATEMENT_PREFIX && !block.suppressPrefixSuffix) { + line = this.injectId(this.STATEMENT_PREFIX, block) + line; + } + if (this.STATEMENT_SUFFIX && !block.suppressPrefixSuffix) { + line = line + this.injectId(this.STATEMENT_SUFFIX, block); + } + } + code.push(line); + } + } + // Blank line between each section. + let codeString = code.join('\n'); + codeString = this.finish(codeString); + // Final scrubbing of whitespace. + codeString = codeString.replace(/^\s+\n/, ''); + codeString = codeString.replace(/\n\s+$/, '\n'); + codeString = codeString.replace(/[ \t]+\n/g, '\n'); + return codeString; + } + + /** + * Prepend a common prefix onto each line of code. + * Intended for indenting code or adding comment markers. + * + * @param text The lines of code. + * @param prefix The common prefix. + * @returns The prefixed lines of code. + */ + prefixLines(text: string, prefix: string): string { + return prefix + text.replace(/(?!\n$)\n/g, '\n' + prefix); + } + + /** + * Recursively spider a tree of blocks, returning all their comments. + * + * @param block The block from which to start spidering. + * @returns Concatenated list of comments. + */ + allNestedComments(block: Block): string { + const comments = []; + const blocks = block.getDescendants(true); + for (let i = 0; i < blocks.length; i++) { + const comment = blocks[i].getCommentText(); + if (comment) { + comments.push(comment); + } + } + // Append an empty string to create a trailing line break when joined. + if (comments.length) { + comments.push(''); + } + return comments.join('\n'); + } + + /** + * Generate code for the specified block (and attached blocks). + * The generator must be initialized before calling this function. + * + * @param block The block to generate code for. + * @param opt_thisOnly True to generate code for only this statement. + * @returns For statement blocks, the generated code. + * For value blocks, an array containing the generated code and an + * operator order value. Returns '' if block is null. + */ + blockToCode( + block: Block | null, + opt_thisOnly?: boolean, + ): string | [string, number] { + if (this.isInitialized === false) { + console.warn( + 'CodeGenerator init was not called before blockToCode was called.', + ); + } + if (!block) { + return ''; + } + if (!block.isEnabled()) { + // Skip past this block if it is disabled. + return opt_thisOnly ? '' : this.blockToCode(block.getNextBlock()); + } + if (block.isInsertionMarker()) { + // Skip past insertion markers. + return opt_thisOnly ? '' : this.blockToCode(block.getChildren(false)[0]); + } + + // Look up block generator function in dictionary. + const func = this.forBlock[block.type]; + if (typeof func !== 'function') { + throw Error( + `${this.name_} generator does not know how to generate code ` + + `for block type "${block.type}".`, + ); + } + // First argument to func.call is the value of 'this' in the generator. + // Prior to 24 September 2013 'this' was the only way to access the block. + // The current preferred method of accessing the block is through the second + // argument to func.call, which becomes the first parameter to the + // generator. + let code = func.call(block, block, this); + if (Array.isArray(code)) { + // Value blocks return tuples of code and operator order. + if (!block.outputConnection) { + throw TypeError('Expecting string from statement block: ' + block.type); + } + return [this.scrub_(block, code[0], opt_thisOnly), code[1]]; + } else if (typeof code === 'string') { + if (this.STATEMENT_PREFIX && !block.suppressPrefixSuffix) { + code = this.injectId(this.STATEMENT_PREFIX, block) + code; + } + if (this.STATEMENT_SUFFIX && !block.suppressPrefixSuffix) { + code = code + this.injectId(this.STATEMENT_SUFFIX, block); + } + return this.scrub_(block, code, opt_thisOnly); + } else if (code === null) { + // Block has handled code generation itself. + return ''; + } + throw SyntaxError('Invalid code generated: ' + code); + } + + /** + * Generate code representing the specified value input. + * + * @param block The block containing the input. + * @param name The name of the input. + * @param outerOrder The maximum binding strength (minimum order value) of any + * operators adjacent to "block". + * @returns Generated code or '' if no blocks are connected. + * @throws ReferenceError if the specified input does not exist. + */ + valueToCode(block: Block, name: string, outerOrder: number): string { + if (isNaN(outerOrder)) { + throw TypeError('Expecting valid order from block: ' + block.type); + } + const targetBlock = block.getInputTargetBlock(name); + if (!targetBlock && !block.getInput(name)) { + throw ReferenceError(`Input "${name}" doesn't exist on "${block.type}"`); + } + if (!targetBlock) { + return ''; + } + const tuple = this.blockToCode(targetBlock); + if (tuple === '') { + // Disabled block. + return ''; + } + // Value blocks must return code and order of operations info. + // Statement blocks must only return code. + if (!Array.isArray(tuple)) { + throw TypeError( + `Expecting tuple from value block: ${targetBlock.type} See ` + + `developers.google.com/blockly/guides/create-custom-blocks/generating-code ` + + `for more information`, + ); + } + let code = tuple[0]; + const innerOrder = tuple[1]; + if (isNaN(innerOrder)) { + throw TypeError( + 'Expecting valid order from value block: ' + targetBlock.type, + ); + } + if (!code) { + return ''; + } + + // Add parentheses if needed. + let parensNeeded = false; + const outerOrderClass = Math.floor(outerOrder); + const innerOrderClass = Math.floor(innerOrder); + if (outerOrderClass <= innerOrderClass) { + if ( + outerOrderClass === innerOrderClass && + (outerOrderClass === 0 || outerOrderClass === 99) + ) { + // Don't generate parens around NONE-NONE and ATOMIC-ATOMIC pairs. + // 0 is the atomic order, 99 is the none order. No parentheses needed. + // In all known languages multiple such code blocks are not order + // sensitive. In fact in Python ('a' 'b') 'c' would fail. + } else { + // The operators outside this code are stronger than the operators + // inside this code. To prevent the code from being pulled apart, + // wrap the code in parentheses. + parensNeeded = true; + // Check for special exceptions. + for (let i = 0; i < this.ORDER_OVERRIDES.length; i++) { + if ( + this.ORDER_OVERRIDES[i][0] === outerOrder && + this.ORDER_OVERRIDES[i][1] === innerOrder + ) { + parensNeeded = false; + break; + } + } + } + } + if (parensNeeded) { + // Technically, this should be handled on a language-by-language basis. + // However all known (sane) languages use parentheses for grouping. + code = '(' + code + ')'; + } + return code; + } + + /** + * Generate a code string representing the blocks attached to the named + * statement input. Indent the code. + * This is mainly used in generators. When trying to generate code to evaluate + * look at using workspaceToCode or blockToCode. + * + * @param block The block containing the input. + * @param name The name of the input. + * @returns Generated code or '' if no blocks are connected. + * @throws ReferenceError if the specified input does not exist. + */ + statementToCode(block: Block, name: string): string { + const targetBlock = block.getInputTargetBlock(name); + if (!targetBlock && !block.getInput(name)) { + throw ReferenceError(`Input "${name}" doesn't exist on "${block.type}"`); + } + let code = this.blockToCode(targetBlock); + // Value blocks must return code and order of operations info. + // Statement blocks must only return code. + if (typeof code !== 'string') { + throw TypeError( + 'Expecting code from statement block: ' + + (targetBlock && targetBlock.type), + ); + } + if (code) { + code = this.prefixLines(code, this.INDENT); + } + return code; + } + + /** + * Add an infinite loop trap to the contents of a loop. + * Add statement suffix at the start of the loop block (right after the loop + * statement executes), and a statement prefix to the end of the loop block + * (right before the loop statement executes). + * + * @param branch Code for loop contents. + * @param block Enclosing block. + * @returns Loop contents, with infinite loop trap added. + */ + addLoopTrap(branch: string, block: Block): string { + if (this.INFINITE_LOOP_TRAP) { + branch = + this.prefixLines( + this.injectId(this.INFINITE_LOOP_TRAP, block), + this.INDENT, + ) + branch; + } + if (this.STATEMENT_SUFFIX && !block.suppressPrefixSuffix) { + branch = + this.prefixLines( + this.injectId(this.STATEMENT_SUFFIX, block), + this.INDENT, + ) + branch; + } + if (this.STATEMENT_PREFIX && !block.suppressPrefixSuffix) { + branch = + branch + + this.prefixLines( + this.injectId(this.STATEMENT_PREFIX, block), + this.INDENT, + ); + } + return branch; + } + + /** + * Inject a block ID into a message to replace '%1'. + * Used for STATEMENT_PREFIX, STATEMENT_SUFFIX, and INFINITE_LOOP_TRAP. + * + * @param msg Code snippet with '%1'. + * @param block Block which has an ID. + * @returns Code snippet with ID. + */ + injectId(msg: string, block: Block): string { + const id = block.id.replace(/\$/g, '$$$$'); // Issue 251. + return msg.replace(/%1/g, "'" + id + "'"); + } + + /** + * Add one or more words to the list of reserved words for this language. + * + * @param words Comma-separated list of words to add to the list. + * No spaces. Duplicates are ok. + */ + addReservedWords(words: string) { + this.RESERVED_WORDS_ += words + ','; + } + + /** + * Define a developer-defined function (not a user-defined procedure) to be + * included in the generated code. Used for creating private helper + * functions. The first time this is called with a given desiredName, the code + * is saved and an actual name is generated. Subsequent calls with the same + * desiredName have no effect but have the same return value. + * + * It is up to the caller to make sure the same desiredName is not + * used for different helper functions (e.g. use "colourRandom" and + * "listRandom", not "random"). There is no danger of colliding with reserved + * words, or user-defined variable or procedure names. + * + * The code gets output when CodeGenerator.finish() is called. + * + * @param desiredName The desired name of the function (e.g. mathIsPrime). + * @param code A list of statements or one multi-line code string. Use ' ' + * for indents (they will be replaced). + * @returns The actual name of the new function. This may differ from + * desiredName if the former has already been taken by the user. + */ + provideFunction_(desiredName: string, code: string[] | string): string { + if (!this.definitions_[desiredName]) { + const functionName = this.nameDB_!.getDistinctName( + desiredName, + NameType.PROCEDURE, + ); + this.functionNames_[desiredName] = functionName; + if (Array.isArray(code)) { + code = code.join('\n'); + } + let codeText = code + .trim() + .replace(this.FUNCTION_NAME_PLACEHOLDER_REGEXP_, functionName); + // Change all ' ' indents into the desired indent. + // To avoid an infinite loop of replacements, change all indents to '\0' + // character first, then replace them all with the indent. + // We are assuming that no provided functions contain a literal null char. + let oldCodeText; + while (oldCodeText !== codeText) { + oldCodeText = codeText; + codeText = codeText.replace(/^(( {2})*) {2}/gm, '$1\0'); + } + codeText = codeText.replace(/\0/g, this.INDENT); + this.definitions_[desiredName] = codeText; + } + return this.functionNames_[desiredName]; + } + + /** + * Gets a unique, legal name for a user-defined variable. + * Before calling this method, the `nameDB_` property of the class + * must have been initialized already. This is typically done in + * the `init` function of the code generator class. + * + * @param nameOrId The ID of the variable to get a name for, + * or the proposed name for a variable not associated with an id. + * @returns A unique, legal name for the variable. + */ + getVariableName(nameOrId: string): string { + return this.getName(nameOrId, NameType.VARIABLE); + } + + /** + * Gets a unique, legal name for a user-defined procedure. + * Before calling this method, the `nameDB_` property of the class + * must have been initialized already. This is typically done in + * the `init` function of the code generator class. + * + * @param name The proposed name for a procedure. + * @returns A unique, legal name for the procedure. + */ + getProcedureName(name: string): string { + return this.getName(name, NameType.PROCEDURE); + } + + private getName(nameOrId: string, type: NameType): string { + if (!this.nameDB_) { + throw new Error( + 'Name database is not defined. You must initialize `nameDB_` in your generator class and call `init` first.', + ); + } + return this.nameDB_.getName(nameOrId, type); + } + + /** + * Hook for code to run before code generation starts. + * Subclasses may override this, e.g. to initialise the database of variable + * names. + * + * @param _workspace Workspace to generate code from. + */ + init(_workspace: Workspace) { + // Optionally override + // Create a dictionary of definitions to be printed before the code. + this.definitions_ = Object.create(null); + // Create a dictionary mapping desired developer-defined function names in + // definitions_ to actual function names (to avoid collisions with + // user-defined procedures). + this.functionNames_ = Object.create(null); + } + + /** + * Common tasks for generating code from blocks. This is called from + * blockToCode and is called on every block, not just top level blocks. + * Subclasses may override this, e.g. to generate code for statements + * following the block, or to handle comments for the specified block and any + * connected value blocks. + * + * @param _block The current block. + * @param code The code created for this block. + * @param _opt_thisOnly True to generate code for only this statement. + * @returns Code with comments and subsequent blocks added. + */ + scrub_(_block: Block, code: string, _opt_thisOnly?: boolean): string { + // Optionally override + return code; + } + + /** + * Hook for code to run at end of code generation. + * Subclasses may override this, e.g. to prepend the generated code with + * import statements or variable definitions. + * + * @param code Generated code. + * @returns Completed code. + */ + finish(code: string): string { + // Optionally override + // Clean up temporary data. + this.definitions_ = Object.create(null); + this.functionNames_ = Object.create(null); + return code; + } + + /** + * Naked values are top-level blocks with outputs that aren't plugged into + * anything. + * Subclasses may override this, e.g. if their language does not allow + * naked values. + * + * @param line Line of generated code. + * @returns Legal line of code. + */ + scrubNakedValue(line: string): string { + // Optionally override + return line; + } +} diff --git a/core/gesture.js b/core/gesture.js deleted file mode 100644 index ba09192cf39..00000000000 --- a/core/gesture.js +++ /dev/null @@ -1,783 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 The class representing an in-progress gesture, usually a drag - * or a tap. - * @author fenichel@google.com (Rachel Fenichel) - */ -'use strict'; - -goog.provide('Blockly.Gesture'); - -goog.require('Blockly.BlockDragger'); -goog.require('Blockly.constants'); -goog.require('Blockly.FlyoutDragger'); -goog.require('Blockly.Tooltip'); -goog.require('Blockly.Touch'); -goog.require('Blockly.WorkspaceDragger'); - -goog.require('goog.asserts'); -goog.require('goog.math.Coordinate'); - - -/** - * NB: In this file "start" refers to touchstart, mousedown, and pointerstart - * events. "End" refers to touchend, mouseup, and pointerend events. - * TODO: Consider touchcancel/pointercancel. - */ - -/** - * Class for one gesture. - * @param {!Event} e The event that kicked off this gesture. - * @param {!Blockly.WorkspaceSvg} creatorWorkspace The workspace that created - * this gesture and has a reference to it. - * @constructor - */ -Blockly.Gesture = function(e, creatorWorkspace) { - - /** - * The position of the mouse when the gesture started. Units are css pixels, - * with (0, 0) at the top left of the browser window (mouseEvent clientX/Y). - * @type {goog.math.Coordinate} - */ - this.mouseDownXY_ = null; - - /** - * How far the mouse has moved during this drag, in pixel units. - * (0, 0) is at this.mouseDownXY_. - * @type {goog.math.Coordinate} - * private - */ - this.currentDragDeltaXY_ = 0; - - /** - * The field that the gesture started on, or null if it did not start on a - * field. - * @type {Blockly.Field} - * @private - */ - this.startField_ = null; - - /** - * The block that the gesture started on, or null if it did not start on a - * block. - * @type {Blockly.BlockSvg} - * @private - */ - this.startBlock_ = null; - - /** - * The block that this gesture targets. If the gesture started on a - * shadow block, this is the first non-shadow parent of the block. If the - * gesture started in the flyout, this is the root block of the block group - * that was clicked or dragged. - * @type {Blockly.BlockSvg} - * @private - */ - this.targetBlock_ = null; - - /** - * The workspace that the gesture started on. There may be multiple - * workspaces on a page; this is more accurate than using - * Blockly.getMainWorkspace(). - * @type {Blockly.WorkspaceSvg} - * @private - */ - this.startWorkspace_ = null; - - /** - * The workspace that created this gesture. This workspace keeps a reference - * to the gesture, which will need to be cleared at deletion. - * This may be different from the start workspace. For instance, a flyout is - * a workspace, but its parent workspace manages gestures for it. - * @type {Blockly.WorkspaceSvg} - * @private - */ - this.creatorWorkspace_ = creatorWorkspace; - - /** - * Whether the pointer has at any point moved out of the drag radius. - * A gesture that exceeds the drag radius is a drag even if it ends exactly at - * its start point. - * @type {boolean} - * @private - */ - this.hasExceededDragRadius_ = false; - - /** - * Whether the workspace is currently being dragged. - * @type {boolean} - * @private - */ - this.isDraggingWorkspace_ = false; - - /** - * Whether the block is currently being dragged. - * @type {boolean} - * @private - */ - this.isDraggingBlock_ = false; - - /** - * The event that most recently updated this gesture. - * @type {!Event} - * @private - */ - this.mostRecentEvent_ = e; - - /** - * A handle to use to unbind a mouse move listener at the end of a drag. - * Opaque data returned from Blockly.bindEventWithChecks_. - * @type {Array.} - * @private - */ - this.onMoveWrapper_ = null; - - /** - * A handle to use to unbind a mouse up listener at the end of a drag. - * Opaque data returned from Blockly.bindEventWithChecks_. - * @type {Array.} - * @private - */ - this.onUpWrapper_ = null; - - /** - * The object tracking a block drag, or null if none is in progress. - * @type {Blockly.BlockDragger} - * @private - */ - this.blockDragger_ = null; - - /** - * The object tracking a workspace or flyout workspace drag, or null if none - * is in progress. - * @type {Blockly.WorkspaceDragger} - * @private - */ - this.workspaceDragger_ = null; - - /** - * The flyout a gesture started in, if any. - * @type {Blockly.Flyout} - * @private - */ - this.flyout_ = null; - - /** - * Boolean for sanity-checking that some code is only called once. - * @type {boolean} - * @private - */ - this.calledUpdateIsDragging_ = false; - - /** - * Boolean for sanity-checking that some code is only called once. - * @type {boolean} - * @private - */ - this.hasStarted_ = false; - - /** - * Boolean used internally to break a cycle in disposal. - * @type {boolean} - * @private - */ - this.isEnding_ = false; -}; - -/** - * Sever all links from this object. - * @package - */ -Blockly.Gesture.prototype.dispose = function() { - Blockly.Touch.clearTouchIdentifier(); - Blockly.Tooltip.unblock(); - // Clear the owner's reference to this gesture. - this.creatorWorkspace_.clearGesture(); - - if (this.onMoveWrapper_) { - Blockly.unbindEvent_(this.onMoveWrapper_); - } - if (this.onUpWrapper_) { - Blockly.unbindEvent_(this.onUpWrapper_); - } - - - this.startField_ = null; - this.startBlock_ = null; - this.targetBlock_ = null; - this.startWorkspace_ = null; - this.flyout_ = null; - - if (this.blockDragger_) { - this.blockDragger_.dispose(); - this.blockDragger_ = null; - } - if (this.workspaceDragger_) { - this.workspaceDragger_.dispose(); - this.workspaceDragger_ = null; - } -}; - -/** - * Update internal state based on an event. - * @param {!Event} e The most recent mouse or touch event. - * @private - */ -Blockly.Gesture.prototype.updateFromEvent_ = function(e) { - var currentXY = new goog.math.Coordinate(e.clientX, e.clientY); - var changed = this.updateDragDelta_(currentXY); - // Exceeded the drag radius for the first time. - if (changed){ - this.updateIsDragging_(); - Blockly.longStop_(); - } - this.mostRecentEvent_ = e; -}; - -/** - * DO MATH to set currentDragDeltaXY_ based on the most recent mouse position. - * @param {!goog.math.Coordinate} currentXY The most recent mouse/pointer - * position, in pixel units, with (0, 0) at the window's top left corner. - * @return {boolean} True if the drag just exceeded the drag radius for the - * first time. - * @private - */ -Blockly.Gesture.prototype.updateDragDelta_ = function(currentXY) { - this.currentDragDeltaXY_ = goog.math.Coordinate.difference(currentXY, - this.mouseDownXY_); - - if (!this.hasExceededDragRadius_) { - var currentDragDelta = goog.math.Coordinate.magnitude( - this.currentDragDeltaXY_); - - // The flyout has a different drag radius from the rest of Blockly. - var limitRadius = this.flyout_ ? Blockly.FLYOUT_DRAG_RADIUS : - Blockly.DRAG_RADIUS; - - this.hasExceededDragRadius_ = currentDragDelta > limitRadius; - return this.hasExceededDragRadius_; - } - return false; -}; - -/** - * Update this gesture to record whether a block is being dragged from the - * flyout. - * This function should be called on a mouse/touch move event the first time the - * drag radius is exceeded. It should be called no more than once per gesture. - * If a block should be dragged from the flyout this function creates the new - * block on the main workspace and updates targetBlock_ and startWorkspace_. - * @return {boolean} True if a block is being dragged from the flyout. - * @private - */ -Blockly.Gesture.prototype.updateIsDraggingFromFlyout_ = function() { - // Disabled blocks may not be dragged from the flyout. - if (this.targetBlock_.disabled) { - return false; - } - if (!this.flyout_.isScrollable() || - this.flyout_.isDragTowardWorkspace(this.currentDragDeltaXY_)) { - this.startWorkspace_ = this.flyout_.targetWorkspace_; - this.startWorkspace_.updateScreenCalculationsIfScrolled(); - // Start the event group now, so that the same event group is used for block - // creation and block dragging. - if (!Blockly.Events.getGroup()) { - Blockly.Events.setGroup(true); - } - // The start block is no longer relevant, because this is a drag. - this.startBlock_ = null; - this.targetBlock_ = this.flyout_.createBlock(this.targetBlock_); - this.targetBlock_.select(); - return true; - } - return false; -}; - -/** - * Update this gesture to record whether a block is being dragged. - * This function should be called on a mouse/touch move event the first time the - * drag radius is exceeded. It should be called no more than once per gesture. - * If a block should be dragged, either from the flyout or in the workspace, - * this function creates the necessary BlockDragger and starts the drag. - * @return {boolean} true if a block is being dragged. - * @private - */ -Blockly.Gesture.prototype.updateIsDraggingBlock_ = function() { - if (!this.targetBlock_) { - return false; - } - - if (this.flyout_) { - this.isDraggingBlock_ = this.updateIsDraggingFromFlyout_(); - } else if (this.targetBlock_.isMovable()){ - this.isDraggingBlock_ = true; - } - - if (this.isDraggingBlock_) { - this.startDraggingBlock_(); - return true; - } - return false; -}; - -/** - * Update this gesture to record whether a workspace is being dragged. - * This function should be called on a mouse/touch move event the first time the - * drag radius is exceeded. It should be called no more than once per gesture. - * If a workspace is being dragged this function creates the necessary - * WorkspaceDragger or FlyoutDragger and starts the drag. - * @private - */ -Blockly.Gesture.prototype.updateIsDraggingWorkspace_ = function() { - var wsMovable = this.flyout_ ? this.flyout_.isScrollable() : - this.startWorkspace_ && this.startWorkspace_.isDraggable(); - - if (!wsMovable) { - return; - } - - if (this.flyout_) { - this.workspaceDragger_ = new Blockly.FlyoutDragger(this.flyout_); - } else { - this.workspaceDragger_ = new Blockly.WorkspaceDragger(this.startWorkspace_); - } - - this.isDraggingWorkspace_ = true; - this.workspaceDragger_.startDrag(); -}; - -/** - * Update this gesture to record whether anything is being dragged. - * This function should be called on a mouse/touch move event the first time the - * drag radius is exceeded. It should be called no more than once per gesture. - * @private - */ -Blockly.Gesture.prototype.updateIsDragging_ = function() { - // Sanity check. - goog.asserts.assert(!this.calledUpdateIsDragging_, - 'updateIsDragging_ should only be called once per gesture.'); - this.calledUpdateIsDragging_ = true; - - // First check if it was a block drag. - if (this.updateIsDraggingBlock_()) { - return; - } - // Then check if it's a workspace drag. - this.updateIsDraggingWorkspace_(); -}; - -/** - * Create a block dragger and start dragging the selected block. - * @private - */ -Blockly.Gesture.prototype.startDraggingBlock_ = function() { - this.blockDragger_ = new Blockly.BlockDragger(this.targetBlock_, - this.startWorkspace_); - this.blockDragger_.startBlockDrag(this.currentDragDeltaXY_); - this.blockDragger_.dragBlock(this.mostRecentEvent_, - this.currentDragDeltaXY_); -}; - -/** - * Start a gesture: update the workspace to indicate that a gesture is in - * progress and bind mousemove and mouseup handlers. - * @param {!Event} e A mouse down or touch start event. - * @package - */ -Blockly.Gesture.prototype.doStart = function(e) { - if (Blockly.utils.isTargetInput(e)) { - this.cancel(); - return; - } - this.hasStarted_ = true; - - Blockly.BlockSvg.disconnectUiStop_(); - this.startWorkspace_.updateScreenCalculationsIfScrolled(); - if (this.startWorkspace_.isMutator) { - // Mutator's coordinate system could be out of date because the bubble was - // dragged, the block was moved, the parent workspace zoomed, etc. - this.startWorkspace_.resize(); - } - this.startWorkspace_.markFocused(); - this.mostRecentEvent_ = e; - - // Hide chaff also hides the flyout, so don't do it if the click is in a flyout. - Blockly.hideChaff(!!this.flyout_); - Blockly.Tooltip.block(); - - if (this.targetBlock_) { - this.targetBlock_.select(); - } - - if (Blockly.utils.isRightButton(e)) { - this.handleRightClick(e); - return; - } - - if (goog.string.caseInsensitiveEquals(e.type, 'touchstart')) { - Blockly.longStart_(e, this); - } - - this.mouseDownXY_ = new goog.math.Coordinate(e.clientX, e.clientY); - - this.onMoveWrapper_ = Blockly.bindEventWithChecks_( - document, 'mousemove', null, this.handleMove.bind(this)); - this.onUpWrapper_ = Blockly.bindEventWithChecks_( - document, 'mouseup', null, this.handleUp.bind(this)); - - e.preventDefault(); - e.stopPropagation(); -}; - -/** - * Handle a mouse move or touch move event. - * @param {!Event} e A mouse move or touch move event. - * @package - */ -Blockly.Gesture.prototype.handleMove = function(e) { - this.updateFromEvent_(e); - if (this.isDraggingWorkspace_) { - this.workspaceDragger_.drag(this.currentDragDeltaXY_); - } else if (this.isDraggingBlock_) { - this.blockDragger_.dragBlock(this.mostRecentEvent_, - this.currentDragDeltaXY_); - } - e.preventDefault(); - e.stopPropagation(); -}; - -/** - * Handle a mouse up or touch end event. - * @param {!Event} e A mouse up or touch end event. - * @package - */ -Blockly.Gesture.prototype.handleUp = function(e) { - this.updateFromEvent_(e); - Blockly.longStop_(); - - if (this.isEnding_) { - console.log('Trying to end a gesture recursively.'); - return; - } - this.isEnding_ = true; - // The ordering of these checks is important: drags have higher priority than - // clicks. Fields have higher priority than blocks; blocks have higher - // priority than workspaces. - if (this.isDraggingBlock_) { - this.blockDragger_.endBlockDrag(e, this.currentDragDeltaXY_); - } else if (this.isDraggingWorkspace_) { - this.workspaceDragger_.endDrag(this.currentDragDeltaXY_); - } else if (this.isFieldClick_()) { - this.doFieldClick_(); - } else if (this.isBlockClick_()) { - this.doBlockClick_(); - } else if (this.isWorkspaceClick_()) { - this.doWorkspaceClick_(); - } - - e.preventDefault(); - e.stopPropagation(); - - this.dispose(); -}; - -/** - * Cancel an in-progress gesture. If a workspace or block drag is in progress, - * end the drag at the most recent location. - * @package - */ -Blockly.Gesture.prototype.cancel = function() { - // Disposing of a block cancels in-progress drags, but dragging to a delete - // area disposes of a block and leads to recursive disposal. Break that cycle. - if (this.isEnding_) { - return; - } - Blockly.longStop_(); - if (this.isDraggingBlock_) { - this.blockDragger_.endBlockDrag(this.mostRecentEvent_, - this.currentDragDeltaXY_); - } else if (this.isDraggingWorkspace_) { - this.workspaceDragger_.endDrag(this.currentDragDeltaXY_); - } - this.dispose(); -}; - -/** - * Handle a real or faked right-click event by showing a context menu. - * @param {!Event} e A mouse move or touch move event. - * @package - */ -Blockly.Gesture.prototype.handleRightClick = function(e) { - if (this.targetBlock_) { - this.bringBlockToFront_(); - Blockly.hideChaff(this.flyout_); - this.targetBlock_.showContextMenu_(e); - } else if (this.startWorkspace_ && !this.flyout_) { - Blockly.hideChaff(); - this.startWorkspace_.showContextMenu_(e); - } - - e.preventDefault(); - e.stopPropagation(); - - this.dispose(); -}; - -/** - * Handle a mousedown/touchstart event on a workspace. - * @param {!Event} e A mouse down or touch start event. - * @param {!Blockly.Workspace} ws The workspace the event hit. - * @package - */ -Blockly.Gesture.prototype.handleWsStart = function(e, ws) { - goog.asserts.assert(!this.hasStarted_, - 'Tried to call gesture.handleWsStart, but the gesture had already been ' + - 'started.'); - this.setStartWorkspace_(ws); - this.mostRecentEvent_ = e; - this.doStart(e); -}; - -/** - * Handle a mousedown/touchstart event on a flyout. - * @param {!Event} e A mouse down or touch start event. - * @param {!Blockly.Flyout} flyout The flyout the event hit. - * @package - */ -Blockly.Gesture.prototype.handleFlyoutStart = function(e, flyout) { - goog.asserts.assert(!this.hasStarted_, - 'Tried to call gesture.handleFlyoutStart, but the gesture had already been ' + - 'started.'); - this.setStartFlyout_(flyout); - this.handleWsStart(e, flyout.getWorkspace()); -}; - -/** - * Handle a mousedown/touchstart event on a block. - * @param {!Event} e A mouse down or touch start event. - * @param {!Blockly.BlockSvg} block The block the event hit. - * @package - */ -Blockly.Gesture.prototype.handleBlockStart = function(e, block) { - goog.asserts.assert(!this.hasStarted_, - 'Tried to call gesture.handleBlockStart, but the gesture had already been ' + - 'started.'); - this.setStartBlock(block); - this.mostRecentEvent_ = e; -}; - -/* Begin functions defining what actions to take to execute clicks on each type - * of target. Any developer wanting to add behaviour on clicks should modify - * only this code. */ - -/** - * Execute a field click. - * @private - */ -Blockly.Gesture.prototype.doFieldClick_ = function() { - this.startField_.showEditor_(); - this.bringBlockToFront_(); -}; - -/** - * Execute a block click. - * @private - */ -Blockly.Gesture.prototype.doBlockClick_ = function() { - // Block click in an autoclosing flyout. - if (this.flyout_ && this.flyout_.autoClose) { - if (!this.targetBlock_.disabled) { - if (!Blockly.Events.getGroup()) { - Blockly.Events.setGroup(true); - } - var newBlock = this.flyout_.createBlock(this.targetBlock_); - newBlock.scheduleSnapAndBump(); - } - } else { - // Clicks events are on the start block, even if it was a shadow. - Blockly.Events.fire( - new Blockly.Events.Ui(this.startBlock_, 'click', undefined, undefined)); - } - this.bringBlockToFront_(); - Blockly.Events.setGroup(false); -}; - -/** - * Execute a workspace click. - * @private - */ -Blockly.Gesture.prototype.doWorkspaceClick_ = function() { - if (Blockly.selected) { - Blockly.selected.unselect(); - } -}; - -/* End functions defining what actions to take to execute clicks on each type - * of target. */ - -/** - * Move the dragged/clicked block to the front of the workspace so that it is - * not occluded by other blocks. - * @private - */ -Blockly.Gesture.prototype.bringBlockToFront_ = function() { - // Blocks in the flyout don't overlap, so skip the work. - if (this.targetBlock_ && !this.flyout_) { - this.targetBlock_.bringToFront(); - } -}; - -/* Begin functions for populating a gesture at mouse down. */ - -/** - * Record the field that a gesture started on. - * @param {Blockly.Field} field The field the gesture started on. - * @package - */ -Blockly.Gesture.prototype.setStartField = function(field) { - goog.asserts.assert(!this.hasStarted_, - 'Tried to call gesture.setStartField, but the gesture had already been ' + - 'started.'); - if (!this.startField_) { - this.startField_ = field; - } -}; - -/** - * Record the block that a gesture started on, and set the target block - * appropriately. - * @param {Blockly.BlockSvg} block The block the gesture started on. - * @package - */ -Blockly.Gesture.prototype.setStartBlock = function(block) { - if (!this.startBlock_) { - this.startBlock_ = block; - if (block.isInFlyout && block != block.getRootBlock()) { - this.setTargetBlock_(block.getRootBlock()); - } else { - this.setTargetBlock_(block); - } - } -}; - -/** - * Record the block that a gesture targets, meaning the block that will be - * dragged if this turns into a drag. If this block is a shadow, that will be - * its first non-shadow parent. - * @param {Blockly.BlockSvg} block The block the gesture targets. - * @private - */ -Blockly.Gesture.prototype.setTargetBlock_ = function(block) { - if (block.isShadow()) { - this.setTargetBlock_(block.getParent()); - } else { - this.targetBlock_ = block; - } -}; - -/** - * Record the workspace that a gesture started on. - * @param {Blockly.WorkspaceSvg} ws The workspace the gesture started on. - * @private - */ -Blockly.Gesture.prototype.setStartWorkspace_ = function(ws) { - if (!this.startWorkspace_) { - this.startWorkspace_ = ws; - } -}; - -/** - * Record the flyout that a gesture started on. - * @param {Blockly.Flyout} flyout The flyout the gesture started on. - * @private - */ -Blockly.Gesture.prototype.setStartFlyout_ = function(flyout) { - if (!this.flyout_) { - this.flyout_ = flyout; - } -}; - -/* End functions for populating a gesture at mouse down. */ - -/* Begin helper functions defining types of clicks. Any developer wanting - * to change the definition of a click should modify only this code. */ - -/** - * Whether this gesture is a click on a block. This should only be called when - * ending a gesture (mouse up, touch end). - * @return {boolean} whether this gesture was a click on a block. - * @private - */ -Blockly.Gesture.prototype.isBlockClick_ = function() { - // A block click starts on a block, never escapes the drag radius, and is not - // a field click. - var hasStartBlock = !!this.startBlock_; - return hasStartBlock && !this.hasExceededDragRadius_ && !this.isFieldClick_(); -}; - -/** - * Whether this gesture is a click on a field. This should only be called when - * ending a gesture (mouse up, touch end). - * @return {boolean} whether this gesture was a click on a field. - * @private - */ -Blockly.Gesture.prototype.isFieldClick_ = function() { - var fieldEditable = this.startField_ ? - this.startField_.isCurrentlyEditable() : false; - return fieldEditable && !this.hasExceededDragRadius_ && (!this.flyout_ || - !this.flyout_.autoClose); -}; - -/** - * Whether this gesture is a click on a workspace. This should only be called - * when ending a gesture (mouse up, touch end). - * @return {boolean} whether this gesture was a click on a workspace. - * @private - */ -Blockly.Gesture.prototype.isWorkspaceClick_ = function() { - var onlyTouchedWorkspace = !this.startBlock_ && !this.startField_; - return onlyTouchedWorkspace && !this.hasExceededDragRadius_; -}; - -/* End helper functions defining types of clicks. */ - -/** - * Whether this gesture is a drag of either a workspace or block. - * This function is called externally to block actions that cannot be taken - * mid-drag (e.g. using the keyboard to delete the selected blocks). - * @return {boolean} true if this gesture is a drag of a workspace or block. - * @package - */ -Blockly.Gesture.prototype.isDragging = function() { - return this.isDraggingWorkspace_ || this.isDraggingBlock_; -}; - -/** - * Whether this gesture has already been started. In theory every mouse down - * has a corresponding mouse up, but in reality it is possible to lose a - * mouse up, leaving an in-process gesture hanging. - * @return {boolean} whether this gesture was a click on a workspace. - * @package - */ -Blockly.Gesture.prototype.hasStarted = function() { - return this.hasStarted_; -}; diff --git a/core/gesture.ts b/core/gesture.ts new file mode 100644 index 00000000000..fa3d8a15138 --- /dev/null +++ b/core/gesture.ts @@ -0,0 +1,1224 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The class representing an in-progress gesture, e.g. a drag, + * tap, or pinch to zoom. + * + * @class + */ +// Former goog.module ID: Blockly.Gesture + +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_click.js'; + +import * as blockAnimations from './block_animations.js'; +import type {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; +import {RenderedWorkspaceComment} from './comments.js'; +import * as common from './common.js'; +import {config} from './config.js'; +import * as dropDownDiv from './dropdowndiv.js'; +import {EventType} from './events/type.js'; +import * as eventUtils from './events/utils.js'; +import type {Field} from './field.js'; +import {getFocusManager} from './focus_manager.js'; +import type {IBubble} from './interfaces/i_bubble.js'; +import {IDraggable, isDraggable} from './interfaces/i_draggable.js'; +import {IDragger} from './interfaces/i_dragger.js'; +import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IIcon} from './interfaces/i_icon.js'; +import {keyboardNavigationController} from './keyboard_navigation_controller.js'; +import * as registry from './registry.js'; +import * as Tooltip from './tooltip.js'; +import * as Touch from './touch.js'; +import {Coordinate} from './utils/coordinate.js'; +import {WorkspaceDragger} from './workspace_dragger.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +/** + * Note: In this file "start" refers to pointerdown + * events. "End" refers to pointerup events. + */ + +/** A multiplier used to convert the gesture scale to a zoom in delta. */ +const ZOOM_IN_MULTIPLIER = 5; + +/** A multiplier used to convert the gesture scale to a zoom out delta. */ +const ZOOM_OUT_MULTIPLIER = 6; + +/** + * Class for one gesture. + */ +export class Gesture { + /** + * The position of the pointer when the gesture started. Units are CSS + * pixels, with (0, 0) at the top left of the browser window (pointer event + * clientX/Y). + */ + private mouseDownXY = new Coordinate(0, 0); + private currentDragDeltaXY: Coordinate; + + /** + * The bubble that the gesture started on, or null if it did not start on a + * bubble. + */ + private startBubble: IBubble | null = null; + + /** + * The field that the gesture started on, or null if it did not start on a + * field. + */ + private startField: Field | null = null; + + /** + * The icon that the gesture started on, or null if it did not start on an + * icon. + */ + private startIcon: IIcon | null = null; + + /** + * The block that the gesture started on, or null if it did not start on a + * block. + */ + private startBlock: BlockSvg | null = null; + + /** + * The comment that the gesture started on, or null if it did not start on a + * comment. + */ + private startComment: RenderedWorkspaceComment | null = null; + + /** + * The block that this gesture targets. If the gesture started on a + * shadow block, this is the first non-shadow parent of the block. If the + * gesture started in the flyout, this is the root block of the block group + * that was clicked or dragged. + */ + private targetBlock: BlockSvg | null = null; + + /** + * The workspace that the gesture started on. There may be multiple + * workspaces on a page; this is more accurate than using + * Blockly.common.getMainWorkspace(). + */ + protected startWorkspace_: WorkspaceSvg | null = null; + + /** + * Whether the pointer has at any point moved out of the drag radius. + * A gesture that exceeds the drag radius is a drag even if it ends exactly + * at its start point. + */ + private hasExceededDragRadius = false; + + /** + * Array holding info needed to unbind events. + * Used for disposing. + * Ex: [[node, name, func], [node, name, func]]. + */ + private boundEvents: browserEvents.Data[] = []; + + private dragger: IDragger | null = null; + + /** + * The object tracking a workspace or flyout workspace drag, or null if none + * is in progress. + */ + private workspaceDragger: WorkspaceDragger | null = null; + + /** Whether the gesture is dragging or not. */ + private dragging: boolean = false; + + /** The flyout a gesture started in, if any. */ + private flyout: IFlyout | null = null; + + /** Boolean for sanity-checking that some code is only called once. */ + private calledUpdateIsDragging = false; + + /** Boolean for sanity-checking that some code is only called once. */ + private gestureHasStarted = false; + + /** Boolean used internally to break a cycle in disposal. */ + protected isEnding_ = false; + + /** The event that most recently updated this gesture. */ + private mostRecentEvent: PointerEvent; + + /** Boolean for whether or not this gesture is a multi-touch gesture. */ + private multiTouch = false; + + /** A map of cached points used for tracking multi-touch gestures. */ + private cachedPoints = new Map(); + + /** + * This is the ratio between the starting distance between the touch points + * and the most recent distance between the touch points. + * Scales between 0 and 1 mean the most recent zoom was a zoom out. + * Scales above 1.0 mean the most recent zoom was a zoom in. + */ + private previousScale = 0; + + /** The starting distance between two touch points. */ + private startDistance = 0; + + /** Boolean for whether or not the workspace supports pinch-zoom. */ + private isPinchZoomEnabled: boolean | null = null; + + /** + * The owner of the dropdownDiv when this gesture first starts. + * Needed because we'll close the dropdown before fields get to + * act on their events, and some fields care about who owns + * the dropdown. + */ + currentDropdownOwner: Field | null = null; + + /** + * @param e The event that kicked off this gesture. + * @param creatorWorkspace The workspace that created this gesture and has a + * reference to it. + */ + constructor( + e: PointerEvent, + private readonly creatorWorkspace: WorkspaceSvg, + ) { + this.mostRecentEvent = e; + + /** + * How far the pointer has moved during this drag, in pixel units. + * (0, 0) is at this.mouseDownXY_. + */ + this.currentDragDeltaXY = new Coordinate(0, 0); + } + + /** + * Sever all links from this object. + * + * @internal + */ + dispose() { + Touch.clearTouchIdentifier(); + Tooltip.unblock(); + // Clear the owner's reference to this gesture. + this.creatorWorkspace.clearGesture(); + + for (const event of this.boundEvents) { + browserEvents.unbind(event); + } + this.boundEvents.length = 0; + + if (this.workspaceDragger) { + this.workspaceDragger.dispose(); + } + } + + /** + * Update internal state based on an event. + * + * @param e The most recent pointer event. + */ + private updateFromEvent(e: PointerEvent) { + const currentXY = new Coordinate(e.clientX, e.clientY); + const changed = this.updateDragDelta(currentXY); + // Exceeded the drag radius for the first time. + if (changed) { + this.updateIsDragging(e); + Touch.longStop(); + } + this.mostRecentEvent = e; + } + + /** + * DO MATH to set currentDragDeltaXY_ based on the most recent pointer + * position. + * + * @param currentXY The most recent pointer position, in pixel units, + * with (0, 0) at the window's top left corner. + * @returns True if the drag just exceeded the drag radius for the first time. + */ + private updateDragDelta(currentXY: Coordinate): boolean { + this.currentDragDeltaXY = Coordinate.difference( + currentXY, + this.mouseDownXY, + ); + + if (!this.hasExceededDragRadius) { + const currentDragDelta = Coordinate.magnitude(this.currentDragDeltaXY); + + // The flyout has a different drag radius from the rest of Blockly. + const limitRadius = this.flyout + ? config.flyoutDragRadius + : config.dragRadius; + + this.hasExceededDragRadius = currentDragDelta > limitRadius; + return this.hasExceededDragRadius; + } + return false; + } + + /** + * Update this gesture to record whether a block is being dragged from the + * flyout. + * This function should be called on a pointermove event the first time + * the drag radius is exceeded. It should be called no more than once per + * gesture. If a block should be dragged from the flyout this function creates + * the new block on the main workspace and updates targetBlock_ and + * startWorkspace_. + * + * @returns True if a block is being dragged from the flyout. + */ + private updateIsDraggingFromFlyout(): boolean { + if (!this.targetBlock || !this.flyout?.isBlockCreatable(this.targetBlock)) { + return false; + } + if (!this.flyout.targetWorkspace) { + throw new Error(`Cannot update dragging from the flyout because the ' + + 'flyout's target workspace is undefined`); + } + if ( + !this.flyout.isScrollable() || + this.flyout.isDragTowardWorkspace(this.currentDragDeltaXY) + ) { + this.startWorkspace_ = this.flyout.targetWorkspace; + this.startWorkspace_.updateScreenCalculationsIfScrolled(); + // Start the event group now, so that the same event group is used for + // block creation and block dragging. + if (!eventUtils.getGroup()) { + eventUtils.setGroup(true); + } + // The start block is no longer relevant, because this is a drag. + this.startBlock = null; + this.targetBlock = this.flyout.createBlock(this.targetBlock); + getFocusManager().focusNode(this.targetBlock); + return true; + } + return false; + } + + /** + * Check whether to start a workspace drag. If a workspace is being dragged, + * create the necessary WorkspaceDragger and start the drag. + * + * This function should be called on a pointermove event the first time + * the drag radius is exceeded. It should be called no more than once per + * gesture. If a workspace is being dragged this function creates the + * necessary WorkspaceDragger and starts the drag. + */ + private updateIsDraggingWorkspace() { + if (!this.startWorkspace_) { + throw new Error( + 'Cannot update dragging the workspace because the ' + + 'start workspace is undefined', + ); + } + + const wsMovable = this.flyout + ? this.flyout.isScrollable() + : this.startWorkspace_ && this.startWorkspace_.isDraggable(); + if (!wsMovable) return; + + this.dragging = true; + this.workspaceDragger = new WorkspaceDragger(this.startWorkspace_); + + this.workspaceDragger.startDrag(); + } + + /** + * Update this gesture to record whether anything is being dragged. + * This function should be called on a pointermove event the first time + * the drag radius is exceeded. It should be called no more than once per + * gesture. + */ + private updateIsDragging(e: PointerEvent) { + if (!this.startWorkspace_) { + throw new Error( + 'Cannot update dragging because the start workspace is undefined', + ); + } + + if (this.calledUpdateIsDragging) { + throw Error('updateIsDragging_ should only be called once per gesture.'); + } + this.calledUpdateIsDragging = true; + + // If we drag a block out of the flyout, it updates `common.getSelected` + // to return the new block. + if (this.flyout) this.updateIsDraggingFromFlyout(); + + const selected = common.getSelected(); + if (selected && isDraggable(selected) && selected.isMovable()) { + this.dragging = true; + this.dragger = this.createDragger(selected, this.startWorkspace_); + this.dragger.onDragStart(e); + this.dragger.onDrag(e, this.currentDragDeltaXY); + } else { + this.updateIsDraggingWorkspace(); + } + } + + private createDragger( + draggable: IDraggable, + workspace: WorkspaceSvg, + ): IDragger { + const DraggerClass = registry.getClassFromOptions( + registry.Type.BLOCK_DRAGGER, + this.creatorWorkspace.options, + true, + ); + return new DraggerClass!(draggable, workspace); + } + + /** + * Start a gesture: update the workspace to indicate that a gesture is in + * progress and bind pointermove and pointerup handlers. + * + * @param e A pointerdown event. + * @internal + */ + doStart(e: PointerEvent) { + if (!this.startWorkspace_) { + throw new Error( + 'Cannot start the touch gesture becauase the start ' + + 'workspace is undefined', + ); + } + this.isPinchZoomEnabled = + this.startWorkspace_.options.zoomOptions && + this.startWorkspace_.options.zoomOptions.pinch; + + if (browserEvents.isTargetInput(e)) { + this.cancel(); + return; + } + + this.gestureHasStarted = true; + + blockAnimations.disconnectUiStop(); + + this.startWorkspace_.updateScreenCalculationsIfScrolled(); + if (this.startWorkspace_.isMutator) { + // Mutator's coordinate system could be out of date because the bubble was + // dragged, the block was moved, the parent workspace zoomed, etc. + this.startWorkspace_.resize(); + } + + // Keep track of which field owns the dropdown before we close it. + this.currentDropdownOwner = dropDownDiv.getOwner(); + // Hide chaff also hides the flyout, so don't do it if the click is in a + // flyout. + this.startWorkspace_.hideChaff(!!this.flyout); + + this.startWorkspace_.markFocused(); + this.mostRecentEvent = e; + + Tooltip.block(); + + if (browserEvents.isRightButton(e)) { + this.handleRightClick(e); + return; + } + + if (e.type.toLowerCase() === 'pointerdown' && e.pointerType !== 'mouse') { + Touch.longStart(e, this); + } + + this.mouseDownXY = new Coordinate(e.clientX, e.clientY); + + this.bindMouseEvents(e); + + if (!this.isEnding_) { + this.handleTouchStart(e); + } + } + + /** + * Bind gesture events. + * + * @param e A pointerdown event. + * @internal + */ + bindMouseEvents(e: PointerEvent) { + this.boundEvents.push( + browserEvents.conditionalBind( + document, + 'pointerdown', + null, + this.handleStart.bind(this), + /* opt_noCaptureIdentifier */ true, + ), + ); + this.boundEvents.push( + browserEvents.conditionalBind( + document, + 'pointermove', + null, + this.handleMove.bind(this), + /* opt_noCaptureIdentifier */ true, + ), + ); + this.boundEvents.push( + browserEvents.conditionalBind( + document, + 'pointerup', + null, + this.handleUp.bind(this), + /* opt_noCaptureIdentifier */ true, + ), + ); + this.boundEvents.push( + browserEvents.conditionalBind( + document, + 'pointercancel', + null, + this.handleUp.bind(this), + /* opt_noCaptureIdentifier */ true, + ), + ); + + e.preventDefault(); + e.stopPropagation(); + } + + /** + * Handle a pointerdown event. + * + * @param e A pointerdown event. + * @internal + */ + handleStart(e: PointerEvent) { + if (this.isDragging()) { + // A drag has already started, so this can no longer be a pinch-zoom. + return; + } + this.handleTouchStart(e); + + if (this.isMultiTouch()) { + Touch.longStop(); + } + } + + /** + * Handle a pointermove event. + * + * @param e A pointermove event. + * @internal + */ + handleMove(e: PointerEvent) { + if ( + (this.isDragging() && Touch.shouldHandleEvent(e)) || + !this.isMultiTouch() + ) { + this.updateFromEvent(e); + if (this.workspaceDragger) { + this.workspaceDragger.drag(this.currentDragDeltaXY); + } else if (this.dragger) { + this.dragger.onDrag(this.mostRecentEvent, this.currentDragDeltaXY); + } + e.preventDefault(); + e.stopPropagation(); + } else if (this.isMultiTouch()) { + this.handleTouchMove(e); + Touch.longStop(); + } + } + + /** + * Handle a pointerup event. + * + * @param e A pointerup event. + * @internal + */ + handleUp(e: PointerEvent) { + if (!this.isDragging()) { + this.handleTouchEnd(e); + } + if (!this.isMultiTouch() || this.isDragging()) { + if (!Touch.shouldHandleEvent(e)) { + return; + } + this.updateFromEvent(e); + Touch.longStop(); + + if (this.isEnding_) { + console.log('Trying to end a gesture recursively.'); + return; + } + this.isEnding_ = true; + // The ordering of these checks is important: drags have higher priority + // than clicks. Fields and icons have higher priority than blocks; blocks + // have higher priority than workspaces. The ordering within drags does + // not matter, because the three types of dragging are exclusive. + if (this.dragger) { + keyboardNavigationController.setIsActive(false); + this.dragger.onDragEnd(e, this.currentDragDeltaXY); + } else if (this.workspaceDragger) { + keyboardNavigationController.setIsActive(false); + this.workspaceDragger.endDrag(this.currentDragDeltaXY); + } else if (this.isBubbleClick()) { + // Do nothing, bubbles don't currently respond to clicks. + } else if (this.isCommentClick()) { + // Do nothing, comments don't currently respond to clicks. + } else if (this.isFieldClick()) { + this.doFieldClick(); + } else if (this.isIconClick()) { + this.doIconClick(); + } else if (this.isBlockClick()) { + this.doBlockClick(); + } else if (this.isWorkspaceClick()) { + this.doWorkspaceClick(e); + } + + e.preventDefault(); + e.stopPropagation(); + + this.dispose(); + } else { + e.preventDefault(); + e.stopPropagation(); + + this.dispose(); + } + } + + /** + * Handle a pointerdown event and keep track of current + * pointers. + * + * @param e A pointerdown event. + * @internal + */ + handleTouchStart(e: PointerEvent) { + const pointerId = Touch.getTouchIdentifierFromEvent(e); + // store the pointerId in the current list of pointers + this.cachedPoints.set(pointerId, this.getTouchPoint(e)); + const pointers = Array.from(this.cachedPoints.keys()); + // If two pointers are down, store info + if (pointers.length === 2) { + const point0 = this.cachedPoints.get(pointers[0])!; + const point1 = this.cachedPoints.get(pointers[1])!; + this.startDistance = Coordinate.distance(point0, point1); + this.multiTouch = true; + e.preventDefault(); + } + } + + /** + * Handle a pointermove event and zoom in/out if two pointers + * are on the screen. + * + * @param e A pointermove event. + * @internal + */ + handleTouchMove(e: PointerEvent) { + const pointerId = Touch.getTouchIdentifierFromEvent(e); + this.cachedPoints.set(pointerId, this.getTouchPoint(e)); + + if (this.isPinchZoomEnabled && this.cachedPoints.size === 2) { + this.handlePinch(e); + } else { + // Handle the move directly instead of calling handleMove + this.updateFromEvent(e); + if (this.workspaceDragger) { + this.workspaceDragger.drag(this.currentDragDeltaXY); + } else if (this.dragger) { + this.dragger.onDrag(this.mostRecentEvent, this.currentDragDeltaXY); + } + e.preventDefault(); + e.stopPropagation(); + } + } + + /** + * Handle pinch zoom gesture. + * + * @param e A pointermove event. + */ + private handlePinch(e: PointerEvent) { + const pointers = Array.from(this.cachedPoints.keys()); + // Calculate the distance between the two pointers + const point0 = this.cachedPoints.get(pointers[0])!; + const point1 = this.cachedPoints.get(pointers[1])!; + const moveDistance = Coordinate.distance(point0, point1); + const scale = moveDistance / this.startDistance; + + if (this.previousScale > 0 && this.previousScale < Infinity) { + const gestureScale = scale - this.previousScale; + const delta = + gestureScale > 0 + ? gestureScale * ZOOM_IN_MULTIPLIER + : gestureScale * ZOOM_OUT_MULTIPLIER; + if (!this.startWorkspace_) { + throw new Error( + 'Cannot handle a pinch because the start workspace ' + 'is undefined', + ); + } + const workspace = this.startWorkspace_; + const position = browserEvents.mouseToSvg( + e, + workspace.getParentSvg(), + workspace.getInverseScreenCTM(), + ); + workspace.zoom(position.x, position.y, delta); + } + this.previousScale = scale; + e.preventDefault(); + } + + /** + * Handle a pointerup event and end the gesture. + * + * @param e A pointerup event. + * @internal + */ + handleTouchEnd(e: PointerEvent) { + const pointerId = Touch.getTouchIdentifierFromEvent(e); + if (this.cachedPoints.has(pointerId)) { + this.cachedPoints.delete(pointerId); + } + if (this.cachedPoints.size < 2) { + this.cachedPoints.clear(); + this.previousScale = 0; + } + } + + /** + * Helper function returning the current touch point coordinate. + * + * @param e A pointer event. + * @returns The current touch point coordinate + * @internal + */ + getTouchPoint(e: PointerEvent): Coordinate | null { + if (!this.startWorkspace_) { + return null; + } + return new Coordinate(e.pageX, e.pageY); + } + + /** + * Whether this gesture is part of a multi-touch gesture. + * + * @returns Whether this gesture is part of a multi-touch gesture. + * @internal + */ + isMultiTouch(): boolean { + return this.multiTouch; + } + + /** + * Cancel an in-progress gesture. If a workspace or block drag is in + * progress, end the drag at the most recent location. + * + * @internal + */ + cancel() { + // Disposing of a block cancels in-progress drags, but dragging to a delete + // area disposes of a block and leads to recursive disposal. Break that + // cycle. + if (this.isEnding_) { + return; + } + Touch.longStop(); + if (this.dragger) { + this.dragger.onDragEnd(this.mostRecentEvent, this.currentDragDeltaXY); + } else if (this.workspaceDragger) { + this.workspaceDragger.endDrag(this.currentDragDeltaXY); + } + this.dispose(); + } + + /** + * Handle a real or faked right-click event by showing a context menu. + * + * @param e A pointerdown event. + * @internal + */ + handleRightClick(e: PointerEvent) { + if (this.targetBlock) { + this.bringBlockToFront(); + this.targetBlock.workspace.hideChaff(!!this.flyout); + this.targetBlock.showContextMenu(e); + } else if (this.startBubble) { + this.startBubble.showContextMenu(e); + } else if (this.startComment) { + this.startComment.workspace.hideChaff(); + this.startComment.showContextMenu(e); + } else if (this.startWorkspace_ && !this.flyout) { + this.startWorkspace_.hideChaff(); + getFocusManager().focusNode(this.startWorkspace_); + this.startWorkspace_.showContextMenu(e); + } + + // TODO: Handle right-click on a bubble. + e.preventDefault(); + e.stopPropagation(); + + keyboardNavigationController.setIsActive(false); + + this.dispose(); + } + + /** + * Handle a pointerdown event on a workspace. + * + * @param e A pointerdown event. + * @param ws The workspace the event hit. + * @internal + */ + handleWsStart(e: PointerEvent, ws: WorkspaceSvg) { + if (this.gestureHasStarted) { + throw Error( + 'Tried to call gesture.handleWsStart, ' + + 'but the gesture had already been started.', + ); + } + this.setStartWorkspace(ws); + this.mostRecentEvent = e; + + if (!this.startBlock && !this.startBubble && !this.startComment) { + // Ensure the workspace is selected if nothing else should be. Note that + // this is focusNode() instead of focusTree() because if any active node + // is focused in the workspace it should be defocused. + getFocusManager().focusNode(ws); + } else if (this.startBlock) { + getFocusManager().focusNode(this.startBlock); + } + + this.doStart(e); + } + + /** + * Fires a workspace click event. + * + * @param ws The workspace that a user clicks on. + */ + private fireWorkspaceClick(ws: WorkspaceSvg) { + eventUtils.fire( + new (eventUtils.get(EventType.CLICK))(null, ws.id, 'workspace'), + ); + } + + /** + * Handle a pointerdown event on a flyout. + * + * @param e A pointerdown event. + * @param flyout The flyout the event hit. + * @internal + */ + handleFlyoutStart(e: PointerEvent, flyout: IFlyout) { + if (this.gestureHasStarted) { + throw Error( + 'Tried to call gesture.handleFlyoutStart, ' + + 'but the gesture had already been started.', + ); + } + this.setStartFlyout(flyout); + this.handleWsStart(e, flyout.getWorkspace()); + } + + /** + * Handle a pointerdown event on a block. + * + * @param e A pointerdown event. + * @param block The block the event hit. + * @internal + */ + handleBlockStart(e: PointerEvent, block: BlockSvg) { + if (this.gestureHasStarted) { + throw Error( + 'Tried to call gesture.handleBlockStart, ' + + 'but the gesture had already been started.', + ); + } + this.setStartBlock(block); + this.mostRecentEvent = e; + } + + /** + * Handle a pointerdown event on a bubble. + * + * @param e A pointerdown event. + * @param bubble The bubble the event hit. + * @internal + */ + handleBubbleStart(e: PointerEvent, bubble: IBubble) { + if (this.gestureHasStarted) { + throw Error( + 'Tried to call gesture.handleBubbleStart, ' + + 'but the gesture had already been started.', + ); + } + this.setStartBubble(bubble); + this.mostRecentEvent = e; + } + + /** + * Handle a pointerdown event on a workspace comment. + * + * @param e A pointerdown event. + * @param comment The comment the event hit. + * @internal + */ + handleCommentStart(e: PointerEvent, comment: RenderedWorkspaceComment) { + if (this.gestureHasStarted) { + throw Error( + 'Tried to call gesture.handleCommentStart, ' + + 'but the gesture had already been started.', + ); + } + this.setStartComment(comment); + this.mostRecentEvent = e; + } + + /* Begin functions defining what actions to take to execute clicks on each + * type of target. Any developer wanting to add behaviour on clicks should + * modify only this code. */ + + /** Execute a field click. */ + private doFieldClick() { + if (!this.startField) { + throw new Error( + 'Cannot do a field click because the start field is undefined', + ); + } + + // Note that the order is important here: bringing a block to the front will + // cause it to become focused and showing the field editor will capture + // focus ephemerally. It's important to ensure that focus is properly + // restored back to the block after field editing has completed. + this.bringBlockToFront(); + + // Only show the editor if the field's editor wasn't already open + // right before this gesture started. + const dropdownAlreadyOpen = this.currentDropdownOwner === this.startField; + if (!dropdownAlreadyOpen) { + this.startField.showEditor(this.mostRecentEvent); + } + } + + /** Execute an icon click. */ + private doIconClick() { + if (!this.startIcon) { + throw new Error( + 'Cannot do an icon click because the start icon is undefined', + ); + } + this.bringBlockToFront(); + this.startIcon.onClick(); + } + + /** Execute a block click. */ + private doBlockClick() { + // Block click in an autoclosing flyout. + if (this.flyout && this.flyout.autoClose) { + if (!this.targetBlock) { + throw new Error( + 'Cannot do a block click because the target block is ' + 'undefined', + ); + } + if (this.flyout.isBlockCreatable(this.targetBlock)) { + if (!eventUtils.getGroup()) { + eventUtils.setGroup(true); + } + const newBlock = this.flyout.createBlock(this.targetBlock); + newBlock.snapToGrid(); + newBlock.bumpNeighbours(); + + // If a new block was added, make sure that it's correctly focused. + getFocusManager().focusNode(newBlock); + } + } else { + if (!this.startWorkspace_) { + throw new Error( + 'Cannot do a block click because the start workspace ' + + 'is undefined', + ); + } + // Clicks events are on the start block, even if it was a shadow. + const event = new (eventUtils.get(EventType.CLICK))( + this.startBlock, + this.startWorkspace_.id, + 'block', + ); + eventUtils.fire(event); + } + this.bringBlockToFront(); + eventUtils.setGroup(false); + } + + /** + * Execute a workspace click. When in accessibility mode shift clicking will + * move the cursor. + * + * @param _e A pointerup event. + */ + private doWorkspaceClick(_e: PointerEvent) { + this.fireWorkspaceClick(this.startWorkspace_ || this.creatorWorkspace); + } + + /* End functions defining what actions to take to execute clicks on each type + * of target. */ + + // TODO (fenichel): Move bubbles to the front. + + /** + * Move the dragged/clicked block to the front of the workspace so that it is + * not occluded by other blocks. + */ + private bringBlockToFront() { + // Blocks in the flyout don't overlap, so skip the work. + if (this.targetBlock && !this.flyout) { + // Always ensure the block being dragged/clicked has focus. + getFocusManager().focusNode(this.targetBlock); + this.targetBlock.bringToFront(); + } + } + + /* Begin functions for populating a gesture at pointerdown. */ + + /** + * Record the field that a gesture started on. + * + * @param field The field the gesture started on. + * @internal + */ + setStartField(field: Field) { + if (this.gestureHasStarted) { + throw Error( + 'Tried to call gesture.setStartField, ' + + 'but the gesture had already been started.', + ); + } + if (!this.startField) { + this.startField = field as Field; + } + } + + /** + * Record the icon that a gesture started on. + * + * @param icon The icon the gesture started on. + * @internal + */ + setStartIcon(icon: IIcon) { + if (this.gestureHasStarted) { + throw Error( + 'Tried to call gesture.setStartIcon, ' + + 'but the gesture had already been started.', + ); + } + + if (!this.startIcon) this.startIcon = icon; + } + + /** + * Record the bubble that a gesture started on + * + * @param bubble The bubble the gesture started on. + * @internal + */ + setStartBubble(bubble: IBubble) { + if (!this.startBubble) { + this.startBubble = bubble; + } + } + + /** + * Record the comment that a gesture started on + * + * @param comment The comment the gesture started on. + * @internal + */ + setStartComment(comment: RenderedWorkspaceComment) { + if (!this.startComment) { + this.startComment = comment; + } + } + + /** + * Record the block that a gesture started on, and set the target block + * appropriately. + * + * @param block The block the gesture started on. + * @internal + */ + setStartBlock(block: BlockSvg) { + // If the gesture already went through a bubble, don't set the start block. + if (!this.startBlock && !this.startBubble) { + this.startBlock = block; + if (block.isInFlyout && block !== block.getRootBlock()) { + this.setTargetBlock(block.getRootBlock()); + } else { + this.setTargetBlock(block); + } + } + } + + /** + * Record the block that a gesture targets, meaning the block that will be + * dragged if this turns into a drag. If this block is a shadow, that will be + * its first non-shadow parent. + * + * @param block The block the gesture targets. + */ + private setTargetBlock(block: BlockSvg) { + if (block.isShadow()) { + // Non-null assertion is fine b/c it is an invariant that shadows always + // have parents. + this.setTargetBlock(block.getParent()!); + } else { + this.targetBlock = block; + getFocusManager().focusNode(block); + } + } + + /** + * Record the workspace that a gesture started on. + * + * @param ws The workspace the gesture started on. + */ + private setStartWorkspace(ws: WorkspaceSvg) { + if (!this.startWorkspace_) { + this.startWorkspace_ = ws; + } + } + + /** + * Record the flyout that a gesture started on. + * + * @param flyout The flyout the gesture started on. + */ + private setStartFlyout(flyout: IFlyout) { + if (!this.flyout) { + this.flyout = flyout; + } + } + + /* End functions for populating a gesture at pointerdown. */ + + /* Begin helper functions defining types of clicks. Any developer wanting + * to change the definition of a click should modify only this code. */ + + /** + * Whether this gesture is a click on a bubble. This should only be called + * when ending a gesture (pointerup). + * + * @returns Whether this gesture was a click on a bubble. + */ + private isBubbleClick(): boolean { + // A bubble click starts on a bubble and never escapes the drag radius. + const hasStartBubble = !!this.startBubble; + return hasStartBubble && !this.hasExceededDragRadius; + } + + private isCommentClick(): boolean { + return !!this.startComment && !this.hasExceededDragRadius; + } + + /** + * Whether this gesture is a click on a block. This should only be called + * when ending a gesture (pointerup). + * + * @returns Whether this gesture was a click on a block. + */ + private isBlockClick(): boolean { + // A block click starts on a block, never escapes the drag radius, and is + // not a field click. + const hasStartBlock = !!this.startBlock; + return ( + hasStartBlock && + !this.hasExceededDragRadius && + !this.isFieldClick() && + !this.isIconClick() + ); + } + + /** + * Whether this gesture is a click on a field that should be handled. This should only be called + * when ending a gesture (pointerup). + * + * @returns Whether this gesture was a click on a field. + */ + private isFieldClick(): boolean { + if (!this.startField) return false; + return ( + this.startField.isClickable() && + !this.hasExceededDragRadius && + (!this.flyout || + this.startField.isClickableInFlyout(this.flyout.autoClose)) + ); + } + + /** @returns Whether this gesture is a click on an icon that should be handled. */ + private isIconClick(): boolean { + if (!this.startIcon) return false; + const handleInFlyout = + !this.flyout || + !this.startIcon.isClickableInFlyout || + this.startIcon.isClickableInFlyout(this.flyout.autoClose); + return !this.hasExceededDragRadius && handleInFlyout; + } + + /** + * Whether this gesture is a click on a workspace. This should only be called + * when ending a gesture (pointerup). + * + * @returns Whether this gesture was a click on a workspace. + */ + private isWorkspaceClick(): boolean { + const onlyTouchedWorkspace = + !this.startBlock && !this.startBubble && !this.startField; + return onlyTouchedWorkspace && !this.hasExceededDragRadius; + } + + /* End helper functions defining types of clicks. */ + + /** Returns the current dragger if the gesture is a drag. */ + getCurrentDragger(): WorkspaceDragger | IDragger | null { + return this.workspaceDragger ?? this.dragger ?? null; + } + + /** + * Whether this gesture is a drag of either a workspace or block. + * This function is called externally to block actions that cannot be taken + * mid-drag (e.g. using the keyboard to delete the selected blocks). + * + * @returns True if this gesture is a drag of a workspace or block. + * @internal + */ + isDragging(): boolean { + return this.dragging; + } + + /** + * Whether this gesture has already been started. In theory every pointerdown + * has a corresponding pointerup, but in reality it is possible to lose a + * pointerup, leaving an in-process gesture hanging. + * + * @returns Whether this gesture was a click on a workspace. + * @internal + */ + hasStarted(): boolean { + return this.gestureHasStarted; + } + + /** + * Is a drag or other gesture currently in progress on any workspace? + * + * @returns True if gesture is occurring. + */ + static inProgress(): boolean { + const workspaces = common.getAllWorkspaces(); + for (let i = 0, workspace; (workspace = workspaces[i]); i++) { + // Not actually necessarily a WorkspaceSvg, but it doesn't matter b/c + // we're just checking if the property exists. Theoretically we would + // want to use instanceof, but that causes a circular dependency. + if ((workspace as WorkspaceSvg).currentGesture_) { + return true; + } + } + return false; + } +} diff --git a/core/grid.js b/core/grid.js deleted file mode 100644 index e87df60218b..00000000000 --- a/core/grid.js +++ /dev/null @@ -1,222 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2017 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Object for configuring and updating a workspace grid in - * Blockly. - * @author fenichel@google.com (Rachel Fenichel) - */ -'use strict'; - -goog.provide('Blockly.Grid'); - -goog.require('Blockly.utils'); - -goog.require('goog.userAgent'); - - -/** - * Class for a workspace's grid. - * @param {!SVGElement} pattern The grid's SVG pattern, created during injection. - * @param {!Object} options A dictionary of normalized options for the grid. - * See grid documentation: - * https://developers.google.com/blockly/guides/configure/web/grid - * @constructor - */ -Blockly.Grid = function(pattern, options) { - /** - * The grid's SVG pattern, created during injection. - * @type {!SVGElement} - * @private - */ - this.gridPattern_ = pattern; - - /** - * The spacing of the grid lines (in px). - * @type {number} - * @private - */ - this.spacing_ = options['spacing']; - - /** - * How long the grid lines should be (in px). - * @type {number} - * @private - */ - this.length_ = options['length']; - - /** - * The horizontal grid line, if it exists. - * @type {SVGElement} - * @private - */ - this.line1_ = pattern.firstChild; - - /** - * The vertical grid line, if it exists. - * @type {SVGElement} - * @private - */ - this.line2_ = this.line1_ && this.line1_.nextSibling; - - /** - * Whether blocks should snap to the grid. - * @type {boolean} - * @private - */ - this.snapToGrid_ = options['snap']; -}; - -/** - * The scale of the grid, used to set stroke width on grid lines. - * This should always be the same as the workspace scale. - * @type {number} - * @private - */ -Blockly.Grid.prototype.scale_ = 1; - -/** - * Dispose of this grid and unlink from the DOM. - * @package - */ -Blockly.Grid.prototype.dispose = function() { - this.gridPattern_ = null; -}; - -/** - * Whether blocks should snap to the grid, based on the initial configuration. - * @return {boolean} True if blocks should snap, false otherwise. - * @package - */ -Blockly.Grid.prototype.shouldSnap = function() { - return this.snapToGrid_; -}; - -/** - * Get the spacing of the grid points (in px). - * @return {number} The spacing of the grid points. - * @package - */ -Blockly.Grid.prototype.getSpacing = function() { - return this.spacing_; -}; - -/** - * Get the id of the pattern element, which should be randomized to avoid - * conflicts with other Blockly instances on the page. - * @return {string} The pattern id. - * @package - */ -Blockly.Grid.prototype.getPatternId = function() { - return this.gridPattern_.id; -}; - -/** - * Update the grid with a new scale. - * @param {number} scale The new workspace scale. - * @package - */ -Blockly.Grid.prototype.update = function(scale) { - this.scale_ = scale; - // MSIE freaks if it sees a 0x0 pattern, so set empty patterns to 100x100. - var safeSpacing = (this.spacing_ * scale) || 100; - - this.gridPattern_.setAttribute('width', safeSpacing); - this.gridPattern_.setAttribute('height', safeSpacing); - - var half = Math.floor(this.spacing_ / 2) + 0.5; - var start = half - this.length_ / 2; - var end = half + this.length_ / 2; - - half *= scale; - start *= scale; - end *= scale; - - this.setLineAttributes_(this.line1_, scale, start, end, half, half); - this.setLineAttributes_(this.line2_, scale, half, half, start, end); -}; - -/** - * Set the attributes on one of the lines in the grid. Use this to update the - * length and stroke width of the grid lines. - * @param {!SVGElement} line Which line to update. - * @param {number} width The new stroke size (in px). - * @param {number} x1 The new x start position of the line (in px). - * @param {number} x2 The new x end position of the line (in px). - * @param {number} y1 The new y start position of the line (in px). - * @param {number} y2 The new y end position of the line (in px). - * @private - */ -Blockly.Grid.prototype.setLineAttributes_ = function(line, width, x1, x2, y1, y2) { - if (line) { - line.setAttribute('stroke-width', width); - line.setAttribute('x1', x1); - line.setAttribute('y1', y1); - line.setAttribute('x2', x2); - line.setAttribute('y2', y2); - } -}; - -/** - * Move the grid to a new x and y position, and make sure that change is visible. - * @param {number} x The new x position of the grid (in px). - * @param {number} y The new y position ofthe grid (in px). - * @package - */ -Blockly.Grid.prototype.moveTo = function(x, y) { - this.gridPattern_.setAttribute('x', x); - this.gridPattern_.setAttribute('y', y); - - if (goog.userAgent.IE || goog.userAgent.EDGE) { - // IE/Edge doesn't notice that the x/y offsets have changed. - // Force an update. - this.update(this.scale_); - } -}; - -/** - * Create the DOM for the grid described by options. - * @param {string} rnd A random ID to append to the pattern's ID. - * @param {!Object} gridOptions The object containing grid configuration. - * @param {!SVGElement} defs The root SVG element for this workspace's defs. - * @return {!SVGElement} The SVG element for the grid pattern. - * @package - */ -Blockly.Grid.createDom = function(rnd, gridOptions, defs) { - /* - - - - - */ - var gridPattern = Blockly.utils.createSvgElement('pattern', - {'id': 'blocklyGridPattern' + rnd, - 'patternUnits': 'userSpaceOnUse'}, defs); - if (gridOptions['length'] > 0 && gridOptions['spacing'] > 0) { - Blockly.utils.createSvgElement('line', - {'stroke': gridOptions['colour']}, gridPattern); - if (gridOptions['length'] > 1) { - Blockly.utils.createSvgElement('line', - {'stroke': gridOptions['colour']}, gridPattern); - } - // x1, y1, x1, x2 properties will be set later in update. - } - return gridPattern; -}; diff --git a/core/grid.ts b/core/grid.ts new file mode 100644 index 00000000000..2d88973adc2 --- /dev/null +++ b/core/grid.ts @@ -0,0 +1,267 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Object for configuring and updating a workspace grid in + * Blockly. + * + * @class + */ +// Former goog.module ID: Blockly.Grid + +import {GridOptions} from './options.js'; +import {Coordinate} from './utils/coordinate.js'; +import * as dom from './utils/dom.js'; +import {Svg} from './utils/svg.js'; + +/** + * Class for a workspace's grid. + */ +export class Grid { + private spacing: number; + private length: number; + private scale: number = 1; + private readonly line1: SVGElement; + private readonly line2: SVGElement; + private snapToGrid: boolean; + + /** + * @param pattern The grid's SVG pattern, created during injection. + * @param options A dictionary of normalized options for the grid. + * See grid documentation: + * https://developers.google.com/blockly/guides/configure/web/grid + */ + constructor( + private pattern: SVGElement, + options: GridOptions, + ) { + /** The spacing of the grid lines (in px). */ + this.spacing = options['spacing'] ?? 0; + + /** How long the grid lines should be (in px). */ + this.length = options['length'] ?? 1; + + /** The horizontal grid line, if it exists. */ + this.line1 = pattern.firstChild as SVGElement; + + /** The vertical grid line, if it exists. */ + this.line2 = this.line1 && (this.line1.nextSibling as SVGElement); + + /** Whether blocks should snap to the grid. */ + this.snapToGrid = options['snap'] ?? false; + } + + /** + * Sets the spacing between the centers of the grid lines. + * + * This does not trigger snapping to the newly spaced grid. If you want to + * snap blocks to the grid programmatically that needs to be triggered + * on individual top-level blocks. The next time a block is dragged and + * dropped it will snap to the grid if snapping to the grid is enabled. + */ + setSpacing(spacing: number) { + this.spacing = spacing; + this.update(this.scale); + } + + /** + * Get the spacing of the grid points (in px). + * + * @returns The spacing of the grid points. + */ + getSpacing(): number { + return this.spacing; + } + + /** Sets the length of the grid lines. */ + setLength(length: number) { + this.length = length; + this.update(this.scale); + } + + /** Get the length of the grid lines (in px). */ + getLength(): number { + return this.length; + } + + /** + * Sets whether blocks should snap to the grid or not. + * + * Setting this to true does not trigger snapping. If you want to snap blocks + * to the grid programmatically that needs to be triggered on individual + * top-level blocks. The next time a block is dragged and dropped it will + * snap to the grid. + */ + setSnapToGrid(snap: boolean) { + this.snapToGrid = snap; + } + + /** + * Whether blocks should snap to the grid. + * + * @returns True if blocks should snap, false otherwise. + */ + shouldSnap(): boolean { + return this.snapToGrid; + } + + /** + * Get the ID of the pattern element, which should be randomized to avoid + * conflicts with other Blockly instances on the page. + * + * @returns The pattern ID. + * @internal + */ + getPatternId(): string { + return this.pattern.id; + } + + /** + * Update the grid with a new scale. + * + * @param scale The new workspace scale. + * @internal + */ + update(scale: number) { + this.scale = scale; + const safeSpacing = this.spacing * scale; + + this.pattern.setAttribute('width', `${safeSpacing}`); + this.pattern.setAttribute('height', `${safeSpacing}`); + + let half = Math.floor(this.spacing / 2) + 0.5; + let start = half - this.length / 2; + let end = half + this.length / 2; + + half *= scale; + start *= scale; + end *= scale; + + this.setLineAttributes(this.line1, scale, start, end, half, half); + this.setLineAttributes(this.line2, scale, half, half, start, end); + } + + /** + * Set the attributes on one of the lines in the grid. Use this to update the + * length and stroke width of the grid lines. + * + * @param line Which line to update. + * @param width The new stroke size (in px). + * @param x1 The new x start position of the line (in px). + * @param x2 The new x end position of the line (in px). + * @param y1 The new y start position of the line (in px). + * @param y2 The new y end position of the line (in px). + */ + private setLineAttributes( + line: SVGElement, + width: number, + x1: number, + x2: number, + y1: number, + y2: number, + ) { + if (line) { + line.setAttribute('stroke-width', `${width}`); + line.setAttribute('x1', `${x1}`); + line.setAttribute('y1', `${y1}`); + line.setAttribute('x2', `${x2}`); + line.setAttribute('y2', `${y2}`); + } + } + + /** + * Move the grid to a new x and y position, and make sure that change is + * visible. + * + * @param x The new x position of the grid (in px). + * @param y The new y position of the grid (in px). + * @internal + */ + moveTo(x: number, y: number) { + this.pattern.setAttribute('x', `${x}`); + this.pattern.setAttribute('y', `${y}`); + } + + /** + * Given a coordinate, return the nearest coordinate aligned to the grid. + * + * @param xy A workspace coordinate. + * @returns Workspace coordinate of nearest grid point. + * If there's no change, return the same coordinate object. + */ + alignXY(xy: Coordinate): Coordinate { + const spacing = this.getSpacing(); + const half = spacing / 2; + const x = Math.round(Math.round((xy.x - half) / spacing) * spacing + half); + const y = Math.round(Math.round((xy.y - half) / spacing) * spacing + half); + if (x === xy.x && y === xy.y) { + // No change. + return xy; + } + return new Coordinate(x, y); + } + + /** + * Create the DOM for the grid described by options. + * + * @param rnd A random ID to append to the pattern's ID. + * @param gridOptions The object containing grid configuration. + * @param defs The root SVG element for this workspace's defs. + * @param injectionDiv The div containing the parent workspace and all related + * workspaces and block containers. CSS variables representing SVG patterns + * will be scoped to this container. + * @returns The SVG element for the grid pattern. + * @internal + */ + static createDom( + rnd: string, + gridOptions: GridOptions, + defs: SVGElement, + injectionDiv?: HTMLElement, + ): SVGElement { + /* + + + + + */ + const gridPattern = dom.createSvgElement( + Svg.PATTERN, + {'id': 'blocklyGridPattern' + rnd, 'patternUnits': 'userSpaceOnUse'}, + defs, + ); + // x1, y1, x1, x2 properties will be set later in update. + if ((gridOptions['length'] ?? 1) > 0 && (gridOptions['spacing'] ?? 0) > 0) { + dom.createSvgElement( + Svg.LINE, + {'stroke': gridOptions['colour']}, + gridPattern, + ); + if (gridOptions['length'] ?? 1 > 1) { + dom.createSvgElement( + Svg.LINE, + {'stroke': gridOptions['colour']}, + gridPattern, + ); + } + } else { + // Edge 16 doesn't handle empty patterns + dom.createSvgElement(Svg.LINE, {}, gridPattern); + } + + if (injectionDiv) { + // Add CSS variables scoped to the injection div referencing the created + // patterns so that CSS can apply the patterns to any element in the + // injection div. + injectionDiv.style.setProperty( + '--blocklyGridPattern', + `url(#${gridPattern.id})`, + ); + } + + return gridPattern; + } +} diff --git a/core/icon.js b/core/icon.js deleted file mode 100644 index 37dd6768d33..00000000000 --- a/core/icon.js +++ /dev/null @@ -1,205 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2013 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Object representing an icon on a block. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Icon'); - -goog.require('goog.dom'); -goog.require('goog.math.Coordinate'); - - -/** - * Class for an icon. - * @param {Blockly.Block} block The block associated with this icon. - * @constructor - */ -Blockly.Icon = function(block) { - this.block_ = block; -}; - -/** - * Does this icon get hidden when the block is collapsed. - */ -Blockly.Icon.prototype.collapseHidden = true; - -/** - * Height and width of icons. - */ -Blockly.Icon.prototype.SIZE = 17; - -/** - * Bubble UI (if visible). - * @type {Blockly.Bubble} - * @private - */ -Blockly.Icon.prototype.bubble_ = null; - -/** - * Absolute coordinate of icon's center. - * @type {goog.math.Coordinate} - * @private - */ -Blockly.Icon.prototype.iconXY_ = null; - -/** - * Create the icon on the block. - */ -Blockly.Icon.prototype.createIcon = function() { - if (this.iconGroup_) { - // Icon already exists. - return; - } - /* Here's the markup that will be generated: - - ... - - */ - this.iconGroup_ = Blockly.utils.createSvgElement('g', - {'class': 'blocklyIconGroup'}, null); - if (this.block_.isInFlyout) { - Blockly.utils.addClass(/** @type {!Element} */ (this.iconGroup_), - 'blocklyIconGroupReadonly'); - } - this.drawIcon_(this.iconGroup_); - - this.block_.getSvgRoot().appendChild(this.iconGroup_); - Blockly.bindEventWithChecks_(this.iconGroup_, 'mouseup', this, - this.iconClick_); - this.updateEditable(); -}; - -/** - * Dispose of this icon. - */ -Blockly.Icon.prototype.dispose = function() { - // Dispose of and unlink the icon. - goog.dom.removeNode(this.iconGroup_); - this.iconGroup_ = null; - // Dispose of and unlink the bubble. - this.setVisible(false); - this.block_ = null; -}; - -/** - * Add or remove the UI indicating if this icon may be clicked or not. - */ -Blockly.Icon.prototype.updateEditable = function() { -}; - -/** - * Is the associated bubble visible? - * @return {boolean} True if the bubble is visible. - */ -Blockly.Icon.prototype.isVisible = function() { - return !!this.bubble_; -}; - -/** - * Clicking on the icon toggles if the bubble is visible. - * @param {!Event} e Mouse click event. - * @private - */ -Blockly.Icon.prototype.iconClick_ = function(e) { - if (this.block_.workspace.isDragging()) { - // Drag operation is concluding. Don't open the editor. - return; - } - if (!this.block_.isInFlyout && !Blockly.utils.isRightButton(e)) { - this.setVisible(!this.isVisible()); - } -}; - -/** - * Change the colour of the associated bubble to match its block. - */ -Blockly.Icon.prototype.updateColour = function() { - if (this.isVisible()) { - this.bubble_.setColour(this.block_.getColour()); - } -}; - -/** - * Render the icon. - * @param {number} cursorX Horizontal offset at which to position the icon. - * @return {number} Horizontal offset for next item to draw. - */ -Blockly.Icon.prototype.renderIcon = function(cursorX) { - if (this.collapseHidden && this.block_.isCollapsed()) { - this.iconGroup_.setAttribute('display', 'none'); - return cursorX; - } - this.iconGroup_.setAttribute('display', 'block'); - - var TOP_MARGIN = 5; - var width = this.SIZE; - if (this.block_.RTL) { - cursorX -= width; - } - this.iconGroup_.setAttribute('transform', - 'translate(' + cursorX + ',' + TOP_MARGIN + ')'); - this.computeIconLocation(); - if (this.block_.RTL) { - cursorX -= Blockly.BlockSvg.SEP_SPACE_X; - } else { - cursorX += width + Blockly.BlockSvg.SEP_SPACE_X; - } - return cursorX; -}; - -/** - * Notification that the icon has moved. Update the arrow accordingly. - * @param {!goog.math.Coordinate} xy Absolute location in workspace coordinates. - */ -Blockly.Icon.prototype.setIconLocation = function(xy) { - this.iconXY_ = xy; - if (this.isVisible()) { - this.bubble_.setAnchorLocation(xy); - } -}; - -/** - * Notification that the icon has moved, but we don't really know where. - * Recompute the icon's location from scratch. - */ -Blockly.Icon.prototype.computeIconLocation = function() { - // Find coordinates for the centre of the icon and update the arrow. - var blockXY = this.block_.getRelativeToSurfaceXY(); - var iconXY = Blockly.utils.getRelativeXY(this.iconGroup_); - var newXY = new goog.math.Coordinate( - blockXY.x + iconXY.x + this.SIZE / 2, - blockXY.y + iconXY.y + this.SIZE / 2); - if (!goog.math.Coordinate.equals(this.getIconLocation(), newXY)) { - this.setIconLocation(newXY); - } -}; - -/** - * Returns the center of the block's icon relative to the surface. - * @return {!goog.math.Coordinate} Object with x and y properties in workspace - * coordinates. - */ -Blockly.Icon.prototype.getIconLocation = function() { - return this.iconXY_; -}; diff --git a/core/icons.ts b/core/icons.ts new file mode 100644 index 00000000000..fcc7c98c663 --- /dev/null +++ b/core/icons.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CommentIcon, CommentState} from './icons/comment_icon.js'; +import * as exceptions from './icons/exceptions.js'; +import {Icon} from './icons/icon.js'; +import {IconType} from './icons/icon_types.js'; +import {MutatorIcon} from './icons/mutator_icon.js'; +import * as registry from './icons/registry.js'; +import {WarningIcon} from './icons/warning_icon.js'; + +export { + CommentIcon, + CommentState, + exceptions, + Icon, + IconType, + MutatorIcon, + registry, + WarningIcon, +}; diff --git a/core/icons/comment_icon.ts b/core/icons/comment_icon.ts new file mode 100644 index 00000000000..8f5a82c0d15 --- /dev/null +++ b/core/icons/comment_icon.ts @@ -0,0 +1,429 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.Comment + +import type {Block} from '../block.js'; +import type {BlockSvg} from '../block_svg.js'; +import {TextInputBubble} from '../bubbles/textinput_bubble.js'; +import {EventType} from '../events/type.js'; +import * as eventUtils from '../events/utils.js'; +import type {IHasBubble} from '../interfaces/i_has_bubble.js'; +import type {ISerializable} from '../interfaces/i_serializable.js'; +import * as renderManagement from '../render_management.js'; +import {Coordinate} from '../utils.js'; +import * as dom from '../utils/dom.js'; +import {Rect} from '../utils/rect.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {Icon} from './icon.js'; +import {IconType} from './icon_types.js'; +import * as registry from './registry.js'; + +/** The size of the comment icon in workspace-scale units. */ +const SIZE = 17; + +/** The default width in workspace-scale units of the text input bubble. */ +const DEFAULT_BUBBLE_WIDTH = 160; + +/** The default height in workspace-scale units of the text input bubble. */ +const DEFAULT_BUBBLE_HEIGHT = 80; + +/** + * An icon which allows the user to add comment text to a block. + */ +export class CommentIcon extends Icon implements IHasBubble, ISerializable { + /** The type string used to identify this icon. */ + static readonly TYPE = IconType.COMMENT; + + /** + * The weight this icon has relative to other icons. Icons with more positive + * weight values are rendered farther toward the end of the block. + */ + static readonly WEIGHT = 3; + + /** The bubble used to show comment text to the user. */ + private textInputBubble: TextInputBubble | null = null; + + /** The text of this comment. */ + private text = ''; + + /** The size of this comment (which is applied to the editable bubble). */ + private bubbleSize = new Size(DEFAULT_BUBBLE_WIDTH, DEFAULT_BUBBLE_HEIGHT); + + /** The location of the comment bubble in workspace coordinates. */ + private bubbleLocation?: Coordinate; + + /** + * The visibility of the bubble for this comment. + * + * This is used to track what the visible state /should/ be, not necessarily + * what it currently /is/. E.g. sometimes this will be true, but the block + * hasn't been rendered yet, so the bubble will not currently be visible. + */ + private bubbleVisiblity = false; + + constructor(protected readonly sourceBlock: Block) { + super(sourceBlock); + } + + override getType(): IconType { + return CommentIcon.TYPE; + } + + override initView(pointerdownListener: (e: PointerEvent) => void): void { + if (this.svgRoot) return; // Already initialized. + + super.initView(pointerdownListener); + + // Circle. + dom.createSvgElement( + Svg.CIRCLE, + {'class': 'blocklyIconShape', 'r': '8', 'cx': '8', 'cy': '8'}, + this.svgRoot, + ); + // Can't use a real '?' text character since different browsers and + // operating systems render it differently. Body of question mark. + dom.createSvgElement( + Svg.PATH, + { + 'class': 'blocklyIconSymbol', + 'd': + 'm6.8,10h2c0.003,-0.617 0.271,-0.962 0.633,-1.266 2.875,-2.405' + + '0.607,-5.534 -3.765,-3.874v1.7c3.12,-1.657 3.698,0.118 2.336,1.25' + + '-1.201,0.998 -1.201,1.528 -1.204,2.19z', + }, + this.svgRoot, + ); + // Dot of question mark. + dom.createSvgElement( + Svg.RECT, + { + 'class': 'blocklyIconSymbol', + 'x': '6.8', + 'y': '10.78', + 'height': '2', + 'width': '2', + }, + this.svgRoot, + ); + dom.addClass(this.svgRoot!, 'blocklyCommentIcon'); + } + + override dispose() { + super.dispose(); + this.textInputBubble?.dispose(); + } + + override getWeight(): number { + return CommentIcon.WEIGHT; + } + + override getSize(): Size { + return new Size(SIZE, SIZE); + } + + override applyColour(): void { + super.applyColour(); + const colour = (this.sourceBlock as BlockSvg).getColour(); + this.textInputBubble?.setColour(colour); + } + + /** + * Updates the state of the bubble (editable / noneditable) to reflect the + * state of the bubble if the bubble is currently shown. + */ + override async updateEditable(): Promise { + super.updateEditable(); + if (this.bubbleIsVisible()) { + // Close and reopen the bubble to display the correct UI. + await this.setBubbleVisible(false); + await this.setBubbleVisible(true); + } + } + + override onLocationChange(blockOrigin: Coordinate): void { + const oldLocation = this.workspaceLocation; + super.onLocationChange(blockOrigin); + if (this.bubbleLocation) { + const newLocation = this.workspaceLocation; + const delta = Coordinate.difference(newLocation, oldLocation); + this.bubbleLocation = Coordinate.sum(this.bubbleLocation, delta); + } + const anchorLocation = this.getAnchorLocation(); + this.textInputBubble?.setAnchorLocation(anchorLocation); + } + + /** Sets the text of this comment. Updates any bubbles if they are visible. */ + setText(text: string) { + const oldText = this.text; + eventUtils.fire( + new (eventUtils.get(EventType.BLOCK_CHANGE))( + this.sourceBlock, + 'comment', + null, + oldText, + text, + ), + ); + this.text = text; + this.textInputBubble?.setText(this.text); + } + + /** Returns the text of this comment. */ + getText(): string { + return this.text; + } + + /** + * Sets the size of the editable bubble for this comment. Resizes the + * bubble if it is visible. + */ + setBubbleSize(size: Size) { + this.bubbleSize = size; + this.textInputBubble?.setSize(this.bubbleSize, true); + } + + /** @returns the size of the editable bubble for this comment. */ + getBubbleSize(): Size { + return this.bubbleSize; + } + + /** + * Sets the location of the comment bubble in the workspace. + */ + setBubbleLocation(location: Coordinate) { + this.bubbleLocation = location; + this.textInputBubble?.moveDuringDrag(location); + } + + /** + * @returns the location of the comment bubble in the workspace. + */ + getBubbleLocation(): Coordinate | undefined { + return this.bubbleLocation; + } + + /** + * @returns the state of the comment as a JSON serializable value if the + * comment has text. Otherwise returns null. + */ + saveState(): CommentState | null { + if (this.text) { + const state: CommentState = { + 'text': this.text, + 'pinned': this.bubbleIsVisible(), + 'height': this.bubbleSize.height, + 'width': this.bubbleSize.width, + }; + const location = this.getBubbleLocation(); + if (location) { + state['x'] = this.sourceBlock.workspace.RTL + ? this.sourceBlock.workspace.getWidth() - + (location.x + this.bubbleSize.width) + : location.x; + state['y'] = location.y; + } + return state; + } + return null; + } + + /** Applies the given state to this comment. */ + loadState(state: CommentState) { + this.text = state['text'] ?? ''; + this.bubbleSize = new Size( + state['width'] ?? DEFAULT_BUBBLE_WIDTH, + state['height'] ?? DEFAULT_BUBBLE_HEIGHT, + ); + this.bubbleVisiblity = state['pinned'] ?? false; + this.setBubbleVisible(this.bubbleVisiblity); + let x = state['x']; + const y = state['y']; + renderManagement.finishQueuedRenders().then(() => { + if (x && y) { + x = this.sourceBlock.workspace.RTL + ? this.sourceBlock.workspace.getWidth() - (x + this.bubbleSize.width) + : x; + this.setBubbleLocation(new Coordinate(x, y)); + } + }); + } + + override onClick(): void { + super.onClick(); + this.setBubbleVisible(!this.bubbleIsVisible()); + } + + override isClickableInFlyout(): boolean { + return false; + } + + /** + * Updates the text of this comment in response to changes in the text of + * the input bubble. + */ + onTextChange(): void { + if (!this.textInputBubble) return; + + const newText = this.textInputBubble.getText(); + if (this.text === newText) return; + + eventUtils.fire( + new (eventUtils.get(EventType.BLOCK_CHANGE))( + this.sourceBlock, + 'comment', + null, + this.text, + newText, + ), + ); + this.text = newText; + } + + /** + * Updates the size of this icon in response to changes in the size of the + * input bubble. + */ + onSizeChange(): void { + if (this.textInputBubble) { + this.bubbleSize = this.textInputBubble.getSize(); + } + } + + onBubbleLocationChange(): void { + if (this.textInputBubble) { + this.bubbleLocation = this.textInputBubble.getRelativeToSurfaceXY(); + } + } + + bubbleIsVisible(): boolean { + return this.bubbleVisiblity; + } + + async setBubbleVisible(visible: boolean): Promise { + if (this.bubbleVisiblity === visible) return; + this.bubbleVisiblity = visible; + + await renderManagement.finishQueuedRenders(); + + if ( + !this.sourceBlock.rendered || + this.sourceBlock.isInFlyout || + this.sourceBlock.isInsertionMarker() + ) + return; + + if (visible) { + if (this.sourceBlock.isEditable()) { + this.showEditableBubble(); + } else { + this.showNonEditableBubble(); + } + this.applyColour(); + } else { + this.hideBubble(); + } + + eventUtils.fire( + new (eventUtils.get(EventType.BUBBLE_OPEN))( + this.sourceBlock, + visible, + 'comment', + ), + ); + } + + /** See IHasBubble.getBubble. */ + getBubble(): TextInputBubble | null { + return this.textInputBubble; + } + + /** + * Shows the editable text bubble for this comment, and adds change listeners + * to update the state of this icon in response to changes in the bubble. + */ + private showEditableBubble() { + this.createBubble(); + this.textInputBubble?.addTextChangeListener(() => this.onTextChange()); + this.textInputBubble?.addSizeChangeListener(() => this.onSizeChange()); + } + + /** Shows the non editable text bubble for this comment. */ + private showNonEditableBubble() { + this.createBubble(); + this.textInputBubble?.setEditable(false); + } + + protected createBubble() { + this.textInputBubble = new TextInputBubble( + this.sourceBlock.workspace as WorkspaceSvg, + this.getAnchorLocation(), + this.getBubbleOwnerRect(), + this, + ); + this.textInputBubble.setText(this.getText()); + this.textInputBubble.setSize(this.bubbleSize, true); + if (this.bubbleLocation) { + this.textInputBubble.moveDuringDrag(this.bubbleLocation); + } + this.textInputBubble.addTextChangeListener(() => this.onTextChange()); + this.textInputBubble.addSizeChangeListener(() => this.onSizeChange()); + this.textInputBubble.addLocationChangeListener(() => + this.onBubbleLocationChange(), + ); + } + + /** Hides any open bubbles owned by this comment. */ + private hideBubble() { + this.textInputBubble?.dispose(); + this.textInputBubble = null; + } + + /** + * @returns the location the bubble should be anchored to. + * I.E. the middle of this icon. + */ + private getAnchorLocation(): Coordinate { + const midIcon = SIZE / 2; + return Coordinate.sum( + this.workspaceLocation, + new Coordinate(midIcon, midIcon), + ); + } + + /** + * @returns the rect the bubble should avoid overlapping. + * I.E. the block that owns this icon. + */ + private getBubbleOwnerRect(): Rect { + return (this.sourceBlock as BlockSvg).getBoundingRectangleWithoutChildren(); + } +} + +/** The save state format for a comment icon. */ +export interface CommentState { + /** The text of the comment. */ + text?: string; + + /** True if the comment is open, false otherwise. */ + pinned?: boolean; + + /** The height of the comment bubble. */ + height?: number; + + /** The width of the comment bubble. */ + width?: number; + + /** The X coordinate of the comment bubble. */ + x?: number; + + /** The Y coordinate of the comment bubble. */ + y?: number; +} + +registry.register(CommentIcon.TYPE, CommentIcon); diff --git a/core/icons/exceptions.ts b/core/icons/exceptions.ts new file mode 100644 index 00000000000..26b48e7c88a --- /dev/null +++ b/core/icons/exceptions.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IIcon} from '../interfaces/i_icon.js'; + +/** + * Thrown when you add more than one icon of the same type to a block. + */ +export class DuplicateIconType extends Error { + /** + * @internal + */ + constructor(public icon: IIcon) { + super( + `Tried to append an icon of type ${icon.getType()} when an icon of ` + + `that type already exists on the block. ` + + `Use getIcon to access the existing icon.`, + ); + } +} diff --git a/core/icons/icon.ts b/core/icons/icon.ts new file mode 100644 index 00000000000..f5f76603875 --- /dev/null +++ b/core/icons/icon.ts @@ -0,0 +1,199 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Block} from '../block.js'; +import type {BlockSvg} from '../block_svg.js'; +import * as browserEvents from '../browser_events.js'; +import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; +import {hasBubble} from '../interfaces/i_has_bubble.js'; +import type {IIcon} from '../interfaces/i_icon.js'; +import * as tooltip from '../tooltip.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as dom from '../utils/dom.js'; +import * as idGenerator from '../utils/idgenerator.js'; +import {Rect} from '../utils/rect.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import type {IconType} from './icon_types.js'; + +/** + * The abstract icon class. Icons are visual elements that live in the top-start + * corner of the block. Usually they provide more "meta" information about a + * block (such as warnings or comments) as opposed to fields, which provide + * "actual" information, related to how a block functions. + */ +export abstract class Icon implements IIcon { + /** + * The position of this icon relative to its blocks top-start, + * in workspace units. + */ + protected offsetInBlock: Coordinate = new Coordinate(0, 0); + + /** The position of this icon in workspace coordinates. */ + protected workspaceLocation: Coordinate = new Coordinate(0, 0); + + /** The root svg element visually representing this icon. */ + protected svgRoot: SVGGElement | null = null; + + /** The tooltip for this icon. */ + protected tooltip: tooltip.TipInfo; + + /** The unique ID of this icon. */ + private id: string; + + constructor(protected sourceBlock: Block) { + this.tooltip = sourceBlock; + this.id = idGenerator.getNextUniqueId(); + } + + getType(): IconType { + throw new Error('Icons must implement getType'); + } + + initView(pointerdownListener: (e: PointerEvent) => void): void { + if (this.svgRoot) return; // The icon has already been initialized. + + const svgBlock = this.sourceBlock as BlockSvg; + this.svgRoot = dom.createSvgElement(Svg.G, { + 'class': 'blocklyIconGroup', + 'id': this.id, + }); + svgBlock.getSvgRoot().appendChild(this.svgRoot); + this.updateSvgRootOffset(); + browserEvents.conditionalBind( + this.svgRoot, + 'pointerdown', + this, + pointerdownListener, + ); + (this.svgRoot as any).tooltip = this; + tooltip.bindMouseEvents(this.svgRoot); + } + + dispose(): void { + tooltip.unbindMouseEvents(this.svgRoot); + dom.removeNode(this.svgRoot); + } + + getWeight(): number { + return -1; + } + + getSize(): Size { + return new Size(0, 0); + } + + /** + * Sets the tooltip for this icon to the given value. Null to show the + * tooltip of the block. + */ + setTooltip(tip: tooltip.TipInfo | null) { + this.tooltip = tip ?? this.sourceBlock; + } + + /** Returns the tooltip for this icon. */ + getTooltip(): tooltip.TipInfo { + return this.tooltip; + } + + applyColour(): void {} + + updateEditable(): void {} + + updateCollapsed(): void { + if (!this.svgRoot) return; + if (this.sourceBlock.isCollapsed()) { + this.svgRoot.style.display = 'none'; + } else { + this.svgRoot.style.display = 'block'; + } + if (hasBubble(this)) { + this.setBubbleVisible(false); + } + } + + hideForInsertionMarker(): void { + if (!this.svgRoot) return; + this.svgRoot.style.display = 'none'; + } + + isShownWhenCollapsed(): boolean { + return false; + } + + setOffsetInBlock(offset: Coordinate): void { + this.offsetInBlock = offset; + this.updateSvgRootOffset(); + } + + private updateSvgRootOffset(): void { + this.svgRoot?.setAttribute( + 'transform', + `translate(${this.offsetInBlock.x}, ${this.offsetInBlock.y})`, + ); + } + + onLocationChange(blockOrigin: Coordinate): void { + this.workspaceLocation = Coordinate.sum(blockOrigin, this.offsetInBlock); + } + + onClick(): void {} + + /** + * Check whether the icon should be clickable while the block is in a flyout. + * The default is that icons are clickable in all flyouts (auto-closing or not). + * Subclasses may override this function to change this behavior. + * + * @param autoClosingFlyout true if the containing flyout is an auto-closing one. + * @returns Whether the icon should be clickable while the block is in a flyout. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isClickableInFlyout(autoClosingFlyout: boolean): boolean { + return true; + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + const svgRoot = this.svgRoot; + if (!svgRoot) throw new Error('Attempting to focus uninitialized icon.'); + return svgRoot; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.sourceBlock.workspace as WorkspaceSvg; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + const blockBounds = (this.sourceBlock as BlockSvg).getBoundingRectangle(); + const bounds = new Rect( + blockBounds.top + this.offsetInBlock.y, + blockBounds.top + this.offsetInBlock.y + this.getSize().height, + blockBounds.left + this.offsetInBlock.x, + blockBounds.left + this.offsetInBlock.x + this.getSize().width, + ); + (this.sourceBlock as BlockSvg).workspace.scrollBoundsIntoView(bounds); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } + + /** + * Returns the block that this icon is attached to. + * + * @returns The block this icon is attached to. + */ + getSourceBlock(): Block { + return this.sourceBlock; + } +} diff --git a/core/icons/icon_types.ts b/core/icons/icon_types.ts new file mode 100644 index 00000000000..c5edb0f7487 --- /dev/null +++ b/core/icons/icon_types.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ICommentIcon} from '../interfaces/i_comment_icon.js'; +import {IIcon} from '../interfaces/i_icon.js'; +import {MutatorIcon} from './mutator_icon.js'; +import {WarningIcon} from './warning_icon.js'; + +/** + * Defines the type of an icon, so that it can be retrieved from block.getIcon + */ +export class IconType<_T extends IIcon> { + /** @param name The name of the registry type. */ + constructor(private readonly name: string) {} + + /** @returns the name of the type. */ + toString(): string { + return this.name; + } + + /** @returns true if this icon type is equivalent to the given icon type. */ + equals(type: IconType): boolean { + return this.name === type.toString(); + } + + static MUTATOR = new IconType('mutator'); + static WARNING = new IconType('warning'); + static COMMENT = new IconType('comment'); +} diff --git a/core/icons/mutator_icon.ts b/core/icons/mutator_icon.ts new file mode 100644 index 00000000000..9055a91ea8f --- /dev/null +++ b/core/icons/mutator_icon.ts @@ -0,0 +1,360 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.Mutator + +import type {BlockSvg} from '../block_svg.js'; +import type {BlocklyOptions} from '../blockly_options.js'; +import {MiniWorkspaceBubble} from '../bubbles/mini_workspace_bubble.js'; +import type {Abstract} from '../events/events_abstract.js'; +import {BlockChange} from '../events/events_block_change.js'; +import {isBlockChange, isBlockCreate} from '../events/predicates.js'; +import {EventType} from '../events/type.js'; +import * as eventUtils from '../events/utils.js'; +import type {IHasBubble} from '../interfaces/i_has_bubble.js'; +import * as renderManagement from '../render_management.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as dom from '../utils/dom.js'; +import {Rect} from '../utils/rect.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {Icon} from './icon.js'; +import {IconType} from './icon_types.js'; + +/** The size of the mutator icon in workspace-scale units. */ +const SIZE = 17; + +/** + * The distance between the root block in the mini workspace and that + * workspace's edges. + */ +const WORKSPACE_MARGIN = 16; + +/** + * An icon that allows the user to change the shape of the block. + * + * For example, it could be used to add additional fields or inputs to + * the block. + */ +export class MutatorIcon extends Icon implements IHasBubble { + /** The type string used to identify this icon. */ + static readonly TYPE = IconType.MUTATOR; + + /** + * The weight this icon has relative to other icons. Icons with more positive + * weight values are rendered farther toward the end of the block. + */ + static readonly WEIGHT = 1; + + /** The bubble used to show the mini workspace to the user. */ + private miniWorkspaceBubble: MiniWorkspaceBubble | null = null; + + /** The root block in the mini workspace. */ + private rootBlock: BlockSvg | null = null; + + /** The PID tracking updating the workkspace in response to user events. */ + private updateWorkspacePid: ReturnType | null = null; + + /** + * The change listener in the main workspace that triggers the saveConnections + * method when anything in the main workspace changes. + * + * Only actually registered to listen for events while the mutator bubble is + * open. + */ + private saveConnectionsListener: (() => void) | null = null; + + constructor( + private readonly flyoutBlockTypes: string[], + protected readonly sourceBlock: BlockSvg, + ) { + super(sourceBlock); + } + + override getType(): IconType { + return MutatorIcon.TYPE; + } + + override initView(pointerdownListener: (e: PointerEvent) => void): void { + if (this.svgRoot) return; // Already initialized. + + super.initView(pointerdownListener); + + // Square with rounded corners. + dom.createSvgElement( + Svg.RECT, + { + 'class': 'blocklyIconShape', + 'rx': '4', + 'ry': '4', + 'height': '16', + 'width': '16', + }, + this.svgRoot, + ); + // Gear teeth. + dom.createSvgElement( + Svg.PATH, + { + 'class': 'blocklyIconSymbol', + 'd': + 'm4.203,7.296 0,1.368 -0.92,0.677 -0.11,0.41 0.9,1.559 0.41,' + + '0.11 1.043,-0.457 1.187,0.683 0.127,1.134 0.3,0.3 1.8,0 0.3,' + + '-0.299 0.127,-1.138 1.185,-0.682 1.046,0.458 0.409,-0.11 0.9,' + + '-1.559 -0.11,-0.41 -0.92,-0.677 0,-1.366 0.92,-0.677 0.11,' + + '-0.41 -0.9,-1.559 -0.409,-0.109 -1.046,0.458 -1.185,-0.682 ' + + '-0.127,-1.138 -0.3,-0.299 -1.8,0 -0.3,0.3 -0.126,1.135 -1.187,' + + '0.682 -1.043,-0.457 -0.41,0.11 -0.899,1.559 0.108,0.409z', + }, + this.svgRoot, + ); + // Axle hole. + dom.createSvgElement( + Svg.CIRCLE, + {'class': 'blocklyIconShape', 'r': '2.7', 'cx': '8', 'cy': '8'}, + this.svgRoot, + ); + dom.addClass(this.svgRoot!, 'blocklyMutatorIcon'); + } + + override dispose(): void { + super.dispose(); + this.miniWorkspaceBubble?.dispose(); + } + + override getWeight(): number { + return MutatorIcon.WEIGHT; + } + + override getSize(): Size { + return new Size(SIZE, SIZE); + } + + override applyColour(): void { + super.applyColour(); + this.miniWorkspaceBubble?.setColour(this.sourceBlock.getColour()); + this.miniWorkspaceBubble?.updateBlockStyles(); + } + + override updateCollapsed(): void { + super.updateCollapsed(); + if (this.sourceBlock.isCollapsed()) this.setBubbleVisible(false); + } + + override onLocationChange(blockOrigin: Coordinate): void { + super.onLocationChange(blockOrigin); + this.miniWorkspaceBubble?.setAnchorLocation(this.getAnchorLocation()); + } + + override onClick(): void { + super.onClick(); + if (this.sourceBlock.isEditable()) { + this.setBubbleVisible(!this.bubbleIsVisible()); + } + } + + override isClickableInFlyout(): boolean { + return false; + } + + bubbleIsVisible(): boolean { + return !!this.miniWorkspaceBubble; + } + + async setBubbleVisible(visible: boolean): Promise { + if (this.bubbleIsVisible() === visible) return; + + await renderManagement.finishQueuedRenders(); + + if (visible) { + this.miniWorkspaceBubble = new MiniWorkspaceBubble( + this.getMiniWorkspaceConfig(), + this.sourceBlock.workspace, + this.getAnchorLocation(), + this.getBubbleOwnerRect(), + ); + this.applyColour(); + this.createRootBlock(); + this.addSaveConnectionsListener(); + this.miniWorkspaceBubble?.addWorkspaceChangeListener( + this.createMiniWorkspaceChangeListener(), + ); + } else { + this.miniWorkspaceBubble?.dispose(); + this.miniWorkspaceBubble = null; + if (this.saveConnectionsListener) { + this.sourceBlock.workspace.removeChangeListener( + this.saveConnectionsListener, + ); + } + this.saveConnectionsListener = null; + } + + eventUtils.fire( + new (eventUtils.get(EventType.BUBBLE_OPEN))( + this.sourceBlock, + visible, + 'mutator', + ), + ); + } + + /** See IHasBubble.getBubble. */ + getBubble(): MiniWorkspaceBubble | null { + return this.miniWorkspaceBubble; + } + + /** @returns the configuration the mini workspace should have. */ + private getMiniWorkspaceConfig() { + const options: BlocklyOptions = { + 'disable': false, + 'media': this.sourceBlock.workspace.options.pathToMedia, + 'rtl': this.sourceBlock.RTL, + 'renderer': this.sourceBlock.workspace.options.renderer, + 'rendererOverrides': + this.sourceBlock.workspace.options.rendererOverrides ?? undefined, + }; + + if (this.flyoutBlockTypes.length) { + options.toolbox = { + 'kind': 'flyoutToolbox', + 'contents': this.flyoutBlockTypes.map((type) => ({ + 'kind': 'block', + 'type': type, + })), + }; + } + + return options; + } + + /** + * @returns the location the bubble should be anchored to. + * I.E. the middle of this icon. + */ + private getAnchorLocation(): Coordinate { + const midIcon = SIZE / 2; + return Coordinate.sum( + this.workspaceLocation, + new Coordinate(midIcon, midIcon), + ); + } + + /** + * @returns the rect the bubble should avoid overlapping. + * I.E. the block that owns this icon. + */ + private getBubbleOwnerRect(): Rect { + const bbox = this.sourceBlock.getSvgRoot().getBBox(); + return new Rect(bbox.y, bbox.y + bbox.height, bbox.x, bbox.x + bbox.width); + } + + /** Decomposes the source block to create blocks in the mini workspace. */ + private createRootBlock() { + if (!this.sourceBlock.decompose) { + throw new Error( + 'Blocks with mutator icons must include a decompose method', + ); + } + this.rootBlock = this.sourceBlock.decompose( + this.miniWorkspaceBubble!.getWorkspace(), + )!; + + for (const child of this.rootBlock.getDescendants(false)) { + child.queueRender(); + } + + this.rootBlock.setMovable(false); + this.rootBlock.setDeletable(false); + + const flyoutWidth = + this.miniWorkspaceBubble?.getWorkspace()?.getFlyout()?.getWidth() ?? 0; + this.rootBlock.moveBy( + this.rootBlock.RTL ? -(flyoutWidth + WORKSPACE_MARGIN) : WORKSPACE_MARGIN, + WORKSPACE_MARGIN, + ); + } + + /** Adds a listen to the source block that triggers saving connections. */ + private addSaveConnectionsListener() { + if (!this.sourceBlock.saveConnections || !this.rootBlock) return; + this.saveConnectionsListener = () => { + if (!this.sourceBlock.saveConnections || !this.rootBlock) return; + this.sourceBlock.saveConnections(this.rootBlock); + }; + this.saveConnectionsListener(); + this.sourceBlock.workspace.addChangeListener(this.saveConnectionsListener); + } + + /** + * Creates a change listener to add to the mini workspace which recomposes + * the block. + */ + private createMiniWorkspaceChangeListener() { + return (e: Abstract) => { + if (!MutatorIcon.isIgnorableMutatorEvent(e) && !this.updateWorkspacePid) { + this.updateWorkspacePid = setTimeout(() => { + this.updateWorkspacePid = null; + this.recomposeSourceBlock(); + }, 0); + } + }; + } + + /** + * Returns true if the given event is not one the mutator needs to + * care about. + * + * @internal + */ + static isIgnorableMutatorEvent(e: Abstract) { + return ( + e.isUiEvent || + isBlockCreate(e) || + (isBlockChange(e) && e.element === 'disabled') + ); + } + + /** Recomposes the source block based on changes to the mini workspace. */ + private recomposeSourceBlock() { + if (!this.rootBlock) return; + if (!this.sourceBlock.compose) { + throw new Error( + 'Blocks with mutator icons must include a compose method', + ); + } + + const existingGroup = eventUtils.getGroup(); + if (!existingGroup) eventUtils.setGroup(true); + + const oldExtraState = BlockChange.getExtraBlockState_(this.sourceBlock); + this.sourceBlock.compose(this.rootBlock); + const newExtraState = BlockChange.getExtraBlockState_(this.sourceBlock); + + if (oldExtraState !== newExtraState) { + eventUtils.fire( + new (eventUtils.get(EventType.BLOCK_CHANGE))( + this.sourceBlock, + 'mutation', + null, + oldExtraState, + newExtraState, + ), + ); + } + + eventUtils.setGroup(existingGroup); + } + + /** + * @returns The workspace of the mini workspace bubble, if the bubble is + * currently open. + */ + getWorkspace(): WorkspaceSvg | undefined { + return this.miniWorkspaceBubble?.getWorkspace(); + } +} diff --git a/core/icons/registry.ts b/core/icons/registry.ts new file mode 100644 index 00000000000..ed6be0a004b --- /dev/null +++ b/core/icons/registry.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Block} from '../block.js'; +import type {IIcon} from '../interfaces/i_icon.js'; +import * as registry from '../registry.js'; +import {IconType} from './icon_types.js'; + +/** + * Registers the given icon so that it can be deserialized. + * + * @param type The type of the icon to register. This should be the same string + * that is returned from its `getType` method. + * @param iconConstructor The icon class/constructor to register. + */ +export function register( + type: IconType, + iconConstructor: new (block: Block) => IIcon, +) { + registry.register(registry.Type.ICON, type.toString(), iconConstructor); +} + +/** + * Unregisters the icon associated with the given type. + * + * @param type The type of the icon to unregister. + */ +export function unregister(type: string) { + registry.unregister(registry.Type.ICON, type); +} diff --git a/core/icons/warning_icon.ts b/core/icons/warning_icon.ts new file mode 100644 index 00000000000..f24a6a56190 --- /dev/null +++ b/core/icons/warning_icon.ts @@ -0,0 +1,226 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.Warning + +import type {BlockSvg} from '../block_svg.js'; +import {TextBubble} from '../bubbles/text_bubble.js'; +import {EventType} from '../events/type.js'; +import * as eventUtils from '../events/utils.js'; +import type {IBubble} from '../interfaces/i_bubble.js'; +import type {IHasBubble} from '../interfaces/i_has_bubble.js'; +import * as renderManagement from '../render_management.js'; +import {Size} from '../utils.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as dom from '../utils/dom.js'; +import {Rect} from '../utils/rect.js'; +import {Svg} from '../utils/svg.js'; +import {Icon} from './icon.js'; +import {IconType} from './icon_types.js'; + +/** The size of the warning icon in workspace-scale units. */ +const SIZE = 17; + +/** + * An icon that warns the user that something is wrong with their block. + * + * For example, this could be used to warn them about incorrect field values, + * or incorrect placement of the block (putting it somewhere it doesn't belong). + */ +export class WarningIcon extends Icon implements IHasBubble { + /** The type string used to identify this icon. */ + static readonly TYPE = IconType.WARNING; + + /** + * The weight this icon has relative to other icons. Icons with more positive + * weight values are rendered farther toward the end of the block. + */ + static readonly WEIGHT = 2; + + /** A map of warning IDs to warning text. */ + private textMap: Map = new Map(); + + /** The bubble used to display the warnings to the user. */ + private textBubble: TextBubble | null = null; + + /** @internal */ + constructor(protected readonly sourceBlock: BlockSvg) { + super(sourceBlock); + } + + override getType(): IconType { + return WarningIcon.TYPE; + } + + override initView(pointerdownListener: (e: PointerEvent) => void): void { + if (this.svgRoot) return; // Already initialized. + + super.initView(pointerdownListener); + + // Triangle with rounded corners. + dom.createSvgElement( + Svg.PATH, + { + 'class': 'blocklyIconShape', + 'd': 'M2,15Q-1,15 0.5,12L6.5,1.7Q8,-1 9.5,1.7L15.5,12Q17,15 14,15z', + }, + this.svgRoot, + ); + // Can't use a real '!' text character since different browsers and + // operating systems render it differently. Body of exclamation point. + dom.createSvgElement( + Svg.PATH, + { + 'class': 'blocklyIconSymbol', + 'd': 'm7,4.8v3.16l0.27,2.27h1.46l0.27,-2.27v-3.16z', + }, + this.svgRoot, + ); + // Dot of exclamation point. + dom.createSvgElement( + Svg.RECT, + { + 'class': 'blocklyIconSymbol', + 'x': '7', + 'y': '11', + 'height': '2', + 'width': '2', + }, + this.svgRoot, + ); + dom.addClass(this.svgRoot!, 'blocklyWarningIcon'); + } + + override dispose() { + super.dispose(); + this.textBubble?.dispose(); + } + + override getWeight(): number { + return WarningIcon.WEIGHT; + } + + override getSize(): Size { + return new Size(SIZE, SIZE); + } + + override applyColour(): void { + super.applyColour(); + this.textBubble?.setColour(this.sourceBlock.getColour()); + } + + override updateCollapsed(): void { + // We are shown when collapsed, so do nothing! I.e. skip the default + // behavior of hiding. + } + + /** Tells blockly that this icon is shown when the block is collapsed. */ + override isShownWhenCollapsed(): boolean { + return true; + } + + /** Updates the location of the icon's bubble if it is open. */ + override onLocationChange(blockOrigin: Coordinate): void { + super.onLocationChange(blockOrigin); + this.textBubble?.setAnchorLocation(this.getAnchorLocation()); + } + + /** + * Adds a warning message to this warning icon. + * + * @param text The text of the message to add. + * @param id The id of the message to add. + * @internal + */ + addMessage(text: string, id: string): this { + if (this.textMap.get(id) === text) return this; + + if (text) { + this.textMap.set(id, text); + } else { + this.textMap.delete(id); + } + + this.textBubble?.setText(this.getText()); + return this; + } + + /** + * @returns the display text for this icon. Includes all warning messages + * concatenated together with newlines. + * @internal + */ + getText(): string { + return [...this.textMap.values()].join('\n'); + } + + /** Toggles the visibility of the bubble. */ + override onClick(): void { + super.onClick(); + this.setBubbleVisible(!this.bubbleIsVisible()); + } + + override isClickableInFlyout(): boolean { + return false; + } + + bubbleIsVisible(): boolean { + return !!this.textBubble; + } + + async setBubbleVisible(visible: boolean): Promise { + if (this.bubbleIsVisible() === visible) return; + + await renderManagement.finishQueuedRenders(); + + if (visible) { + this.textBubble = new TextBubble( + this.getText(), + this.sourceBlock.workspace, + this.getAnchorLocation(), + this.getBubbleOwnerRect(), + ); + this.applyColour(); + } else { + this.textBubble?.dispose(); + this.textBubble = null; + } + + eventUtils.fire( + new (eventUtils.get(EventType.BUBBLE_OPEN))( + this.sourceBlock, + visible, + 'warning', + ), + ); + } + + /** See IHasBubble.getBubble. */ + getBubble(): IBubble | null { + return this.textBubble; + } + + /** + * @returns the location the bubble should be anchored to. + * I.E. the middle of this icon. + */ + private getAnchorLocation(): Coordinate { + const midIcon = SIZE / 2; + return Coordinate.sum( + this.workspaceLocation, + new Coordinate(midIcon, midIcon), + ); + } + + /** + * @returns the rect the bubble should avoid overlapping. + * I.E. the block that owns this icon. + */ + private getBubbleOwnerRect(): Rect { + const bbox = this.sourceBlock.getSvgRoot().getBBox(); + return new Rect(bbox.y, bbox.y + bbox.height, bbox.x, bbox.x + bbox.width); + } +} diff --git a/core/inject.js b/core/inject.js deleted file mode 100644 index ff196202ece..00000000000 --- a/core/inject.js +++ /dev/null @@ -1,393 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2011 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Functions for injecting Blockly into a web page. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.inject'); - -goog.require('Blockly.BlockDragSurfaceSvg'); -goog.require('Blockly.Css'); -goog.require('Blockly.Grid'); -goog.require('Blockly.Options'); -goog.require('Blockly.WorkspaceSvg'); -goog.require('Blockly.WorkspaceDragSurfaceSvg'); -goog.require('goog.dom'); -goog.require('goog.ui.Component'); -goog.require('goog.userAgent'); - - -/** - * Inject a Blockly editor into the specified container element (usually a div). - * @param {!Element|string} container Containing element, or its ID, - * or a CSS selector. - * @param {Object=} opt_options Optional dictionary of options. - * @return {!Blockly.Workspace} Newly created main workspace. - */ -Blockly.inject = function(container, opt_options) { - if (goog.isString(container)) { - container = document.getElementById(container) || - document.querySelector(container); - } - // Verify that the container is in document. - if (!goog.dom.contains(document, container)) { - throw 'Error: container is not in current document.'; - } - var options = new Blockly.Options(opt_options || {}); - var subContainer = goog.dom.createDom('div', 'injectionDiv'); - container.appendChild(subContainer); - var svg = Blockly.createDom_(subContainer, options); - - // Create surfaces for dragging things. These are optimizations - // so that the broowser does not repaint during the drag. - var blockDragSurface = new Blockly.BlockDragSurfaceSvg(subContainer); - var workspaceDragSurface = new Blockly.WorkspaceDragSurfaceSvg(subContainer); - - var workspace = Blockly.createMainWorkspace_(svg, options, blockDragSurface, - workspaceDragSurface); - Blockly.init_(workspace); - Blockly.mainWorkspace = workspace; - - Blockly.svgResize(workspace); - return workspace; -}; - -/** - * Create the SVG image. - * @param {!Element} container Containing element. - * @param {!Blockly.Options} options Dictionary of options. - * @return {!Element} Newly created SVG image. - * @private - */ -Blockly.createDom_ = function(container, options) { - // Sadly browsers (Chrome vs Firefox) are currently inconsistent in laying - // out content in RTL mode. Therefore Blockly forces the use of LTR, - // then manually positions content in RTL as needed. - container.setAttribute('dir', 'LTR'); - // Closure can be trusted to create HTML widgets with the proper direction. - goog.ui.Component.setDefaultRightToLeft(options.RTL); - - // Load CSS. - Blockly.Css.inject(options.hasCss, options.pathToMedia); - - // Build the SVG DOM. - /* - - ... - - */ - var svg = Blockly.utils.createSvgElement('svg', { - 'xmlns': 'http://www.w3.org/2000/svg', - 'xmlns:html': 'http://www.w3.org/1999/xhtml', - 'xmlns:xlink': 'http://www.w3.org/1999/xlink', - 'version': '1.1', - 'class': 'blocklySvg' - }, container); - /* - - ... filters go here ... - - */ - var defs = Blockly.utils.createSvgElement('defs', {}, svg); - // Each filter/pattern needs a unique ID for the case of multiple Blockly - // instances on a page. Browser behaviour becomes undefined otherwise. - // https://neil.fraser.name/news/2015/11/01/ - var rnd = String(Math.random()).substring(2); - /* - - - - - - - - - */ - var embossFilter = Blockly.utils.createSvgElement('filter', - {'id': 'blocklyEmbossFilter' + rnd}, defs); - Blockly.utils.createSvgElement('feGaussianBlur', - {'in': 'SourceAlpha', 'stdDeviation': 1, 'result': 'blur'}, embossFilter); - var feSpecularLighting = Blockly.utils.createSvgElement('feSpecularLighting', - {'in': 'blur', 'surfaceScale': 1, 'specularConstant': 0.5, - 'specularExponent': 10, 'lighting-color': 'white', 'result': 'specOut'}, - embossFilter); - Blockly.utils.createSvgElement('fePointLight', - {'x': -5000, 'y': -10000, 'z': 20000}, feSpecularLighting); - Blockly.utils.createSvgElement('feComposite', - {'in': 'specOut', 'in2': 'SourceAlpha', 'operator': 'in', - 'result': 'specOut'}, embossFilter); - Blockly.utils.createSvgElement('feComposite', - {'in': 'SourceGraphic', 'in2': 'specOut', 'operator': 'arithmetic', - 'k1': 0, 'k2': 1, 'k3': 1, 'k4': 0}, embossFilter); - options.embossFilterId = embossFilter.id; - /* - - - - - */ - var disabledPattern = Blockly.utils.createSvgElement('pattern', - {'id': 'blocklyDisabledPattern' + rnd, - 'patternUnits': 'userSpaceOnUse', - 'width': 10, 'height': 10}, defs); - Blockly.utils.createSvgElement('rect', - {'width': 10, 'height': 10, 'fill': '#aaa'}, disabledPattern); - Blockly.utils.createSvgElement('path', - {'d': 'M 0 0 L 10 10 M 10 0 L 0 10', 'stroke': '#cc0'}, disabledPattern); - options.disabledPatternId = disabledPattern.id; - - options.gridPattern = Blockly.Grid.createDom(rnd, options.gridOptions, defs); - return svg; -}; - -/** - * Create a main workspace and add it to the SVG. - * @param {!Element} svg SVG element with pattern defined. - * @param {!Blockly.Options} options Dictionary of options. - * @param {!Blockly.BlockDragSurfaceSvg} blockDragSurface Drag surface SVG - * for the blocks. - * @param {!Blockly.WorkspaceDragSurfaceSvg} workspaceDragSurface Drag surface - * SVG for the workspace. - * @return {!Blockly.Workspace} Newly created main workspace. - * @private - */ -Blockly.createMainWorkspace_ = function(svg, options, blockDragSurface, workspaceDragSurface) { - options.parentWorkspace = null; - var mainWorkspace = new Blockly.WorkspaceSvg(options, blockDragSurface, workspaceDragSurface); - mainWorkspace.scale = options.zoomOptions.startScale; - svg.appendChild(mainWorkspace.createDom('blocklyMainBackground')); - - if (!options.hasCategories && options.languageTree) { - // Add flyout as an that is a sibling of the workspace svg. - var flyout = mainWorkspace.addFlyout_('svg'); - Blockly.utils.insertAfter_(flyout, svg); - } - - // A null translation will also apply the correct initial scale. - mainWorkspace.translate(0, 0); - Blockly.mainWorkspace = mainWorkspace; - - if (!options.readOnly && !options.hasScrollbars) { - var workspaceChanged = function() { - if (!mainWorkspace.isDragging()) { - var metrics = mainWorkspace.getMetrics(); - var edgeLeft = metrics.viewLeft + metrics.absoluteLeft; - var edgeTop = metrics.viewTop + metrics.absoluteTop; - if (metrics.contentTop < edgeTop || - metrics.contentTop + metrics.contentHeight > - metrics.viewHeight + edgeTop || - metrics.contentLeft < - (options.RTL ? metrics.viewLeft : edgeLeft) || - metrics.contentLeft + metrics.contentWidth > (options.RTL ? - metrics.viewWidth : metrics.viewWidth + edgeLeft)) { - // One or more blocks may be out of bounds. Bump them back in. - var MARGIN = 25; - var blocks = mainWorkspace.getTopBlocks(false); - for (var b = 0, block; block = blocks[b]; b++) { - var blockXY = block.getRelativeToSurfaceXY(); - var blockHW = block.getHeightWidth(); - // Bump any block that's above the top back inside. - var overflowTop = edgeTop + MARGIN - blockHW.height - blockXY.y; - if (overflowTop > 0) { - block.moveBy(0, overflowTop); - } - // Bump any block that's below the bottom back inside. - var overflowBottom = - edgeTop + metrics.viewHeight - MARGIN - blockXY.y; - if (overflowBottom < 0) { - block.moveBy(0, overflowBottom); - } - // Bump any block that's off the left back inside. - var overflowLeft = MARGIN + edgeLeft - - blockXY.x - (options.RTL ? 0 : blockHW.width); - if (overflowLeft > 0) { - block.moveBy(overflowLeft, 0); - } - // Bump any block that's off the right back inside. - var overflowRight = edgeLeft + metrics.viewWidth - MARGIN - - blockXY.x + (options.RTL ? blockHW.width : 0); - if (overflowRight < 0) { - block.moveBy(overflowRight, 0); - } - } - } - } - }; - mainWorkspace.addChangeListener(workspaceChanged); - } - // The SVG is now fully assembled. - Blockly.svgResize(mainWorkspace); - Blockly.WidgetDiv.createDom(); - Blockly.Tooltip.createDom(); - return mainWorkspace; -}; - -/** - * Initialize Blockly with various handlers. - * @param {!Blockly.Workspace} mainWorkspace Newly created main workspace. - * @private - */ -Blockly.init_ = function(mainWorkspace) { - var options = mainWorkspace.options; - var svg = mainWorkspace.getParentSvg(); - - // Suppress the browser's context menu. - Blockly.bindEventWithChecks_(svg.parentNode, 'contextmenu', null, - function(e) { - if (!Blockly.utils.isTargetInput(e)) { - e.preventDefault(); - } - }); - - var workspaceResizeHandler = Blockly.bindEventWithChecks_(window, 'resize', - null, - function() { - Blockly.hideChaff(true); - Blockly.svgResize(mainWorkspace); - }); - mainWorkspace.setResizeHandlerWrapper(workspaceResizeHandler); - - Blockly.inject.bindDocumentEvents_(); - - if (options.languageTree) { - if (mainWorkspace.toolbox_) { - mainWorkspace.toolbox_.init(mainWorkspace); - } else if (mainWorkspace.flyout_) { - // Build a fixed flyout with the root blocks. - mainWorkspace.flyout_.init(mainWorkspace); - mainWorkspace.flyout_.show(options.languageTree.childNodes); - mainWorkspace.flyout_.scrollToStart(); - // Translate the workspace sideways to avoid the fixed flyout. - mainWorkspace.scrollX = mainWorkspace.flyout_.width_; - if (options.toolboxPosition == Blockly.TOOLBOX_AT_RIGHT) { - mainWorkspace.scrollX *= -1; - } - mainWorkspace.translate(mainWorkspace.scrollX, 0); - } - } - - if (options.hasScrollbars) { - mainWorkspace.scrollbar = new Blockly.ScrollbarPair(mainWorkspace); - mainWorkspace.scrollbar.resize(); - } - - // Load the sounds. - if (options.hasSounds) { - Blockly.inject.loadSounds_(options.pathToMedia, mainWorkspace); - } -}; - -/** - * Bind document events, but only once. Destroying and reinjecting Blockly - * should not bind again. - * Bind events for scrolling the workspace. - * Most of these events should be bound to the SVG's surface. - * However, 'mouseup' has to be on the whole document so that a block dragged - * out of bounds and released will know that it has been released. - * Also, 'keydown' has to be on the whole document since the browser doesn't - * understand a concept of focus on the SVG image. - * @private - */ -Blockly.inject.bindDocumentEvents_ = function() { - if (!Blockly.documentEventsBound_) { - Blockly.bindEventWithChecks_(document, 'keydown', null, Blockly.onKeyDown_); - // longStop needs to run to stop the context menu from showing up. It - // should run regardless of what other touch event handlers have run. - Blockly.bindEvent_(document, 'touchend', null, Blockly.longStop_); - Blockly.bindEvent_(document, 'touchcancel', null, Blockly.longStop_); - // Some iPad versions don't fire resize after portrait to landscape change. - if (goog.userAgent.IPAD) { - Blockly.bindEventWithChecks_(window, 'orientationchange', document, - function() { - // TODO(#397): Fix for multiple blockly workspaces. - Blockly.svgResize(Blockly.getMainWorkspace()); - }); - } - } - Blockly.documentEventsBound_ = true; -}; - -/** - * Load sounds for the given workspace. - * @param {string} pathToMedia The path to the media directory. - * @param {!Blockly.Workspace} workspace The workspace to load sounds for. - * @private - */ -Blockly.inject.loadSounds_ = function(pathToMedia, workspace) { - var audioMgr = workspace.getAudioManager(); - audioMgr.load( - [pathToMedia + 'click.mp3', - pathToMedia + 'click.wav', - pathToMedia + 'click.ogg'], 'click'); - audioMgr.load( - [pathToMedia + 'disconnect.wav', - pathToMedia + 'disconnect.mp3', - pathToMedia + 'disconnect.ogg'], 'disconnect'); - audioMgr.load( - [pathToMedia + 'delete.mp3', - pathToMedia + 'delete.ogg', - pathToMedia + 'delete.wav'], 'delete'); - - // Bind temporary hooks that preload the sounds. - var soundBinds = []; - var unbindSounds = function() { - while (soundBinds.length) { - Blockly.unbindEvent_(soundBinds.pop()); - } - audioMgr.preload(); - }; - - // These are bound on mouse/touch events with Blockly.bindEventWithChecks_, so - // they restrict the touch identifier that will be recognized. But this is - // really something that happens on a click, not a drag, so that's not - // necessary. - - // Android ignores any sound not loaded as a result of a user action. - soundBinds.push( - Blockly.bindEventWithChecks_(document, 'mousemove', null, unbindSounds, - true)); - soundBinds.push( - Blockly.bindEventWithChecks_(document, 'touchstart', null, unbindSounds, - true)); -}; - -/** - * Modify the block tree on the existing toolbox. - * @param {Node|string} tree DOM tree of blocks, or text representation of same. - * @deprecated April 2015 - */ -Blockly.updateToolbox = function(tree) { - console.warn('Deprecated call to Blockly.updateToolbox, ' + - 'use workspace.updateToolbox instead.'); - Blockly.getMainWorkspace().updateToolbox(tree); -}; diff --git a/core/inject.ts b/core/inject.ts new file mode 100644 index 00000000000..4217c515119 --- /dev/null +++ b/core/inject.ts @@ -0,0 +1,393 @@ +/** + * @license + * Copyright 2011 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.inject + +import type {BlocklyOptions} from './blockly_options.js'; +import * as browserEvents from './browser_events.js'; +import * as bumpObjects from './bump_objects.js'; +import * as common from './common.js'; +import * as Css from './css.js'; +import * as dropDownDiv from './dropdowndiv.js'; +import {Grid} from './grid.js'; +import {Options} from './options.js'; +import {ScrollbarPair} from './scrollbar_pair.js'; +import * as Tooltip from './tooltip.js'; +import * as Touch from './touch.js'; +import * as dom from './utils/dom.js'; +import {Svg} from './utils/svg.js'; +import * as WidgetDiv from './widgetdiv.js'; +import {WorkspaceSvg} from './workspace_svg.js'; + +/** + * Inject a Blockly editor into the specified container element (usually a div). + * + * @param container Containing element, or its ID, or a CSS selector. + * @param opt_options Optional dictionary of options. + * @returns Newly created main workspace. + */ +export function inject( + container: Element | string, + opt_options?: BlocklyOptions, +): WorkspaceSvg { + let containerElement: Element | null = null; + if (typeof container === 'string') { + containerElement = + document.getElementById(container) || document.querySelector(container); + } else { + containerElement = container; + } + // Verify that the container is in document. + if ( + !document.contains(containerElement) && + document !== containerElement?.ownerDocument + ) { + throw Error('Error: container is not in current document'); + } + const options = new Options(opt_options || ({} as BlocklyOptions)); + const subContainer = document.createElement('div'); + dom.addClass(subContainer, 'injectionDiv'); + if (opt_options?.rtl) { + dom.addClass(subContainer, 'blocklyRTL'); + } + + containerElement!.appendChild(subContainer); + const svg = createDom(subContainer, options); + + const workspace = createMainWorkspace(subContainer, svg, options); + + init(workspace); + + // Keep focus on the first workspace so entering keyboard navigation looks + // correct. + common.setMainWorkspace(workspace); + + common.svgResize(workspace); + + subContainer.addEventListener('focusin', function () { + common.setMainWorkspace(workspace); + }); + + browserEvents.conditionalBind( + subContainer, + 'keydown', + null, + common.globalShortcutHandler, + ); + + return workspace; +} + +/** + * Create the SVG image. + * + * @param container Containing element. + * @param options Dictionary of options. + * @returns Newly created SVG image. + */ +function createDom(container: HTMLElement, options: Options): SVGElement { + // Sadly browsers (Chrome vs Firefox) are currently inconsistent in laying + // out content in RTL mode. Therefore Blockly forces the use of LTR, + // then manually positions content in RTL as needed. + container.setAttribute('dir', 'LTR'); + + // Load CSS. + Css.inject(options.hasCss, options.pathToMedia); + + // Build the SVG DOM. + /* + + ... + + */ + const svg = dom.createSvgElement( + Svg.SVG, + { + 'xmlns': dom.SVG_NS, + 'xmlns:html': dom.HTML_NS, + 'xmlns:xlink': dom.XLINK_NS, + 'version': '1.1', + 'class': 'blocklySvg', + }, + container, + ); + /* + + ... filters go here ... + + */ + const defs = dom.createSvgElement(Svg.DEFS, {}, svg); + // Each filter/pattern needs a unique ID for the case of multiple Blockly + // instances on a page. Browser behaviour becomes undefined otherwise. + // https://neil.fraser.name/news/2015/11/01/ + const rnd = String(Math.random()).substring(2); + + options.gridPattern = Grid.createDom( + rnd, + options.gridOptions, + defs, + container, + ); + return svg; +} + +/** + * Create a main workspace and add it to the SVG. + * + * @param svg SVG element with pattern defined. + * @param options Dictionary of options. + * @returns Newly created main workspace. + */ +function createMainWorkspace( + injectionDiv: HTMLElement, + svg: SVGElement, + options: Options, +): WorkspaceSvg { + options.parentWorkspace = null; + const mainWorkspace = new WorkspaceSvg(options); + const wsOptions = mainWorkspace.options; + mainWorkspace.scale = wsOptions.zoomOptions.startScale; + svg.appendChild( + mainWorkspace.createDom('blocklyMainBackground', injectionDiv), + ); + + // Set the theme name and renderer name onto the injection div. + const rendererClassName = mainWorkspace.getRenderer().getClassName(); + if (rendererClassName) { + dom.addClass(injectionDiv, rendererClassName); + } + const themeClassName = mainWorkspace.getTheme().getClassName(); + if (themeClassName) { + dom.addClass(injectionDiv, themeClassName); + } + + if (!wsOptions.hasCategories && wsOptions.languageTree) { + // Add flyout as an that is a sibling of the workspace SVG. + const flyout = mainWorkspace.addFlyout(Svg.SVG); + dom.insertAfter(flyout, svg); + } + if (wsOptions.hasTrashcan) { + mainWorkspace.addTrashcan(); + } + if (wsOptions.zoomOptions && wsOptions.zoomOptions.controls) { + mainWorkspace.addZoomControls(); + } + // Register the workspace svg as a UI component. + mainWorkspace + .getThemeManager() + .subscribe(svg, 'workspaceBackgroundColour', 'background-color'); + + // A null translation will also apply the correct initial scale. + mainWorkspace.translate(0, 0); + + mainWorkspace.addChangeListener( + bumpObjects.bumpIntoBoundsHandler(mainWorkspace), + ); + + // The SVG is now fully assembled. + common.svgResize(mainWorkspace); + WidgetDiv.createDom(); + dropDownDiv.createDom(); + Tooltip.createDom(); + return mainWorkspace; +} + +/** + * Initialize Blockly with various handlers. + * + * @param mainWorkspace Newly created main workspace. + */ +function init(mainWorkspace: WorkspaceSvg) { + const options = mainWorkspace.options; + const svg = mainWorkspace.getParentSvg(); + + // Suppress the browser's context menu. + browserEvents.conditionalBind( + svg.parentNode as Element, + 'contextmenu', + null, + function (e: Event) { + if (!browserEvents.isTargetInput(e)) { + e.preventDefault(); + } + }, + ); + + const workspaceResizeHandler = browserEvents.conditionalBind( + window, + 'resize', + null, + function () { + // Don't hide all the chaff. Leave the dropdown and widget divs open if + // possible. + Tooltip.hide(); + mainWorkspace.hideComponents(true); + dropDownDiv.repositionForWindowResize(); + WidgetDiv.repositionForWindowResize(); + common.svgResize(mainWorkspace); + bumpObjects.bumpTopObjectsIntoBounds(mainWorkspace); + }, + ); + mainWorkspace.setResizeHandlerWrapper(workspaceResizeHandler); + + bindDocumentEvents(); + + if (options.languageTree) { + const toolbox = mainWorkspace.getToolbox(); + const flyout = mainWorkspace.getFlyout(true); + if (toolbox) { + toolbox.init(); + } else if (flyout) { + // Build a fixed flyout with the root blocks. + flyout.init(mainWorkspace); + flyout.show(options.languageTree); + if (typeof flyout.scrollToStart === 'function') { + flyout.scrollToStart(); + } + } + } + + if (options.hasTrashcan) { + mainWorkspace.trashcan!.init(); + } + if (options.zoomOptions && options.zoomOptions.controls) { + mainWorkspace.zoomControls_!.init(); + } + + if (options.moveOptions && options.moveOptions.scrollbars) { + const horizontalScroll = + options.moveOptions.scrollbars === true || + !!options.moveOptions.scrollbars.horizontal; + const verticalScroll = + options.moveOptions.scrollbars === true || + !!options.moveOptions.scrollbars.vertical; + mainWorkspace.scrollbar = new ScrollbarPair( + mainWorkspace, + horizontalScroll, + verticalScroll, + 'blocklyMainWorkspaceScrollbar', + ); + mainWorkspace.scrollbar.resize(); + } else { + mainWorkspace.setMetrics({x: 0.5, y: 0.5}); + } + + // Load the sounds. + if (options.hasSounds) { + loadSounds(options.pathToMedia, mainWorkspace); + } +} + +/** + * Whether event handlers have been bound. Document event handlers will only + * be bound once, even if Blockly is destroyed and reinjected. + */ +let documentEventsBound = false; + +/** + * Bind document events, but only once. Destroying and reinjecting Blockly + * should not bind again. + * Bind events for scrolling the workspace. + * Most of these events should be bound to the SVG's surface. + * However, 'mouseup' has to be on the whole document so that a block dragged + * out of bounds and released will know that it has been released. + */ +function bindDocumentEvents() { + if (!documentEventsBound) { + browserEvents.conditionalBind(document, 'scroll', null, function () { + const workspaces = common.getAllWorkspaces(); + for (let i = 0, workspace; (workspace = workspaces[i]); i++) { + if (workspace instanceof WorkspaceSvg) { + workspace.updateInverseScreenCTM(); + } + } + }); + // longStop needs to run to stop the context menu from showing up. It + // should run regardless of what other touch event handlers have run. + browserEvents.bind(document, 'touchend', null, Touch.longStop); + browserEvents.bind(document, 'touchcancel', null, Touch.longStop); + } + documentEventsBound = true; +} + +/** + * Load sounds for the given workspace. + * + * @param pathToMedia The path to the media directory. + * @param workspace The workspace to load sounds for. + */ +function loadSounds(pathToMedia: string, workspace: WorkspaceSvg) { + const audioMgr = workspace.getAudioManager(); + audioMgr.load( + [ + pathToMedia + 'click.mp3', + pathToMedia + 'click.wav', + pathToMedia + 'click.ogg', + ], + 'click', + ); + audioMgr.load( + [ + pathToMedia + 'disconnect.wav', + pathToMedia + 'disconnect.mp3', + pathToMedia + 'disconnect.ogg', + ], + 'disconnect', + ); + audioMgr.load( + [ + pathToMedia + 'delete.mp3', + pathToMedia + 'delete.ogg', + pathToMedia + 'delete.wav', + ], + 'delete', + ); + + // Bind temporary hooks that preload the sounds. + const soundBinds: browserEvents.Data[] = []; + /** + * + */ + function unbindSounds() { + while (soundBinds.length) { + const oldSoundBinding = soundBinds.pop(); + if (oldSoundBinding) { + browserEvents.unbind(oldSoundBinding); + } + } + audioMgr.preload(); + } + + // These are bound on mouse/touch events with + // Blockly.browserEvents.conditionalBind, so they restrict the touch + // identifier that will be recognized. But this is really something that + // happens on a click, not a drag, so that's not necessary. + + // Android ignores any sound not loaded as a result of a user action. + soundBinds.push( + browserEvents.conditionalBind( + document, + 'pointermove', + null, + unbindSounds, + true, + ), + ); + soundBinds.push( + browserEvents.conditionalBind( + document, + 'touchstart', + null, + unbindSounds, + true, + ), + ); +} diff --git a/core/input.js b/core/input.js deleted file mode 100644 index c2c006e5235..00000000000 --- a/core/input.js +++ /dev/null @@ -1,251 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Object representing an input (value, statement, or dummy). - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -goog.provide('Blockly.Input'); - -goog.require('Blockly.Connection'); -goog.require('Blockly.FieldLabel'); -goog.require('goog.asserts'); - - -/** - * Class for an input with an optional field. - * @param {number} type The type of the input. - * @param {string} name Language-neutral identifier which may used to find this - * input again. - * @param {!Blockly.Block} block The block containing this input. - * @param {Blockly.Connection} connection Optional connection for this input. - * @constructor - */ -Blockly.Input = function(type, name, block, connection) { - if (type != Blockly.DUMMY_INPUT && !name) { - throw 'Value inputs and statement inputs must have non-empty name.'; - } - /** @type {number} */ - this.type = type; - /** @type {string} */ - this.name = name; - /** - * @type {!Blockly.Block} - * @private - */ - this.sourceBlock_ = block; - /** @type {Blockly.Connection} */ - this.connection = connection; - /** @type {!Array.} */ - this.fieldRow = []; -}; - -/** - * Alignment of input's fields (left, right or centre). - * @type {number} - */ -Blockly.Input.prototype.align = Blockly.ALIGN_LEFT; - -/** - * Is the input visible? - * @type {boolean} - * @private - */ -Blockly.Input.prototype.visible_ = true; - -/** - * Add a field (or label from string), and all prefix and suffix fields, to the - * end of the input's field row. - * @param {string|!Blockly.Field} field Something to add as a field. - * @param {string=} opt_name Language-neutral identifier which may used to find - * this field again. Should be unique to the host block. - * @return {!Blockly.Input} The input being append to (to allow chaining). - */ -Blockly.Input.prototype.appendField = function(field, opt_name) { - this.insertFieldAt(this.fieldRow.length, field, opt_name); - return this; -}; - -/** - * Inserts a field (or label from string), and all prefix and suffix fields, at - * the location of the input's field row. - * @param {number} index The index at which to insert field. - * @param {string|!Blockly.Field} field Something to add as a field. - * @param {string=} opt_name Language-neutral identifier which may used to find - * this field again. Should be unique to the host block. - * @return {number} The index following the last inserted field. - */ -Blockly.Input.prototype.insertFieldAt = function(index, field, opt_name) { - if (index < 0 || index > this.fieldRow.length) { - throw new Error('index ' + index + ' out of bounds.'); - } - - // Empty string, Null or undefined generates no field, unless field is named. - if (!field && !opt_name) { - return this; - } - // Generate a FieldLabel when given a plain text field. - if (goog.isString(field)) { - field = new Blockly.FieldLabel(/** @type {string} */ (field)); - } - field.setSourceBlock(this.sourceBlock_); - if (this.sourceBlock_.rendered) { - field.init(); - } - field.name = opt_name; - - if (field.prefixField) { - // Add any prefix. - index = this.insertFieldAt(index, field.prefixField); - } - // Add the field to the field row. - this.fieldRow.splice(index, 0, field); - ++index; - if (field.suffixField) { - // Add any suffix. - index = this.insertFieldAt(index, field.suffixField); - } - - if (this.sourceBlock_.rendered) { - this.sourceBlock_.render(); - // Adding a field will cause the block to change shape. - this.sourceBlock_.bumpNeighbours_(); - } - return index; -}; - -/** - * Remove a field from this input. - * @param {string} name The name of the field. - * @throws {goog.asserts.AssertionError} if the field is not present. - */ -Blockly.Input.prototype.removeField = function(name) { - for (var i = 0, field; field = this.fieldRow[i]; i++) { - if (field.name === name) { - field.dispose(); - this.fieldRow.splice(i, 1); - if (this.sourceBlock_.rendered) { - this.sourceBlock_.render(); - // Removing a field will cause the block to change shape. - this.sourceBlock_.bumpNeighbours_(); - } - return; - } - } - goog.asserts.fail('Field "%s" not found.', name); -}; - -/** - * Gets whether this input is visible or not. - * @return {boolean} True if visible. - */ -Blockly.Input.prototype.isVisible = function() { - return this.visible_; -}; - -/** - * Sets whether this input is visible or not. - * Used to collapse/uncollapse a block. - * @param {boolean} visible True if visible. - * @return {!Array.} List of blocks to render. - */ -Blockly.Input.prototype.setVisible = function(visible) { - var renderList = []; - if (this.visible_ == visible) { - return renderList; - } - this.visible_ = visible; - - var display = visible ? 'block' : 'none'; - for (var y = 0, field; field = this.fieldRow[y]; y++) { - field.setVisible(visible); - } - if (this.connection) { - // Has a connection. - if (visible) { - renderList = this.connection.unhideAll(); - } else { - this.connection.hideAll(); - } - var child = this.connection.targetBlock(); - if (child) { - child.getSvgRoot().style.display = display; - if (!visible) { - child.rendered = false; - } - } - } - return renderList; -}; - -/** - * Change a connection's compatibility. - * @param {string|Array.|null} check Compatible value type or - * list of value types. Null if all types are compatible. - * @return {!Blockly.Input} The input being modified (to allow chaining). - */ -Blockly.Input.prototype.setCheck = function(check) { - if (!this.connection) { - throw 'This input does not have a connection.'; - } - this.connection.setCheck(check); - return this; -}; - -/** - * Change the alignment of the connection's field(s). - * @param {number} align One of Blockly.ALIGN_LEFT, ALIGN_CENTRE, ALIGN_RIGHT. - * In RTL mode directions are reversed, and ALIGN_RIGHT aligns to the left. - * @return {!Blockly.Input} The input being modified (to allow chaining). - */ -Blockly.Input.prototype.setAlign = function(align) { - this.align = align; - if (this.sourceBlock_.rendered) { - this.sourceBlock_.render(); - } - return this; -}; - -/** - * Initialize the fields on this input. - */ -Blockly.Input.prototype.init = function() { - if (!this.sourceBlock_.workspace.rendered) { - return; // Headless blocks don't need fields initialized. - } - for (var i = 0; i < this.fieldRow.length; i++) { - this.fieldRow[i].init(); - } -}; - -/** - * Sever all links to this input. - */ -Blockly.Input.prototype.dispose = function() { - for (var i = 0, field; field = this.fieldRow[i]; i++) { - field.dispose(); - } - if (this.connection) { - this.connection.dispose(); - } - this.sourceBlock_ = null; -}; diff --git a/core/inputs.ts b/core/inputs.ts new file mode 100644 index 00000000000..064d37530a5 --- /dev/null +++ b/core/inputs.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Align} from './inputs/align.js'; +import {DummyInput} from './inputs/dummy_input.js'; +import {EndRowInput} from './inputs/end_row_input.js'; +import {Input} from './inputs/input.js'; +import {inputTypes} from './inputs/input_types.js'; +import {StatementInput} from './inputs/statement_input.js'; +import {ValueInput} from './inputs/value_input.js'; + +export { + Align, + DummyInput, + EndRowInput, + Input, + inputTypes, + StatementInput, + ValueInput, +}; diff --git a/core/inputs/align.ts b/core/inputs/align.ts new file mode 100644 index 00000000000..b62846f5d84 --- /dev/null +++ b/core/inputs/align.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Enum for alignment of inputs. + */ +export enum Align { + LEFT = -1, + CENTRE = 0, + RIGHT = 1, +} diff --git a/core/inputs/dummy_input.ts b/core/inputs/dummy_input.ts new file mode 100644 index 00000000000..afb4b375b5f --- /dev/null +++ b/core/inputs/dummy_input.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Block} from '../block.js'; +import {Input} from './input.js'; +import {inputTypes} from './input_types.js'; + +/** Represents an input on a block with no connection. */ +export class DummyInput extends Input { + readonly type = inputTypes.DUMMY; + + /** + * @param name Language-neutral identifier which may used to find this input + * again. + * @param block The block containing this input. + */ + constructor( + public name: string, + block: Block, + ) { + super(name, block); + } +} diff --git a/core/inputs/end_row_input.ts b/core/inputs/end_row_input.ts new file mode 100644 index 00000000000..58227a09457 --- /dev/null +++ b/core/inputs/end_row_input.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Block} from '../block.js'; +import {Input} from './input.js'; +import {inputTypes} from './input_types.js'; + +/** + * Represents an input on a block that is always the last input in the row. Any + * following input will be rendered on the next row even if the block has inline + * inputs. Any newline character in a JSON block definition's message will + * automatically be parsed as an end-row input. + */ +export class EndRowInput extends Input { + readonly type = inputTypes.END_ROW; + + /** + * @param name Language-neutral identifier which may used to find this input + * again. + * @param block The block containing this input. + */ + constructor( + public name: string, + block: Block, + ) { + super(name, block); + } +} diff --git a/core/inputs/input.ts b/core/inputs/input.ts new file mode 100644 index 00000000000..f8783aea35f --- /dev/null +++ b/core/inputs/input.ts @@ -0,0 +1,317 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Object representing an input (value, statement, or dummy). + * + * @class + */ +// Former goog.module ID: Blockly.Input + +// Unused import preserved for side-effects. Remove if unneeded. +import '../field_label.js'; + +import type {Block} from '../block.js'; +import type {BlockSvg} from '../block_svg.js'; +import type {Connection} from '../connection.js'; +import type {ConnectionType} from '../connection_type.js'; +import type {Field} from '../field.js'; +import * as fieldRegistry from '../field_registry.js'; +import {RenderedConnection} from '../rendered_connection.js'; +import {Align} from './align.js'; +import {inputTypes} from './input_types.js'; + +/** Class for an input with optional fields. */ +export class Input { + fieldRow: Field[] = []; + /** Alignment of input's fields (left, right or centre). */ + align = Align.LEFT; + + /** Is the input visible? */ + private visible = true; + + public readonly type: inputTypes = inputTypes.CUSTOM; + + public connection: Connection | null = null; + + /** + * @param name Language-neutral identifier which may used to find this input + * again. + * @param sourceBlock The block containing this input. + */ + constructor( + public name: string, + private sourceBlock: Block, + ) {} + + /** + * Get the source block for this input. + * + * @returns The block this input is part of. + */ + getSourceBlock(): Block { + return this.sourceBlock; + } + + /** + * Add a field (or label from string), and all prefix and suffix fields, to + * the end of the input's field row. + * + * @param field Something to add as a field. + * @param opt_name Language-neutral identifier which may used to find this + * field again. Should be unique to the host block. + * @returns The input being append to (to allow chaining). + */ + appendField(field: string | Field, opt_name?: string): Input { + this.insertFieldAt(this.fieldRow.length, field, opt_name); + return this; + } + + /** + * Inserts a field (or label from string), and all prefix and suffix fields, + * at the location of the input's field row. + * + * @param index The index at which to insert field. + * @param field Something to add as a field. + * @param opt_name Language-neutral identifier which may used to find this + * field again. Should be unique to the host block. + * @returns The index following the last inserted field. + */ + insertFieldAt( + index: number, + field: string | Field, + opt_name?: string, + ): number { + if (index < 0 || index > this.fieldRow.length) { + throw Error('index ' + index + ' out of bounds.'); + } + // Falsy field values don't generate a field, unless the field is an empty + // string and named. + if (!field && !(field === '' && opt_name)) { + return index; + } + + // Generate a FieldLabel when given a plain text field. + if (typeof field === 'string') { + field = fieldRegistry.fromJson({ + type: 'field_label', + text: field, + })!; + } + + field.setSourceBlock(this.sourceBlock); + if (this.sourceBlock.initialized) this.initField(field); + field.name = opt_name; + field.setVisible(this.isVisible()); + + if (field.prefixField) { + // Add any prefix. + index = this.insertFieldAt(index, field.prefixField); + } + // Add the field to the field row. + this.fieldRow.splice(index, 0, field as Field); + index++; + if (field.suffixField) { + // Add any suffix. + index = this.insertFieldAt(index, field.suffixField); + } + + if (this.sourceBlock.rendered) { + (this.sourceBlock as BlockSvg).queueRender(); + } + return index; + } + + /** + * Remove a field from this input. + * + * @param name The name of the field. + * @param opt_quiet True to prevent an error if field is not present. + * @returns True if operation succeeds, false if field is not present and + * opt_quiet is true. + * @throws {Error} if the field is not present and opt_quiet is false. + */ + removeField(name: string, opt_quiet?: boolean): boolean { + for (let i = 0, field; (field = this.fieldRow[i]); i++) { + if (field.name === name) { + field.dispose(); + this.fieldRow.splice(i, 1); + if (this.sourceBlock.rendered) { + (this.sourceBlock as BlockSvg).queueRender(); + } + return true; + } + } + if (opt_quiet) { + return false; + } + throw Error('Field "' + name + '" not found.'); + } + + /** + * Gets whether this input is visible or not. + * + * @returns True if visible. + */ + isVisible(): boolean { + return this.visible; + } + + /** + * Sets whether this input is visible or not. + * Should only be used to collapse/uncollapse a block. + * + * @param visible True if visible. + * @returns List of blocks to render. + * @internal + */ + setVisible(visible: boolean): BlockSvg[] { + // Note: Currently there are only unit tests for block.setCollapsed() + // because this function is package. If this function goes back to being a + // public API tests (lots of tests) should be added. + let renderList: AnyDuringMigration[] = []; + if (this.visible === visible) { + return renderList; + } + this.visible = visible; + + for (let y = 0, field; (field = this.fieldRow[y]); y++) { + field.setVisible(visible); + } + if (this.connection && this.connection instanceof RenderedConnection) { + // Has a connection. + if (visible) { + renderList = this.connection.startTrackingAll(); + } else { + this.connection.stopTrackingAll(); + } + const child = this.connection.targetBlock(); + if (child) { + child.getSvgRoot().style.display = visible ? 'block' : 'none'; + } + } + return renderList; + } + + /** + * Mark all fields on this input as dirty. + * + * @internal + */ + markDirty() { + for (let y = 0, field; (field = this.fieldRow[y]); y++) { + field.markDirty(); + } + } + + /** + * Change a connection's compatibility. + * + * @param check Compatible value type or list of value types. Null if all + * types are compatible. + * @returns The input being modified (to allow chaining). + */ + setCheck(check: string | string[] | null): Input { + if (!this.connection) { + throw Error('This input does not have a connection.'); + } + this.connection.setCheck(check); + return this; + } + + /** + * Change the alignment of the connection's field(s). + * + * @param align One of the values of Align. In RTL mode directions + * are reversed, and Align.RIGHT aligns to the left. + * @returns The input being modified (to allow chaining). + */ + setAlign(align: Align): Input { + this.align = align; + if (this.sourceBlock.rendered) { + const sourceBlock = this.sourceBlock as BlockSvg; + sourceBlock.queueRender(); + } + return this; + } + + /** + * Changes the connection's shadow block. + * + * @param shadow DOM representation of a block or null. + * @returns The input being modified (to allow chaining). + */ + setShadowDom(shadow: Element | null): Input { + if (!this.connection) { + throw Error('This input does not have a connection.'); + } + this.connection.setShadowDom(shadow); + return this; + } + + /** + * Returns the XML representation of the connection's shadow block. + * + * @returns Shadow DOM representation of a block or null. + */ + getShadowDom(): Element | null { + if (!this.connection) { + throw Error('This input does not have a connection.'); + } + return this.connection.getShadowDom(); + } + + /** Initialize the fields on this input. */ + init() { + for (const field of this.fieldRow) { + field.init(); + } + } + + /** + * Initializes the fields on this input for a headless block. + * + * @internal + */ + public initModel() { + for (const field of this.fieldRow) { + field.initModel(); + } + } + + /** Initializes the given field. */ + private initField(field: Field) { + if (this.sourceBlock.rendered) { + field.init(); + } else { + field.initModel(); + } + } + + /** + * Sever all links to this input. + */ + dispose() { + for (let i = 0, field; (field = this.fieldRow[i]); i++) { + field.dispose(); + } + if (this.connection) { + this.connection.dispose(); + } + } + + /** + * Constructs a connection based on the type of this input's source block. + * Properly handles constructing headless connections for headless blocks + * and rendered connections for rendered blocks. + * + * @returns a connection of the given type, which is either a headless + * or rendered connection, based on the type of this input's source block. + */ + protected makeConnection(type: ConnectionType): Connection { + return this.sourceBlock.makeConnection_(type); + } +} diff --git a/core/inputs/input_types.ts b/core/inputs/input_types.ts new file mode 100644 index 00000000000..cdae653dee1 --- /dev/null +++ b/core/inputs/input_types.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.inputTypes + +import {ConnectionType} from '../connection_type.js'; + +/** + * Enum for the type of a connection or input. + */ +export enum inputTypes { + // A right-facing value input. E.g. 'set item to' or 'return'. + VALUE = ConnectionType.INPUT_VALUE, + // A down-facing block stack. E.g. 'if-do' or 'else'. + STATEMENT = ConnectionType.NEXT_STATEMENT, + // A dummy input. Used to add field(s) with no input. + DUMMY = 5, + // An unknown type of input defined by an external developer. + CUSTOM = 6, + // An input with no connections that is always the last input of a row. Any + // subsequent input will be rendered on the next row. Any newline character in + // a JSON block definition's message will be parsed as an end-row input. + END_ROW = 7, +} diff --git a/core/inputs/statement_input.ts b/core/inputs/statement_input.ts new file mode 100644 index 00000000000..cf97de2343c --- /dev/null +++ b/core/inputs/statement_input.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Block} from '../block.js'; +import type {Connection} from '../connection.js'; +import {ConnectionType} from '../connection_type.js'; +import {Input} from './input.js'; +import {inputTypes} from './input_types.js'; + +/** Represents an input on a block with a statement connection. */ +export class StatementInput extends Input { + readonly type = inputTypes.STATEMENT; + + public connection: Connection; + + /** + * @param name Language-neutral identifier which may used to find this input + * again. + * @param block The block containing this input. + */ + constructor( + public name: string, + block: Block, + ) { + // Errors are maintained for people not using typescript. + if (!name) throw new Error('Statement inputs must have a non-empty name'); + + super(name, block); + this.connection = this.makeConnection(ConnectionType.NEXT_STATEMENT); + } +} diff --git a/core/inputs/value_input.ts b/core/inputs/value_input.ts new file mode 100644 index 00000000000..e8049b471f4 --- /dev/null +++ b/core/inputs/value_input.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Block} from '../block.js'; +import {ConnectionType} from '../connection_type.js'; +import {Input} from './input.js'; +import {inputTypes} from './input_types.js'; + +/** Represents an input on a block with a value connection. */ +export class ValueInput extends Input { + readonly type = inputTypes.VALUE; + + /** + * @param name Language-neutral identifier which may used to find this input + * again. + * @param block The block containing this input. + */ + constructor( + public name: string, + block: Block, + ) { + // Errors are maintained for people not using typescript. + if (!name) throw new Error('Value inputs must have a non-empty name'); + super(name, block); + this.connection = this.makeConnection(ConnectionType.INPUT_VALUE); + } +} diff --git a/core/insertion_marker_previewer.ts b/core/insertion_marker_previewer.ts new file mode 100644 index 00000000000..8b5b82468c5 --- /dev/null +++ b/core/insertion_marker_previewer.ts @@ -0,0 +1,268 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from './block_svg.js'; +import {ConnectionType} from './connection_type.js'; +import * as eventUtils from './events/utils.js'; +import {IConnectionPreviewer} from './interfaces/i_connection_previewer.js'; +import * as registry from './registry.js'; +import * as renderManagement from './render_management.js'; +import {RenderedConnection} from './rendered_connection.js'; +import {Renderer as ZelosRenderer} from './renderers/zelos/renderer.js'; +import * as blocks from './serialization/blocks.js'; +import {WorkspaceSvg} from './workspace_svg.js'; + +export class InsertionMarkerPreviewer implements IConnectionPreviewer { + private readonly workspace: WorkspaceSvg; + + private fadedBlock: BlockSvg | null = null; + + private markerConn: RenderedConnection | null = null; + + private draggedConn: RenderedConnection | null = null; + + private staticConn: RenderedConnection | null = null; + + constructor(draggedBlock: BlockSvg) { + this.workspace = draggedBlock.workspace; + } + + /** + * Display a connection preview where the draggedCon connects to the + * staticCon, replacing the replacedBlock (currently connected to the + * staticCon). + * + * @param draggedConn The connection on the block stack being dragged. + * @param staticConn The connection not being dragged that we are + * connecting to. + * @param replacedBlock The block currently connected to the staticCon that + * is being replaced. + */ + previewReplacement( + draggedConn: RenderedConnection, + staticConn: RenderedConnection, + replacedBlock: BlockSvg, + ) { + eventUtils.disable(); + try { + this.hidePreview(); + this.fadedBlock = replacedBlock; + replacedBlock.fadeForReplacement(true); + if (this.workspace.getRenderer().shouldHighlightConnection(staticConn)) { + staticConn.highlight(); + this.staticConn = staticConn; + } + } finally { + eventUtils.enable(); + } + } + + /** + * Display a connection preview where the draggedCon connects to the + * staticCon, and no block is being relaced. + * + * @param draggedConn The connection on the block stack being dragged. + * @param staticConn The connection not being dragged that we are + * connecting to. + */ + previewConnection( + draggedConn: RenderedConnection, + staticConn: RenderedConnection, + ) { + if (draggedConn === this.draggedConn && staticConn === this.staticConn) { + return; + } + + eventUtils.disable(); + try { + this.hidePreview(); + + // TODO(7898): Instead of special casing, we should change the dragger to + // track the change in distance between the dragged connection and the + // static connection, so that it doesn't disconnect unless that + // (+ a bit) has been exceeded. + if (this.shouldUseMarkerPreview(draggedConn, staticConn)) { + this.markerConn = this.previewMarker(draggedConn, staticConn); + } + + if (this.workspace.getRenderer().shouldHighlightConnection(staticConn)) { + staticConn.highlight(); + } + + this.draggedConn = draggedConn; + this.staticConn = staticConn; + } finally { + eventUtils.enable(); + } + } + + private shouldUseMarkerPreview( + _draggedConn: RenderedConnection, + staticConn: RenderedConnection, + ): boolean { + return ( + staticConn.type === ConnectionType.PREVIOUS_STATEMENT || + staticConn.type === ConnectionType.NEXT_STATEMENT || + !(this.workspace.getRenderer() instanceof ZelosRenderer) + ); + } + + private previewMarker( + draggedConn: RenderedConnection, + staticConn: RenderedConnection, + ): RenderedConnection | null { + const dragged = draggedConn.getSourceBlock(); + const marker = this.createInsertionMarker(dragged); + const markerConn = this.getMatchingConnection(dragged, marker, draggedConn); + if (!markerConn) return null; + + // Render disconnected from everything else so that we have a valid + // connection location. + marker.queueRender(); + renderManagement.triggerQueuedRenders(); + + // Connect() also renders the insertion marker. + markerConn.connect(staticConn); + + const originalOffsetToTarget = { + x: staticConn.x - markerConn.x, + y: staticConn.y - markerConn.y, + }; + const originalOffsetInBlock = markerConn.getOffsetInBlock().clone(); + renderManagement.finishQueuedRenders().then(() => { + if (marker.isDeadOrDying()) return; + eventUtils.disable(); + try { + // Position so that the existing block doesn't move. + marker?.positionNearConnection( + markerConn, + originalOffsetToTarget, + originalOffsetInBlock, + ); + marker?.getSvgRoot().setAttribute('visibility', 'visible'); + } finally { + eventUtils.enable(); + } + }); + return markerConn; + } + + /** + * Transforms the given block into a JSON representation used to construct an + * insertion marker. + * + * @param block The block to serialize and use as an insertion marker. + * @returns A JSON-formatted string corresponding to a serialized + * representation of the given block suitable for use as an insertion + * marker. + */ + protected serializeBlockToInsertionMarker(block: BlockSvg) { + const blockJson = blocks.save(block, { + addCoordinates: false, + addInputBlocks: false, + addNextBlocks: false, + doFullSerialization: false, + }); + + if (!blockJson) { + throw new Error( + `Failed to serialize source block. ${block.toDevString()}`, + ); + } + + return blockJson; + } + + private createInsertionMarker(origBlock: BlockSvg) { + const blockJson = this.serializeBlockToInsertionMarker(origBlock); + const result = blocks.append(blockJson, this.workspace) as BlockSvg; + + // Turn shadow blocks that are created programmatically during + // initalization to insertion markers too. + for (const block of result.getDescendants(false)) { + block.setInsertionMarker(true); + } + + result.initSvg(); + result.getSvgRoot().setAttribute('visibility', 'hidden'); + return result; + } + + /** + * Gets the connection on the marker block that matches the original + * connection on the original block. + * + * @param orig The original block. + * @param marker The marker block (where we want to find the matching + * connection). + * @param origConn The original connection. + */ + private getMatchingConnection( + orig: BlockSvg, + marker: BlockSvg, + origConn: RenderedConnection, + ): RenderedConnection | null { + const origConns = orig.getConnections_(true); + const markerConns = marker.getConnections_(true); + if (origConns.length !== markerConns.length) return null; + for (let i = 0; i < origConns.length; i++) { + if (origConns[i] === origConn) { + return markerConns[i]; + } + } + return null; + } + + /** Hide any previews that are currently displayed. */ + hidePreview() { + eventUtils.disable(); + try { + if (this.staticConn) { + this.staticConn.unhighlight(); + this.staticConn = null; + } + if (this.fadedBlock) { + this.fadedBlock.fadeForReplacement(false); + this.fadedBlock = null; + } + if (this.markerConn) { + this.hideInsertionMarker(this.markerConn); + this.markerConn = null; + this.draggedConn = null; + } + } finally { + eventUtils.enable(); + } + } + + private hideInsertionMarker(markerConn: RenderedConnection) { + const marker = markerConn.getSourceBlock(); + const markerPrev = marker.previousConnection; + const markerOutput = marker.outputConnection; + + if (!markerPrev?.targetConnection && !markerOutput?.targetConnection) { + // If we are the top block, unplugging doesn't do anything. + // The marker connection may not have a target block if we are hiding + // as part of applying connections. + markerConn.targetBlock()?.unplug(false); + } else { + marker.unplug(true); + } + + marker.dispose(); + } + + /** Dispose of any references held by this connection previewer. */ + dispose() { + this.hidePreview(); + } +} + +registry.register( + registry.Type.CONNECTION_PREVIEWER, + registry.DEFAULT, + InsertionMarkerPreviewer, +); diff --git a/core/interfaces/i_autohideable.ts b/core/interfaces/i_autohideable.ts new file mode 100644 index 00000000000..1193023d21b --- /dev/null +++ b/core/interfaces/i_autohideable.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IAutoHideable + +import type {IComponent} from './i_component.js'; + +/** + * Interface for a component that can be automatically hidden. + */ +export interface IAutoHideable extends IComponent { + /** + * Hides the component. Called in WorkspaceSvg.hideChaff. + * + * @param onlyClosePopups Whether only popups should be closed. + * Flyouts should not be closed if this is true. + */ + autoHide(onlyClosePopups: boolean): void; +} + +/** Returns true if the given object is autohideable. */ +export function isAutoHideable(obj: any): obj is IAutoHideable { + return obj && typeof obj.autoHide === 'function'; +} diff --git a/core/interfaces/i_bounded_element.ts b/core/interfaces/i_bounded_element.ts new file mode 100644 index 00000000000..aac26855bd6 --- /dev/null +++ b/core/interfaces/i_bounded_element.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IBoundedElement + +import type {Rect} from '../utils/rect.js'; + +/** + * A bounded element interface. + */ +export interface IBoundedElement { + /** + * Returns the coordinates of a bounded element describing the dimensions of + * the element. Coordinate system: workspace coordinates. + * + * @returns Object with coordinates of the bounded element. + */ + getBoundingRectangle(): Rect; + + /** + * Move the element by a relative offset. + * + * @param dx Horizontal offset in workspace units. + * @param dy Vertical offset in workspace units. + * @param reason Why is this move happening? 'user', 'bump', 'snap'... + */ + moveBy(dx: number, dy: number, reason?: string[]): void; +} diff --git a/core/interfaces/i_bubble.ts b/core/interfaces/i_bubble.ts new file mode 100644 index 00000000000..553f86e9e9e --- /dev/null +++ b/core/interfaces/i_bubble.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IBubble + +import type {Coordinate} from '../utils/coordinate.js'; +import type {IContextMenu} from './i_contextmenu.js'; +import type {IDraggable} from './i_draggable.js'; +import {IFocusableNode} from './i_focusable_node.js'; + +/** + * A bubble interface. + */ +export interface IBubble extends IDraggable, IContextMenu, IFocusableNode { + /** + * Return the coordinates of the top-left corner of this bubble's body + * relative to the drawing surface's origin (0,0), in workspace units. + * + * @returns Object with .x and .y properties. + */ + getRelativeToSurfaceXY(): Coordinate; + + /** + * Return the root node of the bubble's SVG group. + * + * @returns The root SVG node of the bubble's group. + */ + getSvgRoot(): SVGElement; + + /** + * Sets whether or not this bubble is being dragged. + * + * @param adding True if dragging, false otherwise. + */ + setDragging(dragging: boolean): void; + + /** + * Move this bubble during a drag. + * + * @param newLoc The location to translate to, in workspace coordinates. + */ + moveDuringDrag(newLoc: Coordinate): void; + + /** + * Move the bubble to the specified location in workspace coordinates. + * + * @param x The x position to move to. + * @param y The y position to move to. + */ + moveTo(x: number, y: number): void; + + /** + * Update the style of this bubble when it is dragged over a delete area. + * + * @param enable True if the bubble is about to be deleted, false otherwise. + */ + setDeleteStyle(enable: boolean): void; + + /** Dispose of this bubble. */ + dispose(): void; +} diff --git a/core/interfaces/i_collapsible_toolbox_item.ts b/core/interfaces/i_collapsible_toolbox_item.ts new file mode 100644 index 00000000000..0b591b4a6ff --- /dev/null +++ b/core/interfaces/i_collapsible_toolbox_item.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.ICollapsibleToolboxItem + +import type {ISelectableToolboxItem} from './i_selectable_toolbox_item.js'; +import type {IToolboxItem} from './i_toolbox_item.js'; + +/** + * Interface for an item in the toolbox that can be collapsed. + */ +export interface ICollapsibleToolboxItem extends ISelectableToolboxItem { + /** + * Gets any children toolbox items. (ex. Gets the subcategories) + * + * @returns The child toolbox items. + */ + getChildToolboxItems(): IToolboxItem[]; + + /** + * Whether the toolbox item is expanded to show its child subcategories. + * + * @returns True if the toolbox item shows its children, false if it is + * collapsed. + */ + isExpanded(): boolean; + + /** Toggles whether or not the toolbox item is expanded. */ + toggleExpanded(): void; +} diff --git a/core/interfaces/i_comment_icon.ts b/core/interfaces/i_comment_icon.ts new file mode 100644 index 00000000000..1ab5bead447 --- /dev/null +++ b/core/interfaces/i_comment_icon.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CommentState} from '../icons/comment_icon.js'; +import {IconType} from '../icons/icon_types.js'; +import {Coordinate} from '../utils/coordinate.js'; +import {Size} from '../utils/size.js'; +import {IHasBubble, hasBubble} from './i_has_bubble.js'; +import {IIcon, isIcon} from './i_icon.js'; +import {ISerializable, isSerializable} from './i_serializable.js'; + +export interface ICommentIcon extends IIcon, IHasBubble, ISerializable { + setText(text: string): void; + + getText(): string; + + setBubbleSize(size: Size): void; + + getBubbleSize(): Size; + + setBubbleLocation(location: Coordinate): void; + + getBubbleLocation(): Coordinate | undefined; + + saveState(): CommentState; + + loadState(state: CommentState): void; +} + +/** Checks whether the given object is an ICommentIcon. */ +export function isCommentIcon(obj: any): obj is ICommentIcon { + return ( + isIcon(obj) && + hasBubble(obj) && + isSerializable(obj) && + typeof (obj as any).setText === 'function' && + typeof (obj as any).getText === 'function' && + typeof (obj as any).setBubbleSize === 'function' && + typeof (obj as any).getBubbleSize === 'function' && + typeof (obj as any).setBubbleLocation === 'function' && + typeof (obj as any).getBubbleLocation === 'function' && + obj.getType() === IconType.COMMENT + ); +} diff --git a/core/interfaces/i_component.ts b/core/interfaces/i_component.ts new file mode 100644 index 00000000000..03f4b1fd2bf --- /dev/null +++ b/core/interfaces/i_component.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IComponent + +/** + * The interface for a workspace component that can be registered with the + * ComponentManager. + */ +export interface IComponent { + /** + * The unique ID for this component that is used to register with the + * ComponentManager. + */ + id: string; +} diff --git a/core/interfaces/i_connection_checker.ts b/core/interfaces/i_connection_checker.ts new file mode 100644 index 00000000000..352b719d665 --- /dev/null +++ b/core/interfaces/i_connection_checker.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IConnectionChecker + +import type {Connection} from '../connection.js'; +import type {RenderedConnection} from '../rendered_connection.js'; + +/** + * Class for connection type checking logic. + */ +export interface IConnectionChecker { + /** + * Check whether the current connection can connect with the target + * connection. + * + * @param a Connection to check compatibility with. + * @param b Connection to check compatibility with. + * @param isDragging True if the connection is being made by dragging a block. + * @param opt_distance The max allowable distance between the connections for + * drag checks. + * @returns Whether the connection is legal. + */ + canConnect( + a: Connection | null, + b: Connection | null, + isDragging: boolean, + opt_distance?: number, + ): boolean; + + /** + * Checks whether the current connection can connect with the target + * connection, and return an error code if there are problems. + * + * @param a Connection to check compatibility with. + * @param b Connection to check compatibility with. + * @param isDragging True if the connection is being made by dragging a block. + * @param opt_distance The max allowable distance between the connections for + * drag checks. + * @returns Connection.CAN_CONNECT if the connection is legal, an error code + * otherwise. + */ + canConnectWithReason( + a: Connection | null, + b: Connection | null, + isDragging: boolean, + opt_distance?: number, + ): number; + + /** + * Helper method that translates a connection error code into a string. + * + * @param errorCode The error code. + * @param a One of the two connections being checked. + * @param b The second of the two connections being checked. + * @returns A developer-readable error string. + */ + getErrorMessage( + errorCode: number, + a: Connection | null, + b: Connection | null, + ): string; + + /** + * Check that connecting the given connections is safe, meaning that it would + * not break any of Blockly's basic assumptions (e.g. no self connections). + * + * @param a The first of the connections to check. + * @param b The second of the connections to check. + * @returns An enum with the reason this connection is safe or unsafe. + */ + doSafetyChecks(a: Connection | null, b: Connection | null): number; + + /** + * Check whether this connection is compatible with another connection with + * respect to the value type system. E.g. square_root("Hello") is not + * compatible. + * + * @param a Connection to compare. + * @param b Connection to compare against. + * @returns True if the connections share a type. + */ + doTypeChecks(a: Connection, b: Connection): boolean; + + /** + * Check whether this connection can be made by dragging. + * + * @param a Connection to compare. + * @param b Connection to compare against. + * @param distance The maximum allowable distance between connections. + * @returns True if the connection is allowed during a drag. + */ + doDragChecks( + a: RenderedConnection, + b: RenderedConnection, + distance: number, + ): boolean; +} diff --git a/core/interfaces/i_connection_previewer.ts b/core/interfaces/i_connection_previewer.ts new file mode 100644 index 00000000000..df7906a29dc --- /dev/null +++ b/core/interfaces/i_connection_previewer.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from '../block_svg'; +import type {RenderedConnection} from '../rendered_connection'; + +/** + * Displays visual "previews" of where a block will be connected if it is + * dropped. + */ +export interface IConnectionPreviewer { + /** + * Display a connection preview where the draggedCon connects to the + * staticCon, replacing the replacedBlock (currently connected to the + * staticCon). + * + * @param draggedCon The connection on the block stack being dragged. + * @param staticCon The connection not being dragged that we are + * connecting to. + * @param replacedBlock The block currently connected to the staticCon that + * is being replaced. + */ + previewReplacement( + draggedConn: RenderedConnection, + staticConn: RenderedConnection, + replacedBlock: BlockSvg, + ): void; + + /** + * Display a connection preview where the draggedCon connects to the + * staticCon, and no block is being relaced. + * + * @param draggedCon The connection on the block stack being dragged. + * @param staticCon The connection not being dragged that we are + * connecting to. + */ + previewConnection( + draggedConn: RenderedConnection, + staticConn: RenderedConnection, + ): void; + + /** Hide any previews that are currently displayed. */ + hidePreview(): void; + + /** Dispose of any references held by this connection previewer. */ + dispose(): void; +} diff --git a/core/interfaces/i_contextmenu.ts b/core/interfaces/i_contextmenu.ts new file mode 100644 index 00000000000..cba71259fa1 --- /dev/null +++ b/core/interfaces/i_contextmenu.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IContextMenu + +export interface IContextMenu { + /** + * Show the context menu for this object. + * + * @param e Mouse event. + */ + showContextMenu(e: Event): void; +} diff --git a/core/interfaces/i_copyable.ts b/core/interfaces/i_copyable.ts new file mode 100644 index 00000000000..8d1853967d4 --- /dev/null +++ b/core/interfaces/i_copyable.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.ICopyable + +import type {ISelectable} from './i_selectable.js'; + +export interface ICopyable extends ISelectable { + /** + * Encode for copying. + * + * @returns Copy metadata. + */ + toCopyData(): T | null; + + /** + * Whether this instance is currently copyable. The standard implementation + * is to return true if isOwnDeletable and isOwnMovable return true. + * + * @returns True if it can currently be copied. + */ + isCopyable?(): boolean; +} + +export namespace ICopyable { + export interface ICopyData { + paster: string; + } +} + +export type ICopyData = ICopyable.ICopyData; + +/** @returns true if the given object is an ICopyable. */ +export function isCopyable(obj: any): obj is ICopyable { + return obj && typeof obj.toCopyData === 'function'; +} diff --git a/core/interfaces/i_deletable.ts b/core/interfaces/i_deletable.ts new file mode 100644 index 00000000000..156e43ddc50 --- /dev/null +++ b/core/interfaces/i_deletable.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IDeletable + +/** + * The interface for an object that can be deleted. + */ +export interface IDeletable { + /** + * Get whether this object is deletable or not. + * + * @returns True if deletable. + */ + isDeletable(): boolean; + + /** Disposes of this object, cleaning up any references or DOM elements. */ + dispose(): void; + + /** Visually indicates that the object is pending deletion. */ + setDeleteStyle(wouldDelete: boolean): void; +} + +/** Returns whether the given object is an IDeletable. */ +export function isDeletable(obj: any): obj is IDeletable { + return ( + obj && + typeof obj.isDeletable === 'function' && + typeof obj.dispose === 'function' && + typeof obj.setDeleteStyle === 'function' + ); +} diff --git a/core/interfaces/i_delete_area.ts b/core/interfaces/i_delete_area.ts new file mode 100644 index 00000000000..86d2673bbf8 --- /dev/null +++ b/core/interfaces/i_delete_area.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IDeleteArea + +import type {IDragTarget} from './i_drag_target.js'; +import type {IDraggable} from './i_draggable.js'; + +/** + * Interface for a component that can delete a block or bubble that is dropped + * on top of it. + */ +export interface IDeleteArea extends IDragTarget { + /** + * Returns whether the provided block or bubble would be deleted if dropped on + * this area. + * This method should check if the element is deletable and is always called + * before onDragEnter/onDragOver/onDragExit. + * + * @param element The block or bubble currently being dragged. + * @returns Whether the element provided would be deleted if dropped on this + * area. + */ + wouldDelete(element: IDraggable): boolean; +} diff --git a/core/interfaces/i_drag_target.ts b/core/interfaces/i_drag_target.ts new file mode 100644 index 00000000000..395b2345123 --- /dev/null +++ b/core/interfaces/i_drag_target.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IDragTarget + +import {Rect} from '../utils/rect.js'; +import type {IComponent} from './i_component.js'; +import {IDraggable} from './i_draggable.js'; + +/** + * Interface for a component with custom behaviour when a block or bubble is + * dragged over or dropped on top of it. + */ +export interface IDragTarget extends IComponent { + /** + * Returns the bounding rectangle of the drag target area in pixel units + * relative to viewport. + * + * @returns The component's bounding box. Null if drag target area should be + * ignored. + */ + getClientRect(): Rect | null; + + /** + * Handles when a cursor with a block or bubble enters this drag target. + * + * @param dragElement The block or bubble currently being dragged. + */ + onDragEnter(dragElement: IDraggable): void; + + /** + * Handles when a cursor with a block or bubble is dragged over this drag + * target. + * + * @param dragElement The block or bubble currently being dragged. + */ + onDragOver(dragElement: IDraggable): void; + + /** + * Handles when a cursor with a block or bubble exits this drag target. + * + * @param dragElement The block or bubble currently being dragged. + */ + onDragExit(dragElement: IDraggable): void; + + /** + * Handles when a block or bubble is dropped on this component. + * Should not handle delete here. + * + * @param dragElement The block or bubble currently being dragged. + */ + onDrop(dragElement: IDraggable): void; + + /** + * Returns whether the provided block or bubble should not be moved after + * being dropped on this component. If true, the element will return to where + * it was when the drag started. + * + * @param dragElement The block or bubble currently being dragged. + * @returns Whether the block or bubble provided should be returned to drag + * start. + */ + shouldPreventMove(dragElement: IDraggable): boolean; +} diff --git a/core/interfaces/i_draggable.ts b/core/interfaces/i_draggable.ts new file mode 100644 index 00000000000..9130381163f --- /dev/null +++ b/core/interfaces/i_draggable.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Coordinate} from '../utils/coordinate'; + +/** + * Represents an object that can be dragged. + */ +export interface IDraggable extends IDragStrategy { + /** + * Returns the current location of the draggable in workspace + * coordinates. + * + * @returns Coordinate of current location on workspace. + */ + getRelativeToSurfaceXY(): Coordinate; +} + +export interface IDragStrategy { + /** Returns true iff the element is currently movable. */ + isMovable(): boolean; + + /** + * Handles any drag startup (e.g moving elements to the front of the + * workspace). + * + * @param e PointerEvent that started the drag; can be used to + * check modifier keys, etc. May be missing when dragging is + * triggered programatically rather than by user. + */ + startDrag(e?: PointerEvent): void; + + /** + * Handles moving elements to the new location, and updating any + * visuals based on that (e.g connection previews for blocks). + * + * @param newLoc Workspace coordinate to which the draggable has + * been dragged. + * @param e PointerEvent that continued the drag. Can be + * used to check modifier keys, etc. + */ + drag(newLoc: Coordinate, e?: PointerEvent): void; + + /** + * Handles any drag cleanup, including e.g. connecting or deleting + * blocks. + * + * @param newLoc Workspace coordinate at which the drag finished. + * been dragged. + * @param e PointerEvent that finished the drag. Can be + * used to check modifier keys, etc. + */ + endDrag(e?: PointerEvent): void; + + /** Moves the draggable back to where it was at the start of the drag. */ + revertDrag(): void; +} + +/** Returns whether the given object is an IDraggable or not. */ +export function isDraggable(obj: any): obj is IDraggable { + return ( + obj && + typeof obj.getRelativeToSurfaceXY === 'function' && + typeof obj.isMovable === 'function' && + typeof obj.startDrag === 'function' && + typeof obj.drag === 'function' && + typeof obj.endDrag === 'function' && + typeof obj.revertDrag === 'function' + ); +} diff --git a/core/interfaces/i_dragger.ts b/core/interfaces/i_dragger.ts new file mode 100644 index 00000000000..1e8ad0ab6c4 --- /dev/null +++ b/core/interfaces/i_dragger.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Coordinate} from '../utils/coordinate'; + +export interface IDragger { + /** + * Handles any drag startup. + * + * @param e PointerEvent that started the drag. + */ + onDragStart(e: PointerEvent): void; + + /** + * Handles dragging, including calculating where the element should + * actually be moved to. + * + * @param e PointerEvent that continued the drag. + * @param totalDelta The total distance, in pixels, that the mouse + * has moved since the start of the drag. + */ + onDrag(e: PointerEvent, totalDelta: Coordinate): void; + + /** + * Handles any drag cleanup. + * + * @param e PointerEvent that finished the drag. + * @param totalDelta The total distance, in pixels, that the mouse + * has moved since the start of the drag. + */ + onDragEnd(e: PointerEvent, totalDelta: Coordinate): void; +} diff --git a/core/interfaces/i_flyout.ts b/core/interfaces/i_flyout.ts new file mode 100644 index 00000000000..067cd5ef20d --- /dev/null +++ b/core/interfaces/i_flyout.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IFlyout + +import type {BlockSvg} from '../block_svg.js'; +import type {FlyoutItem} from '../flyout_item.js'; +import type {Coordinate} from '../utils/coordinate.js'; +import type {Svg} from '../utils/svg.js'; +import type {FlyoutDefinition} from '../utils/toolbox.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {IFocusableTree} from './i_focusable_tree.js'; +import type {IRegistrable} from './i_registrable.js'; + +/** + * Interface for a flyout. + */ +export interface IFlyout extends IRegistrable, IFocusableTree { + /** Whether the flyout is laid out horizontally or not. */ + horizontalLayout: boolean; + + /** Is RTL vs LTR. */ + RTL: boolean; + + /** The target workspace */ + targetWorkspace: WorkspaceSvg | null; + + /** Margin around the edges of the blocks in the flyout. */ + readonly MARGIN: number; + + /** Does the flyout automatically close when a block is created? */ + autoClose: boolean; + + /** Corner radius of the flyout background. */ + readonly CORNER_RADIUS: number; + + /** + * Creates the flyout's DOM. Only needs to be called once. The flyout can + * either exist as its own svg element or be a g element nested inside a + * separate svg element. + * + * @param tagName The type of tag to put the flyout in. This should be + * or . + * @returns The flyout's SVG group. + */ + createDom( + tagName: string | Svg | Svg, + ): SVGElement; + + /** + * Initializes the flyout. + * + * @param targetWorkspace The workspace in which to create new blocks. + */ + init(targetWorkspace: WorkspaceSvg): void; + + /** + * Dispose of this flyout. + * Unlink from all DOM elements to prevent memory leaks. + */ + dispose(): void; + + /** + * Get the width of the flyout. + * + * @returns The width of the flyout. + */ + getWidth(): number; + + /** + * Get the height of the flyout. + * + * @returns The height of the flyout. + */ + getHeight(): number; + + /** + * Get the workspace inside the flyout. + * + * @returns The workspace inside the flyout. + */ + getWorkspace(): WorkspaceSvg; + + /** + * Is the flyout visible? + * + * @returns True if visible. + */ + isVisible(): boolean; + + /** + * Set whether the flyout is visible. A value of true does not necessarily + * mean that the flyout is shown. It could be hidden because its container is + * hidden. + * + * @param visible True if visible. + */ + setVisible(visible: boolean): void; + + /** + * Set whether this flyout's container is visible. + * + * @param visible Whether the container is visible. + */ + setContainerVisible(visible: boolean): void; + + /** Hide and empty the flyout. */ + hide(): void; + + /** + * Show and populate the flyout. + * + * @param flyoutDef Contents to display in the flyout. This is either an array + * of Nodes, a NodeList, a toolbox definition, or a string with the name + * of the dynamic category. + */ + show(flyoutDef: FlyoutDefinition | string): void; + + /** + * Returns the list of flyout items currently present in the flyout. + * The `show` method parses the flyout definition into a list of actual + * flyout items. This method should return those concrete items, which + * may be used for e.g. keyboard navigation. + * + * @returns List of flyout items. + */ + getContents(): FlyoutItem[]; + + /** + * Create a copy of this block on the workspace. + * + * @param originalBlock The block to copy from the flyout. + * @returns The newly created block. + * @throws {Error} if something went wrong with deserialization. + */ + createBlock(originalBlock: BlockSvg): BlockSvg; + + /** Reflow blocks and their mats. */ + reflow(): void; + + /** + * @returns True if this flyout may be scrolled with a scrollbar or by + * dragging. + */ + isScrollable(): boolean; + + /** + * Calculates the x coordinate for the flyout position. + * + * @returns X coordinate. + */ + getX(): number; + + /** + * Calculates the y coordinate for the flyout position. + * + * @returns Y coordinate. + */ + getY(): number; + + /** Position the flyout. */ + position(): void; + + /** + * Determine if a drag delta is toward the workspace, based on the position + * and orientation of the flyout. This is used in determineDragIntention_ to + * determine if a new block should be created or if the flyout should scroll. + * + * @param currentDragDeltaXY How far the pointer has moved from the position + * at mouse down, in pixel units. + * @returns True if the drag is toward the workspace. + */ + isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean; + + /** + * Does this flyout allow you to create a new instance of the given block? + * Used for deciding if a block can be "dragged out of" the flyout. + * + * @param block The block to copy from the flyout. + * @returns True if you can create a new instance of the block, false + * otherwise. + */ + isBlockCreatable(block: BlockSvg): boolean; + + /** Scroll the flyout to the beginning of its contents. */ + scrollToStart(): void; +} diff --git a/core/interfaces/i_flyout_inflater.ts b/core/interfaces/i_flyout_inflater.ts new file mode 100644 index 00000000000..e3c1f5db48f --- /dev/null +++ b/core/interfaces/i_flyout_inflater.ts @@ -0,0 +1,51 @@ +import type {FlyoutItem} from '../flyout_item.js'; +import type {IFlyout} from './i_flyout.js'; + +export interface IFlyoutInflater { + /** + * Loads the object represented by the given state onto the workspace. + * + * Note that this method's interface is identical to that in ISerializer, to + * allow for code reuse. + * + * @param state A JSON representation of an element to inflate on the flyout. + * @param flyout The flyout on whose workspace the inflated element + * should be created. If the inflated element is an `IRenderedElement` it + * itself or the inflater should append it to the workspace; the flyout + * will not do so itself. The flyout is responsible for positioning the + * element, however. + * @returns The newly inflated flyout element. + */ + load(state: object, flyout: IFlyout): FlyoutItem; + + /** + * Returns the amount of spacing that should follow the element corresponding + * to the given JSON representation. + * + * @param state A JSON representation of the element preceding the gap. + * @param defaultGap The default gap for elements in this flyout. + * @returns The gap that should follow the given element. + */ + gapForItem(state: object, defaultGap: number): number; + + /** + * Disposes of the given element. + * + * If the element in question resides on the flyout workspace, it should remove + * itself. Implementers are not otherwise required to fully dispose of the + * element; it may be e.g. cached for performance purposes. + * + * @param element The flyout element to dispose of. + */ + disposeItem(item: FlyoutItem): void; + + /** + * Returns the type of items that this inflater is responsible for inflating. + * This should be the same as the name under which this inflater registers + * itself, as well as the value returned by `getType()` on the `FlyoutItem` + * objects returned by `load()`. + * + * @returns The type of items this inflater creates. + */ + getType(): string; +} diff --git a/core/interfaces/i_focusable_node.ts b/core/interfaces/i_focusable_node.ts new file mode 100644 index 00000000000..57ec1a126e1 --- /dev/null +++ b/core/interfaces/i_focusable_node.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableTree} from './i_focusable_tree.js'; + +/** Represents anything that can have input focus. */ +export interface IFocusableNode { + /** + * Returns the DOM element that can be explicitly requested to receive focus. + * + * IMPORTANT: Please note that this element is expected to have a visual + * presence on the page as it will both be explicitly focused and have its + * style changed depending on its current focus state (i.e. blurred, actively + * focused, and passively focused). The element will have one of two styles + * attached (where no style indicates blurred/not focused): + * - blocklyActiveFocus + * - blocklyPassiveFocus + * + * The returned element must also have a valid ID specified, and this ID + * should be unique across the entire page. Failing to have a properly unique + * ID could result in trying to focus one node (such as via a mouse click) + * leading to another node with the same ID actually becoming focused by + * FocusManager. + * + * The returned element must be visible if the node is ever focused via + * FocusManager.focusNode() or FocusManager.focusTree(). It's allowed for an + * element to be hidden until onNodeFocus() is called, or become hidden with a + * call to onNodeBlur(). + * + * It's expected the actual returned element will not change for the lifetime + * of the node (that is, its properties can change but a new element should + * never be returned). Also, the returned element will have its tabindex + * overwritten throughout the lifecycle of this node and FocusManager. + * + * If a node requires the ability to be focused directly without first being + * focused via FocusManager then it must set its own tab index. + * + * @returns The HTMLElement or SVGElement which can both receive focus and be + * visually represented as actively or passively focused for this node. + */ + getFocusableElement(): HTMLElement | SVGElement; + + /** + * Returns the closest parent tree of this node (in cases where a tree has + * distinct trees underneath it), which represents the tree to which this node + * belongs. + * + * @returns The node's IFocusableTree. + */ + getFocusableTree(): IFocusableTree; + + /** + * Called when this node receives active focus. + * + * Note that it's fine for implementations to change visibility modifiers, but + * they should avoid the following: + * - Creating or removing DOM elements (including via the renderer or drawer). + * - Affecting focus via DOM focus() calls or the FocusManager. + * + * Implementations may consider scrolling themselves into view here; that is + * not handled by the focus manager. + */ + onNodeFocus(): void; + + /** + * Called when this node loses active focus. It may still have passive focus. + * + * This has the same implementation restrictions as onNodeFocus(). + */ + onNodeBlur(): void; + + /** + * Indicates whether this node allows focus. If this returns false then none + * of the other IFocusableNode methods will be called. + * + * Note that special care must be taken if implementations of this function + * dynamically change their return value value over the lifetime of the node + * as certain environment conditions could affect the focusability of this + * node's DOM element (such as whether the element has a positive or zero + * tabindex). Also, changing from a true to a false value while the node holds + * focus will not immediately change the current focus of the node nor + * FocusManager's internal state, and thus may result in some of the node's + * functions being called later on when defocused (since it was previously + * considered focusable at the time of being focused). + * + * Implementations should generally always return true here unless there are + * circumstances under which this node should be skipped for focus + * considerations. Examples may include being disabled, read-only, a purely + * visual decoration, or a node with no visual representation that must + * implement this interface (e.g. due to a parent interface extending it). + * Keep in mind accessibility best practices when determining whether a node + * should be focusable since even disabled and read-only elements are still + * often relevant to providing organizational context to users (particularly + * when using a screen reader). + * + * @returns Whether this node can be focused by FocusManager. + */ + canBeFocused(): boolean; +} + +/** + * Determines whether the provided object fulfills the contract of + * IFocusableNode. + * + * @param obj The object to test. + * @returns Whether the provided object can be used as an IFocusableNode. + */ +export function isFocusableNode(obj: any): obj is IFocusableNode { + return ( + obj && + typeof obj.getFocusableElement === 'function' && + typeof obj.getFocusableTree === 'function' && + typeof obj.onNodeFocus === 'function' && + typeof obj.onNodeBlur === 'function' && + typeof obj.canBeFocused === 'function' + ); +} diff --git a/core/interfaces/i_focusable_tree.ts b/core/interfaces/i_focusable_tree.ts new file mode 100644 index 00000000000..c33189fcdf0 --- /dev/null +++ b/core/interfaces/i_focusable_tree.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from './i_focusable_node.js'; + +/** + * Represents a tree of focusable elements with its own active/passive focus + * context. + * + * Note that focus is handled by FocusManager, and tree implementations can have + * at most one IFocusableNode focused at one time. If the tree itself has focus, + * then the tree's focused node is considered 'active' ('passive' if another + * tree has focus). + * + * Focus is shared between one or more trees, where each tree can have exactly + * one active or passive node (and only one active node can exist on the whole + * page at any given time). The idea of passive focus is to provide context to + * users on where their focus will be restored upon navigating back to a + * previously focused tree. + * + * Note that if the tree's current focused node (passive or active) is needed, + * FocusableTreeTraverser.findFocusedNode can be used. + * + * Note that if specific nodes are needed to be retrieved for this tree, either + * use lookUpFocusableNode or FocusableTreeTraverser.findFocusableNodeFor. + */ +export interface IFocusableTree { + /** + * Returns the top-level focusable node of the tree. + * + * It's expected that the returned node will be focused in cases where + * FocusManager wants to focus a tree in a situation where it does not + * currently have a focused node. + */ + getRootFocusableNode(): IFocusableNode; + + /** + * Returns the IFocusableNode of this tree that should receive active focus + * when the tree itself has focus returned to it. + * + * There are some very important notes to consider about a tree's focus + * lifecycle when implementing a version of this method that doesn't return + * null: + * 1. A null previousNode does not guarantee first-time focus state as nodes + * can be deleted. + * 2. This method is only used when the tree itself is focused, either through + * tab navigation or via FocusManager.focusTree(). In many cases, the + * previously focused node will be directly focused instead which will + * bypass this method. + * 3. The default behavior (i.e. returning null here) involves either + * restoring the previous node (previousNode) or focusing the tree's root. + * 4. The provided node may sometimes no longer be valid, such as in the case + * an attempt is made to focus a node that has been recently removed from + * its parent tree. Implementations can check for the validity of the node + * in order to specialize the node to which focus should fall back. + * + * This method is largely intended to provide tree implementations with the + * means of specifying a better default node than their root. + * + * @param previousNode The node that previously held passive focus for this + * tree, or null if the tree hasn't yet been focused. + * @returns The IFocusableNode that should now receive focus, or null if + * default behavior should be used, instead. + */ + getRestoredFocusableNode( + previousNode: IFocusableNode | null, + ): IFocusableNode | null; + + /** + * Returns all directly nested trees under this tree. + * + * Note that the returned list of trees doesn't need to be stable, however all + * returned trees *do* need to be registered with FocusManager. Additionally, + * this must return actual nested trees as omitting a nested tree will affect + * how focus changes map to a specific node and its tree, potentially leading + * to user confusion. + */ + getNestedTrees(): Array; + + /** + * Returns the IFocusableNode corresponding to the specified element ID, or + * null if there's no exact node within this tree with that ID or if the ID + * corresponds to the root of the tree. + * + * This will never match against nested trees. + * + * @param id The ID of the node's focusable HTMLElement or SVGElement. + */ + lookUpFocusableNode(id: string): IFocusableNode | null; + + /** + * Called when a node of this tree has received active focus. + * + * Note that a null previousTree does not necessarily indicate that this is + * the first time Blockly is receiving focus. In fact, few assumptions can be + * made about previous focus state as a previous null tree simply indicates + * that Blockly did not hold active focus prior to this tree becoming focused + * (which can happen due to focus exiting the Blockly injection div, or for + * other cases like ephemeral focus). + * + * See IFocusableNode.onNodeFocus() as implementations have the same + * restrictions as with that method. + * + * @param node The node receiving active focus. + * @param previousTree The previous tree that held active focus, or null if + * none. + */ + onTreeFocus(node: IFocusableNode, previousTree: IFocusableTree | null): void; + + /** + * Called when the previously actively focused node of this tree is now + * passively focused and there is no other active node of this tree taking its + * place. + * + * This has the same implementation restrictions and considerations as + * onTreeFocus(). + * + * @param nextTree The next tree receiving active focus, or null if none (such + * as in the case that Blockly is entirely losing DOM focus). + */ + onTreeBlur(nextTree: IFocusableTree | null): void; +} + +/** + * Determines whether the provided object fulfills the contract of + * IFocusableTree. + * + * @param obj The object to test. + * @returns Whether the provided object can be used as an IFocusableTree. + */ +export function isFocusableTree(obj: any): obj is IFocusableTree { + return ( + obj && + typeof obj.getRootFocusableNode === 'function' && + typeof obj.getRestoredFocusableNode === 'function' && + typeof obj.getNestedTrees === 'function' && + typeof obj.lookUpFocusableNode === 'function' && + typeof obj.onTreeFocus === 'function' && + typeof obj.onTreeBlur === 'function' + ); +} diff --git a/core/interfaces/i_has_bubble.ts b/core/interfaces/i_has_bubble.ts new file mode 100644 index 00000000000..0c2e257a440 --- /dev/null +++ b/core/interfaces/i_has_bubble.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IBubble} from './i_bubble'; + +export interface IHasBubble { + /** @returns True if the bubble is currently open, false otherwise. */ + bubbleIsVisible(): boolean; + + /** Sets whether the bubble is open or not. */ + setBubbleVisible(visible: boolean): Promise; + + /** + * Returns the current IBubble that implementations are managing, or null if + * there isn't one. + * + * Note that this cannot be expected to return null if bubbleIsVisible() + * returns false, i.e., the nullability of the returned bubble does not + * necessarily imply visibility. + * + * @returns The current IBubble maintained by implementations, or null if + * there is not one. + */ + getBubble(): IBubble | null; +} + +/** Type guard that checks whether the given object is a IHasBubble. */ +export function hasBubble(obj: any): obj is IHasBubble { + return ( + typeof obj.bubbleIsVisible === 'function' && + typeof obj.setBubbleVisible === 'function' && + typeof obj.getBubble === 'function' + ); +} diff --git a/core/interfaces/i_icon.ts b/core/interfaces/i_icon.ts new file mode 100644 index 00000000000..06f416424ef --- /dev/null +++ b/core/interfaces/i_icon.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IconType} from '../icons/icon_types.js'; +import type {Coordinate} from '../utils/coordinate.js'; +import type {Size} from '../utils/size.js'; +import {IFocusableNode, isFocusableNode} from './i_focusable_node.js'; + +export interface IIcon extends IFocusableNode { + /** + * @returns the IconType representing the type of the icon. This value should + * also be used to register the icon via `Blockly.icons.registry.register`. + */ + getType(): IconType; + + /** + * Creates the SVG elements for the icon that will live on the block. + * + * @param pointerdownListener An event listener that must be attached to the + * root SVG element by the implementation of `initView`. Used by Blockly's + * gesture system to properly handle clicks and drags. + */ + initView(pointerdownListener: (e: PointerEvent) => void): void; + + /** + * Disposes of any elements of the icon. + * + * @remarks + * + * In particular, if this icon is currently showing a bubble, this should be + * used to hide it. + */ + dispose(): void; + + /** + * @returns the "weight" of the icon, which determines the static order which + * icons should be rendered in. More positive numbers are rendered farther + * toward the end of the block. + */ + getWeight(): number; + + /** @returns The dimensions of the icon for use in rendering. */ + getSize(): Size; + + /** Updates the icon's color when the block's color changes.. */ + applyColour(): void; + + /** Hides the icon when it is part of an insertion marker. */ + hideForInsertionMarker(): void; + + /** Updates the icon's editability when the block's editability changes. */ + updateEditable(): void; + + /** + * Updates the icon's collapsed-ness/view when the block's collapsed-ness + * changes. + */ + updateCollapsed(): void; + + /** + * @returns Whether this icon is shown when the block is collapsed. Used + * to allow renderers to account for padding. + */ + isShownWhenCollapsed(): boolean; + + /** + * Notifies the icon where it is relative to its block's top-start, in + * workspace units. + */ + setOffsetInBlock(offset: Coordinate): void; + + /** + * Notifies the icon that it has changed locations. + * + * @param blockOrigin The location of this icon's block's top-start corner + * in workspace coordinates. + */ + onLocationChange(blockOrigin: Coordinate): void; + + /** + * Notifies the icon that it has been clicked. + */ + onClick(): void; + + /** + * Check whether the icon should be clickable while the block is in a flyout. + * If this function is not defined, the icon will be clickable in all flyouts. + * + * @param autoClosingFlyout true if the containing flyout is an auto-closing one. + * @returns Whether the icon should be clickable while the block is in a flyout. + */ + isClickableInFlyout?(autoClosingFlyout: boolean): boolean; +} + +/** Type guard that checks whether the given object is an IIcon. */ +export function isIcon(obj: any): obj is IIcon { + return ( + isFocusableNode(obj) && + typeof (obj as IIcon).getType === 'function' && + typeof (obj as IIcon).initView === 'function' && + typeof (obj as IIcon).dispose === 'function' && + typeof (obj as IIcon).getWeight === 'function' && + typeof (obj as IIcon).getSize === 'function' && + typeof (obj as IIcon).applyColour === 'function' && + typeof (obj as IIcon).hideForInsertionMarker === 'function' && + typeof (obj as IIcon).updateEditable === 'function' && + typeof (obj as IIcon).updateCollapsed === 'function' && + typeof (obj as IIcon).isShownWhenCollapsed === 'function' && + typeof (obj as IIcon).setOffsetInBlock === 'function' && + typeof (obj as IIcon).onLocationChange === 'function' && + typeof (obj as IIcon).onClick === 'function' + ); +} diff --git a/core/interfaces/i_keyboard_accessible.ts b/core/interfaces/i_keyboard_accessible.ts new file mode 100644 index 00000000000..4d04e9d4f08 --- /dev/null +++ b/core/interfaces/i_keyboard_accessible.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IKeyboardAccessible + +import {KeyboardShortcut} from '../shortcut_registry.js'; + +/** + * An interface for an object that handles keyboard shortcuts. + */ +export interface IKeyboardAccessible { + /** + * Handles the given keyboard shortcut. + * + * @param shortcut The shortcut to be handled. + * @returns True if the shortcut has been handled, false otherwise. + */ + onShortcut(shortcut: KeyboardShortcut): boolean; +} diff --git a/core/interfaces/i_legacy_procedure_blocks.ts b/core/interfaces/i_legacy_procedure_blocks.ts new file mode 100644 index 00000000000..c723a5ed77c --- /dev/null +++ b/core/interfaces/i_legacy_procedure_blocks.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Legacy means of representing a procedure signature. The elements are + * respectively: name, parameter names, and whether it has a return value. + */ +export type ProcedureTuple = [string, string[], boolean]; + +/** + * Procedure block type. + * + * @internal + */ +export interface ProcedureBlock { + getProcedureCall: () => string; + renameProcedure: (p1: string, p2: string) => void; + getProcedureDef: () => ProcedureTuple; +} + +/** @internal */ +export interface LegacyProcedureDefBlock { + getProcedureDef: () => ProcedureTuple; +} + +/** @internal */ +export function isLegacyProcedureDefBlock( + obj: any, +): obj is LegacyProcedureDefBlock { + return obj && typeof obj.getProcedureDef === 'function'; +} + +/** @internal */ +export interface LegacyProcedureCallBlock { + getProcedureCall: () => string; + renameProcedure: (p1: string, p2: string) => void; +} + +/** @internal */ +export function isLegacyProcedureCallBlock( + obj: any, +): obj is LegacyProcedureCallBlock { + return ( + obj && + typeof obj.getProcedureCall === 'function' && + typeof obj.renameProcedure === 'function' + ); +} diff --git a/core/interfaces/i_metrics_manager.ts b/core/interfaces/i_metrics_manager.ts new file mode 100644 index 00000000000..6fc0d080cc2 --- /dev/null +++ b/core/interfaces/i_metrics_manager.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IMetricsManager + +import type { + AbsoluteMetrics, + ContainerRegion, + ToolboxMetrics, + UiMetrics, +} from '../metrics_manager.js'; +import type {Metrics} from '../utils/metrics.js'; +import type {Size} from '../utils/size.js'; + +/** + * Interface for a metrics manager. + */ +export interface IMetricsManager { + /** + * Returns whether the scroll area has fixed edges. + * + * @returns Whether the scroll area has fixed edges. + * @internal + */ + hasFixedEdges(): boolean; + + /** + * Returns the metrics for the scroll area of the workspace. + * + * @param opt_getWorkspaceCoordinates True to get the scroll metrics in + * workspace coordinates, false to get them in pixel coordinates. + * @param opt_viewMetrics The view metrics if they have been previously + * computed. Passing in null may cause the view metrics to be computed + * again, if it is needed. + * @param opt_contentMetrics The content metrics if they have been previously + * computed. Passing in null may cause the content metrics to be computed + * again, if it is needed. + * @returns The metrics for the scroll container + */ + getScrollMetrics( + opt_getWorkspaceCoordinates?: boolean, + opt_viewMetrics?: ContainerRegion, + opt_contentMetrics?: ContainerRegion, + ): ContainerRegion; + + /** + * Gets the width and the height of the flyout in pixel + * coordinates. By default, will get metrics for either a simple flyout (owned + * directly by the workspace) or for the flyout owned by the toolbox. If you + * pass `opt_own` as `true` then only metrics for the simple flyout will be + * returned, and it will return 0 for the width and height if the workspace + * has a category toolbox instead of a simple toolbox. + * + * @param opt_own Whether to only return the workspace's own flyout metrics. + * @returns The width and height of the flyout. + */ + getFlyoutMetrics(opt_own?: boolean): ToolboxMetrics; + + /** + * Gets the width, height and position of the toolbox on the workspace in + * pixel coordinates. Returns 0 for the width and height if the workspace has + * a simple toolbox instead of a category toolbox. To get the width and height + * of a simple toolbox, see {@link IMetricsManager.getFlyoutMetrics}. + * + * @returns The object with the width, height and position of the toolbox. + */ + getToolboxMetrics(): ToolboxMetrics; + + /** + * Gets the width and height of the workspace's parent SVG element in pixel + * coordinates. This area includes the toolbox and the visible workspace area. + * + * @returns The width and height of the workspace's parent SVG element. + */ + getSvgMetrics(): Size; + + /** + * Gets the absolute left and absolute top in pixel coordinates. + * This is where the visible workspace starts in relation to the SVG + * container. + * + * @returns The absolute metrics for the workspace. + */ + getAbsoluteMetrics(): AbsoluteMetrics; + + /** + * Gets the metrics for the visible workspace in either pixel or workspace + * coordinates. The visible workspace does not include the toolbox or flyout. + * + * @param opt_getWorkspaceCoordinates True to get the view metrics in + * workspace coordinates, false to get them in pixel coordinates. + * @returns The width, height, top and left of the viewport in either + * workspace coordinates or pixel coordinates. + */ + getViewMetrics(opt_getWorkspaceCoordinates?: boolean): ContainerRegion; + + /** + * Gets content metrics in either pixel or workspace coordinates. + * The content area is a rectangle around all the top bounded elements on the + * workspace (workspace comments and blocks). + * + * @param opt_getWorkspaceCoordinates True to get the content metrics in + * workspace coordinates, false to get them in pixel coordinates. + * @returns The metrics for the content container. + */ + getContentMetrics(opt_getWorkspaceCoordinates?: boolean): ContainerRegion; + + /** + * Returns an object with all the metrics required to size scrollbars for a + * top level workspace. The following properties are computed: + * Coordinate system: pixel coordinates, -left, -up, +right, +down + * .viewHeight: Height of the visible portion of the workspace. + * .viewWidth: Width of the visible portion of the workspace. + * .contentHeight: Height of the content. + * .contentWidth: Width of the content. + * .svgHeight: Height of the Blockly div (the view + the toolbox, + * simple or otherwise), + * .svgWidth: Width of the Blockly div (the view + the toolbox, + * simple or otherwise), + * .viewTop: Top-edge of the visible portion of the workspace, relative to + * the workspace origin. + * .viewLeft: Left-edge of the visible portion of the workspace, relative to + * the workspace origin. + * .contentTop: Top-edge of the content, relative to the workspace origin. + * .contentLeft: Left-edge of the content relative to the workspace origin. + * .absoluteTop: Top-edge of the visible portion of the workspace, relative + * to the blocklyDiv. + * .absoluteLeft: Left-edge of the visible portion of the workspace, relative + * to the blocklyDiv. + * .toolboxWidth: Width of the toolbox, if it exists. Otherwise zero. + * .toolboxHeight: Height of the toolbox, if it exists. Otherwise zero. + * .flyoutWidth: Width of the flyout if it is always open. Otherwise zero. + * .flyoutHeight: Height of the flyout if it is always open. Otherwise zero. + * .toolboxPosition: Top, bottom, left or right. Use TOOLBOX_AT constants to + * compare. + * + * @returns Contains size and position metrics of a top level workspace. + */ + getMetrics(): Metrics; + + /** + * Returns common metrics used by UI elements. + * + * @returns The UI metrics. + */ + getUiMetrics(): UiMetrics; +} diff --git a/core/interfaces/i_movable.ts b/core/interfaces/i_movable.ts new file mode 100644 index 00000000000..cc2a2b727c3 --- /dev/null +++ b/core/interfaces/i_movable.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IMovable + +/** + * The interface for an object that is movable. + */ +export interface IMovable { + /** + * Get whether this is movable or not. + * + * @returns True if movable. + */ + isMovable(): boolean; +} diff --git a/core/interfaces/i_navigation_policy.ts b/core/interfaces/i_navigation_policy.ts new file mode 100644 index 00000000000..8e1ce6c1005 --- /dev/null +++ b/core/interfaces/i_navigation_policy.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from './i_focusable_node.js'; + +/** + * A set of rules that specify where keyboard navigation should proceed. + */ +export interface INavigationPolicy { + /** + * Returns the first child element of the given element, if any. + * + * @param current The element which the user is navigating into. + * @returns The current element's first child, or null if it has none. + */ + getFirstChild(current: T): IFocusableNode | null; + + /** + * Returns the parent element of the given element, if any. + * + * @param current The element which the user is navigating out of. + * @returns The parent element of the current element, or null if it has none. + */ + getParent(current: T): IFocusableNode | null; + + /** + * Returns the peer element following the given element, if any. + * + * @param current The element which the user is navigating past. + * @returns The next peer element of the current element, or null if there is + * none. + */ + getNextSibling(current: T): IFocusableNode | null; + + /** + * Returns the peer element preceding the given element, if any. + * + * @param current The element which the user is navigating past. + * @returns The previous peer element of the current element, or null if + * there is none. + */ + getPreviousSibling(current: T): IFocusableNode | null; + + /** + * Returns whether or not the given instance should be reachable via keyboard + * navigation. + * + * Implementors should generally return true, unless there are circumstances + * under which this item should be skipped while using keyboard navigation. + * Common examples might include being disabled, invalid, readonly, or purely + * a visual decoration. For example, while Fields are navigable, non-editable + * fields return false, since they cannot be interacted with when focused. + * + * @returns True if this element should be included in keyboard navigation. + */ + isNavigable(current: T): boolean; + + /** + * Returns whether or not this navigation policy corresponds to the type of + * the given object. + * + * @param current An instance to check whether this policy applies to. + * @returns True if the given object is of a type handled by this policy. + */ + isApplicable(current: any): current is T; +} diff --git a/core/interfaces/i_observable.ts b/core/interfaces/i_observable.ts new file mode 100644 index 00000000000..8db0c237874 --- /dev/null +++ b/core/interfaces/i_observable.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * An object that fires events optionally. + * + * @internal + */ +export interface IObservable { + startPublishing(): void; + stopPublishing(): void; +} + +/** + * Type guard for checking if an object fulfills IObservable. + * + * @internal + */ +export function isObservable(obj: any): obj is IObservable { + return ( + obj && + typeof obj.startPublishing === 'function' && + typeof obj.stopPublishing === 'function' + ); +} diff --git a/core/interfaces/i_parameter_model.ts b/core/interfaces/i_parameter_model.ts new file mode 100644 index 00000000000..6b351b6b3a7 --- /dev/null +++ b/core/interfaces/i_parameter_model.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ParameterState} from '../serialization/procedures'; +import {IProcedureModel} from './i_procedure_model'; + +/** + * A data model for a procedure. + */ +export interface IParameterModel { + /** + * Sets the name of this parameter to the given name. + */ + setName(name: string): this; + + /** + * Sets the types of this parameter to the given type. + */ + setTypes(types: string[]): this; + + /** + * Returns the name of this parameter. + */ + getName(): string; + + /** + * Return the types of this parameter. + */ + getTypes(): string[]; + + /** + * Returns the unique language-neutral ID for the parameter. + * + * This represents the identify of the variable model which does not change + * over time. + */ + getId(): string; + + /** Sets the procedure model this parameter is associated with. */ + setProcedureModel(model: IProcedureModel): this; + + /** + * Serializes the state of the parameter to JSON. + * + * @returns JSON serializable state of the parameter. + */ + saveState(): ParameterState; +} diff --git a/core/interfaces/i_paster.ts b/core/interfaces/i_paster.ts new file mode 100644 index 00000000000..128913a26b1 --- /dev/null +++ b/core/interfaces/i_paster.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Coordinate} from '../utils/coordinate.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; +import {ICopyable, ICopyData} from './i_copyable.js'; + +/** An object that can paste data into a workspace. */ +export interface IPaster> { + paste( + copyData: U, + workspace: WorkspaceSvg, + coordinate?: Coordinate, + ): T | null; +} + +/** @returns True if the given object is a paster. */ +export function isPaster( + obj: any, +): obj is IPaster> { + return obj && typeof obj.paste === 'function'; +} diff --git a/core/interfaces/i_positionable.ts b/core/interfaces/i_positionable.ts new file mode 100644 index 00000000000..4ea7dafa08d --- /dev/null +++ b/core/interfaces/i_positionable.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IPositionable + +import type {UiMetrics} from '../metrics_manager.js'; +import type {Rect} from '../utils/rect.js'; +import type {IComponent} from './i_component.js'; + +/** + * Interface for a component that is positioned on top of the workspace. + */ +export interface IPositionable extends IComponent { + /** + * Positions the element. Called when the window is resized. + * + * @param metrics The workspace metrics. + * @param savedPositions List of rectangles that are already on the workspace. + */ + position(metrics: UiMetrics, savedPositions: Rect[]): void; + + /** + * Returns the bounding rectangle of the UI element in pixel units relative to + * the Blockly injection div. + * + * @returns The UI elements's bounding box. Null if bounding box should be + * ignored by other UI elements. + */ + getBoundingRectangle(): Rect | null; +} diff --git a/core/interfaces/i_procedure_block.ts b/core/interfaces/i_procedure_block.ts new file mode 100644 index 00000000000..3a6dc4847b9 --- /dev/null +++ b/core/interfaces/i_procedure_block.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.procedures.IProcedureBlock + +import type {Block} from '../block.js'; +import {IProcedureModel} from './i_procedure_model.js'; + +/** The interface for a block which models a procedure. */ +export interface IProcedureBlock { + getProcedureModel(): IProcedureModel; + doProcedureUpdate(): void; + isProcedureDef(): boolean; +} + +/** A type guard which checks if the given block is a procedure block. */ +export function isProcedureBlock( + block: Block | IProcedureBlock, +): block is IProcedureBlock { + block = block as IProcedureBlock; + return ( + typeof block.getProcedureModel === 'function' && + typeof block.doProcedureUpdate === 'function' && + typeof block.isProcedureDef === 'function' + ); +} diff --git a/core/interfaces/i_procedure_map.ts b/core/interfaces/i_procedure_map.ts new file mode 100644 index 00000000000..e14d53a3384 --- /dev/null +++ b/core/interfaces/i_procedure_map.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {IProcedureModel} from './i_procedure_model.js'; + +export interface IProcedureMap extends Map { + /** + * Adds the given ProcedureModel to the map of procedure models, so that + * blocks can find it. + */ + add(proc: IProcedureModel): this; + + /** Returns all of the procedures stored in this map. */ + getProcedures(): IProcedureModel[]; +} diff --git a/core/interfaces/i_procedure_model.ts b/core/interfaces/i_procedure_model.ts new file mode 100644 index 00000000000..61026adaeca --- /dev/null +++ b/core/interfaces/i_procedure_model.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {State} from '../serialization/procedures.js'; +import {IParameterModel} from './i_parameter_model.js'; + +/** + * A data model for a procedure. + */ +export interface IProcedureModel { + /** Sets the human-readable name of the procedure. */ + setName(name: string): this; + + /** + * Inserts a parameter into the list of parameters. + * + * To move a parameter, first delete it, and then re-insert. + */ + insertParameter(parameterModel: IParameterModel, index: number): this; + + /** Removes the parameter at the given index from the parameter list. */ + deleteParameter(index: number): this; + + /** + * Sets the return type(s) of the procedure. + * + * Pass null to represent a procedure that does not return. + */ + setReturnTypes(types: string[] | null): this; + + /** + * Sets whether this procedure is enabled/disabled. If a procedure is disabled + * all procedure caller blocks should be disabled as well. + */ + setEnabled(enabled: boolean): this; + + /** Returns the unique language-neutral ID for the procedure. */ + getId(): string; + + /** Returns the human-readable name of the procedure. */ + getName(): string; + + /** Returns the parameter at the given index in the parameter list. */ + getParameter(index: number): IParameterModel; + + /** Returns an array of all of the parameters in the parameter list. */ + getParameters(): IParameterModel[]; + + /** + * Returns the return type(s) of the procedure. + * + * Null represents a procedure that does not return a value. + */ + getReturnTypes(): string[] | null; + + /** + * Returns whether the procedure is enabled/disabled. If a procedure is + * disabled, all procedure caller blocks should be disabled as well. + */ + getEnabled(): boolean; + + /** + * Serializes the state of the procedure to JSON. + * + * @returns JSON serializable state of the procedure. + */ + saveState(): State; +} diff --git a/core/interfaces/i_registrable.ts b/core/interfaces/i_registrable.ts new file mode 100644 index 00000000000..7fb469eee7d --- /dev/null +++ b/core/interfaces/i_registrable.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IRegistrable + +/** + * The interface for a Blockly component that can be registered. + */ +export interface IRegistrable {} diff --git a/core/interfaces/i_rendered_element.ts b/core/interfaces/i_rendered_element.ts new file mode 100644 index 00000000000..2f82487e9be --- /dev/null +++ b/core/interfaces/i_rendered_element.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface IRenderedElement { + /** + * @returns The root SVG element of this rendered element. + */ + getSvgRoot(): SVGElement; +} + +/** + * @returns True if the given object is an IRenderedElement. + */ +export function isRenderedElement(obj: any): obj is IRenderedElement { + return obj && typeof obj.getSvgRoot === 'function'; +} diff --git a/core/interfaces/i_selectable.ts b/core/interfaces/i_selectable.ts new file mode 100644 index 00000000000..5374f50cd3a --- /dev/null +++ b/core/interfaces/i_selectable.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.ISelectable + +import type {Workspace} from '../workspace.js'; +import {IFocusableNode, isFocusableNode} from './i_focusable_node.js'; + +/** + * The interface for an object that is selectable. + * + * Implementations are generally expected to use their implementations of + * onNodeFocus() and onNodeBlur() to call setSelected() with themselves and + * null, respectively, in order to ensure that selections are correctly updated + * and the selection change event is fired. + */ +export interface ISelectable extends IFocusableNode { + id: string; + + workspace: Workspace; + + /** Select this. Highlight it visually. */ + select(): void; + + /** Unselect this. Unhighlight it visually. */ + unselect(): void; +} + +/** Checks whether the given object is an ISelectable. */ +export function isSelectable(obj: any): obj is ISelectable { + return ( + isFocusableNode(obj) && + typeof (obj as ISelectable).id === 'string' && + typeof (obj as ISelectable).workspace === 'object' && + typeof (obj as ISelectable).select === 'function' && + typeof (obj as ISelectable).unselect === 'function' + ); +} diff --git a/core/interfaces/i_selectable_toolbox_item.ts b/core/interfaces/i_selectable_toolbox_item.ts new file mode 100644 index 00000000000..890d4e370af --- /dev/null +++ b/core/interfaces/i_selectable_toolbox_item.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.ISelectableToolboxItem + +import type {FlyoutItemInfoArray} from '../utils/toolbox'; +import type {IToolboxItem} from './i_toolbox_item.js'; + +/** + * Interface for an item in the toolbox that can be selected. + */ +export interface ISelectableToolboxItem extends IToolboxItem { + /** + * Gets the name of the toolbox item. Used for emitting events. + * + * @returns The name of the toolbox item. + */ + getName(): string; + + /** + * Gets the contents of the toolbox item. These are items that are meant to be + * displayed in the flyout. + * + * @returns The definition of items to be displayed in the flyout. + */ + getContents(): FlyoutItemInfoArray | string; + + /** + * Sets the current toolbox item as selected. + * + * @param _isSelected True if this category is selected, false otherwise. + */ + setSelected(_isSelected: boolean): void; + + /** + * Gets the HTML element that is clickable. + * The parent toolbox element receives clicks. The parent toolbox will add an + * ID to this element so it can pass the onClick event to the correct + * toolboxItem. + * + * @returns The HTML element that receives clicks. + */ + getClickTarget(): Element; + + /** + * Handles when the toolbox item is clicked. + * + * @param _e Click event to handle. + */ + onClick(_e: Event): void; +} + +/** + * Type guard that checks whether an IToolboxItem is an ISelectableToolboxItem. + */ +export function isSelectableToolboxItem( + toolboxItem: IToolboxItem, +): toolboxItem is ISelectableToolboxItem { + return toolboxItem.isSelectable(); +} diff --git a/core/interfaces/i_serializable.ts b/core/interfaces/i_serializable.ts new file mode 100644 index 00000000000..99e597da37a --- /dev/null +++ b/core/interfaces/i_serializable.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ISerializable { + /** + * @param doFullSerialization If true, this signals that any backing data + * structures used by this ISerializable should also be serialized. This + * is used for copy-paste. + * @returns a JSON serializable value that records the ISerializable's state. + */ + saveState(doFullSerialization: boolean): any; + + /** + * Takes in a JSON serializable value and sets the ISerializable's state + * based on that. + * + * @param state The state to apply to the ISerializable. + */ + loadState(state: any): void; +} + +/** Type guard that checks whether the given object is a ISerializable. */ +export function isSerializable(obj: any): obj is ISerializable { + return ( + obj && + typeof obj.saveState === 'function' && + typeof obj.loadState === 'function' + ); +} diff --git a/core/interfaces/i_serializer.ts b/core/interfaces/i_serializer.ts new file mode 100644 index 00000000000..f5fbb67d100 --- /dev/null +++ b/core/interfaces/i_serializer.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.serialization.ISerializer + +import type {Workspace} from '../workspace.js'; + +/** + * Serializes and deserializes a plugin or system. + */ +export interface ISerializer { + /** + * A priority value used to determine the order of deserializing state. + * More positive priorities are deserialized before less positive + * priorities. Eg if you have priorities (0, -10, 10, 100) the order of + * deserialiation will be (100, 10, 0, -10). + * If two serializers have the same priority, they are deserialized in an + * arbitrary order relative to each other. + */ + priority: number; + + /** + * Saves the state of the plugin or system. + * + * @param workspace The workspace the system to serialize is associated with. + * @returns A JS object containing the system's state, or null if there is no + * state to record. + */ + save(workspace: Workspace): object | null; + + /** + * Loads the state of the plugin or system. + * + * @param state The state of the system to deserialize. This will always be + * non-null. + * @param workspace The workspace the system to deserialize is associated + * with. + */ + load(state: object, workspace: Workspace): void; + + /** + * Clears the state of the plugin or system. + * + * @param workspace The workspace the system to clear the state of is + * associated with. + */ + clear(workspace: Workspace): void; +} diff --git a/core/interfaces/i_styleable.ts b/core/interfaces/i_styleable.ts new file mode 100644 index 00000000000..cb043b213f9 --- /dev/null +++ b/core/interfaces/i_styleable.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IStyleable + +/** + * Interface for an object that a style can be added to. + */ +export interface IStyleable { + /** + * Adds a style on the toolbox. Usually used to change the cursor. + * + * @param style The name of the class to add. + */ + addStyle(style: string): void; + + /** + * Removes a style from the toolbox. Usually used to change the cursor. + * + * @param style The name of the class to remove. + */ + removeStyle(style: string): void; +} diff --git a/core/interfaces/i_toolbox.ts b/core/interfaces/i_toolbox.ts new file mode 100644 index 00000000000..f5d9c9fd7c6 --- /dev/null +++ b/core/interfaces/i_toolbox.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IToolbox + +import type {ToolboxInfo} from '../utils/toolbox.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import type {IFlyout} from './i_flyout.js'; +import type {IFocusableTree} from './i_focusable_tree.js'; +import type {IRegistrable} from './i_registrable.js'; +import type {IToolboxItem} from './i_toolbox_item.js'; + +/** + * Interface for a toolbox. + */ +export interface IToolbox extends IRegistrable, IFocusableTree { + /** Initializes the toolbox. */ + init(): void; + + /** + * Fills the toolbox with new toolbox items and removes any old contents. + * + * @param toolboxDef Object holding information for creating a toolbox. + */ + render(toolboxDef: ToolboxInfo): void; + + /** + * Gets the width of the toolbox. + * + * @returns The width of the toolbox. + */ + getWidth(): number; + + /** + * Gets the height of the toolbox. + * + * @returns The height of the toolbox. + */ + getHeight(): number; + + /** + * Gets the toolbox flyout. + * + * @returns The toolbox flyout. + */ + getFlyout(): IFlyout | null; + + /** + * Gets the workspace for the toolbox. + * + * @returns The parent workspace for the toolbox. + */ + getWorkspace(): WorkspaceSvg; + + /** + * Gets whether or not the toolbox is horizontal. + * + * @returns True if the toolbox is horizontal, false if the toolbox is + * vertical. + */ + isHorizontal(): boolean; + + /** + * Positions the toolbox based on whether it is a horizontal toolbox and + * whether the workspace is in rtl. + */ + position(): void; + + /** Handles resizing the toolbox when a toolbox item resizes. */ + handleToolboxItemResize(): void; + + /** Unhighlights any previously selected item. */ + clearSelection(): void; + + /** + * Updates the category colours and background colour of selected categories. + */ + refreshTheme(): void; + + /** + * Updates the flyout's content without closing it. Should be used in + * response to a change in one of the dynamic categories, such as variables or + * procedures. + */ + refreshSelection(): void; + + /** + * Sets the visibility of the toolbox. + * + * @param isVisible True if toolbox should be visible. + */ + setVisible(isVisible: boolean): void; + + /** + * Selects the toolbox item by its position in the list of toolbox items. + * + * @param position The position of the item to select. + */ + selectItemByPosition(position: number): void; + + /** + * Gets the selected item. + * + * @returns The selected item, or null if no item is currently selected. + */ + getSelectedItem(): IToolboxItem | null; + + /** + * Sets the selected item. + * + * @param item The toolbox item to select, or null to remove the current + * selection. + */ + setSelectedItem(item: IToolboxItem | null): void; + + /** Disposes of this toolbox. */ + dispose(): void; +} diff --git a/core/interfaces/i_toolbox_item.ts b/core/interfaces/i_toolbox_item.ts new file mode 100644 index 00000000000..661624fd7e8 --- /dev/null +++ b/core/interfaces/i_toolbox_item.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.IToolboxItem + +import type {IFocusableNode} from './i_focusable_node.js'; + +/** + * Interface for an item in the toolbox. + */ +export interface IToolboxItem extends IFocusableNode { + /** + * Initializes the toolbox item. + * This includes creating the DOM and updating the state of any items based + * on the info object. + */ + init(): void; + + /** + * Gets the div for the toolbox item. + * + * @returns The div for the toolbox item. + */ + getDiv(): Element | null; + + /** + * Gets a unique identifier for this toolbox item. + * + * @returns The ID for the toolbox item. + */ + getId(): string; + + /** + * Gets the parent if the toolbox item is nested. + * + * @returns The parent toolbox item, or null if this toolbox item is not + * nested. + */ + getParent(): IToolboxItem | null; + + /** + * Gets the nested level of the category. + * + * @returns The nested level of the category. + * @internal + */ + getLevel(): number; + + /** + * Whether the toolbox item is selectable. + * + * @returns True if the toolbox item can be selected. + */ + isSelectable(): boolean; + + /** + * Whether the toolbox item is collapsible. + * + * @returns True if the toolbox item is collapsible. + */ + isCollapsible(): boolean; + + /** Dispose of this toolbox item. No-op by default. */ + dispose(): void; + + /** + * Gets the HTML element that is clickable. + * + * @returns The HTML element that receives clicks. + */ + getClickTarget(): Element | null; + + /** + * Sets whether the category is visible or not. + * For a category to be visible its parent category must also be expanded. + * + * @param isVisible True if category should be visible. + */ + setVisible_(isVisible: boolean): void; +} diff --git a/core/interfaces/i_variable_backed_parameter_model.ts b/core/interfaces/i_variable_backed_parameter_model.ts new file mode 100644 index 00000000000..444deb60105 --- /dev/null +++ b/core/interfaces/i_variable_backed_parameter_model.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {IParameterModel} from './i_parameter_model.js'; +import type {IVariableModel, IVariableState} from './i_variable_model.js'; + +/** Interface for a parameter model that holds a variable model. */ +export interface IVariableBackedParameterModel extends IParameterModel { + /** Returns the variable model held by this type. */ + getVariableModel(): IVariableModel; +} + +/** + * Returns whether the given object is a variable holder or not. + */ +export function isVariableBackedParameterModel( + param: IParameterModel, +): param is IVariableBackedParameterModel { + return (param as any).getVariableModel !== undefined; +} diff --git a/core/interfaces/i_variable_map.ts b/core/interfaces/i_variable_map.ts new file mode 100644 index 00000000000..22b4eda9012 --- /dev/null +++ b/core/interfaces/i_variable_map.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IVariableModel, IVariableState} from './i_variable_model.js'; + +/** + * Variable maps are container objects responsible for storing and managing the + * set of variables referenced on a workspace. + * + * Any of these methods may define invariants about which names and types are + * legal, and throw if they are not met. + */ +export interface IVariableMap> { + /* Returns the variable corresponding to the given ID, or null if none. */ + getVariableById(id: string): T | null; + + /** + * Returns the variable with the given name, or null if not found. If `type` + * is provided, the variable's type must also match, or null should be + * returned. + */ + getVariable(name: string, type?: string): T | null; + + /* Returns a list of all variables managed by this variable map. */ + getAllVariables(): T[]; + + /** + * Returns a list of all of the variables of the given type managed by this + * variable map. + */ + getVariablesOfType(type: string): T[]; + + /** + * Returns a list of the set of types of the variables managed by this + * variable map. + */ + getTypes(): string[]; + + /** + * Creates a new variable with the given name. If ID is not specified, the + * variable map should create one. Returns the new variable. + */ + createVariable(name: string, type?: string, id?: string | null): T; + + /* Adds a variable to this variable map. */ + addVariable(variable: T): void; + + /** + * Changes the name of the given variable to the name provided and returns the + * renamed variable. + */ + renameVariable(variable: T, newName: string): T; + + /* Changes the type of the given variable and returns it. */ + changeVariableType(variable: T, newType: string): T; + + /* Deletes the given variable. */ + deleteVariable(variable: T): void; + + /* Removes all variables from this variable map. */ + clear(): void; +} diff --git a/core/interfaces/i_variable_model.ts b/core/interfaces/i_variable_model.ts new file mode 100644 index 00000000000..791b1072567 --- /dev/null +++ b/core/interfaces/i_variable_model.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Workspace} from '../workspace.js'; + +/* Representation of a variable. */ +export interface IVariableModel { + /* Returns the unique ID of this variable. */ + getId(): string; + + /* Returns the user-visible name of this variable. */ + getName(): string; + + /** + * Returns the type of the variable like 'int' or 'string'. Does not need to be + * unique. This will default to '' which is a specific type. + */ + getType(): string; + + /* Sets the user-visible name of this variable. */ + setName(name: string): this; + + /* Sets the type of this variable. */ + setType(type: string): this; + + getWorkspace(): Workspace; + + /* Serializes this variable */ + save(): T; +} + +export interface IVariableModelStatic { + new ( + workspace: Workspace, + name: string, + type?: string, + id?: string, + ): IVariableModel; + + /** + * Creates a new IVariableModel corresponding to the given state on the + * specified workspace. This method must be static in your implementation. + */ + load(state: T, workspace: Workspace): IVariableModel; +} + +/** + * Represents the state of a given variable. + */ +export interface IVariableState { + name: string; + id: string; + type?: string; +} diff --git a/core/internal_constants.ts b/core/internal_constants.ts new file mode 100644 index 00000000000..27c945dc08b --- /dev/null +++ b/core/internal_constants.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Former goog.module ID: Blockly.internalConstants + +import {ConnectionType} from './connection_type.js'; + +/** + * Number of characters to truncate a collapsed block to. + * + * @internal + */ +export const COLLAPSE_CHARS = 30; + +/** + * Lookup table for determining the opposite type of a connection. + * + * @internal + */ +export const OPPOSITE_TYPE: number[] = []; +OPPOSITE_TYPE[ConnectionType.INPUT_VALUE] = ConnectionType.OUTPUT_VALUE; +OPPOSITE_TYPE[ConnectionType.OUTPUT_VALUE] = ConnectionType.INPUT_VALUE; +OPPOSITE_TYPE[ConnectionType.NEXT_STATEMENT] = + ConnectionType.PREVIOUS_STATEMENT; +OPPOSITE_TYPE[ConnectionType.PREVIOUS_STATEMENT] = + ConnectionType.NEXT_STATEMENT; + +/** + * String for use in the dropdown created in field_variable. + * This string indicates that this option in the dropdown is 'Rename + * variable...' and if selected, should trigger the prompt to rename a variable. + * + * @internal + */ +export const RENAME_VARIABLE_ID = 'RENAME_VARIABLE_ID'; + +/** + * String for use in the dropdown created in field_variable. + * This string indicates that this option in the dropdown is 'Delete the "%1" + * variable' and if selected, should trigger the prompt to delete a variable. + * + * @internal + */ +export const DELETE_VARIABLE_ID = 'DELETE_VARIABLE_ID'; diff --git a/core/keyboard_nav/block_comment_navigation_policy.ts b/core/keyboard_nav/block_comment_navigation_policy.ts new file mode 100644 index 00000000000..f2f1ab7e107 --- /dev/null +++ b/core/keyboard_nav/block_comment_navigation_policy.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {TextInputBubble} from '../bubbles/textinput_bubble.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from an TextInputBubble. + */ +export class BlockCommentNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given block comment. + * + * @param current The block comment to return the first child of. + * @returns The text editor of the given block comment bubble. + */ + getFirstChild(current: TextInputBubble): IFocusableNode | null { + return current.getEditor(); + } + + /** + * Returns the parent of the given block comment. + * + * @param current The block comment to return the parent of. + * @returns The parent block of the given block comment. + */ + getParent(current: TextInputBubble): IFocusableNode | null { + return current.getOwner() ?? null; + } + + /** + * Returns the next peer node of the given block comment. + * + * @param _current The block comment to find the following element of. + * @returns Null. + */ + getNextSibling(_current: TextInputBubble): IFocusableNode | null { + return null; + } + + /** + * Returns the previous peer node of the given block comment. + * + * @param _current The block comment to find the preceding element of. + * @returns Null. + */ + getPreviousSibling(_current: TextInputBubble): IFocusableNode | null { + return null; + } + + /** + * Returns whether or not the given block comment can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given block comment can be focused. + */ + isNavigable(current: TextInputBubble): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an TextInputBubble. + */ + isApplicable(current: any): current is TextInputBubble { + return current instanceof TextInputBubble; + } +} diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts new file mode 100644 index 00000000000..9f56b538455 --- /dev/null +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from '../block_svg.js'; +import {ConnectionType} from '../connection_type.js'; +import type {Field} from '../field.js'; +import type {Icon} from '../icons/icon.js'; +import type {IBoundedElement} from '../interfaces/i_bounded_element.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {isFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import type {ISelectable} from '../interfaces/i_selectable.js'; +import {RenderedConnection} from '../rendered_connection.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +/** + * Set of rules controlling keyboard navigation from a block. + */ +export class BlockNavigationPolicy implements INavigationPolicy { + /** + * Returns the first child of the given block. + * + * @param current The block to return the first child of. + * @returns The first field or input of the given block, if any. + */ + getFirstChild(current: BlockSvg): IFocusableNode | null { + const candidates = getBlockNavigationCandidates(current, true); + return candidates[0]; + } + + /** + * Returns the parent of the given block. + * + * @param current The block to return the parent of. + * @returns The top block of the given block's stack, or the connection to + * which it is attached. + */ + getParent(current: BlockSvg): IFocusableNode | null { + if (current.previousConnection?.targetBlock()) { + const surroundParent = current.getSurroundParent(); + if (surroundParent) return surroundParent; + } else if (current.outputConnection?.targetBlock()) { + return current.outputConnection.targetBlock(); + } + + return current.workspace; + } + + /** + * Returns the next peer node of the given block. + * + * @param current The block to find the following element of. + * @returns The first node of the next input/stack if the given block is a terminal + * block, or its next connection. + */ + getNextSibling(current: BlockSvg): IFocusableNode | null { + if (current.nextConnection?.targetBlock()) { + return current.nextConnection?.targetBlock(); + } else if (current.outputConnection?.targetBlock()) { + return navigateBlock(current, 1); + } else if (current.getSurroundParent()) { + return navigateBlock(current.getTopStackBlock(), 1); + } else if (this.getParent(current) instanceof WorkspaceSvg) { + return navigateStacks(current, 1); + } + + return null; + } + + /** + * Returns the previous peer node of the given block. + * + * @param current The block to find the preceding element of. + * @returns The block's previous/output connection, or the last + * connection/block of the previous block stack if it is a root block. + */ + getPreviousSibling(current: BlockSvg): IFocusableNode | null { + if (current.previousConnection?.targetBlock()) { + return current.previousConnection?.targetBlock(); + } else if (current.outputConnection?.targetBlock()) { + return navigateBlock(current, -1); + } else if (this.getParent(current) instanceof WorkspaceSvg) { + return navigateStacks(current, -1); + } + + return null; + } + + /** + * Returns whether or not the given block can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given block can be focused. + */ + isNavigable(current: BlockSvg): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a BlockSvg. + */ + isApplicable(current: any): current is BlockSvg { + return current instanceof BlockSvg; + } +} + +/** + * Returns a list of the navigable children of the given block. + * + * @param block The block to retrieve the navigable children of. + * @returns A list of navigable/focusable children of the given block. + */ +function getBlockNavigationCandidates( + block: BlockSvg, + forward: boolean, +): IFocusableNode[] { + const candidates: IFocusableNode[] = block.getIcons(); + + for (const input of block.inputList) { + if (!input.isVisible()) continue; + candidates.push(...input.fieldRow); + if (input.connection?.targetBlock()) { + const connectedBlock = input.connection.targetBlock() as BlockSvg; + if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) { + const lastStackBlock = connectedBlock + .lastConnectionInStack(false) + ?.getSourceBlock(); + if (lastStackBlock) { + candidates.push(lastStackBlock); + } + } else { + candidates.push(connectedBlock); + } + } else if (input.connection?.type === ConnectionType.INPUT_VALUE) { + candidates.push(input.connection as RenderedConnection); + } + } + + return candidates; +} + +/** + * Returns the next/previous stack relative to the given element's stack. + * + * @param current The element whose stack will be navigated relative to. + * @param delta The difference in index to navigate; positive values navigate + * to the nth next stack, while negative values navigate to the nth previous + * stack. + * @returns The first element in the stack offset by `delta` relative to the + * current element's stack, or the last element in the stack offset by + * `delta` relative to the current element's stack when navigating backwards. + */ +export function navigateStacks(current: ISelectable, delta: number) { + const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg) + .getTopBoundedElements(true) + .filter((element: IBoundedElement) => isFocusableNode(element)); + const currentIndex = stacks.indexOf( + current instanceof BlockSvg ? current.getRootBlock() : current, + ); + const targetIndex = currentIndex + delta; + let result: IFocusableNode | null = null; + if (targetIndex >= 0 && targetIndex < stacks.length) { + result = stacks[targetIndex]; + } else if (targetIndex < 0) { + result = stacks[stacks.length - 1]; + } else if (targetIndex >= stacks.length) { + result = stacks[0]; + } + + // When navigating to a previous block stack, our previous sibling is the last + // block in it. + if (delta < 0 && result instanceof BlockSvg) { + return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; + } + + return result; +} + +/** + * Returns the next navigable item relative to the provided block child. + * + * @param current The navigable block child item to navigate relative to. + * @param delta The difference in index to navigate; positive values navigate + * forward by n, while negative values navigate backwards by n. + * @returns The navigable block child offset by `delta` relative to `current`. + */ +export function navigateBlock( + current: Icon | Field | RenderedConnection | BlockSvg, + delta: number, +): IFocusableNode | null { + const block = + current instanceof BlockSvg + ? (current.outputConnection?.targetBlock() ?? current.getSurroundParent()) + : current.getSourceBlock(); + if (!(block instanceof BlockSvg)) return null; + + const candidates = getBlockNavigationCandidates(block, delta > 0); + const currentIndex = candidates.indexOf(current); + if (currentIndex === -1) return null; + + const targetIndex = currentIndex + delta; + if (targetIndex >= 0 && targetIndex < candidates.length) { + return candidates[targetIndex]; + } + + return null; +} diff --git a/core/keyboard_nav/comment_bar_button_navigation_policy.ts b/core/keyboard_nav/comment_bar_button_navigation_policy.ts new file mode 100644 index 00000000000..6654d2d8fef --- /dev/null +++ b/core/keyboard_nav/comment_bar_button_navigation_policy.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CommentBarButton} from '../comments/comment_bar_button.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a CommentBarButton. + */ +export class CommentBarButtonNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given CommentBarButton. + * + * @param _current The CommentBarButton to return the first child of. + * @returns Null. + */ + getFirstChild(_current: CommentBarButton): IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given CommentBarButton. + * + * @param current The CommentBarButton to return the parent of. + * @returns The parent comment of the given CommentBarButton. + */ + getParent(current: CommentBarButton): IFocusableNode | null { + return current + .getCommentView() + .workspace.getCommentById(current.getCommentView().commentId); + } + + /** + * Returns the next peer node of the given CommentBarButton. + * + * @param current The CommentBarButton to find the following element of. + * @returns The next CommentBarButton, if any. + */ + getNextSibling(current: CommentBarButton): IFocusableNode | null { + const children = current.getCommentView().getCommentBarButtons(); + const currentIndex = children.indexOf(current); + if (currentIndex >= 0 && currentIndex + 1 < children.length) { + return children[currentIndex + 1]; + } + return null; + } + + /** + * Returns the previous peer node of the given CommentBarButton. + * + * @param current The CommentBarButton to find the preceding element of. + * @returns The CommentBarButton's previous CommentBarButton, if any. + */ + getPreviousSibling(current: CommentBarButton): IFocusableNode | null { + const children = current.getCommentView().getCommentBarButtons(); + const currentIndex = children.indexOf(current); + if (currentIndex > 0) { + return children[currentIndex - 1]; + } + return null; + } + + /** + * Returns whether or not the given CommentBarButton can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given CommentBarButton can be focused. + */ + isNavigable(current: CommentBarButton): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an CommentBarButton. + */ + isApplicable(current: any): current is CommentBarButton { + return current instanceof CommentBarButton; + } +} diff --git a/core/keyboard_nav/comment_editor_navigation_policy.ts b/core/keyboard_nav/comment_editor_navigation_policy.ts new file mode 100644 index 00000000000..456df8e97c8 --- /dev/null +++ b/core/keyboard_nav/comment_editor_navigation_policy.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CommentEditor} from '../comments/comment_editor.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a comment editor. + * This is a no-op placeholder (other than isNavigable/isApplicable) since + * comment editors handle their own navigation when editing ends. + */ +export class CommentEditorNavigationPolicy + implements INavigationPolicy +{ + getFirstChild(_current: CommentEditor): IFocusableNode | null { + return null; + } + + getParent(_current: CommentEditor): IFocusableNode | null { + return null; + } + + getNextSibling(_current: CommentEditor): IFocusableNode | null { + return null; + } + + getPreviousSibling(_current: CommentEditor): IFocusableNode | null { + return null; + } + + /** + * Returns whether or not the given comment editor can be navigated to. + * + * @param current The instance to check for navigability. + * @returns False. + */ + isNavigable(current: CommentEditor): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a CommentEditor. + */ + isApplicable(current: any): current is CommentEditor { + return current instanceof CommentEditor; + } +} diff --git a/core/keyboard_nav/connection_navigation_policy.ts b/core/keyboard_nav/connection_navigation_policy.ts new file mode 100644 index 00000000000..bf685d0635c --- /dev/null +++ b/core/keyboard_nav/connection_navigation_policy.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from '../block_svg.js'; +import {ConnectionType} from '../connection_type.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../rendered_connection.js'; +import {navigateBlock} from './block_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a connection. + */ +export class ConnectionNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given connection. + * + * @param current The connection to return the first child of. + * @returns The connection's first child element, or null if not none. + */ + getFirstChild(current: RenderedConnection): IFocusableNode | null { + if (current.getParentInput()) { + return current.targetConnection; + } + + return null; + } + + /** + * Returns the parent of the given connection. + * + * @param current The connection to return the parent of. + * @returns The given connection's parent connection or block. + */ + getParent(current: RenderedConnection): IFocusableNode | null { + return current.getSourceBlock(); + } + + /** + * Returns the next element following the given connection. + * + * @param current The connection to navigate from. + * @returns The field, input connection or block following this connection. + */ + getNextSibling(current: RenderedConnection): IFocusableNode | null { + if (current.getParentInput()) { + return navigateBlock(current, 1); + } else if (current.type === ConnectionType.NEXT_STATEMENT) { + const nextBlock = current.targetConnection; + // If this connection is the last one in the stack, our next sibling is + // the next block stack. + const sourceBlock = current.getSourceBlock(); + if ( + !nextBlock && + sourceBlock.getRootBlock().lastConnectionInStack(false) === current + ) { + const topBlocks = sourceBlock.workspace.getTopBlocks(true); + let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) + 1; + if (targetIndex >= topBlocks.length) { + targetIndex = 0; + } + const nextBlock = topBlocks[targetIndex]; + return this.getParentConnection(nextBlock) ?? nextBlock; + } + + return nextBlock; + } + + return current.getSourceBlock(); + } + + /** + * Returns the element preceding the given connection. + * + * @param current The connection to navigate from. + * @returns The field, input connection or block preceding this connection. + */ + getPreviousSibling(current: RenderedConnection): IFocusableNode | null { + if (current.getParentInput()) { + return navigateBlock(current, -1); + } else if ( + current.type === ConnectionType.PREVIOUS_STATEMENT || + current.type === ConnectionType.OUTPUT_VALUE + ) { + const previousConnection = + current.targetConnection && !current.targetConnection.getParentInput() + ? current.targetConnection + : null; + + // If this connection is a disconnected previous/output connection, our + // previous sibling is the previous block stack's last connection/block. + const sourceBlock = current.getSourceBlock(); + if ( + !previousConnection && + this.getParentConnection(sourceBlock.getRootBlock()) === current + ) { + const topBlocks = sourceBlock.workspace.getTopBlocks(true); + let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) - 1; + if (targetIndex < 0) { + targetIndex = topBlocks.length - 1; + } + const previousRootBlock = topBlocks[targetIndex]; + return ( + previousRootBlock.lastConnectionInStack(false) ?? previousRootBlock + ); + } + + return previousConnection; + } else if (current.type === ConnectionType.NEXT_STATEMENT) { + return current.getSourceBlock(); + } + return null; + } + + /** + * Gets the parent connection on a block. + * This is either an output connection, previous connection or undefined. + * If both connections exist return the one that is actually connected + * to another block. + * + * @param block The block to find the parent connection on. + * @returns The connection connecting to the parent of the block. + */ + protected getParentConnection(block: BlockSvg) { + if (!block.outputConnection || block.previousConnection?.isConnected()) { + return block.previousConnection; + } + return block.outputConnection; + } + + /** + * Returns whether or not the given connection can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given connection can be focused. + */ + isNavigable(current: RenderedConnection): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a RenderedConnection. + */ + isApplicable(current: any): current is RenderedConnection { + return current instanceof RenderedConnection; + } +} diff --git a/core/keyboard_nav/field_navigation_policy.ts b/core/keyboard_nav/field_navigation_policy.ts new file mode 100644 index 00000000000..f9df406c22c --- /dev/null +++ b/core/keyboard_nav/field_navigation_policy.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from '../block_svg.js'; +import {Field} from '../field.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {navigateBlock} from './block_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a field. + */ +export class FieldNavigationPolicy implements INavigationPolicy> { + /** + * Returns null since fields do not have children. + * + * @param _current The field to navigate from. + * @returns Null. + */ + getFirstChild(_current: Field): IFocusableNode | null { + return null; + } + + /** + * Returns the parent block of the given field. + * + * @param current The field to navigate from. + * @returns The given field's parent block. + */ + getParent(current: Field): IFocusableNode | null { + return current.getSourceBlock() as BlockSvg; + } + + /** + * Returns the next field or input following the given field. + * + * @param current The field to navigate from. + * @returns The next field or input in the given field's block. + */ + getNextSibling(current: Field): IFocusableNode | null { + return navigateBlock(current, 1); + } + + /** + * Returns the field or input preceding the given field. + * + * @param current The field to navigate from. + * @returns The preceding field or input in the given field's block. + */ + getPreviousSibling(current: Field): IFocusableNode | null { + return navigateBlock(current, -1); + } + + /** + * Returns whether or not the given field can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given field can be focused and navigated to. + */ + isNavigable(current: Field): boolean { + return ( + current.canBeFocused() && + current.isVisible() && + (current.isClickable() || current.isCurrentlyEditable()) && + !( + current.getSourceBlock()?.isSimpleReporter() && + current.isFullBlockField() + ) && + current.getParentInput().isVisible() + ); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a Field. + */ + isApplicable(current: any): current is Field { + return current instanceof Field; + } +} diff --git a/core/keyboard_nav/flyout_button_navigation_policy.ts b/core/keyboard_nav/flyout_button_navigation_policy.ts new file mode 100644 index 00000000000..6c39c3061e7 --- /dev/null +++ b/core/keyboard_nav/flyout_button_navigation_policy.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FlyoutButton} from '../flyout_button.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a flyout button. + */ +export class FlyoutButtonNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns null since flyout buttons have no children. + * + * @param _current The FlyoutButton instance to navigate from. + * @returns Null. + */ + getFirstChild(_current: FlyoutButton): IFocusableNode | null { + return null; + } + + /** + * Returns the parent workspace of the given flyout button. + * + * @param current The FlyoutButton instance to navigate from. + * @returns The given flyout button's parent workspace. + */ + getParent(current: FlyoutButton): IFocusableNode | null { + return current.getWorkspace(); + } + + /** + * Returns null since inter-item navigation is done by FlyoutNavigationPolicy. + * + * @param _current The FlyoutButton instance to navigate from. + * @returns Null. + */ + getNextSibling(_current: FlyoutButton): IFocusableNode | null { + return null; + } + + /** + * Returns null since inter-item navigation is done by FlyoutNavigationPolicy. + * + * @param _current The FlyoutButton instance to navigate from. + * @returns Null. + */ + getPreviousSibling(_current: FlyoutButton): IFocusableNode | null { + return null; + } + + /** + * Returns whether or not the given flyout button can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given flyout button can be focused. + */ + isNavigable(current: FlyoutButton): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a FlyoutButton. + */ + isApplicable(current: any): current is FlyoutButton { + return current instanceof FlyoutButton; + } +} diff --git a/core/keyboard_nav/flyout_navigation_policy.ts b/core/keyboard_nav/flyout_navigation_policy.ts new file mode 100644 index 00000000000..6552c27b499 --- /dev/null +++ b/core/keyboard_nav/flyout_navigation_policy.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFlyout} from '../interfaces/i_flyout.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Generic navigation policy that navigates between items in the flyout. + */ +export class FlyoutNavigationPolicy implements INavigationPolicy { + /** + * Creates a new FlyoutNavigationPolicy instance. + * + * @param policy The policy to defer to for parents/children. + * @param flyout The flyout this policy will control navigation in. + */ + constructor( + private policy: INavigationPolicy, + private flyout: IFlyout, + ) {} + + /** + * Returns null to prevent navigating into flyout items. + * + * @param _current The flyout item to navigate from. + * @returns Null to prevent navigating into flyout items. + */ + getFirstChild(_current: T): IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given flyout item. + * + * @param current The flyout item to navigate from. + * @returns The parent of the given flyout item. + */ + getParent(current: T): IFocusableNode | null { + return this.policy.getParent(current); + } + + /** + * Returns the next item in the flyout relative to the given item. + * + * @param current The flyout item to navigate from. + * @returns The flyout item following the given one. + */ + getNextSibling(current: T): IFocusableNode | null { + const flyoutContents = this.flyout.getContents(); + if (!flyoutContents) return null; + + let index = flyoutContents.findIndex( + (flyoutItem) => flyoutItem.getElement() === current, + ); + + if (index === -1) return null; + index++; + if (index >= flyoutContents.length) { + index = 0; + } + + return flyoutContents[index].getElement(); + } + + /** + * Returns the previous item in the flyout relative to the given item. + * + * @param current The flyout item to navigate from. + * @returns The flyout item preceding the given one. + */ + getPreviousSibling(current: T): IFocusableNode | null { + const flyoutContents = this.flyout.getContents(); + if (!flyoutContents) return null; + + let index = flyoutContents.findIndex( + (flyoutItem) => flyoutItem.getElement() === current, + ); + + if (index === -1) return null; + index--; + if (index < 0) { + index = flyoutContents.length - 1; + } + + return flyoutContents[index].getElement(); + } + + /** + * Returns whether or not the given flyout item can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given flyout item can be focused. + */ + isNavigable(current: T): boolean { + return this.policy.isNavigable(current); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a BlockSvg. + */ + isApplicable(current: any): current is T { + return this.policy.isApplicable(current); + } +} diff --git a/core/keyboard_nav/flyout_separator_navigation_policy.ts b/core/keyboard_nav/flyout_separator_navigation_policy.ts new file mode 100644 index 00000000000..eb7ca4eb783 --- /dev/null +++ b/core/keyboard_nav/flyout_separator_navigation_policy.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FlyoutSeparator} from '../flyout_separator.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a flyout separator. + * This is a no-op placeholder, since flyout separators can't be navigated to. + */ +export class FlyoutSeparatorNavigationPolicy + implements INavigationPolicy +{ + getFirstChild(_current: FlyoutSeparator): IFocusableNode | null { + return null; + } + + getParent(_current: FlyoutSeparator): IFocusableNode | null { + return null; + } + + getNextSibling(_current: FlyoutSeparator): IFocusableNode | null { + return null; + } + + getPreviousSibling(_current: FlyoutSeparator): IFocusableNode | null { + return null; + } + + /** + * Returns whether or not the given flyout separator can be navigated to. + * + * @param _current The instance to check for navigability. + * @returns False. + */ + isNavigable(_current: FlyoutSeparator): boolean { + return false; + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a FlyoutSeparator. + */ + isApplicable(current: any): current is FlyoutSeparator { + return current instanceof FlyoutSeparator; + } +} diff --git a/core/keyboard_nav/icon_navigation_policy.ts b/core/keyboard_nav/icon_navigation_policy.ts new file mode 100644 index 00000000000..112239d0655 --- /dev/null +++ b/core/keyboard_nav/icon_navigation_policy.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from '../block_svg.js'; +import {getFocusManager} from '../focus_manager.js'; +import {CommentIcon} from '../icons/comment_icon.js'; +import {Icon} from '../icons/icon.js'; +import {MutatorIcon} from '../icons/mutator_icon.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {navigateBlock} from './block_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from an icon. + */ +export class IconNavigationPolicy implements INavigationPolicy { + /** + * Returns the first child of the given icon. + * + * @param current The icon to return the first child of. + * @returns Null. + */ + getFirstChild(current: Icon): IFocusableNode | null { + if ( + current instanceof MutatorIcon && + current.bubbleIsVisible() && + getFocusManager().getFocusedNode() === current + ) { + return current.getBubble()?.getWorkspace() ?? null; + } else if ( + current instanceof CommentIcon && + current.bubbleIsVisible() && + getFocusManager().getFocusedNode() === current + ) { + return current.getBubble()?.getEditor() ?? null; + } + + return null; + } + + /** + * Returns the parent of the given icon. + * + * @param current The icon to return the parent of. + * @returns The source block of the given icon. + */ + getParent(current: Icon): IFocusableNode | null { + return current.getSourceBlock() as BlockSvg; + } + + /** + * Returns the next peer node of the given icon. + * + * @param current The icon to find the following element of. + * @returns The next icon, field or input following this icon, if any. + */ + getNextSibling(current: Icon): IFocusableNode | null { + return navigateBlock(current, 1); + } + + /** + * Returns the previous peer node of the given icon. + * + * @param current The icon to find the preceding element of. + * @returns The icon's previous icon, if any. + */ + getPreviousSibling(current: Icon): IFocusableNode | null { + return navigateBlock(current, -1); + } + + /** + * Returns whether or not the given icon can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given icon can be focused. + */ + isNavigable(current: Icon): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an Icon. + */ + isApplicable(current: any): current is Icon { + return current instanceof Icon; + } +} diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts new file mode 100644 index 00000000000..30770e47d2d --- /dev/null +++ b/core/keyboard_nav/line_cursor.ts @@ -0,0 +1,414 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview The class representing a line cursor. + * A line cursor tries to traverse the blocks and connections on a block as if + * they were lines of code in a text editor. Previous and next traverse previous + * connections, next connections and blocks, while in and out traverse input + * connections and fields. + * @author aschmiedt@google.com (Abby Schmiedt) + */ + +import {BlockSvg} from '../block_svg.js'; +import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; +import {getFocusManager} from '../focus_manager.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import * as registry from '../registry.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {Marker} from './marker.js'; + +/** + * Class for a line cursor. + */ +export class LineCursor extends Marker { + override type = 'cursor'; + + /** Locations to try moving the cursor to after a deletion. */ + private potentialNodes: IFocusableNode[] | null = null; + + /** + * @param workspace The workspace this cursor belongs to. + */ + constructor(protected readonly workspace: WorkspaceSvg) { + super(); + } + + /** + * Moves the cursor to the next block or workspace comment in the pre-order + * traversal. + * + * @returns The next node, or null if the current node is not set or there is + * no next value. + */ + next(): IFocusableNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + const newNode = this.getNextNode( + curNode, + (candidate: IFocusableNode | null) => { + return ( + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + candidate instanceof RenderedWorkspaceComment + ); + }, + true, + ); + + if (newNode) { + this.setCurNode(newNode); + } + return newNode; + } + + /** + * Moves the cursor to the next input connection or field + * in the pre order traversal. + * + * @returns The next node, or null if the current node is + * not set or there is no next value. + */ + in(): IFocusableNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + + const newNode = this.getNextNode(curNode, () => true, true); + + if (newNode) { + this.setCurNode(newNode); + } + return newNode; + } + /** + * Moves the cursor to the previous block or workspace comment in the + * pre-order traversal. + * + * @returns The previous node, or null if the current node is not set or there + * is no previous value. + */ + prev(): IFocusableNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + const newNode = this.getPreviousNode( + curNode, + (candidate: IFocusableNode | null) => { + return ( + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + candidate instanceof RenderedWorkspaceComment + ); + }, + true, + ); + + if (newNode) { + this.setCurNode(newNode); + } + return newNode; + } + + /** + * Moves the cursor to the previous input connection or field in the pre order + * traversal. + * + * @returns The previous node, or null if the current node + * is not set or there is no previous value. + */ + out(): IFocusableNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + + const newNode = this.getPreviousNode(curNode, () => true, true); + + if (newNode) { + this.setCurNode(newNode); + } + return newNode; + } + + /** + * Returns true iff the node to which we would navigate if in() were + * called is the same as the node to which we would navigate if next() were + * called - in effect, if the LineCursor is at the end of the 'current + * line' of the program. + */ + atEndOfLine(): boolean { + const curNode = this.getCurNode(); + if (!curNode) return false; + const inNode = this.getNextNode(curNode, () => true, true); + const nextNode = this.getNextNode( + curNode, + (candidate: IFocusableNode | null) => { + return ( + candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock() + ); + }, + true, + ); + + return inNode === nextNode; + } + + /** + * Uses pre order traversal to navigate the Blockly AST. This will allow + * a user to easily navigate the entire Blockly AST without having to go in + * and out levels on the tree. + * + * @param node The current position in the AST. + * @param isValid A function true/false depending on whether the given node + * should be traversed. + * @param visitedNodes A set of previously visited nodes used to avoid cycles. + * @returns The next node in the traversal. + */ + private getNextNodeImpl( + node: IFocusableNode | null, + isValid: (p1: IFocusableNode | null) => boolean, + visitedNodes: Set = new Set(), + ): IFocusableNode | null { + if (!node || visitedNodes.has(node)) return null; + let newNode = + this.workspace.getNavigator().getFirstChild(node) || + this.workspace.getNavigator().getNextSibling(node); + + let target = node; + while (target && !newNode) { + const parent = this.workspace.getNavigator().getParent(target); + if (!parent) break; + newNode = this.workspace.getNavigator().getNextSibling(parent); + target = parent; + } + + if (isValid(newNode)) return newNode; + if (newNode) { + visitedNodes.add(node); + return this.getNextNodeImpl(newNode, isValid, visitedNodes); + } + return null; + } + + /** + * Get the next node in the AST, optionally allowing for loopback. + * + * @param node The current position in the AST. + * @param isValid A function true/false depending on whether the given node + * should be traversed. + * @param loop Whether to loop around to the beginning of the workspace if no + * valid node was found. + * @returns The next node in the traversal. + */ + getNextNode( + node: IFocusableNode | null, + isValid: (p1: IFocusableNode | null) => boolean, + loop: boolean, + ): IFocusableNode | null { + if (!node || (!loop && this.getLastNode() === node)) return null; + + return this.getNextNodeImpl(node, isValid); + } + + /** + * Reverses the pre order traversal in order to find the previous node. This + * will allow a user to easily navigate the entire Blockly AST without having + * to go in and out levels on the tree. + * + * @param node The current position in the AST. + * @param isValid A function true/false depending on whether the given node + * should be traversed. + * @param visitedNodes A set of previously visited nodes used to avoid cycles. + * @returns The previous node in the traversal or null if no previous node + * exists. + */ + private getPreviousNodeImpl( + node: IFocusableNode | null, + isValid: (p1: IFocusableNode | null) => boolean, + visitedNodes: Set = new Set(), + ): IFocusableNode | null { + if (!node || visitedNodes.has(node)) return null; + + const newNode = + this.getRightMostChild( + this.workspace.getNavigator().getPreviousSibling(node), + node, + ) || this.workspace.getNavigator().getParent(node); + + if (isValid(newNode)) return newNode; + if (newNode) { + visitedNodes.add(node); + return this.getPreviousNodeImpl(newNode, isValid, visitedNodes); + } + return null; + } + + /** + * Get the previous node in the AST, optionally allowing for loopback. + * + * @param node The current position in the AST. + * @param isValid A function true/false depending on whether the given node + * should be traversed. + * @param loop Whether to loop around to the end of the workspace if no valid + * node was found. + * @returns The previous node in the traversal or null if no previous node + * exists. + */ + getPreviousNode( + node: IFocusableNode | null, + isValid: (p1: IFocusableNode | null) => boolean, + loop: boolean, + ): IFocusableNode | null { + if (!node || (!loop && this.getFirstNode() === node)) return null; + + return this.getPreviousNodeImpl(node, isValid); + } + + /** + * Get the right most child of a node. + * + * @param node The node to find the right most child of. + * @returns The right most child of the given node, or the node if no child + * exists. + */ + private getRightMostChild( + node: IFocusableNode | null, + stopIfFound: IFocusableNode, + ): IFocusableNode | null { + if (!node) return node; + let newNode = this.workspace.getNavigator().getFirstChild(node); + if (!newNode || newNode === stopIfFound) return node; + for ( + let nextNode: IFocusableNode | null = newNode; + nextNode; + nextNode = this.workspace.getNavigator().getNextSibling(newNode) + ) { + if (nextNode === stopIfFound) break; + newNode = nextNode; + } + return this.getRightMostChild(newNode, stopIfFound); + } + + /** + * Prepare for the deletion of a block by making a list of nodes we + * could move the cursor to afterwards and save it to + * this.potentialNodes. + * + * After the deletion has occurred, call postDelete to move it to + * the first valid node on that list. + * + * The locations to try (in order of preference) are: + * + * - The current location. + * - The connection to which the deleted block is attached. + * - The block connected to the next connection of the deleted block. + * - The parent block of the deleted block. + * - A location on the workspace beneath the deleted block. + * + * N.B.: When block is deleted, all of the blocks conneccted to that + * block's inputs are also deleted, but not blocks connected to its + * next connection. + * + * @param deletedBlock The block that is being deleted. + */ + preDelete(deletedBlock: BlockSvg) { + const curNode = this.getCurNode(); + + const nodes: IFocusableNode[] = curNode ? [curNode] : []; + // The connection to which the deleted block is attached. + const parentConnection = + deletedBlock.previousConnection?.targetConnection ?? + deletedBlock.outputConnection?.targetConnection; + if (parentConnection) { + nodes.push(parentConnection); + } + // The block connected to the next connection of the deleted block. + const nextBlock = deletedBlock.getNextBlock(); + if (nextBlock) { + nodes.push(nextBlock); + } + // The parent block of the deleted block. + const parentBlock = deletedBlock.getParent(); + if (parentBlock) { + nodes.push(parentBlock); + } + // A location on the workspace beneath the deleted block. + // Move to the workspace. + nodes.push(this.workspace); + this.potentialNodes = nodes; + } + + /** + * Move the cursor to the first valid location in + * this.potentialNodes, following a block deletion. + */ + postDelete() { + const nodes = this.potentialNodes; + this.potentialNodes = null; + if (!nodes) throw new Error('must call preDelete first'); + for (const node of nodes) { + if (!this.getSourceBlockFromNode(node)?.disposed) { + this.setCurNode(node); + return; + } + } + throw new Error('no valid nodes in this.potentialNodes'); + } + + /** + * Get the current location of the cursor. + * + * Overrides normal Marker getCurNode to update the current node from the + * selected block. This typically happens via the selection listener but that + * is not called immediately when `Gesture` calls + * `Blockly.common.setSelected`. In particular the listener runs after showing + * the context menu. + * + * @returns The current field, connection, or block the cursor is on. + */ + getCurNode(): IFocusableNode | null { + return getFocusManager().getFocusedNode(); + } + + /** + * Set the location of the cursor and draw it. + * + * Overrides normal Marker setCurNode logic to call + * this.drawMarker() instead of this.drawer.draw() directly. + * + * @param newNode The new location of the cursor. + */ + setCurNode(newNode: IFocusableNode) { + getFocusManager().focusNode(newNode); + } + + /** + * Get the first navigable node on the workspace, or null if none exist. + * + * @returns The first navigable node on the workspace, or null. + */ + getFirstNode(): IFocusableNode | null { + return this.workspace.getNavigator().getFirstChild(this.workspace); + } + + /** + * Get the last navigable node on the workspace, or null if none exist. + * + * @returns The last navigable node on the workspace, or null. + */ + getLastNode(): IFocusableNode | null { + const first = this.getFirstNode(); + return this.getPreviousNode(first, () => true, true); + } +} + +registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor); diff --git a/core/keyboard_nav/marker.ts b/core/keyboard_nav/marker.ts new file mode 100644 index 00000000000..0cd066c163c --- /dev/null +++ b/core/keyboard_nav/marker.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The class representing a marker. + * Used primarily for keyboard navigation to show a marked location. + * + * @class + */ +// Former goog.module ID: Blockly.Marker + +import {BlockSvg} from '../block_svg.js'; +import {Field} from '../field.js'; +import {Icon} from '../icons/icon.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {RenderedConnection} from '../rendered_connection.js'; + +/** + * Class for a marker. + * This is used in keyboard navigation to save a location in the Blockly AST. + */ +export class Marker { + /** The colour of the marker. */ + colour: string | null = null; + + /** The current location of the marker. */ + protected curNode: IFocusableNode | null = null; + + /** The type of the marker. */ + type = 'marker'; + + /** + * Gets the current location of the marker. + * + * @returns The current field, connection, or block the marker is on. + */ + getCurNode(): IFocusableNode | null { + return this.curNode; + } + + /** + * Set the location of the marker and call the update method. + * + * @param newNode The new location of the marker, or null to remove it. + */ + setCurNode(newNode: IFocusableNode | null) { + this.curNode = newNode; + } + + /** Dispose of this marker. */ + dispose() { + this.curNode = null; + } + + /** + * Returns the block that the given node is a child of. + * + * @returns The parent block of the node if any, otherwise null. + */ + getSourceBlockFromNode(node: IFocusableNode | null): BlockSvg | null { + if (node instanceof BlockSvg) { + return node; + } else if (node instanceof Field) { + return node.getSourceBlock() as BlockSvg; + } else if (node instanceof RenderedConnection) { + return node.getSourceBlock(); + } else if (node instanceof Icon) { + return node.getSourceBlock() as BlockSvg; + } + + return null; + } + + /** + * Returns the block that this marker's current node is a child of. + * + * @returns The parent block of the marker's current node if any, otherwise + * null. + */ + getSourceBlock(): BlockSvg | null { + return this.getSourceBlockFromNode(this.getCurNode()); + } +} diff --git a/core/keyboard_nav/workspace_comment_navigation_policy.ts b/core/keyboard_nav/workspace_comment_navigation_policy.ts new file mode 100644 index 00000000000..7fe70ceadef --- /dev/null +++ b/core/keyboard_nav/workspace_comment_navigation_policy.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {navigateStacks} from './block_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from an RenderedWorkspaceComment. + */ +export class WorkspaceCommentNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given workspace comment. + * + * @param current The workspace comment to return the first child of. + * @returns The first child button of the given comment. + */ + getFirstChild(current: RenderedWorkspaceComment): IFocusableNode | null { + return current.view.getCommentBarButtons()[0]; + } + + /** + * Returns the parent of the given workspace comment. + * + * @param current The workspace comment to return the parent of. + * @returns The parent workspace of the given comment. + */ + getParent(current: RenderedWorkspaceComment): IFocusableNode | null { + return current.workspace; + } + + /** + * Returns the next peer node of the given workspace comment. + * + * @param current The workspace comment to find the following element of. + * @returns The next workspace comment or block stack, if any. + */ + getNextSibling(current: RenderedWorkspaceComment): IFocusableNode | null { + return navigateStacks(current, 1); + } + + /** + * Returns the previous peer node of the given workspace comment. + * + * @param current The workspace comment to find the preceding element of. + * @returns The previous workspace comment or block stack, if any. + */ + getPreviousSibling(current: RenderedWorkspaceComment): IFocusableNode | null { + return navigateStacks(current, -1); + } + + /** + * Returns whether or not the given workspace comment can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given workspace comment can be focused. + */ + isNavigable(current: RenderedWorkspaceComment): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an RenderedWorkspaceComment. + */ + isApplicable(current: any): current is RenderedWorkspaceComment { + return current instanceof RenderedWorkspaceComment; + } +} diff --git a/core/keyboard_nav/workspace_navigation_policy.ts b/core/keyboard_nav/workspace_navigation_policy.ts new file mode 100644 index 00000000000..b671f8fe739 --- /dev/null +++ b/core/keyboard_nav/workspace_navigation_policy.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +/** + * Set of rules controlling keyboard navigation from a workspace. + */ +export class WorkspaceNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given workspace. + * + * @param current The workspace to return the first child of. + * @returns The top block of the first block stack, if any. + */ + getFirstChild(current: WorkspaceSvg): IFocusableNode | null { + const blocks = current.getTopBlocks(true); + return blocks.length ? blocks[0] : null; + } + + /** + * Returns the parent of the given workspace. + * + * @param _current The workspace to return the parent of. + * @returns Null. + */ + getParent(_current: WorkspaceSvg): IFocusableNode | null { + return null; + } + + /** + * Returns the next sibling of the given workspace. + * + * @param _current The workspace to return the next sibling of. + * @returns Null. + */ + getNextSibling(_current: WorkspaceSvg): IFocusableNode | null { + return null; + } + + /** + * Returns the previous sibling of the given workspace. + * + * @param _current The workspace to return the previous sibling of. + * @returns Null. + */ + getPreviousSibling(_current: WorkspaceSvg): IFocusableNode | null { + return null; + } + + /** + * Returns whether or not the given workspace can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given workspace can be focused. + */ + isNavigable(current: WorkspaceSvg): boolean { + return current.canBeFocused() && !current.isMutator; + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a WorkspaceSvg. + */ + isApplicable(current: any): current is WorkspaceSvg { + return current instanceof WorkspaceSvg; + } +} diff --git a/core/keyboard_navigation_controller.ts b/core/keyboard_navigation_controller.ts new file mode 100644 index 00000000000..d0a766daff2 --- /dev/null +++ b/core/keyboard_navigation_controller.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The KeyboardNavigationController handles coordinating Blockly-wide + * keyboard navigation behavior, such as enabling/disabling full + * cursor visualization. + */ +export class KeyboardNavigationController { + /** Whether the user is actively using keyboard navigation. */ + private isActive = false; + /** Css class name added to body if keyboard nav is active. */ + private activeClassName = 'blocklyKeyboardNavigation'; + + /** + * Sets whether a user is actively using keyboard navigation. + * + * If they are, apply a css class to the entire page so that + * focused items can apply additional styling for keyboard users. + * + * Note that since enabling keyboard navigation presents significant UX changes + * (such as cursor visualization and move mode), callers should take care to + * only set active keyboard navigation when they have a high confidence in that + * being the correct state. In general, in any given mouse or key input situation + * callers can choose one of three paths: + * 1. Do nothing. This should be the choice for neutral actions that don't + * predominantly imply keyboard or mouse usage (such as clicking to select a block). + * 2. Disable keyboard navigation. This is the best choice when a user is definitely + * predominantly using the mouse (such as using a right click to open the context menu). + * 3. Enable keyboard navigation. This is the best choice when there's high confidence + * a user actually intends to use it (such as attempting to use the arrow keys to move + * around). + * + * @param isUsing + */ + setIsActive(isUsing: boolean = true) { + this.isActive = isUsing; + this.updateActiveVisualization(); + } + + /** + * @returns true if the user is actively using keyboard navigation + * (e.g., has recently taken some action that is only relevant to keyboard users) + */ + getIsActive(): boolean { + return this.isActive; + } + + /** Adds or removes the css class that indicates keyboard navigation is active. */ + private updateActiveVisualization() { + if (this.isActive) { + document.body.classList.add(this.activeClassName); + } else { + document.body.classList.remove(this.activeClassName); + } + } +} + +/** Singleton instance of the keyboard navigation controller. */ +export const keyboardNavigationController = new KeyboardNavigationController(); diff --git a/core/label_flyout_inflater.ts b/core/label_flyout_inflater.ts new file mode 100644 index 00000000000..ffa69ae4806 --- /dev/null +++ b/core/label_flyout_inflater.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FlyoutButton} from './flyout_button.js'; +import {FlyoutItem} from './flyout_item.js'; +import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import * as registry from './registry.js'; +import {ButtonOrLabelInfo} from './utils/toolbox.js'; +const LABEL_TYPE = 'label'; + +/** + * Class responsible for creating labels for flyouts. + */ +export class LabelFlyoutInflater implements IFlyoutInflater { + /** + * Inflates a flyout label from the given state and adds it to the flyout. + * + * @param state A JSON representation of a flyout label. + * @param flyout The flyout to create the label on. + * @returns A FlyoutButton configured as a label. + */ + load(state: object, flyout: IFlyout): FlyoutItem { + const label = new FlyoutButton( + flyout.getWorkspace(), + flyout.targetWorkspace!, + state as ButtonOrLabelInfo, + true, + ); + label.show(); + + return new FlyoutItem(label, LABEL_TYPE); + } + + /** + * Returns the amount of space that should follow this label. + * + * @param state A JSON representation of a flyout label. + * @param defaultGap The default spacing for flyout items. + * @returns The amount of space that should follow this label. + */ + gapForItem(state: object, defaultGap: number): number { + return defaultGap; + } + + /** + * Disposes of the given label. + * + * @param item The flyout label to dispose of. + */ + disposeItem(item: FlyoutItem): void { + const element = item.getElement(); + if (element instanceof FlyoutButton) { + element.dispose(); + } + } + + /** + * Returns the type of items this inflater is responsible for creating. + * + * @returns An identifier for the type of items this inflater creates. + */ + getType() { + return LABEL_TYPE; + } +} + +registry.register( + registry.Type.FLYOUT_INFLATER, + LABEL_TYPE, + LabelFlyoutInflater, +); diff --git a/core/layer_manager.ts b/core/layer_manager.ts new file mode 100644 index 00000000000..fd7d8fe235a --- /dev/null +++ b/core/layer_manager.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {getFocusManager} from './focus_manager.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import {IRenderedElement} from './interfaces/i_rendered_element.js'; +import * as layerNums from './layers.js'; +import {Coordinate} from './utils/coordinate.js'; +import * as dom from './utils/dom.js'; +import {Svg} from './utils/svg.js'; +import {WorkspaceSvg} from './workspace_svg.js'; + +/** @internal */ +export class LayerManager { + /** The layer elements being dragged are appended to. */ + private dragLayer: SVGGElement | undefined; + /** The layer elements being animated are appended to. */ + private animationLayer: SVGGElement | undefined; + /** The layers elements not being dragged are appended to. */ + private layers = new Map(); + + /** @internal */ + constructor(private workspace: WorkspaceSvg) { + const injectionDiv = workspace.getInjectionDiv(); + // `getInjectionDiv` is actually nullable. We hit this if the workspace + // is part of a flyout and the workspace the flyout is attached to hasn't + // been appended yet. + if (injectionDiv) { + this.dragLayer = this.createDragLayer(injectionDiv); + this.animationLayer = this.createAnimationLayer(injectionDiv); + } + + // We construct these manually so we can add the css class for backwards + // compatibility. + const blockLayer = this.createLayer(layerNums.BLOCK); + dom.addClass(blockLayer, 'blocklyBlockCanvas'); + const bubbleLayer = this.createLayer(layerNums.BUBBLE); + dom.addClass(bubbleLayer, 'blocklyBubbleCanvas'); + } + + private createDragLayer(injectionDiv: Element) { + const svg = dom.createSvgElement(Svg.SVG, { + 'class': 'blocklyBlockDragSurface', + 'xmlns': dom.SVG_NS, + 'xmlns:html': dom.HTML_NS, + 'xmlns:xlink': dom.XLINK_NS, + 'version': '1.1', + }); + injectionDiv.append(svg); + return dom.createSvgElement(Svg.G, {}, svg); + } + + private createAnimationLayer(injectionDiv: Element) { + const svg = dom.createSvgElement(Svg.SVG, { + 'class': 'blocklyAnimationLayer', + 'xmlns': dom.SVG_NS, + 'xmlns:html': dom.HTML_NS, + 'xmlns:xlink': dom.XLINK_NS, + 'version': '1.1', + }); + injectionDiv.append(svg); + return dom.createSvgElement(Svg.G, {}, svg); + } + + /** + * Appends the element to the animation layer. The animation layer doesn't + * move when the workspace moves, so e.g. delete animations don't move + * when a block delete triggers a workspace resize. + * + * @internal + */ + appendToAnimationLayer(elem: IRenderedElement) { + const currentTransform = this.dragLayer?.getAttribute('transform'); + // Only update the current transform when appending, so animations don't + // move if the workspace moves. + if (currentTransform) { + this.animationLayer?.setAttribute('transform', currentTransform); + } + this.animationLayer?.appendChild(elem.getSvgRoot()); + } + + /** + * Translates layers when the workspace is dragged or zoomed. + * + * @internal + */ + translateLayers(newCoord: Coordinate, newScale: number) { + const translation = `translate(${newCoord.x}, ${newCoord.y}) scale(${newScale})`; + this.dragLayer?.setAttribute('transform', translation); + for (const [_, layer] of this.layers) { + layer.setAttribute('transform', translation); + } + } + + /** + * Moves the given element to the drag layer, which exists on top of all other + * layers, and the drag surface. + * + * @internal + */ + moveToDragLayer(elem: IRenderedElement & IFocusableNode) { + this.dragLayer?.appendChild(elem.getSvgRoot()); + + if (elem.canBeFocused()) { + // Since moving the element to the drag layer will cause it to lose focus, + // ensure it regains focus (to ensure proper highlights & sent events). + getFocusManager().focusNode(elem); + } + } + + /** + * Moves the given element off of the drag layer. + * + * @internal + */ + moveOffDragLayer(elem: IRenderedElement & IFocusableNode, layerNum: number) { + this.append(elem, layerNum); + + if (elem.canBeFocused()) { + // Since moving the element off the drag layer will cause it to lose focus, + // ensure it regains focus (to ensure proper highlights & sent events). + getFocusManager().focusNode(elem); + } + } + + /** + * Appends the given element to a layer. If the layer does not exist, it is + * created. + * + * @internal + */ + append(elem: IRenderedElement, layerNum: number) { + if (!this.layers.has(layerNum)) { + this.createLayer(layerNum); + } + const childElem = elem.getSvgRoot(); + if (this.layers.get(layerNum)?.lastChild !== childElem) { + // Only append the child if it isn't already last (to avoid re-firing + // events like focused). + this.layers.get(layerNum)?.appendChild(childElem); + } + } + + /** + * Creates a layer and inserts it at the proper place given the layer number. + * + * More positive layers exist later in the dom and are rendered ontop of + * less positive layers. Layers are added to the layer map as a side effect. + */ + private createLayer(layerNum: number): SVGGElement { + const parent = this.workspace.getSvgGroup(); + const layer = dom.createSvgElement(Svg.G, {}); + + let inserted = false; + const sortedLayers = [...this.layers].sort((a, b) => a[0] - b[0]); + for (const [num, sib] of sortedLayers) { + if (layerNum < num) { + parent.insertBefore(layer, sib); + inserted = true; + break; + } + } + if (!inserted) { + parent.appendChild(layer); + } + this.layers.set(layerNum, layer); + return layer; + } + + /** + * Returns true if the given element is a layer managed by the layer manager. + * False otherwise. + * + * @internal + */ + hasLayer(elem: SVGElement) { + return ( + elem === this.dragLayer || + new Set(this.layers.values()).has(elem as SVGGElement) + ); + } + + /** + * We need to be able to access this layer explicitly for backwards + * compatibility. + * + * @internal + */ + getBlockLayer(): SVGGElement { + return this.layers.get(layerNums.BLOCK)!; + } + + /** + * We need to be able to access this layer explicitly for backwards + * compatibility. + * + * @internal + */ + getBubbleLayer(): SVGGElement { + return this.layers.get(layerNums.BUBBLE)!; + } +} diff --git a/core/layers.ts b/core/layers.ts new file mode 100644 index 00000000000..c62c40f3e53 --- /dev/null +++ b/core/layers.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The layer to place blocks on. + * + */ +export const BLOCK = 50; + +/** + * The layer to place bubbles on. + * + */ +export const BUBBLE = 100; diff --git a/core/main.ts b/core/main.ts new file mode 100644 index 00000000000..c301e989034 --- /dev/null +++ b/core/main.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file The entrypoint for blockly_compressed.js. Provides various + * backwards-compatibility hacks. Not used when loading in + * uncompressed mode. + */ + +// Former goog.module ID: Blockly.main + +import * as Msg from './msg.js'; + +// If Blockly is compiled with ADVANCED_COMPILATION and/or loaded as a +// CJS or ES module there will not be a Blockly global variable +// created. This can cause problems because a very common way of +// loading translations is to use a - - - - - - - - - - - - - - - - -

    - Blockly > - Demos > Accessible Blockly -

    - -

    - This is a demo of a version of Blockly designed for screen readers, - optimized for NVDA on Firefox. It allows users to create programs in a - workspace by manipulating groups of blocks. -

      -
    • To explore a group of blocks, use the arrow keys.
    • -
    • To navigate between groups, use Tab or Shift-Tab.
    • -
    • To add new blocks, use the buttons in the menu on the right.
    • -
    • To delete or add links to existing blocks, press Enter while you're on that block.
    • -
    -

    - - - - - - - - - - - diff --git a/demos/blockfactory/analytics.js b/demos/blockfactory/analytics.js new file mode 100644 index 00000000000..5611c09a958 --- /dev/null +++ b/demos/blockfactory/analytics.js @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Stubbed interface functions for analytics integration. + */ + +var BlocklyDevTools = BlocklyDevTools || Object.create(null); +BlocklyDevTools.Analytics = BlocklyDevTools.Analytics || Object.create(null); + +/** + * Whether these stub methods should log analytics calls to the console. + * @private + * @const + */ +BlocklyDevTools.Analytics.LOG_TO_CONSOLE_ = false; + +/** + * An import/export type id for a library of BlockFactory's original block + * save files (each a serialized workspace of block definition blocks). + * @package + * @const + */ +BlocklyDevTools.Analytics.BLOCK_FACTORY_LIBRARY = "Block Factory library"; +/** + * An import/export type id for a standard Blockly library of block + * definitions. + * @package + * @const + */ +BlocklyDevTools.Analytics.BLOCK_DEFINITIONS = "Block definitions"; +/** + * An import/export type id for a code generation function, or a + * boilerplate stub of the same. + * + * @package + * @const + */ +BlocklyDevTools.Analytics.GENERATOR = "Generator"; +/** + * An import/export type id for a Blockly Toolbox. + * + * @package + * @const + */ +BlocklyDevTools.Analytics.TOOLBOX = "Toolbox"; +/** + * An import/export type id for the serialized contents of a workspace. + * + * @package + * @const + */ +BlocklyDevTools.Analytics.WORKSPACE_CONTENTS = "Workspace contents"; + +/** + * Format id for imported/exported JavaScript resources. + * + * @package + * @const + */ +BlocklyDevTools.Analytics.FORMAT_JS = "JavaScript"; +/** + * Format id for imported/exported JSON resources. + * + * @package + * @const + */ +BlocklyDevTools.Analytics.FORMAT_JSON = "JSON"; +/** + * Format id for imported/exported XML resources. + * + * @package + * @const + */ +BlocklyDevTools.Analytics.FORMAT_XML = "XML"; + +/** + * Platform id for resources exported for use in Android projects. + * + * @package + * @const + */ +BlocklyDevTools.Analytics.PLATFORM_ANDROID = "Android"; +/** + * Platform id for resources exported for use in iOS projects. + * + * @package + * @const + */ +BlocklyDevTools.Analytics.PLATFORM_IOS = "iOS"; +/** + * Platform id for resources exported for use in web projects. + * + * @package + * @const + */ +BlocklyDevTools.Analytics.PLATFORM_WEB = "web"; + +/** + * Initializes the analytics framework, including noting that the page/app was + * opened. + * @package + */ +BlocklyDevTools.Analytics.init = function() { + // stub + this.LOG_TO_CONSOLE_ && console.log('Analytics.init'); +}; + +/** + * Event noting the user navigated to a specific view. + * + * @package + * @param viewId {string} An identifier for the view state. + */ +BlocklyDevTools.Analytics.onNavigateTo = function(viewId) { + // stub + this.LOG_TO_CONSOLE_ && + console.log('Analytics.onNavigateTo(' + viewId + ')'); +}; + +/** + * Event noting a project resource was saved. In the web Block Factory, this + * means saved to localStorage. + * + * @package + * @param typeId {string} An identifying string for the saved type. + */ +BlocklyDevTools.Analytics.onSave = function(typeId) { + // stub + this.LOG_TO_CONSOLE_ && console.log('Analytics.onSave(' + typeId + ')'); +}; + +/** + * Event noting the user attempted to import a resource file. + * + * @package + * @param typeId {string} An identifying string for the imported type. + * @param optMetadata {Object} Metadata about the import, such as format and + * platform. + */ +BlocklyDevTools.Analytics.onImport = function(typeId, optMetadata) { + // stub + this.LOG_TO_CONSOLE_ && console.log('Analytics.onImport(' + typeId + + (optMetadata ? '): ' + JSON.stringify(optMetadata) : ')')); +}; + +/** + * Event noting a project resource was saved. In the web Block Factory, this + * means downloaded to the user's system. + * + * @package + * @param typeId {string} An identifying string for the exported object type. + * @param optMetadata {Object} Metadata about the import, such as format and + * platform. + */ +BlocklyDevTools.Analytics.onExport = function(typeId, optMetadata) { + // stub + this.LOG_TO_CONSOLE_ && console.log('Analytics.onExport(' + typeId + + (optMetadata ? '): ' + JSON.stringify(optMetadata) : ')')); +}; + +/** + * Event noting the system encountered an error. It should attempt to send + * immediately. + * + * @package + * @param e {!Object} A value representing or describing the error. + */ +BlocklyDevTools.Analytics.onError = function(e) { + // stub + this.LOG_TO_CONSOLE_ && console.log('Analytics.onError("' + e + '")'); +}; + +/** + * Event noting the user was notified with a warning. + * + * @package + * @param msg {string} The warning message, or a description thereof. + */ +BlocklyDevTools.Analytics.onWarning = function(msg) { + // stub + this.LOG_TO_CONSOLE_ && console.log('Analytics.onWarning("' + msg + '")'); +}; + +/** + * Request the analytics framework to send any queued events to the server. + * @package + */ +BlocklyDevTools.Analytics.sendQueued = function() { + // stub + this.LOG_TO_CONSOLE_ && console.log('Analytics.sendQueued'); +}; diff --git a/demos/blockfactory/app_controller.js b/demos/blockfactory/app_controller.js index ac69780b264..3626eccf641 100644 --- a/demos/blockfactory/app_controller.js +++ b/demos/blockfactory/app_controller.js @@ -1,40 +1,14 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview The AppController Class brings together the Block * Factory, Block Library, and Block Exporter functionality into a single web * app. - * - * @author quachtina96 (Tina Quach) */ -goog.provide('AppController'); - -goog.require('BlockFactory'); -goog.require('FactoryUtils'); -goog.require('BlockLibraryController'); -goog.require('BlockExporterController'); -goog.require('goog.dom.classlist'); -goog.require('goog.ui.PopupColorPicker'); -goog.require('goog.ui.ColorPicker'); - /** * Controller for the Blockly Factory @@ -85,6 +59,10 @@ AppController.prototype.importBlockLibraryFromFile = function() { var files = document.getElementById('files'); // If the file list is empty, the user likely canceled in the dialog. if (files.files.length > 0) { + BlocklyDevTools.Analytics.onImport( + BlocklyDevTools.Analytics.BLOCK_FACTORY_LIBRARY, + { format: BlocklyDevTools.Analytics.FORMAT_XML }); + // The input tag doesn't have the "multiple" attribute // so the user can only choose 1 file. var file = files.files[0]; @@ -137,10 +115,25 @@ AppController.prototype.exportBlockLibraryToFile = function() { // Download file if all necessary parameters are provided. if (filename) { FactoryUtils.createAndDownloadFile(blockLibText, filename, 'xml'); + BlocklyDevTools.Analytics.onExport( + BlocklyDevTools.Analytics.BLOCK_FACTORY_LIBRARY, + { format: BlocklyDevTools.Analytics.FORMAT_XML }); } else { - alert('Could not export Block Library without file name under which to ' + - 'save library.'); + var msg = 'Could not export Block Library without file name under which ' + + 'to save library.'; + BlocklyDevTools.Analytics.onWarning(msg); + alert(msg); + } +}; + +AppController.prototype.exportBlockLibraryAsJson = function() { + const blockJson = this.blockLibraryController.getBlockLibraryAsJson(); + if (blockJson.length === 0) { + alert('No blocks in library to export'); + return; } + const filename = 'legacy_block_factory_export.txt'; + FactoryUtils.createAndDownloadFile(JSON.stringify(blockJson), filename, 'plain'); }; /** @@ -151,13 +144,11 @@ AppController.prototype.exportBlockLibraryToFile = function() { */ AppController.prototype.formatBlockLibraryForExport_ = function(blockXmlMap) { // Create DOM for XML. - var xmlDom = goog.dom.createDom('xml', { - 'xmlns':"http://www.w3.org/1999/xhtml" - }); + var xmlDom = Blockly.utils.xml.createElement('xml'); // Append each block node to XML DOM. for (var blockType in blockXmlMap) { - var blockXmlDom = Blockly.Xml.textToDom(blockXmlMap[blockType]); + var blockXmlDom = Blockly.utils.xml.textToDom(blockXmlMap[blockType]); var blockNode = blockXmlDom.firstElementChild; xmlDom.appendChild(blockNode); } @@ -174,34 +165,29 @@ AppController.prototype.formatBlockLibraryForExport_ = function(blockXmlMap) { * @private */ AppController.prototype.formatBlockLibraryForImport_ = function(xmlText) { - var xmlDom = Blockly.Xml.textToDom(xmlText); - - // Get array of XMLs. Use an asterisk (*) instead of a tag name for the XPath - // selector, to match all elements at that level and get all factory_base - // blocks. - var blockNodes = goog.dom.xml.selectNodes(xmlDom, '*'); + var inputXml = Blockly.utils.xml.textToDom(xmlText); + // Convert the live HTMLCollection of child Elements into a static array, + // since the addition to editorWorkspaceXml below removes it from inputXml. + var inputChildren = Array.from(inputXml.children); - // Create empty map. The line below creates a truly empy object. It doesn't + // Create empty map. The line below creates a truly empty object. It doesn't // have built-in attributes/functions such as length or toString. var blockXmlTextMap = Object.create(null); // Populate map. - for (var i = 0, blockNode; blockNode = blockNodes[i]; i++) { - + for (var i = 0, blockNode; blockNode = inputChildren[i]; i++) { // Add outer XML tag to the block for proper injection in to the // main workspace. // Create DOM for XML. - var xmlDom = goog.dom.createDom('xml', { - 'xmlns':"http://www.w3.org/1999/xhtml" - }); - xmlDom.appendChild(blockNode); + var editorWorkspaceXml = Blockly.utils.xml.createElement('xml'); + editorWorkspaceXml.appendChild(blockNode); - xmlText = Blockly.Xml.domToText(xmlDom); + xmlText = Blockly.Xml.domToText(editorWorkspaceXml); // All block types should be lowercase. var blockType = this.getBlockTypeFromXml_(xmlText).toLowerCase(); // Some names are invalid so fix them up. blockType = FactoryUtils.cleanBlockType(blockType); - + blockXmlTextMap[blockType] = xmlText; } @@ -216,14 +202,14 @@ AppController.prototype.formatBlockLibraryForImport_ = function(xmlText) { * @private */ AppController.prototype.getBlockTypeFromXml_ = function(xmlText) { - var xmlDom = Blockly.Xml.textToDom(xmlText); + var xmlDom = Blockly.utils.xml.textToDom(xmlText); // Find factory base block. var factoryBaseBlockXml = xmlDom.getElementsByTagName('block')[0]; // Get field elements from factory base. var fields = factoryBaseBlockXml.getElementsByTagName('field'); for (var i = 0; i < fields.length; i++) { // The field whose name is 'NAME' holds the block type as its value. - if (fields[i].getAttribute('name') == 'NAME') { + if (fields[i].getAttribute('name') === 'NAME') { return fields[i].childNodes[0].nodeValue; } } @@ -280,30 +266,36 @@ AppController.prototype.onTab = function() { var workspaceFactoryTab = this.tabMap[AppController.WORKSPACE_FACTORY]; // Warn user if they have unsaved changes when leaving Block Factory. - if (this.lastSelectedTab == AppController.BLOCK_FACTORY && - this.selectedTab != AppController.BLOCK_FACTORY) { + if (this.lastSelectedTab === AppController.BLOCK_FACTORY && + this.selectedTab !== AppController.BLOCK_FACTORY) { var hasUnsavedChanges = !FactoryUtils.savedBlockChanges(this.blockLibraryController); - if (hasUnsavedChanges && - !confirm('You have unsaved changes in Block Factory.')) { - // If the user doesn't want to switch tabs with unsaved changes, - // stay on Block Factory Tab. - this.setSelected_(AppController.BLOCK_FACTORY); - this.lastSelectedTab = AppController.BLOCK_FACTORY; - return; + if (hasUnsavedChanges) { + var msg = 'You have unsaved changes in Block Factory.'; + var continueAnyway = confirm(msg); + BlocklyDevTools.Analytics.onWarning(msg); + if (!continueAnyway) { + // If the user doesn't want to switch tabs with unsaved changes, + // stay on Block Factory Tab. + this.setSelected_(AppController.BLOCK_FACTORY); + this.lastSelectedTab = AppController.BLOCK_FACTORY; + return; + } } } // Only enable key events in workspace factory if workspace factory tab is // selected. this.workspaceFactoryController.keyEventsEnabled = - this.selectedTab == AppController.WORKSPACE_FACTORY; + this.selectedTab === AppController.WORKSPACE_FACTORY; // Turn selected tab on and other tabs off. this.styleTabs_(); - if (this.selectedTab == AppController.EXPORTER) { + if (this.selectedTab === AppController.EXPORTER) { + BlocklyDevTools.Analytics.onNavigateTo('Exporter'); + // Hide other tabs. FactoryUtils.hide('workspaceFactoryContent'); FactoryUtils.hide('blockFactoryContent'); @@ -324,14 +316,19 @@ AppController.prototype.onTab = function() { // Update the exporter's preview to reflect any changes made to the blocks. this.exporter.updatePreview(); - } else if (this.selectedTab == AppController.BLOCK_FACTORY) { + } else if (this.selectedTab === AppController.BLOCK_FACTORY) { + BlocklyDevTools.Analytics.onNavigateTo('BlockFactory'); + // Hide other tabs. FactoryUtils.hide('blockLibraryExporter'); FactoryUtils.hide('workspaceFactoryContent'); // Show Block Factory. FactoryUtils.show('blockFactoryContent'); - } else if (this.selectedTab == AppController.WORKSPACE_FACTORY) { + } else if (this.selectedTab === AppController.WORKSPACE_FACTORY) { + // TODO: differentiate Workspace and Toolbox editor, based on the other tab state. + BlocklyDevTools.Analytics.onNavigateTo('WorkspaceFactory'); + // Hide other tabs. FactoryUtils.hide('blockLibraryExporter'); FactoryUtils.hide('blockFactoryContent'); @@ -354,10 +351,10 @@ AppController.prototype.onTab = function() { */ AppController.prototype.styleTabs_ = function() { for (var tabName in this.tabMap) { - if (this.selectedTab == tabName) { - goog.dom.classlist.addRemove(this.tabMap[tabName], 'taboff', 'tabon'); + if (this.selectedTab === tabName) { + this.tabMap[tabName].classList.replace('taboff', 'tabon'); } else { - goog.dom.classlist.addRemove(this.tabMap[tabName], 'tabon', 'taboff'); + this.tabMap[tabName].classList.replace('tabon', 'taboff'); } } }; @@ -443,7 +440,7 @@ AppController.prototype.assignExporterChangeListeners = function() { /** * If given checkbox is checked, enable the given elements. Otherwise, disable. * @param {boolean} enabled True if enabled, false otherwise. - * @param {!Array.} idArray Array of element IDs to enable when + * @param {!Array} idArray Array of element IDs to enable when * checkbox is checked. */ AppController.prototype.ifCheckedEnable = function(enabled, idArray) { @@ -504,9 +501,13 @@ AppController.prototype.assignBlockFactoryClickHandlers = function() { self.exportBlockLibraryToFile(); }); + document.getElementById('exportAsJson').addEventListener('click', function() { + self.exportBlockLibraryAsJson(); + }); + document.getElementById('helpButton').addEventListener('click', function() { - open('https://developers.google.com/blockly/custom-blocks/block-factory', + open('https://developers.google.com/blockly/guides/create-custom-blocks/legacy-blockly-developer-tools', 'BlockFactoryHelp'); }); @@ -563,9 +564,9 @@ AppController.prototype.addBlockFactoryEventListeners = function() { document.getElementById('direction') .addEventListener('change', BlockFactory.updatePreview); document.getElementById('languageTA') - .addEventListener('change', BlockFactory.updatePreview); + .addEventListener('change', BlockFactory.manualEdit); document.getElementById('languageTA') - .addEventListener('keyup', BlockFactory.updatePreview); + .addEventListener('keyup', BlockFactory.manualEdit); document.getElementById('format') .addEventListener('change', BlockFactory.formatChange); document.getElementById('language') @@ -579,7 +580,7 @@ AppController.prototype.initializeBlocklyStorage = function() { BlocklyStorage.HTTPREQUEST_ERROR = 'There was a problem with the request.\n'; BlocklyStorage.LINK_ALERT = - 'Share your blocks with this link:\n\n%1'; + 'Share your blocks with this public link. We\'ll delete them if not used for a year. They are not associated with your account and handled as per Google\'s Privacy Policy. Please be sure not to include any private information.:\n\n%1'; BlocklyStorage.HASH_ERROR = 'Sorry, "%1" doesn\'t correspond with any saved Blockly file.'; BlocklyStorage.XML_ERROR = 'Could not load your saved file.\n' + @@ -596,7 +597,7 @@ AppController.prototype.initializeBlocklyStorage = function() { * Handle resizing of elements. */ AppController.prototype.onresize = function(event) { - if (this.selectedTab == AppController.BLOCK_FACTORY) { + if (this.selectedTab === AppController.BLOCK_FACTORY) { // Handle resizing of Block Factory elements. var expandList = [ document.getElementById('blocklyPreviewContainer'), @@ -611,7 +612,7 @@ AppController.prototype.onresize = function(event) { expand.style.width = (expand.parentNode.offsetWidth - 2) + 'px'; expand.style.height = (expand.parentNode.offsetHeight - 2) + 'px'; } - } else if (this.selectedTab == AppController.EXPORTER) { + } else if (this.selectedTab === AppController.EXPORTER) { // Handle resize of Exporter block options. this.exporter.view.centerPreviewBlocks(); } @@ -624,12 +625,14 @@ AppController.prototype.onresize = function(event) { * @param {!Event} e beforeunload event. */ AppController.prototype.confirmLeavePage = function(e) { + BlocklyDevTools.Analytics.sendQueued(); if ((!BlockFactory.isStarterBlock() && !FactoryUtils.savedBlockChanges(blocklyFactory.blockLibraryController)) || blocklyFactory.workspaceFactoryController.hasUnsavedChanges()) { var confirmationMessage = 'You will lose any unsaved changes. ' + 'Are you sure you want to exit this page?'; + BlocklyDevTools.Analytics.onWarning(confirmationMessage); e.returnValue = confirmationMessage; return confirmationMessage; } @@ -640,7 +643,7 @@ AppController.prototype.confirmLeavePage = function(e) { * @param {string} id ID of element to show. */ AppController.prototype.openModal = function(id) { - Blockly.hideChaff(); + Blockly.common.getMainWorkspace().hideChaff(); this.modalName_ = id; document.getElementById(id).style.display = 'block'; document.getElementById('modalShadow').style.display = 'block'; @@ -670,20 +673,6 @@ AppController.prototype.modalName_ = null; * Initialize Blockly and layout. Called on page load. */ AppController.prototype.init = function() { - // Block Factory has a dependency on bits of Closure that core Blockly - // doesn't have. When you run this from file:// without a copy of Closure, - // it breaks it non-obvious ways. Warning about this for now until the - // dependency is broken. - // TODO: #668. - if (!window.goog.dom.xml) { - alert('Sorry: Closure dependency not found. We are working on removing ' + - 'this dependency. In the meantime, you can use our hosted demo\n ' + - 'https://blockly-demo.appspot.com/static/demos/blockfactory/index.html' + - '\nor use these instructions to continue running locally:\n' + - 'https://developers.google.com/blockly/guides/modify/web/closure'); - return; - } - var self = this; // Handle Blockly Storage with App Engine. if ('BlocklyStorage' in window) { @@ -710,6 +699,8 @@ AppController.prototype.init = function() { BlockFactory.mainWorkspace = Blockly.inject('blockly', {collapse: false, toolbox: toolbox, + comments: false, + disable: false, media: '../../media/'}); // Add tab handlers for switching between Block Factory and Block Exporter. diff --git a/demos/blockfactory/block_definition_extractor.js b/demos/blockfactory/block_definition_extractor.js new file mode 100644 index 00000000000..fa1aae7750e --- /dev/null +++ b/demos/blockfactory/block_definition_extractor.js @@ -0,0 +1,742 @@ +/** + * Copyright 2017 Juan Carlos Orozco Arena + * Apache License Version 2.0 + */ + +/** + * @fileoverview + * The BlockDefinitionExtractor is a class that generates a workspace DOM + * suitable for the BlockFactory's block editor, derived from an example + * Blockly.Block. + * + * + * var workspaceDom = new BlockDefinitionExtractor() + * .buildBlockFactoryWorkspace(exampleBlocklyBlock); + * Blockly.Xml.domToWorkspace(workspaceDom, BlockFactory.mainWorkspace); + * + * + * The exampleBlocklyBlock is usually the block loaded into the + * preview workspace after manually entering the block definition. + */ +'use strict'; + +/** + * Namespace to contain all functions needed to extract block definition from + * the block preview data structure. + * @namespace + */ +var BlockDefinitionExtractor = BlockDefinitionExtractor || Object.create(null); + +/** + * Builds a BlockFactory workspace that reflects the block structure of the + * example block. + * + * @param {!Blockly.Block} block The reference block from which the definition + * will be extracted. + * @return {!Element} Returns the root workspace DOM for the block editor + * workspace. + */ +BlockDefinitionExtractor.buildBlockFactoryWorkspace = function(block) { + var workspaceXml = Blockly.utils.xml.createElement('xml'); + workspaceXml.append(BlockDefinitionExtractor.factoryBase_(block, block.type)); + return workspaceXml; +}; + +/** + * Helper function to create a new Element with the provided attributes and + * inner text. + * + * @param {string} name New element tag name. + * @param {!Object=} opt_attrs Optional list of attributes. + * @param {string=} opt_text Optional inner text. + * @return {!Element} The newly created element. + * @private + */ +BlockDefinitionExtractor.newDomElement_ = function(name, opt_attrs, opt_text) { + // Avoid createDom(..)'s attributes argument for being too HTML specific. + var elem = Blockly.utils.xml.createElement(name); + if (opt_attrs) { + for (var key in opt_attrs) { + elem.setAttribute(key, opt_attrs[key]); + } + } + if (opt_text) { + elem.append(opt_text); + } + return elem; +}; + +/** + * Creates an connection type constraint Element representing the + * requested type. + * + * @param {string} type Type name of desired connection constraint. + * @return {!Element} The representing the constraint type. + * @private + */ +BlockDefinitionExtractor.buildBlockForType_ = function(type) { + switch (type) { + case 'Null': + return BlockDefinitionExtractor.typeNull_(); + case 'Boolean': + return BlockDefinitionExtractor.typeBoolean_(); + case 'Number': + return BlockDefinitionExtractor.typeNumber_(); + case 'String': + return BlockDefinitionExtractor.typeString_(); + case 'Array': + return BlockDefinitionExtractor.typeList_(); + default: + return BlockDefinitionExtractor.typeOther_(type); + } +}; + +/** + * Constructs a element representing the type constraints of the + * provided connection. + * + * @param {!Blockly.Connection} connection The connection with desired + * connection constraints. + * @return {!Element} The root element of the constraint definition. + * @private + */ +BlockDefinitionExtractor.buildTypeConstraintBlockForConnection_ = + function(connection) +{ + var typeBlock; + if (connection.check_) { + if (connection.check_.length < 1) { + typeBlock = BlockDefinitionExtractor.typeNullShadow_(); + } else if (connection.check_.length === 1) { + typeBlock = BlockDefinitionExtractor.buildBlockForType_( + connection.check_[0]); + } else if (connection.check_.length > 1) { + typeBlock = BlockDefinitionExtractor.typeGroup_(connection.check_); + } + } else { + typeBlock = BlockDefinitionExtractor.typeNullShadow_(); + } + return typeBlock; +}; + +/** + * Creates the root "factory_base" element for the block definition. + * + * @param {!Blockly.Block} block The example block from which to extract the + * definition. + * @param {string} name Block name. + * @return {!Element} The factory_base block element. + * @private + */ +BlockDefinitionExtractor.factoryBase_ = function(block, name) { + BlockDefinitionExtractor.src = {root: block, current: block}; + var factoryBaseEl = + BlockDefinitionExtractor.newDomElement_('block', {type: 'factory_base'}); + factoryBaseEl.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'NAME'}, name)); + factoryBaseEl.append(BlockDefinitionExtractor.buildInlineField_(block)); + + BlockDefinitionExtractor.buildConnections_(block, factoryBaseEl); + + var inputsStatement = BlockDefinitionExtractor.newDomElement_( + 'statement', {name: 'INPUTS'}); + inputsStatement.append(BlockDefinitionExtractor.parseInputs_(block)); + factoryBaseEl.append(inputsStatement); + + var tooltipValue = + BlockDefinitionExtractor.newDomElement_('value', {name: 'TOOLTIP'}); + tooltipValue.append(BlockDefinitionExtractor.text_(block.tooltip)); + factoryBaseEl.append(tooltipValue); + + var helpUrlValue = + BlockDefinitionExtractor.newDomElement_('value', {name: 'HELPURL'}); + helpUrlValue.append(BlockDefinitionExtractor.text_(block.helpUrl)); + factoryBaseEl.append(helpUrlValue); + + // Convert colour_ to hue value 0-360 degrees + var colour_hue = block.getHue(); // May be null if not set via hue. + if (colour_hue) { + var colourBlock = BlockDefinitionExtractor.colourBlockFromHue_(colour_hue); + var colourInputValue = + BlockDefinitionExtractor.newDomElement_('value', {name: 'COLOUR'}); + colourInputValue.append(colourBlock); + factoryBaseEl.append(colourInputValue); + } else { + // Editor will not have a colour block and preview will render black. + // TODO: Support RGB colours in the block editor. + } + return factoryBaseEl; +}; + +/** + * Generates the appropriate element for the block definition's + * CONNECTIONS field, which determines the next, previous, and output + * connections. + * + * @param {!Blockly.Block} block The example block from which to extract the + * definition. + * @param {!Element} factoryBaseEl The root of the block definition. + * @private + */ +BlockDefinitionExtractor.buildConnections_ = function(block, factoryBaseEl) { + var connections = 'NONE'; + if (block.outputConnection) { + connections = 'LEFT'; + } else { + if (block.previousConnection) { + if (block.nextConnection) { + connections = 'BOTH'; + } else { + connections = 'TOP'; + } + } else if (block.nextConnection) { + connections = 'BOTTOM'; + } + } + factoryBaseEl.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'CONNECTIONS'}, connections)); + + if (connections === 'LEFT') { + var inputValue = + BlockDefinitionExtractor.newDomElement_('value', {name: 'OUTPUTTYPE'}); + inputValue.append( + BlockDefinitionExtractor.buildTypeConstraintBlockForConnection_( + block.outputConnection)); + factoryBaseEl.append(inputValue); + } else { + if (connections === 'UP' || connections === 'BOTH') { + var inputValue = + BlockDefinitionExtractor.newDomElement_('value', {name: 'TOPTYPE'}); + inputValue.append( + BlockDefinitionExtractor.buildTypeConstraintBlockForConnection_( + block.previousConnection)); + factoryBaseEl.append(inputValue); + } + if (connections === 'DOWN' || connections === 'BOTH') { + var inputValue = BlockDefinitionExtractor.newDomElement_( + 'value', {name: 'BOTTOMTYPE'}); + inputValue.append( + BlockDefinitionExtractor.buildTypeConstraintBlockForConnection_( + block.nextConnection)); + factoryBaseEl.append(inputValue); + } + } +}; + +/** + * Generates the appropriate element for the block definition's INLINE + * field. + * + * @param {!Blockly.Block} block The example block from which to extract the + * definition. + * @return {Element} The INLINE with value 'AUTO', 'INT' (internal) or + * 'EXT' (external). + * @private + */ +BlockDefinitionExtractor.buildInlineField_ = function(block) { + var inline = 'AUTO'; // When block.inputsInlineDefault === undefined + if (block.inputsInlineDefault === true) { + inline = 'INT'; + } else if (block.inputsInlineDefault === false) { + inline = 'EXT'; + } + return BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'INLINE'}, inline); +}; + +/** + * Constructs a sequence of elements that represent the inputs of the + * provided block. + * + * @param {!Blockly.Block} block The source block to copy the inputs of. + * @return {Element} The fist element of the sequence + * (and the root of the constructed DOM). + * @private + */ +BlockDefinitionExtractor.parseInputs_ = function(block) { + var firstInputDefElement = null; + var lastInputDefElement = null; + for (var i = 0; i < block.inputList.length; i++) { + var input = block.inputList[i]; + var align = 'LEFT'; // Left alignment is the default. + if (input.align === Blockly.ALIGN_CENTRE) { + align = 'CENTRE'; + } else if (input.align === Blockly.ALIGN_RIGHT) { + align = 'RIGHT'; + } + + var inputDefElement = BlockDefinitionExtractor.input_(input, align); + if (lastInputDefElement) { + var next = BlockDefinitionExtractor.newDomElement_('next'); + next.append(inputDefElement); + lastInputDefElement.append(next); + } else { + firstInputDefElement = inputDefElement; + } + lastInputDefElement = inputDefElement; + } + return firstInputDefElement; +}; + +/** + * Creates a element representing a block input. + * + * @param {!Blockly.Input} input The input object. + * @param {string} align Can be left, right or centre. + * @return {!Element} The element that defines the input. + * @private + */ +BlockDefinitionExtractor.input_ = function(input, align) { + var hasConnector = (input.type === Blockly.inputs.inputTypes.VALUE || input.type === Blockly.inputs.inputTypes.STATEMENT); + var inputTypeAttr = + input.type === Blockly.inputs.inputTypes.DUMMY ? 'input_dummy' : + input.type === Blockly.inputs.inputTypes.END_ROW ? 'input_end_row' : + input.type === Blockly.inputs.inputTypes.VALUE ? 'input_value' : + 'input_statement'; + var inputDefBlock = + BlockDefinitionExtractor.newDomElement_('block', {type: inputTypeAttr}); + + if (hasConnector) { + inputDefBlock.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'INPUTNAME'}, input.name)); + } + inputDefBlock.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'ALIGN'}, align)); + + var fieldsDef = BlockDefinitionExtractor.newDomElement_( + 'statement', {name: 'FIELDS'}); + var fieldsXml = BlockDefinitionExtractor.buildFields_(input.fieldRow); + fieldsDef.append(fieldsXml); + inputDefBlock.append(fieldsDef); + + if (hasConnector) { + var typeValue = BlockDefinitionExtractor.newDomElement_( + 'value', {name: 'TYPE'}); + typeValue.append( + BlockDefinitionExtractor.buildTypeConstraintBlockForConnection_( + input.connection)); + inputDefBlock.append(typeValue); + } + + return inputDefBlock; +}; + +/** + * Constructs a sequence elements representing the field definition. + * @param {Array} fieldRow A list of fields in a Blockly.Input. + * @return {Element} The fist element of the sequence + * (and the root of the constructed DOM). + * @private + */ +BlockDefinitionExtractor.buildFields_ = function(fieldRow) { + var firstFieldDefElement = null; + var lastFieldDefElement = null; + + for (var i = 0; i < fieldRow.length; i++) { + var field = fieldRow[i]; + var fieldDefElement = BlockDefinitionExtractor.buildFieldElement_(field); + + if (lastFieldDefElement) { + var next = BlockDefinitionExtractor.newDomElement_('next'); + next.append(fieldDefElement); + lastFieldDefElement.append(next); + } else { + firstFieldDefElement = fieldDefElement; + } + lastFieldDefElement = fieldDefElement; + } + + return firstFieldDefElement; +}; + +/** + * Constructs a element that describes the provided Blockly.Field. + * @param {!Blockly.Field} field The field from which the definition is copied. + * @param {!Element} A for the Field definition. + * @private + */ +BlockDefinitionExtractor.buildFieldElement_ = function(field) { + if (field instanceof Blockly.FieldLabel) { + return BlockDefinitionExtractor.buildFieldLabel_(field.text_); + } else if (field instanceof Blockly.FieldTextInput) { + return BlockDefinitionExtractor.buildFieldInput_(field.name, field.text_); + } else if (field instanceof Blockly.FieldNumber) { + return BlockDefinitionExtractor.buildFieldNumber_( + field.name, field.text_, field.min_, field.max_, field.presicion_); + } else if (field instanceof Blockly.FieldAngle) { + return BlockDefinitionExtractor.buildFieldAngle_(field.name, field.text_); + } else if (field instanceof Blockly.FieldCheckbox) { + return BlockDefinitionExtractor.buildFieldCheckbox_(field.name, field.state_); + } else if (field instanceof Blockly.FieldColour) { + return BlockDefinitionExtractor.buildFieldColour_(field.name, field.colour_); + } else if (field instanceof Blockly.FieldImage) { + return BlockDefinitionExtractor.buildFieldImage_( + field.src_, field.width_, field.height_, field.text_); + } else if (field instanceof Blockly.FieldVariable) { + // FieldVariable must be before FieldDropdown, because FieldVariable is a + // subclass. + return BlockDefinitionExtractor.buildFieldVariable_(field.name, field.text_); + } else if (field instanceof Blockly.FieldDropdown) { + return BlockDefinitionExtractor.buildFieldDropdown_(field); + } + throw Error('Unrecognized field class: ' + field.constructor.name); +}; + + +/** + * Creates a element representing a FieldLabel definition. + * @param {string} text + * @return {Element} The XML for FieldLabel definition. + * @private + */ +BlockDefinitionExtractor.buildFieldLabel_ = function(text) { + var fieldBlock = + BlockDefinitionExtractor.newDomElement_('block', {type: 'field_static'}); + fieldBlock.append( + BlockDefinitionExtractor.newDomElement_('field', {name: 'TEXT'}, text)); + return fieldBlock; +}; + +/** + * Creates a element representing a FieldInput (text input) definition. + * + * @param {string} fieldName The identifying name of the field. + * @param {string} text The default text string. + * @return {Element} The XML for FieldInput definition. + * @private + */ +BlockDefinitionExtractor.buildFieldInput_ = function(fieldName, text) { + var fieldInput = + BlockDefinitionExtractor.newDomElement_('block', {type: 'field_input'}); + fieldInput.append( + BlockDefinitionExtractor.newDomElement_('field', {name: 'TEXT'}, text)); + fieldInput.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'FIELDNAME'}, fieldName)); + return fieldInput; +}; + +/** + * Creates a element representing a FieldNumber definition. + * + * @param {string} fieldName The identifying name of the field. + * @param {number} value The field's default value. + * @param {number} min The minimum allowed value, or negative infinity. + * @param {number} max The maximum allowed value, or positive infinity. + * @param {number} precision The precision allowed for the number. + * @return {Element} The XML for FieldNumber definition. + * @private + */ +BlockDefinitionExtractor.buildFieldNumber_ = + function(fieldName, value, min, max, precision) +{ + var fieldNumber = + BlockDefinitionExtractor.newDomElement_('block', {type: 'field_number'}); + fieldNumber.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'VALUE'}, value)); + fieldNumber.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'FIELDNAME'}, fieldName)); + fieldNumber.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'MIN'}, min)); + fieldNumber.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'MAX'}, max)); + fieldNumber.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'PRECISION'}, precision)); + return fieldNumber; +}; + +/** + * Creates a element representing a FieldAngle definition. + * + * @param {string} fieldName The identifying name of the field. + * @param {number} angle The field's default value. + * @return {Element} The XML for FieldAngle definition. + * @private + */ +BlockDefinitionExtractor.buildFieldAngle_ = function(angle, fieldName) { + var fieldAngle = + BlockDefinitionExtractor.newDomElement_('block', {type: 'field_angle'}); + fieldAngle.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'ANGLE'}, angle)); + fieldAngle.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'FIELDNAME'}, fieldName)); + return fieldAngle; +}; + +/** + * Creates a element representing a FieldDropdown definition. + * + * @param {Blockly.FieldDropdown} dropdown + * @return {Element} The element representing a similar FieldDropdown. + * @private + */ +BlockDefinitionExtractor.buildFieldDropdown_ = function(dropdown) { + var menuGenerator = dropdown.menuGenerator_; + if (typeof menuGenerator === 'function') { + var options = menuGenerator(); + } else if (Array.isArray(menuGenerator)) { + var options = menuGenerator; + } else { + throw Error('Unrecognized type of menuGenerator: ' + menuGenerator); + } + + var fieldDropdown = BlockDefinitionExtractor.newDomElement_( + 'block', {type: 'field_dropdown'}); + var optionsStr = '['; + + var mutation = BlockDefinitionExtractor.newDomElement_('mutation'); + fieldDropdown.append(mutation); + fieldDropdown.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'FIELDNAME'}, dropdown.name)); + for (var i=0; i element representing a FieldCheckbox definition. + * + * @param {string} fieldName The identifying name of the field. + * @param {string} checked The field's default value, true or false. + * @return {Element} The XML for FieldCheckbox definition. + * @private + */ +BlockDefinitionExtractor.buildFieldCheckbox_ = + function(fieldName, checked) +{ + var fieldCheckbox = BlockDefinitionExtractor.newDomElement_( + 'block', {type: 'field_checkbox'}); + fieldCheckbox.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'CHECKED'}, checked)); + fieldCheckbox.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'FIELDNAME'}, fieldName)); + return fieldCheckbox; +}; + +/** + * Creates a element representing a FieldColour definition. + * + * @param {string} fieldName The identifying name of the field. + * @param {string} colour The field's default value as a string. + * @return {Element} The XML for FieldColour definition. + * @private + */ +BlockDefinitionExtractor.buildFieldColour_ = + function(fieldName, colour) +{ + var fieldColour = BlockDefinitionExtractor.newDomElement_( + 'block', {type: 'field_colour'}); + fieldColour.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'COLOUR'}, colour)); + fieldColour.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'FIELDNAME'}, fieldName)); + return fieldColour; +}; + +/** + * Creates a element representing a FieldVariable definition. + * + * @param {string} fieldName The identifying name of the field. + * @param {string} varName The variables + * @return {Element} The element representing the FieldVariable. + * @private + */ +BlockDefinitionExtractor.buildFieldVariable_ = function(fieldName, varName) { + var fieldVar = BlockDefinitionExtractor.newDomElement_( + 'block', {type: 'field_variable'}); + fieldVar.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'FIELDNAME'}, fieldName)); + fieldVar.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'TEXT'}, varName)); + return fieldVar; +}; + +/** + * Creates a element representing a FieldImage definition. + * + * @param {string} src The URL of the field image. + * @param {number} width The pixel width of the source image + * @param {number} height The pixel height of the source image. + * @param {string} alt Alternate text to describe image. + * @private + */ +BlockDefinitionExtractor.buildFieldImage_ = + function(src, width, height, alt) +{ + var block1 = BlockDefinitionExtractor.newDomElement_( + 'block', {type: 'field_image'}); + block1.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'SRC'}, src)); + block1.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'WIDTH'}, width)); + block1.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'HEIGHT'}, height)); + block1.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'ALT'}, alt)); +}; + +/** + * Creates a element a group of allowed connection constraint types. + * + * @param {Array} types List of type names in this group. + * @return {Element} The element representing the group, with child + * types attached. + * @private + */ +BlockDefinitionExtractor.typeGroup_ = function(types) { + var typeGroupBlock = BlockDefinitionExtractor.newDomElement_( + 'block', {type: 'type_group'}); + typeGroupBlock.append(BlockDefinitionExtractor.newDomElement_( + 'mutation', {types:types.length})); + for (var i=0; i block element representing the default null connection + * constraint. + * @return {Element} The element representing the "null" type + * constraint. + * @private + */ +BlockDefinitionExtractor.typeNullShadow_ = function() { + return BlockDefinitionExtractor.newDomElement_( + 'shadow', {type: 'type_null'}); +}; + +/** + * Creates a element representing null in a connection constraint. + * @return {Element} The element representing the "null" type + * constraint. + * @private + */ +BlockDefinitionExtractor.typeNull_ = function() { + return BlockDefinitionExtractor.newDomElement_('block', {type: 'type_null'}); +}; + +/** + * Creates a element representing the a boolean in a connection + * constraint. + * @return {Element} The element representing the "boolean" type + * constraint. + * @private + */ +BlockDefinitionExtractor.typeBoolean_ = function() { + return BlockDefinitionExtractor.newDomElement_( + 'block', {type: 'type_boolean'}); +}; + +/** + * Creates a element representing the a number in a connection + * constraint. + * @return {Element} The element representing the "number" type + * constraint. + * @private + */ +BlockDefinitionExtractor.typeNumber_ = function() { + return BlockDefinitionExtractor.newDomElement_( + 'block', {type: 'type_number'}); +}; + +/** + * Creates a element representing the a string in a connection + * constraint. + * @return {Element} The element representing the "string" type + * constraint. + * @private + */ +BlockDefinitionExtractor.typeString_ = function() { + return BlockDefinitionExtractor.newDomElement_( + 'block', {type: 'type_string'}); +}; + +/** + * Creates a element representing the a list in a connection + * constraint. + * @return {Element} The element representing the "list" type + * constraint. + * @private + */ +BlockDefinitionExtractor.typeList_ = function() { + return BlockDefinitionExtractor.newDomElement_('block', {type: 'type_list'}); +}; + +/** + * Creates a element representing the given custom connection + * constraint type name. + * + * @param {string} type The connection constraint type name. + * @return {Element} The element representing a custom input type + * constraint. + * @private + */ +BlockDefinitionExtractor.typeOther_ = function(type) { + var block = BlockDefinitionExtractor.newDomElement_( + 'block', {type: 'type_other'}); + block.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'TYPE'}, type)); + return block; +}; + +/** + * Creates a block Element for the colour_hue block, with the given hue. + * @param hue {number} The hue value, from 0 to 360. + * @return {Element} The Element representing a colour_hue block + * with the given hue. + * @private + */ +BlockDefinitionExtractor.colourBlockFromHue_ = function(hue) { + var colourBlock = BlockDefinitionExtractor.newDomElement_( + 'block', {type: 'colour_hue'}); + colourBlock.append(BlockDefinitionExtractor.newDomElement_('mutation', { + colour: Blockly.utils.colour.hueToHex(hue) + })); + colourBlock.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'HUE'}, hue.toString())); + return colourBlock; +}; + +/** + * Creates a block Element for a text block with the given text. + * + * @param text {string} The text value of the block. + * @return {Element} The element representing a "text" block. + * @private + */ +BlockDefinitionExtractor.text_ = function(text) { + var textBlock = + BlockDefinitionExtractor.newDomElement_('block', {type: 'text'}); + if (text) { + textBlock.append(BlockDefinitionExtractor.newDomElement_( + 'field', {name: 'TEXT'}, text)); + } // Else, use empty string default. + return textBlock; +}; diff --git a/demos/blockfactory/block_exporter_controller.js b/demos/blockfactory/block_exporter_controller.js index b237f48a70f..bccd8087de0 100644 --- a/demos/blockfactory/block_exporter_controller.js +++ b/demos/blockfactory/block_exporter_controller.js @@ -1,21 +1,7 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** @@ -23,27 +9,16 @@ * users to export block definitions and generator stubs of their saved blocks * easily using a visual interface. Depends on Block Exporter View and Block * Exporter Tools classes. Interacts with Export Settings in the index.html. - * - * @author quachtina96 (Tina Quach) */ 'use strict'; -goog.provide('BlockExporterController'); - -goog.require('FactoryUtils'); -goog.require('StandardCategories'); -goog.require('BlockExporterView'); -goog.require('BlockExporterTools'); -goog.require('goog.dom.xml'); - - /** * BlockExporter Controller Class * @param {!BlockLibrary.Storage} blockLibStorage Block Library Storage. * @constructor */ -BlockExporterController = function(blockLibStorage) { +function BlockExporterController(blockLibStorage) { // BlockLibrary.Storage object containing user's saved blocks. this.blockLibStorage = blockLibStorage; // Utils for generating code to export. @@ -103,7 +78,9 @@ BlockExporterController.prototype.export = function() { // User wants to export selected blocks' definitions. if (!blockDef_filename) { // User needs to enter filename. - alert('Please enter a filename for your block definition(s) download.'); + var msg = 'Please enter a filename for your block definition(s) download.'; + BlocklyDevTools.Analytics.onWarning(msg); + alert(msg); } else { // Get block definition code in the selected format for the blocks. var blockDefs = this.tools.getBlockDefinitions(blockXmlMap, @@ -111,6 +88,13 @@ BlockExporterController.prototype.export = function() { // Download the file, using .js file ending for JSON or Javascript. FactoryUtils.createAndDownloadFile( blockDefs, blockDef_filename, 'javascript'); + BlocklyDevTools.Analytics.onExport( + BlocklyDevTools.Analytics.BLOCK_DEFINITIONS, + { + format: (definitionFormat === 'JSON' ? + BlocklyDevTools.Analytics.FORMAT_JSON : + BlocklyDevTools.Analytics.FORMAT_JS) + }); } } @@ -118,16 +102,20 @@ BlockExporterController.prototype.export = function() { // User wants to export selected blocks' generator stubs. if (!generatorStub_filename) { // User needs to enter filename. - alert('Please enter a filename for your generator stub(s) download.'); + var msg = 'Please enter a filename for your generator stub(s) download.'; + BlocklyDevTools.Analytics.onWarning(msg); + alert(msg); } else { + // Get generator stub code in the selected language for the blocks. var genStubs = this.tools.getGeneratorCode(blockXmlMap, language); - // Get the correct file extension. - var fileType = (language == 'JavaScript') ? 'javascript' : 'plain'; + // Download the file. FactoryUtils.createAndDownloadFile( - genStubs, generatorStub_filename, fileType); + genStubs, generatorStub_filename + '.js', 'javascript'); + BlocklyDevTools.Analytics.onExport( + BlocklyDevTools.Analytics.GENERATOR, { format: BlocklyDevTools.Analytics.FORMAT_JS }); } } @@ -236,9 +224,9 @@ BlockExporterController.prototype.selectUsedBlocks = function() { var unstoredCustomBlockTypes = []; for (var i = 0, blockType; blockType = this.usedBlockTypes[i]; i++) { - if (storedBlockTypes.indexOf(blockType) != -1) { + if (storedBlockTypes.includes(blockType)) { sharedBlockTypes.push(blockType); - } else if (StandardCategories.coreBlockTypes.indexOf(blockType) == -1) { + } else if (!StandardCategories.coreBlockTypes.includes(blockType)) { unstoredCustomBlockTypes.push(blockType); } } @@ -249,8 +237,8 @@ BlockExporterController.prototype.selectUsedBlocks = function() { } this.view.listSelectedBlocks(); - if (unstoredCustomBlockTypes.length > 0){ - // Warn user to import block defifnitions and generator code for blocks + if (unstoredCustomBlockTypes.length > 0) { + // Warn user to import block definitions and generator code for blocks // not in their Block Library nor Blockly's standard library. var blockTypesText = unstoredCustomBlockTypes.join(', '); var customWarning = 'Custom blocks used in workspace factory but not ' + @@ -263,7 +251,7 @@ BlockExporterController.prototype.selectUsedBlocks = function() { /** * Set the array that holds the block types used in workspace factory. - * @param {!Array.} usedBlockTypes Block types used in + * @param {!Array} usedBlockTypes Block types used in */ BlockExporterController.prototype.setUsedBlockTypes = function(usedBlockTypes) { diff --git a/demos/blockfactory/block_exporter_tools.js b/demos/blockfactory/block_exporter_tools.js index 4d6d9bec65a..58da9be1a3b 100644 --- a/demos/blockfactory/block_exporter_tools.js +++ b/demos/blockfactory/block_exporter_tools.js @@ -1,21 +1,7 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** @@ -23,30 +9,18 @@ * block definitions and generator stubs for given block types. Also generates * toolbox XML for the exporter's workspace. Depends on the FactoryUtils for * its code generation functions. - * - * @author quachtina96 (Tina Quach) */ 'use strict'; -goog.provide('BlockExporterTools'); - -goog.require('FactoryUtils'); -goog.require('BlockOption'); -goog.require('goog.dom'); -goog.require('goog.dom.xml'); - - /** -* Block Exporter Tools Class -* @constructor -*/ -BlockExporterTools = function() { + * Block Exporter Tools Class + * @constructor + */ +function BlockExporterTools() { // Create container for hidden workspace. - this.container = goog.dom.createDom('div', { - 'id': 'blockExporterTools_hiddenWorkspace' - }, ''); // Empty quotes for empty div. - // Hide hidden workspace. - this.container.style.display = 'none'; + this.container = document.createElement('div'); + this.container.id = 'blockExporterTools_hiddenWorkspace'; + this.container.style.display = 'none'; // Hide the hidden workspace. document.body.appendChild(this.container); /** * Hidden workspace for the Block Exporter that holds pieces that make @@ -114,7 +88,7 @@ BlockExporterTools.prototype.getBlockDefinitions = } // Surround json with [] and comma separate items. - if (definitionFormat == "JSON") { + if (definitionFormat === "JSON") { return "[" + blockCode.join(",\n") + "]"; } return blockCode.join("\n\n"); @@ -167,45 +141,6 @@ BlockExporterTools.prototype.addBlockDefinitions = function(blockXmlMap) { eval(blockDefs); }; -/** - * Pulls information about all blocks in the block library to generate XML - * for the selector workpace's toolbox. - * @param {!BlockLibraryStorage} blockLibStorage Block Library Storage object. - * @return {!Element} XML representation of the toolbox. - */ -BlockExporterTools.prototype.generateToolboxFromLibrary - = function(blockLibStorage) { - // Create DOM for XML. - var xmlDom = goog.dom.createDom('xml', { - 'id' : 'blockExporterTools_toolbox', - 'style' : 'display:none' - }); - - var allBlockTypes = blockLibStorage.getBlockTypes(); - // Object mapping block type to XML. - var blockXmlMap = blockLibStorage.getBlockXmlMap(allBlockTypes); - - // Define the custom blocks in order to be able to create instances of - // them in the exporter workspace. - this.addBlockDefinitions(blockXmlMap); - - for (var blockType in blockXmlMap) { - // Get block. - var block = FactoryUtils.getDefinedBlock(blockType, this.hiddenWorkspace); - var category = FactoryUtils.generateCategoryXml([block], blockType); - xmlDom.appendChild(category); - } - - // If there are no blocks in library and the map is empty, append dummy - // category. - if (Object.keys(blockXmlMap).length == 0) { - var category = goog.dom.createDom('category'); - category.setAttribute('name','Next Saved Block'); - xmlDom.appendChild(category); - } - return xmlDom; -}; - /** * Generate XML for the workspace factory's category from imported block * definitions. @@ -233,7 +168,7 @@ BlockExporterTools.prototype.generateCategoryFromBlockLib = }; /** - * Generate selector dom from block library storage. For each block in the + * Generate selector DOM from block library storage. For each block in the * library, it has a block option, which consists of a checkbox, a label, * and a fixed size preview workspace. * @param {!BlockLibraryStorage} blockLibStorage Block Library Storage object. diff --git a/demos/blockfactory/block_exporter_view.js b/demos/blockfactory/block_exporter_view.js index 198598c1418..aa840e59dd6 100644 --- a/demos/blockfactory/block_exporter_view.js +++ b/demos/blockfactory/block_exporter_view.js @@ -1,45 +1,22 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Javascript for the Block Exporter View class. Reads from and * manages a block selector through which users select blocks to export. - * - * @author quachtina96 (Tina Quach) */ 'use strict'; -goog.provide('BlockExporterView'); - -goog.require('BlockExporterTools'); -goog.require('BlockOption'); -goog.require('goog.dom'); - - /** * BlockExporter View Class * @param {!Object} blockOptions Map of block types to BlockOption objects. * @constructor */ -BlockExporterView = function(blockOptions) { +function BlockExporterView(blockOptions) { // Map of block types to BlockOption objects to select from. this.blockOptions = blockOptions; }; @@ -72,7 +49,7 @@ BlockExporterView.prototype.select = function(blockType) { /** * Deselects a block in the selector. - * @param {!Blockly.Block} block Type of block to add to selector workspce. + * @param {!Blockly.Block} block Type of block to add to selector workspace. */ BlockExporterView.prototype.deselect = function(blockType) { this.blockOptions[blockType].setSelected(false); @@ -91,7 +68,7 @@ BlockExporterView.prototype.deselectAllBlocks = function() { /** * Given an array of selected blocks, selects these blocks in the view, marking * the checkboxes accordingly. - * @param {Array.} blockTypes Array of block types to select. + * @param {Array} blockTypes Array of block types to select. */ BlockExporterView.prototype.setSelectedBlockTypes = function(blockTypes) { for (var i = 0, blockType; blockType = blockTypes[i]; i++) { @@ -101,7 +78,7 @@ BlockExporterView.prototype.setSelectedBlockTypes = function(blockTypes) { /** * Returns array of selected blocks. - * @return {!Array.} Array of all selected block types. + * @return {!Array} Array of all selected block types. */ BlockExporterView.prototype.getSelectedBlockTypes = function() { var selectedTypes = []; diff --git a/demos/blockfactory/block_library_controller.js b/demos/blockfactory/block_library_controller.js index 1066ab127d7..8eed54db02c 100644 --- a/demos/blockfactory/block_library_controller.js +++ b/demos/blockfactory/block_library_controller.js @@ -1,21 +1,7 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** @@ -27,27 +13,18 @@ * - delete blocks * - clear their block library * Depends on BlockFactory functions defined in factory.js. - * - * @author quachtina96 (Tina Quach) */ 'use strict'; -goog.provide('BlockLibraryController'); - -goog.require('BlockLibraryStorage'); -goog.require('BlockLibraryView'); -goog.require('BlockFactory'); - - /** * Block Library Controller Class * @param {string} blockLibraryName Desired name of Block Library, also used * to create the key for where it's stored in local storage. - * @param {!BlockLibraryStorage} opt_blockLibraryStorage Optional storage + * @param {!BlockLibraryStorage=} opt_blockLibraryStorage Optional storage * object that allows user to import a block library. * @constructor */ -BlockLibraryController = function(blockLibraryName, opt_blockLibraryStorage) { +function BlockLibraryController(blockLibraryName, opt_blockLibraryStorage) { this.name = blockLibraryName; // Create a new, empty Block Library Storage object, or load existing one. this.storage = opt_blockLibraryStorage || new BlockLibraryStorage(this.name); @@ -110,8 +87,9 @@ BlockLibraryController.prototype.getSelectedBlockType = function() { * updating the dropdown and displaying the starter block (factory_base). */ BlockLibraryController.prototype.clearBlockLibrary = function() { - var check = confirm('Delete all blocks from library?'); - if (check) { + var msg = 'Delete all blocks from library?'; + BlocklyDevTools.Analytics.onWarning(msg); + if (confirm(msg)) { // Clear Block Library Storage. this.storage.clear(); this.storage.saveToLocalStorage(); @@ -131,16 +109,18 @@ BlockLibraryController.prototype.clearBlockLibrary = function() { BlockLibraryController.prototype.saveToBlockLibrary = function() { var blockType = this.getCurrentBlockType(); // If user has not changed the name of the starter block. - if (blockType == 'block_type') { + if (reservedBlockFactoryBlocks.has(blockType) || blockType === 'block_type') { // Do not save block if it has the default type, 'block_type'. - alert('You cannot save a block under the name "block_type". Try changing ' + - 'the name before saving. Then, click on the "Block Library" button ' + - 'to view your saved blocks.'); + var msg = `You cannot save a block under the name "${blockType}". Try ` + + 'changing the name before saving. Then, click on the "Block Library"' + + ' button to view your saved blocks.'; + alert(msg); + BlocklyDevTools.Analytics.onWarning(msg); return; } // Create block XML. - var xmlElement = goog.dom.createDom('xml'); + var xmlElement = Blockly.utils.xml.createElement('xml'); var block = FactoryUtils.getRootBlock(BlockFactory.mainWorkspace); xmlElement.appendChild(Blockly.Xml.blockToDomWithXY(block)); @@ -159,6 +139,7 @@ BlockLibraryController.prototype.saveToBlockLibrary = function() { // Add select handler to the new option. this.addOptionSelectHandler(blockType); + BlocklyDevTools.Analytics.onSave('Block'); }; /** @@ -168,7 +149,7 @@ BlockLibraryController.prototype.saveToBlockLibrary = function() { */ BlockLibraryController.prototype.has = function(blockType) { var blockLibrary = this.storage.blocks; - return (blockType in blockLibrary && blockLibrary[blockType] != null); + return (blockType in blockLibrary && blockLibrary[blockType] !== null); }; /** @@ -192,6 +173,29 @@ BlockLibraryController.prototype.getBlockLibrary = function() { return this.storage.getBlockXmlTextMap(); }; +/** + * @return {Object[]} Array of JSON data, where each item is the data for one block type. + */ +BlockLibraryController.prototype.getBlockLibraryAsJson = function() { + const xmlBlocks = this.storage.getBlockXmlMap(this.storage.getBlockTypes()); + const jsonBlocks = []; + const headlessWorkspace = new Blockly.Workspace(); + + for (const blockName in xmlBlocks) { + // Load the block XML into a workspace so we can save it as JSON + headlessWorkspace.clear(); + const blockXml = xmlBlocks[blockName]; + Blockly.Xml.domToWorkspace(blockXml, headlessWorkspace); + const block = headlessWorkspace.getBlocksByType('factory_base', false)[0]; + + if (!block) continue; + + const json = Blockly.serialization.blocks.save(block, {addCoordinates: false, saveIds: false}); + jsonBlocks.push(json); + } + return jsonBlocks; +} + /** * Return stored XML of a given block type. * @param {string} blockType The type of block. @@ -229,7 +233,7 @@ BlockLibraryController.prototype.hasEmptyBlockLibrary = function() { /** * Get all block types stored in block library. - * @return {!Array.} Array of block types. + * @return {!Array} Array of block types. */ BlockLibraryController.prototype.getStoredBlockTypes = function() { return this.storage.getBlockTypes(); diff --git a/demos/blockfactory/block_library_storage.js b/demos/blockfactory/block_library_storage.js index 750717752f0..c843ae542af 100644 --- a/demos/blockfactory/block_library_storage.js +++ b/demos/blockfactory/block_library_storage.js @@ -1,49 +1,30 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Javascript for Block Library's Storage Class. * Depends on Block Library for its namespace. - * - * @author quachtina96 (Tina Quach) */ 'use strict'; -goog.provide('BlockLibraryStorage'); - - /** * Represents a block library's storage. * @param {string} blockLibraryName Desired name of Block Library, also used * to create the key for where it's stored in local storage. - * @param {Object} opt_blocks Object mapping block type to XML. + * @param {!Object=} opt_blocks Object mapping block type to XML. * @constructor */ -BlockLibraryStorage = function(blockLibraryName, opt_blocks) { +function BlockLibraryStorage(blockLibraryName, opt_blocks) { // Add prefix to this.name to avoid collisions in local storage. this.name = 'BlockLibraryStorage.' + blockLibraryName; if (!opt_blocks) { // Initialize this.blocks by loading from local storage. this.loadFromLocalStorage(); - if (this.blocks == null) { + if (this.blocks === null) { this.blocks = Object.create(null); // The line above is equivalent of {} except that this object is TRULY // empty. It doesn't have built-in attributes/functions such as length or @@ -60,9 +41,7 @@ BlockLibraryStorage = function(blockLibraryName, opt_blocks) { * Reads the named block library from local storage and saves it in this.blocks. */ BlockLibraryStorage.prototype.loadFromLocalStorage = function() { - // goog.global is synonymous to window, and allows for flexibility - // between browsers. - var object = goog.global.localStorage[this.name]; + var object = localStorage[this.name]; this.blocks = object ? JSON.parse(object) : null; }; @@ -70,7 +49,7 @@ BlockLibraryStorage.prototype.loadFromLocalStorage = function() { * Writes the current block library (this.blocks) to local storage. */ BlockLibraryStorage.prototype.saveToLocalStorage = function() { - goog.global.localStorage[this.name] = JSON.stringify(this.blocks); + localStorage[this.name] = JSON.stringify(this.blocks); }; /** @@ -110,7 +89,7 @@ BlockLibraryStorage.prototype.removeBlock = function(blockType) { BlockLibraryStorage.prototype.getBlockXml = function(blockType) { var xml = this.blocks[blockType] || null; if (xml) { - var xml = Blockly.Xml.textToDom(xml); + var xml = Blockly.utils.xml.textToDom(xml); } return xml; }; @@ -119,11 +98,11 @@ BlockLibraryStorage.prototype.getBlockXml = function(blockType) { /** * Returns map of each block type to its corresponding XML stored in current * block library (this.blocks). - * @param {!Array.} blockTypes Types of blocks. + * @param {!Array} blockTypes Types of blocks. * @return {!Object} Map of block type to corresponding XML. */ BlockLibraryStorage.prototype.getBlockXmlMap = function(blockTypes) { - var blockXmlMap = {}; + var blockXmlMap = Object.create(null); for (var i = 0; i < blockTypes.length; i++) { var blockType = blockTypes[i]; var xml = this.getBlockXml(blockType); @@ -134,7 +113,7 @@ BlockLibraryStorage.prototype.getBlockXmlMap = function(blockTypes) { /** * Returns array of all block types stored in current block library. - * @return {!Array.} Array of block types stored in library. + * @return {!Array} Array of block types stored in library. */ BlockLibraryStorage.prototype.getBlockTypes = function() { return Object.keys(this.blocks); @@ -153,7 +132,7 @@ BlockLibraryStorage.prototype.isEmpty = function() { /** * Returns array of all block types stored in current block library. - * @return {!Array.} Map of block type to corresponding XML text. + * @return {!Array} Map of block type to corresponding XML text. */ BlockLibraryStorage.prototype.getBlockXmlTextMap = function() { return this.blocks; diff --git a/demos/blockfactory/block_library_view.js b/demos/blockfactory/block_library_view.js index 16181f290c6..2c91ce3782e 100644 --- a/demos/blockfactory/block_library_view.js +++ b/demos/blockfactory/block_library_view.js @@ -1,38 +1,16 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Javascript for BlockLibraryView class. It manages the display * of the Block Library dropdown, save, and delete buttons. - * - * @author quachtina96 (Tina Quach) */ 'use strict'; -goog.provide('BlockLibraryView'); - -goog.require('goog.dom'); -goog.require('goog.dom.classlist'); - - /** * BlockLibraryView Class * @constructor @@ -59,10 +37,10 @@ var BlockLibraryView = function() { */ BlockLibraryView.prototype.addOption = function(blockType, selected) { // Create option. - var option = goog.dom.createDom('a', { - 'id': 'dropdown_' + blockType, - 'class': 'blockLibOpt' - }, blockType); + var option = document.createElement('a'); + option.id ='dropdown_' + blockType; + option.classList.add('blockLibOpt'); + option.textContent = blockType; // Add option to dropdown. this.dropdown.appendChild(option); @@ -84,7 +62,7 @@ BlockLibraryView.prototype.setSelectedBlockType = function(blockTypeToSelect) { // if null or invalid block type selected. for (var blockType in this.optionMap) { var option = this.optionMap[blockType]; - if (blockType == blockTypeToSelect) { + if (blockType === blockTypeToSelect) { this.selectOption_(option); } else { this.deselectOption_(option); @@ -99,7 +77,7 @@ BlockLibraryView.prototype.setSelectedBlockType = function(blockTypeToSelect) { * @private */ BlockLibraryView.prototype.selectOption_ = function(option) { - goog.dom.classlist.add(option, 'dropdown-content-selected'); + option.classList.add('dropdown-content-selected'); }; /** @@ -109,7 +87,7 @@ BlockLibraryView.prototype.selectOption_ = function(option) { * @private */ BlockLibraryView.prototype.deselectOption_ = function(option) { - goog.dom.classlist.remove(option, 'dropdown-content-selected'); + option.classList.remove('dropdown-content-selected'); }; /** @@ -126,37 +104,36 @@ BlockLibraryView.prototype.updateButtons = // User is editing a block. if (!isInLibrary) { - // Block type has not been saved to library yet. Disable the delete button - // and allow user to save. + // Block type has not been saved to the library yet. + // Disable the delete button. this.saveButton.textContent = 'Save "' + blockType + '"'; - this.saveButton.disabled = false; this.deleteButton.disabled = true; } else { - // Block type has already been saved. Disable the save button unless the - // there are unsaved changes (checked below). + // A version of the block type has already been saved. + // Enable the delete button. this.saveButton.textContent = 'Update "' + blockType + '"'; - this.saveButton.disabled = true; this.deleteButton.disabled = false; } this.deleteButton.textContent = 'Delete "' + blockType + '"'; - // If changes to block have been made and are not saved, make button - // green to encourage user to save the block. + this.saveButton.classList.remove('button_alert', 'button_warn'); if (!savedChanges) { - var buttonFormatClass = 'button_warn'; + var buttonFormatClass; - // If block type is the default, 'block_type', make button red to alert - // user. - if (blockType == 'block_type') { + var isReserved = reservedBlockFactoryBlocks.has(blockType); + if (isReserved || blockType === 'block_type') { + // Make button red to alert user that the block type can't be saved. buttonFormatClass = 'button_alert'; + } else { + // Block type has not been saved to library yet or has unsaved changes. + // Make the button green to encourage the user to save the block. + buttonFormatClass = 'button_warn'; } - goog.dom.classlist.add(this.saveButton, buttonFormatClass); + this.saveButton.classList.add(buttonFormatClass); this.saveButton.disabled = false; } else { // No changes to save. - var classesToRemove = ['button_alert', 'button_warn']; - goog.dom.classlist.removeAll(this.saveButton, classesToRemove); this.saveButton.disabled = true; } diff --git a/demos/blockfactory/block_option.js b/demos/blockfactory/block_option.js index 8bc1a2fd411..184c3c23517 100644 --- a/demos/blockfactory/block_option.js +++ b/demos/blockfactory/block_option.js @@ -1,36 +1,17 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** - * @fileoverview Javascript for the BlockOption class, used to represent each of - * the various blocks that you may select. Each block option has a checkbox, - * a label, and a preview workspace through which to view the block. - * - * @author quachtina96 (Tina Quach) + * @fileoverview Javascript for the BlockOption class, used to represent each + * of the various blocks that you may select in the Block Selector. Each block + * option has a checkbox, a label, and a preview workspace through which to + * view the block. */ 'use strict'; -goog.provide('BlockOption'); -goog.require('goog.dom'); - - /** * BlockOption Class * A block option includes checkbox, label, and div element that shows a preview @@ -63,55 +44,49 @@ var BlockOption = function(blockSelector, blockType, previewBlockXml) { }; /** - * Creates the dom for a single block option. Includes checkbox, label, and div + * Creates the DOM for a single block option. Includes checkbox, label, and div * in which to inject the preview block. - * @return {!Element} Root node of the selector dom which consists of a + * @return {!Element} Root node of the selector DOM which consists of a * checkbox, a label, and a fixed size preview workspace per block. */ BlockOption.prototype.createDom = function() { // Create the div for the block option. - var blockOptContainer = goog.dom.createDom('div', { - 'id': this.blockType, - 'class': 'blockOption' - }, ''); // Empty quotes for empty div. + var blockOptContainer = document.createElement('div'); + blockOptContainer.id = this.blockType; + blockOptContainer.classList.add('blockOption'); // Create and append div in which to inject the workspace for viewing the // block option. - var blockOptionPreview = goog.dom.createDom('div', { - 'id' : this.blockType + '_workspace', - 'class': 'blockOption_preview' - }, ''); + var blockOptionPreview = document.createElement('div'); + blockOptionPreview.id = this.blockType + '_workspace'; + blockOptionPreview.classList.add('blockOption_preview'); blockOptContainer.appendChild(blockOptionPreview); // Create and append container to hold checkbox and label. - var checkLabelContainer = goog.dom.createDom('div', { - 'class': 'blockOption_checkLabel' - }, ''); + var checkLabelContainer = document.createElement('div'); + checkLabelContainer.classList.add('blockOption_checkLabel'); blockOptContainer.appendChild(checkLabelContainer); // Create and append container for checkbox. - var checkContainer = goog.dom.createDom('div', { - 'class': 'blockOption_check' - }, ''); + var checkContainer = document.createElement('div'); + checkContainer.classList.add('blockOption_check'); checkLabelContainer.appendChild(checkContainer); // Create and append checkbox. - this.checkbox = goog.dom.createDom('input', { - 'type': 'checkbox', - 'id': this.blockType + '_check' - }, ''); + this.checkbox = document.createElement('input'); + this.checkbox.id = this.blockType + '_check'; + this.checkbox.setAttribute('type', 'checkbox'); checkContainer.appendChild(this.checkbox); // Create and append container for block label. - var labelContainer = goog.dom.createDom('div', { - 'class': 'blockOption_label' - }, ''); + var labelContainer = document.createElement('div'); + labelContainer.classList.add('blockOption_label'); checkLabelContainer.appendChild(labelContainer); // Create and append text node for the label. - var labelText = goog.dom.createDom('p', { - 'id': this.blockType + '_text' - }, this.blockType); + var labelText = document.createElement('p'); + labelText.id = this.blockType + '_text'; + labelText.textContent = this.blockType; labelContainer.appendChild(labelText); this.dom = blockOptContainer; @@ -126,9 +101,9 @@ BlockOption.prototype.showPreviewBlock = function() { var blockOptPreviewID = this.dom.id + '_workspace'; // Inject preview block. - var workspace = Blockly.inject(blockOptPreviewID, {readOnly:true}); - Blockly.Xml.domToWorkspace(this.previewBlockXml, workspace); - this.previewWorkspace = workspace; + var demoWorkspace = Blockly.inject(blockOptPreviewID, {readOnly:true}); + Blockly.Xml.domToWorkspace(this.previewBlockXml, demoWorkspace); + this.previewWorkspace = demoWorkspace; // Center the preview block in the workspace. this.centerBlock(); diff --git a/demos/blockfactory/blocks.js b/demos/blockfactory/blocks.js index 6dcd0061709..9a983460f5f 100644 --- a/demos/blockfactory/blocks.js +++ b/demos/blockfactory/blocks.js @@ -1,25 +1,11 @@ /** - * Blockly Demos: Block Factory Blocks - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Blocks for Blockly's Block Factory application. - * @author fraser@google.com (Neil Fraser) */ 'use strict'; @@ -46,9 +32,9 @@ Blockly.Blocks['factory_base'] = { ['↑ top connection', 'TOP'], ['↓ bottom connection', 'BOTTOM']], function(option) { - this.sourceBlock_.updateShape_(option); + this.getSourceBlock().updateShape_(option); // Connect a shadow block to this new input. - this.sourceBlock_.spawnOutputShadow_(option); + this.getSourceBlock().spawnOutputShadow_(option); }); this.appendDummyInput() .appendField(dropdown, 'CONNECTIONS'); @@ -67,7 +53,7 @@ Blockly.Blocks['factory_base'] = { 'https://developers.google.com/blockly/guides/create-custom-blocks/block-factory'); }, mutationToDom: function() { - var container = document.createElement('mutation'); + var container = Blockly.utils.xml.createElement('mutation'); container.setAttribute('connections', this.getFieldValue('CONNECTIONS')); return container; }, @@ -77,7 +63,7 @@ Blockly.Blocks['factory_base'] = { }, spawnOutputShadow_: function(option) { // Helper method for deciding which type of outputs this block needs - // to attach shaddow blocks to. + // to attach shadow blocks to. switch (option) { case 'LEFT': this.connectOutputShadow_('OUTPUTTYPE'); @@ -99,28 +85,30 @@ Blockly.Blocks['factory_base'] = { var type = this.workspace.newBlock('type_null'); type.setShadow(true); type.outputConnection.connect(this.getInput(outputType).connection); - type.initSvg(); - type.render(); + if (this.rendered) { + type.initSvg(); + type.render(); + } }, updateShape_: function(option) { var outputExists = this.getInput('OUTPUTTYPE'); var topExists = this.getInput('TOPTYPE'); var bottomExists = this.getInput('BOTTOMTYPE'); - if (option == 'LEFT') { + if (option === 'LEFT') { if (!outputExists) { this.addTypeInput_('OUTPUTTYPE', 'output type'); } } else if (outputExists) { this.removeInput('OUTPUTTYPE'); } - if (option == 'TOP' || option == 'BOTH') { + if (option === 'TOP' || option === 'BOTH') { if (!topExists) { this.addTypeInput_('TOPTYPE', 'top type'); } } else if (topExists) { this.removeInput('TOPTYPE'); } - if (option == 'BOTTOM' || option == 'BOTH') { + if (option === 'BOTTOM' || option === 'BOTH') { if (!bottomExists) { this.addTypeInput_('BOTTOMTYPE', 'bottom type'); } @@ -232,25 +220,64 @@ Blockly.Blocks['input_dummy'] = { "previousStatement": "Input", "nextStatement": "Input", "colour": 210, - "tooltip": "For adding fields on a separate row with no " + - "connections. Alignment options (left, right, centre) " + - "apply only to multi-line fields.", + "tooltip": "For adding fields without any block connections." + + "Alignment options (left, right, centre) only affect " + + "multi-row blocks.", "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=293" }); } }; +Blockly.Blocks['input_end_row'] = { + // End-row input. + init: function() { + this.jsonInit({ + "message0": "end-row input", + "message1": FIELD_MESSAGE, + "args1": FIELD_ARGS, + "previousStatement": "Input", + "nextStatement": "Input", + "colour": 210, + "tooltip": "For adding fields without any block connections that will " + + "be rendered on a separate row from any following inputs. " + + "Alignment options (left, right, centre) only affect " + + "multi-row blocks.", + "helpUrl": "https://developers.google.com/blockly/guides/create-custom-blocks/define-blocks#block_inputs" + }); + } +}; + Blockly.Blocks['field_static'] = { // Text value. init: function() { this.setColour(160); - this.appendDummyInput() + this.appendDummyInput('FIRST') .appendField('text') .appendField(new Blockly.FieldTextInput(''), 'TEXT'); this.setPreviousStatement(true, 'Field'); this.setNextStatement(true, 'Field'); this.setTooltip('Static text that serves as a label.'); this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=88'); + }, +}; + +Blockly.Blocks['field_label_serializable'] = { + // Text value that is saved to XML. + init: function() { + this.setColour(160); + this.appendDummyInput('FIRST') + .appendField('text') + .appendField(new Blockly.FieldTextInput(''), 'TEXT') + .appendField(',') + .appendField(new Blockly.FieldTextInput('NAME'), 'FIELDNAME'); + this.setPreviousStatement(true, 'Field'); + this.setNextStatement(true, 'Field'); + this.setTooltip('Static text that serves as a label, and is saved to' + + ' XML. Use only if you want to modify this label at runtime.'); + this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=88'); + }, + onchange: function() { + fieldNameCheck(this); } }; @@ -328,22 +355,22 @@ Blockly.Blocks['field_dropdown'] = { this.updateShape_(); this.setPreviousStatement(true, 'Field'); this.setNextStatement(true, 'Field'); - this.setMutator(new Blockly.Mutator(['field_dropdown_option_text', - 'field_dropdown_option_image'])); + this.setMutator(new Blockly.icons.MutatorIcon( + ['field_dropdown_option_text', 'field_dropdown_option_image'], this)); this.setColour(160); this.setTooltip('Dropdown menu with a list of options.'); this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=386'); }, mutationToDom: function(workspace) { // Create XML to represent menu options. - var container = document.createElement('mutation'); + var container = Blockly.utils.xml.createElement('mutation'); container.setAttribute('options', JSON.stringify(this.optionList_)); return container; }, domToMutation: function(container) { // Parse XML to restore the menu options. var value = JSON.parse(container.getAttribute('options')); - if (typeof value == 'number') { + if (typeof value === 'number') { // Old format from before images were added. November 2016. this.optionList_ = []; for (var i = 0; i < value; i++) { @@ -375,9 +402,9 @@ Blockly.Blocks['field_dropdown'] = { this.optionList_.length = 0; var data = []; while (optionBlock) { - if (optionBlock.type == 'field_dropdown_option_text') { + if (optionBlock.type === 'field_dropdown_option_text') { this.optionList_.push('text'); - } else if (optionBlock.type == 'field_dropdown_option_image') { + } else if (optionBlock.type === 'field_dropdown_option_image') { this.optionList_.push('image'); } data.push([optionBlock.userData_, optionBlock.cpuData_]); @@ -389,7 +416,7 @@ Blockly.Blocks['field_dropdown'] = { for (var i = 0; i < this.optionList_.length; i++) { var userData = data[i][0]; if (userData !== undefined) { - if (typeof userData == 'string') { + if (typeof userData === 'string') { this.setFieldValue(userData || 'option', 'USER' + i); } else { this.setFieldValue(userData.src, 'SRC' + i); @@ -425,13 +452,13 @@ Blockly.Blocks['field_dropdown'] = { var src = 'https://www.gstatic.com/codesite/ph/images/star_on.gif'; for (var i = 0; i <= this.optionList_.length; i++) { var type = this.optionList_[i]; - if (type == 'text') { + if (type === 'text') { this.appendDummyInput('OPTION' + i) .appendField('•') .appendField(new Blockly.FieldTextInput('option'), 'USER' + i) .appendField(',') .appendField(new Blockly.FieldTextInput('OPTIONNAME'), 'CPU' + i); - } else if (type == 'image') { + } else if (type === 'image') { this.appendDummyInput('OPTION' + i) .appendField('•') .appendField('image') @@ -457,10 +484,10 @@ Blockly.Blocks['field_dropdown'] = { } }, getUserData: function(n) { - if (this.optionList_[n] == 'text') { + if (this.optionList_[n] === 'text') { return this.getFieldValue('USER' + n); } - if (this.optionList_[n] == 'image') { + if (this.optionList_[n] === 'image') { return { src: this.getFieldValue('SRC' + n), width: Number(this.getFieldValue('WIDTH' + n)), @@ -552,24 +579,6 @@ Blockly.Blocks['field_colour'] = { } }; -Blockly.Blocks['field_date'] = { - // Date input. - init: function() { - this.setColour(160); - this.appendDummyInput() - .appendField('date') - .appendField(new Blockly.FieldDate(), 'DATE') - .appendField(',') - .appendField(new Blockly.FieldTextInput('NAME'), 'FIELDNAME'); - this.setPreviousStatement(true, 'Field'); - this.setNextStatement(true, 'Field'); - this.setTooltip('Date input field.'); - }, - onchange: function() { - fieldNameCheck(this); - } -}; - Blockly.Blocks['field_variable'] = { // Dropdown for variables. init: function() { @@ -603,7 +612,9 @@ Blockly.Blocks['field_image'] = { .appendField('height') .appendField(new Blockly.FieldNumber('15', 0, NaN, 1), 'HEIGHT') .appendField('alt text') - .appendField(new Blockly.FieldTextInput('*'), 'ALT'); + .appendField(new Blockly.FieldTextInput('*'), 'ALT') + .appendField('flip RTL') + .appendField(new Blockly.FieldCheckbox('false'), 'FLIP_RTL'); this.setPreviousStatement(true, 'Field'); this.setNextStatement(true, 'Field'); this.setTooltip('Static image (JPEG, PNG, GIF, SVG, BMP).\n' + @@ -619,14 +630,14 @@ Blockly.Blocks['type_group'] = { this.typeCount_ = 2; this.updateShape_(); this.setOutput(true, 'Type'); - this.setMutator(new Blockly.Mutator(['type_group_item'])); + this.setMutator(new Blockly.icons.MutatorIcon(['type_group_item'], this)); this.setColour(230); this.setTooltip('Allows more than one type to be accepted.'); this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=677'); }, mutationToDom: function(workspace) { // Create XML to represent a group of types. - var container = document.createElement('mutation'); + var container = Blockly.utils.xml.createElement('mutation'); container.setAttribute('types', this.typeCount_); return container; }, @@ -640,7 +651,7 @@ Blockly.Blocks['type_group'] = { for (var i = 0; i < this.typeCount_; i++) { var input = this.appendValueInput('TYPE' + i) .setCheck('Type'); - if (i == 0) { + if (i === 0) { input.appendField('any of'); } } @@ -671,7 +682,7 @@ Blockly.Blocks['type_group'] = { // Disconnect any children that don't belong. for (var i = 0; i < this.typeCount_; i++) { var connection = this.getInput('TYPE' + i).connection.targetConnection; - if (connection && connections.indexOf(connection) == -1) { + if (connection && !connections.includes(connection)) { connection.disconnect(); } } @@ -679,7 +690,7 @@ Blockly.Blocks['type_group'] = { this.updateShape_(); // Reconnect any child blocks. for (var i = 0; i < this.typeCount_; i++) { - Blockly.Mutator.reconnect(connections[i], this, 'TYPE' + i); + connections[i]?.reconnect(this, 'TYPE' + i); } }, saveConnections: function(containerBlock) { @@ -700,7 +711,7 @@ Blockly.Blocks['type_group'] = { for (var i = 0; i < this.typeCount_; i++) { if (!this.getInput('TYPE' + i)) { var input = this.appendValueInput('TYPE' + i); - if (i == 0) { + if (i === 0) { input.appendField('any of'); } } @@ -841,11 +852,11 @@ Blockly.Blocks['colour_hue'] = { // Update the current block's colour to match. var hue = parseInt(text, 10); if (!isNaN(hue)) { - this.sourceBlock_.setColour(hue); + this.getSourceBlock().setColour(hue); } }, mutationToDom: function(workspace) { - var container = document.createElement('mutation'); + var container = Blockly.utils.xml.createElement('mutation'); container.setAttribute('colour', this.getColour()); return container; }, @@ -866,11 +877,11 @@ function fieldNameCheck(referenceBlock) { } var name = referenceBlock.getFieldValue('FIELDNAME').toLowerCase(); var count = 0; - var blocks = referenceBlock.workspace.getAllBlocks(); + var blocks = referenceBlock.workspace.getAllBlocks(false); for (var i = 0, block; block = blocks[i]; i++) { var otherName = block.getFieldValue('FIELDNAME'); - if (!block.disabled && !block.getInheritedDisabled() && - otherName && otherName.toLowerCase() == name) { + if (block.isEnabled() && !block.getInheritedDisabled() && + otherName && otherName.toLowerCase() === name) { count++; } } @@ -891,11 +902,11 @@ function inputNameCheck(referenceBlock) { } var name = referenceBlock.getFieldValue('INPUTNAME').toLowerCase(); var count = 0; - var blocks = referenceBlock.workspace.getAllBlocks(); + var blocks = referenceBlock.workspace.getAllBlocks(false); for (var i = 0, block; block = blocks[i]; i++) { var otherName = block.getFieldValue('INPUTNAME'); - if (!block.disabled && !block.getInheritedDisabled() && - otherName && otherName.toLowerCase() == name) { + if (block.isEnabled() && !block.getInheritedDisabled() && + otherName && otherName.toLowerCase() === name) { count++; } } @@ -903,3 +914,7 @@ function inputNameCheck(referenceBlock) { 'There are ' + count + ' input blocks\n with this name.' : null; referenceBlock.setWarningText(msg); } + +// Make a set of all of block types that are required for the block factory. +var reservedBlockFactoryBlocks = + new Set(Object.getOwnPropertyNames(Blockly.Blocks)); diff --git a/demos/blockfactory/cp.css b/demos/blockfactory/cp.css new file mode 100644 index 00000000000..508533e1627 --- /dev/null +++ b/demos/blockfactory/cp.css @@ -0,0 +1,46 @@ +.cp_swatch { + border: outset 3px #888; + display: inline-block; + font-family: sans-serif; + height: 20px; + line-height: 1.4; + margin: 1px; + text-align: center; + width: 30px; + vertical-align: bottom; +} + +#cp_popup { + cursor: default; + font-family: sans-serif; + left: 0; + position: absolute; + text-align: center; + top: 0; + user-select: none; +} + +#cp_popup>table { + border: 2px solid #808080; + background-color: #808080; + border-collapse: collapse; +} + +#cp_popup>table>tbody>tr>td { + border: 1px solid #808080; + background-color: #fff; + width: 20px; + padding: 0; +} + +#cp_popup>table>tbody>tr>td>div { + border: 1px solid #808080; +} + +#cp_popup>table>tbody>tr>td>div:hover { + border-color: #fff; +} + +#cp_popup>table>tbody>tr>td>div.cp_current { + border: 1px solid #000; +} diff --git a/demos/blockfactory/cp.js b/demos/blockfactory/cp.js new file mode 100644 index 00000000000..96248176173 --- /dev/null +++ b/demos/blockfactory/cp.js @@ -0,0 +1,179 @@ +/** + * Colour Picker v2.0 + * + * Copyright 2006 Neil Fraser + * https://neil.fraser.name/software/colourpicker/ + * SPDX-License-Identifier: Apache-2.0 + */ + +// Include at the top of your page: +// +// +// Call with: +// +// + +var cp_grid = [ + ['ffffff', 'ffcccc', 'ffcc99', 'ffff99', 'ffffcc', '99ff99', '99ffff', 'ccffff', 'ccccff', 'ffccff'], + ['cccccc', 'ff6666', 'ff9966', 'ffff66', 'ffff33', '66ff99', '33ffff', '66ffff', '9999ff', 'ff99ff'], + ['c0c0c0', 'ff0000', 'ff9900', 'ffcc66', 'ffff00', '33ff33', '66cccc', '33ccff', '6666cc', 'cc66cc'], + ['999999', 'cc0000', 'ff6600', 'ffcc33', 'ffcc00', '33cc00', '00cccc', '3366ff', '6633ff', 'cc33cc'], + ['666666', '990000', 'cc6600', 'cc9933', '999900', '009900', '339999', '3333ff', '6600cc', '993399'], + ['333333', '660000', '993300', '996633', '666600', '006600', '336666', '000099', '333399', '663366'], + ['000000', '330000', '663300', '663333', '333300', '003300', '003333', '000066', '330099', '330033'], + [''] +]; + +var cp_popupDom = null; +var cp_activeSwatch = null; +var cp_closePid = null; + +function cp_init(id) { + var input = document.getElementById(id); + if (!input) { + throw Error('Colour picker can\'t find "' + id + '"'); + } + if (!input.cp_swatch) { + // Hide the input. + input.type = 'hidden'; + // + var swatch = document.createElement('span'); + swatch.className = 'cp_swatch'; + swatch.addEventListener('click', cp_open); + swatch.addEventListener('mouseover', cp_cancelclose); + swatch.addEventListener('mouseout', cp_closesoon); + input.parentNode.insertBefore(swatch, input); + // Cross-link the swatch and input. + swatch.cp_input = input; + input.cp_swatch = swatch; + } + cp_updateSwatch(input.cp_swatch); +} + +function cp_updateSwatch(swatch) { + var colour = swatch.cp_input.value; + if (colour) { + swatch.style.backgroundColor = '#' + colour; + swatch.textContent = '\xa0'; + } else { + swatch.style.backgroundColor = '#fff'; + swatch.innerHTML = 'X'; + } +} + +function cp_open(e) { + // Create a table of colours. + if (cp_popupDom) { + cp_close(); + return; + } + cp_activeSwatch = e.currentTarget; + var currentColour = cp_activeSwatch.cp_input.value.toLowerCase(); + var element = cp_activeSwatch; + var posX = 0; + var posY = element.offsetHeight; + while (element) { + posX += element.offsetLeft; + posY += element.offsetTop; + element = element.offsetParent; + } + cp_popupDom = document.createElement('div'); + cp_popupDom.id = 'cp_popup'; + var table = document.createElement('table'); + table.addEventListener('mouseover', cp_cancelclose); + table.addEventListener('mouseout', cp_closesoon); + table.addEventListener('click', cp_onclick); + var tbody = document.createElement('tbody'); + var row, cell, div; + for (var y = 0; y < cp_grid.length; y++) { + row = document.createElement('tr'); + tbody.appendChild(row); + for (var x = 0; x < cp_grid[y].length; x++) { + var colour = cp_grid[y][x]; + if (colour === undefined) continue; + cell = document.createElement('td'); + row.appendChild(cell); + div = document.createElement('div'); + cell.appendChild(div); + cell.cp_colour = colour; + if (colour) { + div.style.backgroundColor = '#' + colour; + div.innerHTML = '\xa0'; + } else { + div.innerHTML = 'X'; + } + if (currentColour === colour.toLowerCase()) { + div.className = 'cp_current' + } + } + } + table.appendChild(tbody); + cp_popupDom.appendChild(table); + + document.body.appendChild(cp_popupDom); + // Don't widen the screen. + var rightOverhang = (posX + cp_popupDom.offsetWidth) - + (window.innerWidth + window.scrollX) + 15; // Scrollbar is 15px. + if (rightOverhang > 0) { + posX -= rightOverhang; + } + // Flip to above swatch if no room below. + if (posY + cp_popupDom.offsetHeight >= window.innerHeight + window.scrollY) { + posY -= cp_popupDom.offsetHeight + cp_activeSwatch.offsetHeight; + if (posY < window.scrollY) { + posY = window.scrollY; + } + } + cp_popupDom.style.left = posX + 'px'; + cp_popupDom.style.top = posY + 'px'; +} + +function cp_close() { + // Close the table now. + cp_cancelclose(); + if (cp_popupDom) { + document.body.removeChild(cp_popupDom) + } + cp_popupDom = null; + cp_activeSwatch = null; +} + +function cp_closesoon() { + // Close the table a split-second from now. + cp_closePid = setTimeout(cp_close, 250); +} + +function cp_cancelclose() { + // Don't close the colour table after all. + if (cp_closePid) { + clearTimeout(cp_closePid); + } +} + +function cp_onclick(e) { + // Clicked on a colour. + var element = e.target; + var colour; + // Walk up the DOM, looking for a colour. + while (element) { + colour = element.cp_colour; + if (colour !== undefined) { + break; + } + element = element.parentNode; + } + if (colour !== undefined) { + // Set the colour. + cp_activeSwatch.cp_input.value = colour; + cp_updateSwatch(cp_activeSwatch); + // Fire a change event. + var evt = document.createEvent('HTMLEvents'); + evt.initEvent('change', false, true); + cp_activeSwatch.cp_input.dispatchEvent(evt); + } + // Close the table. + cp_close(); +} diff --git a/demos/blockfactory/factory.css b/demos/blockfactory/factory.css index 6661c139c87..ded1f6cda50 100644 --- a/demos/blockfactory/factory.css +++ b/demos/blockfactory/factory.css @@ -1,21 +1,7 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ html, body { @@ -56,11 +42,11 @@ td { p { display: block; - -webkit-margin-before: 0em; - -webkit-margin-after: 0em; - -webkit-margin-start: 0px; - -webkit-margin-end: 0px; - padding: 5px 0px; + -webkit-margin-before: 0; + -webkit-margin-after: 0; + -webkit-margin-start: 0; + -webkit-margin-end: 0; + padding: 5px 0; } #factoryHeader { @@ -141,6 +127,13 @@ button, .buttonStyle { float: right; } +#legacyBanner { + border: #ccc 1px solid; + background-color: #FFCDD2; + margin: 4px; + padding: 4px; +} + #blockFactoryContent { height: 85%; width: 100%; @@ -233,7 +226,7 @@ button, .buttonStyle { } .subsettings { - margin: 0px 25px; + margin: 0 25px; } #exporterHiddenWorkspace { @@ -335,7 +328,7 @@ button, .buttonStyle { padding: 5px 19px; } -.tab:hover:not(.tabon){ +.tab:hover:not(.tabon) { background-color: #e8e8e8; } @@ -511,53 +504,10 @@ td.taboff:hover { right: 0; bottom: 0; left: 0; - background: rgba(0, 0, 0, 0.05); + background: rgba(0, 0, 0, 0.1); z-index: 100; } -/* Rules for Closure popup color picker */ -.goog-palette { - outline: none; - cursor: default; -} - -.goog-palette-cell { - height: 13px; - width: 15px; - margin: 0; - border: 0; - text-align: center; - vertical-align: middle; - border-right: 1px solid #000; - font-size: 1px; -} - -.goog-palette-colorswatch { - border: 1px solid #000; - height: 13px; - position: relative; - width: 15px; -} - -.goog-palette-cell-hover .goog-palette-colorswatch { - border: 1px solid #fff; -} - -.goog-palette-cell-selected .goog-palette-colorswatch { - border: 1px solid #000; - color: #fff; -} - -.goog-palette-table { - border: 1px solid #000; - border-collapse: collapse; -} - -.goog-popupcolorpicker { - position: absolute; - z-index: 101; /* On top of the modal Shadow. */ -} - /* The container
    - needed to position the dropdown content */ .dropdown { display: inline-block; @@ -566,7 +516,7 @@ td.taboff:hover { /* Dropdown Content (Hidden by Default) */ .dropdown-content { background-color: #fff; - box-shadow: 0px 8px 16px 0px rgba(0,0,0,.2); + box-shadow: 0 8px 16px 0 rgba(0,0,0,.2); display: none; min-width: 170px; opacity: 1; @@ -583,12 +533,12 @@ td.taboff:hover { text-decoration: none; } -/* Change color of dropdown links on hover. */ +/* Change colour of dropdown links on hover. */ .dropdown-content a:hover, .dropdown-content label:hover { background-color: #EEE; } -/* Change color of dropdown links on selected. */ +/* Change colour of dropdown links on selected. */ .dropdown-content-selected { background-color: #DDD; } @@ -598,6 +548,22 @@ td.taboff:hover { display: block; } +#dropdownDiv_editCategory { + padding: 0 1ex; +} + +#dropdownDiv_editCategory>img { + vertical-align: middle; +} + +.cp_swatch { + vertical-align: middle !important; +} + +#cp_popup { + z-index: 999; +} + .shadowBlock>.blocklyPath { fill-opacity: .5; stroke-opacity: .5; @@ -607,3 +573,14 @@ td.taboff:hover { .shadowBlock>.blocklyPathDark { display: none; } + +/* Privacy link */ +.privacyLink { + font-family: Roboto, Arial, Helvetica, sans-serif; + font-size: small; + text-decoration: none; +} + +.privacyButton { + float: right; +} diff --git a/demos/blockfactory/factory.js b/demos/blockfactory/factory.js index 974cd8a7ec5..2e6ebc924e1 100644 --- a/demos/blockfactory/factory.js +++ b/demos/blockfactory/factory.js @@ -1,21 +1,7 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** @@ -24,19 +10,13 @@ * generate a preview block and starter code for the block (block definition and * generator stub. Uses the Block Factory namespace. Depends on the FactoryUtils * for its code generation functions. - * - * @author fraser@google.com (Neil Fraser), quachtina96 (Tina Quach) */ 'use strict'; /** * Namespace for Block Factory. */ -goog.provide('BlockFactory'); - -goog.require('FactoryUtils'); -goog.require('StandardCategories'); - +var BlockFactory = BlockFactory || Object.create(null); /** * Workspace for user to build block. @@ -52,31 +32,57 @@ BlockFactory.previewWorkspace = null; /** * Name of block if not named. + * @type string */ BlockFactory.UNNAMED = 'unnamed'; /** * Existing direction ('ltr' vs 'rtl') of preview. + * @type string */ BlockFactory.oldDir = null; -/* +/** + * Flag to signal that an update came from a manual update to the JSON or JavaScript. + * definition manually. + * @type boolean + */ +// TODO: Replace global state with parameter passed to functions. +BlockFactory.updateBlocksFlag = false; + +/** + * Delayed flag to avoid infinite update after updating the JSON or JavaScript. + * definition manually. + * @type boolean + */ +// TODO: Replace global state with parameter passed to functions. +BlockFactory.updateBlocksFlagDelayed = false; + +/** * The starting XML for the Block Factory main workspace. Contains the * unmovable, undeletable factory_base block. */ -BlockFactory.STARTER_BLOCK_XML_TEXT = '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '230' + - ''; +BlockFactory.STARTER_BLOCK_XML_TEXT = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '230' + + '' + + '' + + '' + + ''; /** * Change the language code format. @@ -85,8 +91,9 @@ BlockFactory.formatChange = function() { var mask = document.getElementById('blocklyMask'); var languagePre = document.getElementById('languagePre'); var languageTA = document.getElementById('languageTA'); - if (document.getElementById('format').value == 'Manual') { - Blockly.hideChaff(); + if (document.getElementById('format').value === 'Manual-JSON' || + document.getElementById('format').value === 'Manual-JS') { + Blockly.common.getMainWorkspace().hideChaff(); mask.style.display = 'block'; languagePre.style.display = 'none'; languageTA.style.display = 'block'; @@ -98,6 +105,9 @@ BlockFactory.formatChange = function() { mask.style.display = 'none'; languageTA.style.display = 'none'; languagePre.style.display = 'block'; + var code = languagePre.textContent.trim(); + languageTA.value = code; + BlockFactory.updateLanguage(); } BlockFactory.disableEnableLink(); @@ -115,10 +125,26 @@ BlockFactory.updateLanguage = function() { if (!blockType) { blockType = BlockFactory.UNNAMED; } - var format = document.getElementById('format').value; - var code = FactoryUtils.getBlockDefinition(blockType, rootBlock, format, - BlockFactory.mainWorkspace); - FactoryUtils.injectCode(code, 'languagePre'); + + if (!BlockFactory.updateBlocksFlag) { + var format = document.getElementById('format').value; + if (format === 'Manual-JSON') { + format = 'JSON'; + } else if (format === 'Manual-JS') { + format = 'JavaScript'; + } + + var code = FactoryUtils.getBlockDefinition(blockType, rootBlock, format, + BlockFactory.mainWorkspace); + FactoryUtils.injectCode(code, 'languagePre'); + if (!BlockFactory.updateBlocksFlagDelayed) { + var languagePre = document.getElementById('languagePre'); + var languageTA = document.getElementById('languageTA'); + code = languagePre.innerText.trim(); + languageTA.value = code; + } + } + BlockFactory.updatePreview(); }; @@ -138,11 +164,11 @@ BlockFactory.updateGenerator = function(block) { BlockFactory.updatePreview = function() { // Toggle between LTR/RTL if needed (also used in first display). var newDir = document.getElementById('direction').value; - if (BlockFactory.oldDir != newDir) { + if (BlockFactory.oldDir !== newDir) { if (BlockFactory.previewWorkspace) { BlockFactory.previewWorkspace.dispose(); } - var rtl = newDir == 'rtl'; + var rtl = newDir === 'rtl'; BlockFactory.previewWorkspace = Blockly.inject('preview', {rtl: rtl, media: '../../media/', @@ -151,60 +177,54 @@ BlockFactory.updatePreview = function() { } BlockFactory.previewWorkspace.clear(); - // Fetch the code and determine its format (JSON or JavaScript). - var format = document.getElementById('format').value; - if (format == 'Manual') { - var code = document.getElementById('languageTA').value; - // If the code is JSON, it will parse, otherwise treat as JS. - try { - JSON.parse(code); - format = 'JSON'; - } catch (e) { - format = 'JavaScript'; - } - } else { - var code = document.getElementById('languagePre').textContent; - } + var format = BlockFactory.getBlockDefinitionFormat(); + var code = document.getElementById('languageTA').value; if (!code.trim()) { // Nothing to render. Happens while cloud storage is loading. return; } - // Backup Blockly.Blocks object so that main workspace and preview don't - // collide if user creates a 'factory_base' block, for instance. - var backupBlocks = Blockly.Blocks; - try { - // Make a shallow copy. - Blockly.Blocks = Object.create(null); - for (var prop in backupBlocks) { - Blockly.Blocks[prop] = backupBlocks[prop]; + // Don't let the user create a block type that already exists, + // because it doesn't work. + var warnExistingBlock = function(blockType) { + if (reservedBlockFactoryBlocks.has(blockType)) { + var text = `You can't make a block called ${blockType} in this tool ` + + `because that name is reserved.`; + FactoryUtils.getRootBlock(BlockFactory.mainWorkspace).setWarningText(text); + console.error(text); + return true; } + return false; + } - if (format == 'JSON') { + var blockType = 'block_type'; + var blockCreated = false; + try { + if (format === 'JSON') { var json = JSON.parse(code); - Blockly.Blocks[json.type || BlockFactory.UNNAMED] = { + blockType = json.type || BlockFactory.UNNAMED; + if (warnExistingBlock(blockType)) { + return; + } + Blockly.Blocks[blockType] = { init: function() { this.jsonInit(json); } }; - } else if (format == 'JavaScript') { - eval(code); - } else { - throw 'Unknown format: ' + format; - } - - // Look for a block on Blockly.Blocks that does not match the backup. - var blockType = null; - for (var type in Blockly.Blocks) { - if (typeof Blockly.Blocks[type].init == 'function' && - Blockly.Blocks[type] != backupBlocks[type]) { - blockType = type; - break; + } else if (format === 'JavaScript') { + try { + blockType = FactoryUtils.getBlockTypeFromJsDefinition(code); + if (warnExistingBlock(blockType)) { + return; + } + eval(code); + } catch (e) { + // TODO: Display error in the UI + console.error("Error while evaluating JavaScript formatted block definition", e); + return; } } - if (!blockType) { - return; - } + blockCreated = true; // Create the preview block. var previewBlock = BlockFactory.previewWorkspace.newBlock(blockType); @@ -219,11 +239,11 @@ BlockFactory.updatePreview = function() { // Warn user only if their block type is already exists in Blockly's // standard library. var rootBlock = FactoryUtils.getRootBlock(BlockFactory.mainWorkspace); - if (StandardCategories.coreBlockTypes.indexOf(blockType) != -1) { + if (StandardCategories.coreBlockTypes.includes(blockType)) { rootBlock.setWarningText('A core Blockly block already exists ' + 'under this name.'); - } else if (blockType == 'block_type') { + } else if (blockType === 'block_type') { // Warn user to let them know they can't save a block under the default // name 'block_type' rootBlock.setWarningText('You cannot save a block with the default ' + @@ -232,12 +252,41 @@ BlockFactory.updatePreview = function() { } else { rootBlock.setWarningText(null); } - + } catch(err) { + // TODO: Show error on the UI + console.log(err); + BlockFactory.updateBlocksFlag = false + BlockFactory.updateBlocksFlagDelayed = false } finally { - Blockly.Blocks = backupBlocks; + // Remove the newly-created block. + // We have to check if the block was actually created so that we don't remove + // one of the built-in blocks, like factory_base. + if (blockCreated) { + delete Blockly.Blocks[blockType]; + } } }; +/** + * Gets the format from the Block Definitions' format selector/drop-down. + * @return Either 'JavaScript' or 'JSON'. + * @throws If selector value is not recognized. + */ +BlockFactory.getBlockDefinitionFormat = function() { + switch (document.getElementById('format').value) { + case 'JSON': + case 'Manual-JSON': + return 'JSON'; + + case 'JavaScript': + case 'Manual-JS': + return 'JavaScript'; + + default: + throw 'Unknown format: ' + format; + } +} + /** * Disable link and save buttons if the format is 'Manual', enable otherwise. */ @@ -245,7 +294,7 @@ BlockFactory.disableEnableLink = function() { var linkButton = document.getElementById('linkButton'); var saveBlockButton = document.getElementById('localSaveButton'); var saveToLibButton = document.getElementById('saveToBlockLibraryButton'); - var disabled = document.getElementById('format').value == 'Manual'; + var disabled = document.getElementById('format').value.substr(0, 6) === 'Manual'; linkButton.disabled = disabled; saveBlockButton.disabled = disabled; saveToLibButton.disabled = disabled; @@ -256,7 +305,7 @@ BlockFactory.disableEnableLink = function() { */ BlockFactory.showStarterBlock = function() { BlockFactory.mainWorkspace.clear(); - var xml = Blockly.Xml.textToDom(BlockFactory.STARTER_BLOCK_XML_TEXT); + var xml = Blockly.utils.xml.textToDom(BlockFactory.STARTER_BLOCK_XML_TEXT); Blockly.Xml.domToWorkspace(xml, BlockFactory.mainWorkspace); }; @@ -265,12 +314,25 @@ BlockFactory.showStarterBlock = function() { */ BlockFactory.isStarterBlock = function() { var rootBlock = FactoryUtils.getRootBlock(BlockFactory.mainWorkspace); - // The starter block does not have blocks nested into the factory_base block. - return !(rootBlock.getChildren().length > 0 || + return rootBlock && !( + // The starter block does not have blocks nested into the factory_base block. + rootBlock.getChildren().length > 0 || // The starter block's name is the default, 'block_type'. - rootBlock.getFieldValue('NAME').trim().toLowerCase() != 'block_type' || + rootBlock.getFieldValue('NAME').trim().toLowerCase() !== 'block_type' || // The starter block has no connections. - rootBlock.getFieldValue('CONNECTIONS') != 'NONE' || + rootBlock.getFieldValue('CONNECTIONS') !== 'NONE' || // The starter block has automatic inputs. - rootBlock.getFieldValue('INLINE') != 'AUTO'); + rootBlock.getFieldValue('INLINE') !== 'AUTO' + ); +}; + +/** + * Updates blocks from the manually edited js or json from their text area. + */ +BlockFactory.manualEdit = function() { + // TODO(#1267): Replace these global state flags with parameters passed to + // the right functions. + BlockFactory.updateBlocksFlag = true; + BlockFactory.updateBlocksFlagDelayed = true; + BlockFactory.updateLanguage(); }; diff --git a/demos/blockfactory/factory_utils.js b/demos/blockfactory/factory_utils.js index d66225c3adc..4731d1ce9e7 100644 --- a/demos/blockfactory/factory_utils.js +++ b/demos/blockfactory/factory_utils.js @@ -1,21 +1,7 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** @@ -24,15 +10,14 @@ * Exporter applications within Blockly Factory. Holds functions to generate * block definitions and generator stubs and to create and download files. * - * @author fraser@google.com (Neil Fraser), quachtina96 (Tina Quach) + * (Juan Carlos Orozco) */ - 'use strict'; +'use strict'; /** * Namespace for FactoryUtils. */ -goog.provide('FactoryUtils'); - +var FactoryUtils = FactoryUtils || Object.create(null); /** * Get block definition code for the current block. @@ -73,19 +58,32 @@ FactoryUtils.cleanBlockType = function(blockType) { * Get the generator code for a given block. * @param {!Blockly.Block} block Rendered block in preview workspace. * @param {string} generatorLanguage 'JavaScript', 'Python', 'PHP', 'Lua', - * 'Dart'. + * or 'Dart'. * @return {string} Generator code for multiple blocks. */ FactoryUtils.getGeneratorStub = function(block, generatorLanguage) { + // Build factory blocks from block + if (BlockFactory.updateBlocksFlag) { // TODO: Move this to updatePreview() + BlockFactory.mainWorkspace.clear(); + var xml = BlockDefinitionExtractor.buildBlockFactoryWorkspace(block); + Blockly.Xml.domToWorkspace(xml, BlockFactory.mainWorkspace); + // Calculate timer to avoid infinite update loops + // TODO(#1267): Remove the global variables and any infinite loops. + BlockFactory.updateBlocksFlag = false; + setTimeout( + function() {BlockFactory.updateBlocksFlagDelayed = false;}, 3000); + } + BlockFactory.lastUpdatedBlock = block; // Variable to share the block value. + function makeVar(root, name) { name = name.toLowerCase().replace(/\W/g, '_'); return ' var ' + root + '_' + name; } // The makevar function lives in the original update generator. - var language = generatorLanguage; + var language = generatorLanguage.toLowerCase(); var code = []; - code.push("Blockly." + language + "['" + block.type + - "'] = function(block) {"); + code.push(`${language}.${language}Generator.forBlock['${block.type}'] = ` + + 'function(block, generator) {'); // Generate getters for any fields or inputs. for (var i = 0, input; input = block.inputList[i]; i++) { @@ -95,54 +93,43 @@ FactoryUtils.getGeneratorStub = function(block, generatorLanguage) { continue; } if (field instanceof Blockly.FieldVariable) { - // Subclass of Blockly.FieldDropdown, must test first. - code.push(makeVar('variable', name) + - " = Blockly." + language + - ".variableDB_.getName(block.getFieldValue('" + name + - "'), Blockly.Variables.NAME_TYPE);"); - } else if (field instanceof Blockly.FieldAngle) { - // Subclass of Blockly.FieldTextInput, must test first. - code.push(makeVar('angle', name) + - " = block.getFieldValue('" + name + "');"); - } else if (Blockly.FieldDate && field instanceof Blockly.FieldDate) { - // Blockly.FieldDate may not be compiled into Blockly. - code.push(makeVar('date', name) + - " = block.getFieldValue('" + name + "');"); - } else if (field instanceof Blockly.FieldColour) { - code.push(makeVar('colour', name) + - " = block.getFieldValue('" + name + "');"); + // FieldVariable is subclass of FieldDropdown; must test first. + code.push(`${makeVar('variable', name)} = ` + + `generator.nameDB_.getName(block.getFieldValue('${name}'), ` + + `Blockly.Variables.NAME_TYPE);`); } else if (field instanceof Blockly.FieldCheckbox) { - code.push(makeVar('checkbox', name) + - " = block.getFieldValue('" + name + "') == 'TRUE';"); - } else if (field instanceof Blockly.FieldDropdown) { - code.push(makeVar('dropdown', name) + - " = block.getFieldValue('" + name + "');"); - } else if (field instanceof Blockly.FieldNumber) { - code.push(makeVar('number', name) + - " = block.getFieldValue('" + name + "');"); - } else if (field instanceof Blockly.FieldTextInput) { - code.push(makeVar('text', name) + - " = block.getFieldValue('" + name + "');"); + code.push(`${makeVar('checkbox', name)} = ` + + `block.getFieldValue('${name}') === 'TRUE';`); + } else { + let prefix = + // Angle is subclass of FieldTextInput; must test first. + field instanceof Blockly.FieldAngle ? 'angle' : + field instanceof Blockly.FieldColour ? 'colour' : + field instanceof Blockly.FieldDropdown ? 'dropdown' : + field instanceof Blockly.FieldNumber ? 'number' : + field instanceof Blockly.FieldTextInput ? 'text' : + 'field'; // Default if subclass not found. + code.push(`${makeVar(prefix, name)} = block.getFieldValue('${name}');`); } } var name = input.name; if (name) { - if (input.type == Blockly.INPUT_VALUE) { - code.push(makeVar('value', name) + - " = Blockly." + language + ".valueToCode(block, '" + name + - "', Blockly." + language + ".ORDER_ATOMIC);"); - } else if (input.type == Blockly.NEXT_STATEMENT) { - code.push(makeVar('statements', name) + - " = Blockly." + language + ".statementToCode(block, '" + - name + "');"); + if (input.type === Blockly.INPUT_VALUE) { + code.push(`${makeVar('value', name)} = ` + + `generator.valueToCode(block, '${name}', ` + + `${language}.Order.ATOMIC);`); + } else if (input.type === Blockly.NEXT_STATEMENT) { + code.push(`${makeVar('statements', name)} = ` + + `generator.statementToCode(block, '${name}');`); } } } - // Most languages end lines with a semicolon. Python does not. + // Most languages end lines with a semicolon. Python & Lua do not. var lineEnd = { 'JavaScript': ';', 'Python': '', 'PHP': ';', + 'Lua': '', 'Dart': ';' }; code.push(" // TODO: Assemble " + language + " into code variable."); @@ -163,7 +150,7 @@ FactoryUtils.getGeneratorStub = function(block, generatorLanguage) { * Update the language code as JSON. * @param {string} blockType Name of block. * @param {!Blockly.Block} rootBlock Factory_base block. - * @return {string} Generanted language code. + * @return {string} Generated language code. * @private */ FactoryUtils.formatJson_ = function(blockType, rootBlock) { @@ -176,11 +163,11 @@ FactoryUtils.formatJson_ = function(blockType, rootBlock) { var contentsBlock = rootBlock.getInputTargetBlock('INPUTS'); var lastInput = null; while (contentsBlock) { - if (!contentsBlock.disabled && !contentsBlock.getInheritedDisabled()) { + if (contentsBlock.isEnabled() && !contentsBlock.getInheritedDisabled()) { var fields = FactoryUtils.getFieldsJson_( contentsBlock.getInputTargetBlock('FIELDS')); for (var i = 0; i < fields.length; i++) { - if (typeof fields[i] == 'string') { + if (typeof fields[i] === 'string') { message.push(fields[i].replace(/%/g, '%%')); } else { args.push(fields[i]); @@ -190,7 +177,8 @@ FactoryUtils.formatJson_ = function(blockType, rootBlock) { var input = {type: contentsBlock.type}; // Dummy inputs don't have names. Other inputs do. - if (contentsBlock.type != 'input_dummy') { + if (contentsBlock.type !== 'input_dummy' && + contentsBlock.type !== 'input_end_row') { input.name = contentsBlock.getFieldValue('INPUTNAME'); } var check = JSON.parse( @@ -199,7 +187,7 @@ FactoryUtils.formatJson_ = function(blockType, rootBlock) { input.check = check; } var align = contentsBlock.getFieldValue('ALIGN'); - if (align != 'LEFT') { + if (align !== 'LEFT') { input.align = align; } args.push(input); @@ -210,12 +198,12 @@ FactoryUtils.formatJson_ = function(blockType, rootBlock) { contentsBlock.nextConnection.targetBlock(); } // Remove last input if dummy and not empty. - if (lastInput && lastInput.type == 'input_dummy') { + if (lastInput && lastInput.type === 'input_dummy') { var fields = lastInput.getInputTargetBlock('FIELDS'); - if (fields && FactoryUtils.getFieldsJson_(fields).join('').trim() != '') { + if (fields && FactoryUtils.getFieldsJson_(fields).join('').trim() !== '') { var align = lastInput.getFieldValue('ALIGN'); - if (align != 'LEFT') { - JS.lastDummyAlign0 = align; + if (align !== 'LEFT') { + JS.implicitAlign0 = align; } args.pop(); message.pop(); @@ -226,9 +214,9 @@ FactoryUtils.formatJson_ = function(blockType, rootBlock) { JS.args0 = args; } // Generate inline/external switch. - if (rootBlock.getFieldValue('INLINE') == 'EXT') { + if (rootBlock.getFieldValue('INLINE') === 'EXT') { JS.inputsInline = false; - } else if (rootBlock.getFieldValue('INLINE') == 'INT') { + } else if (rootBlock.getFieldValue('INLINE') === 'INT') { JS.inputsInline = true; } // Generate output, or next/previous connections. @@ -259,7 +247,7 @@ FactoryUtils.formatJson_ = function(blockType, rootBlock) { } // Generate colour. var colourBlock = rootBlock.getInputTargetBlock('COLOUR'); - if (colourBlock && !colourBlock.disabled) { + if (colourBlock && colourBlock.isEnabled()) { var hue = parseInt(colourBlock.getFieldValue('HUE'), 10); JS.colour = hue; } @@ -285,13 +273,15 @@ FactoryUtils.formatJavaScript_ = function(blockType, rootBlock, workspace) { // Generate inputs. var TYPES = {'input_value': 'appendValueInput', 'input_statement': 'appendStatementInput', - 'input_dummy': 'appendDummyInput'}; + 'input_dummy': 'appendDummyInput', + 'input_end_row': 'appendEndRowInput'}; var contentsBlock = rootBlock.getInputTargetBlock('INPUTS'); while (contentsBlock) { - if (!contentsBlock.disabled && !contentsBlock.getInheritedDisabled()) { + if (contentsBlock.isEnabled() && !contentsBlock.getInheritedDisabled()) { var name = ''; // Dummy inputs don't have names. Other inputs do. - if (contentsBlock.type != 'input_dummy') { + if (contentsBlock.type !== 'input_dummy' && + contentsBlock.type !== 'input_end_row') { name = JSON.stringify(contentsBlock.getFieldValue('INPUTNAME')); } @@ -301,7 +291,7 @@ FactoryUtils.formatJavaScript_ = function(blockType, rootBlock, workspace) { code.push(' .setCheck(' + check + ')'); } var align = contentsBlock.getFieldValue('ALIGN'); - if (align != 'LEFT') { + if (align !== 'LEFT') { code.push(' .setAlign(Blockly.ALIGN_' + align + ')'); } var fields = FactoryUtils.getFieldsJs_( @@ -316,9 +306,9 @@ FactoryUtils.formatJavaScript_ = function(blockType, rootBlock, workspace) { contentsBlock.nextConnection.targetBlock(); } // Generate inline/external switch. - if (rootBlock.getFieldValue('INLINE') == 'EXT') { + if (rootBlock.getFieldValue('INLINE') === 'EXT') { code.push(' this.setInputsInline(false);'); - } else if (rootBlock.getFieldValue('INLINE') == 'INT') { + } else if (rootBlock.getFieldValue('INLINE') === 'INT') { code.push(' this.setInputsInline(true);'); } // Generate output, or next/previous connections. @@ -343,7 +333,7 @@ FactoryUtils.formatJavaScript_ = function(blockType, rootBlock, workspace) { } // Generate colour. var colourBlock = rootBlock.getInputTargetBlock('COLOUR'); - if (colourBlock && !colourBlock.disabled) { + if (colourBlock && colourBlock.isEnabled()) { var hue = parseInt(colourBlock.getFieldValue('HUE'), 10); if (!isNaN(hue)) { code.push(' this.setColour(' + hue + ');'); @@ -381,18 +371,24 @@ FactoryUtils.connectionLineJs_ = function(functionName, typeName, workspace) { /** * Returns field strings and any config. * @param {!Blockly.Block} block Input block. - * @return {!Array.} Field strings. + * @return {!Array} Field strings. * @private */ FactoryUtils.getFieldsJs_ = function(block) { var fields = []; while (block) { - if (!block.disabled && !block.getInheritedDisabled()) { + if (block.isEnabled() && !block.getInheritedDisabled()) { switch (block.type) { case 'field_static': // Result: 'hello' fields.push(JSON.stringify(block.getFieldValue('TEXT'))); break; + case 'field_label_serializable': + // Result: new Blockly.FieldLabelSerializable('Hello'), 'GREET' + fields.push('new Blockly.FieldLabelSerializable(' + + JSON.stringify(block.getFieldValue('TEXT')) + '), ' + + JSON.stringify(block.getFieldValue('FIELDNAME'))); + break; case 'field_input': // Result: new Blockly.FieldTextInput('Hello'), 'GREET' fields.push('new Blockly.FieldTextInput(' + @@ -408,11 +404,11 @@ FactoryUtils.getFieldsJs_ = function(block) { Number(block.getFieldValue('PRECISION')) ]; // Remove any trailing arguments that aren't needed. - if (args[3] == 0) { + if (args[3] === 0) { args.pop(); - if (args[2] == Infinity) { + if (args[2] === Infinity) { args.pop(); - if (args[1] == -Infinity) { + if (args[1] === -Infinity) { args.pop(); } } @@ -423,7 +419,7 @@ FactoryUtils.getFieldsJs_ = function(block) { case 'field_angle': // Result: new Blockly.FieldAngle(90), 'ANGLE' fields.push('new Blockly.FieldAngle(' + - parseFloat(block.getFieldValue('ANGLE')) + '), ' + + Number(block.getFieldValue('ANGLE')) + '), ' + JSON.stringify(block.getFieldValue('FIELDNAME'))); break; case 'field_checkbox': @@ -440,12 +436,6 @@ FactoryUtils.getFieldsJs_ = function(block) { '), ' + JSON.stringify(block.getFieldValue('FIELDNAME'))); break; - case 'field_date': - // Result: new Blockly.FieldDate('2015-02-04'), 'DATE' - fields.push('new Blockly.FieldDate(' + - JSON.stringify(block.getFieldValue('DATE')) + '), ' + - JSON.stringify(block.getFieldValue('FIELDNAME'))); - break; case 'field_variable': // Result: new Blockly.FieldVariable('item'), 'VAR' var varname @@ -473,8 +463,10 @@ FactoryUtils.getFieldsJs_ = function(block) { var width = Number(block.getFieldValue('WIDTH')); var height = Number(block.getFieldValue('HEIGHT')); var alt = JSON.stringify(block.getFieldValue('ALT')); + var flipRtl = JSON.stringify(block.getFieldValue('FLIP_RTL')); fields.push('new Blockly.FieldImage(' + - src + ', ' + width + ', ' + height + ', ' + alt + ')'); + src + ', ' + width + ', ' + height + + ', { alt: ' + alt + ', flipRtl: ' + flipRtl + ' })'); break; } } @@ -486,18 +478,25 @@ FactoryUtils.getFieldsJs_ = function(block) { /** * Returns field strings and any config. * @param {!Blockly.Block} block Input block. - * @return {!Array.} Array of static text and field configs. + * @return {!Array} Array of static text and field configs. * @private */ FactoryUtils.getFieldsJson_ = function(block) { var fields = []; while (block) { - if (!block.disabled && !block.getInheritedDisabled()) { + if (block.isEnabled() && !block.getInheritedDisabled()) { switch (block.type) { case 'field_static': // Result: 'hello' fields.push(block.getFieldValue('TEXT')); break; + case 'field_label_serializable': + fields.push({ + type: block.type, + name: block.getFieldValue('FIELDNAME'), + text: block.getFieldValue('TEXT') + }); + break; case 'field_input': fields.push({ type: block.type, @@ -509,17 +508,17 @@ FactoryUtils.getFieldsJson_ = function(block) { var obj = { type: block.type, name: block.getFieldValue('FIELDNAME'), - value: parseFloat(block.getFieldValue('VALUE')) + value: Number(block.getFieldValue('VALUE')) }; - var min = parseFloat(block.getFieldValue('MIN')); + var min = Number(block.getFieldValue('MIN')); if (min > -Infinity) { obj.min = min; } - var max = parseFloat(block.getFieldValue('MAX')); + var max = Number(block.getFieldValue('MAX')); if (max < Infinity) { obj.max = max; } - var precision = parseFloat(block.getFieldValue('PRECISION')); + var precision = Number(block.getFieldValue('PRECISION')); if (precision) { obj.precision = precision; } @@ -536,7 +535,7 @@ FactoryUtils.getFieldsJson_ = function(block) { fields.push({ type: block.type, name: block.getFieldValue('FIELDNAME'), - checked: block.getFieldValue('CHECKED') == 'TRUE' + checked: block.getFieldValue('CHECKED') === 'TRUE' }); break; case 'field_colour': @@ -546,13 +545,6 @@ FactoryUtils.getFieldsJson_ = function(block) { colour: block.getFieldValue('COLOUR') }); break; - case 'field_date': - fields.push({ - type: block.type, - name: block.getFieldValue('FIELDNAME'), - date: block.getFieldValue('DATE') - }); - break; case 'field_variable': fields.push({ type: block.type, @@ -580,7 +572,8 @@ FactoryUtils.getFieldsJson_ = function(block) { src: block.getFieldValue('SRC'), width: Number(block.getFieldValue('WIDTH')), height: Number(block.getFieldValue('HEIGHT')), - alt: block.getFieldValue('ALT') + alt: block.getFieldValue('ALT'), + flipRtl: block.getFieldValue('FLIP_RTL') === 'TRUE' }); break; } @@ -599,11 +592,11 @@ FactoryUtils.getFieldsJson_ = function(block) { */ FactoryUtils.getOptTypesFrom = function(block, name) { var types = FactoryUtils.getTypesFrom_(block, name); - if (types.length == 0) { + if (types.length === 0) { return undefined; - } else if (types.indexOf('null') != -1) { + } else if (types.includes('null')) { return 'null'; - } else if (types.length == 1) { + } else if (types.length === 1) { return types[0]; } else { return '[' + types.join(', ') + ']'; @@ -615,17 +608,17 @@ FactoryUtils.getOptTypesFrom = function(block, name) { * Fetch the type(s) defined in the given input. * @param {!Blockly.Block} block Block with input. * @param {string} name Name of the input. - * @return {!Array.} List of types. + * @return {!Array} List of types. * @private */ FactoryUtils.getTypesFrom_ = function(block, name) { var typeBlock = block.getInputTargetBlock(name); var types; - if (!typeBlock || typeBlock.disabled) { + if (!typeBlock || !typeBlock.isEnabled()) { types = []; - } else if (typeBlock.type == 'type_other') { + } else if (typeBlock.type === 'type_other') { types = [JSON.stringify(typeBlock.getFieldValue('TYPE'))]; - } else if (typeBlock.type == 'type_group') { + } else if (typeBlock.type === 'type_group') { types = []; for (var n = 0; n < typeBlock.typeCount_; n++) { types = types.concat(FactoryUtils.getTypesFrom_(typeBlock, 'TYPE' + n)); @@ -653,7 +646,7 @@ FactoryUtils.getTypesFrom_ = function(block, name) { FactoryUtils.getRootBlock = function(workspace) { var blocks = workspace.getTopBlocks(false); for (var i = 0, block; block = blocks[i]; i++) { - if (block.type == 'factory_base') { + if (block.type === 'factory_base') { return block; } } @@ -736,23 +729,23 @@ FactoryUtils.getDefinedBlock = function(blockType, workspace) { FactoryUtils.getBlockTypeFromJsDefinition = function(blockDef) { var indexOfStartBracket = blockDef.indexOf('[\''); var indexOfEndBracket = blockDef.indexOf('\']'); - if (indexOfStartBracket != -1 && indexOfEndBracket != -1) { + if (indexOfStartBracket !== -1 && indexOfEndBracket !== -1) { return blockDef.substring(indexOfStartBracket + 2, indexOfEndBracket); } else { - throw new Error ('Could not parse block type out of JavaScript block ' + + throw Error('Could not parse block type out of JavaScript block ' + 'definition. Brackets normally enclosing block type not found.'); } }; /** * Generates a category containing blocks of the specified block types. - * @param {!Array.} blocks Blocks to include in the category. + * @param {!Array} blocks Blocks to include in the category. * @param {string} categoryName Name to use for the generated category. * @return {!Element} Category XML containing the given block types. */ FactoryUtils.generateCategoryXml = function(blocks, categoryName) { // Create category DOM element. - var categoryElement = goog.dom.createDom('category'); + var categoryElement = Blockly.utils.xml.createElement('category'); categoryElement.setAttribute('name', categoryName); // For each block, add block element to category. @@ -772,15 +765,15 @@ FactoryUtils.generateCategoryXml = function(blocks, categoryName) { * Parses a string containing JavaScript block definition(s) to create an array * in which each element is a single block definition. * @param {string} blockDefsString JavaScript block definition(s). - * @return {!Array.} Array of block definitions. + * @return {!Array} Array of block definitions. */ FactoryUtils.parseJsBlockDefinitions = function(blockDefsString) { var blockDefArray = []; var defStart = blockDefsString.indexOf('Blockly.Blocks'); - while (blockDefsString.indexOf('Blockly.Blocks', defStart) != -1) { + while (blockDefsString.includes('Blockly.Blocks', defStart)) { var nextStart = blockDefsString.indexOf('Blockly.Blocks', defStart + 1); - if (nextStart == -1) { + if (nextStart === -1) { // This is the last block definition. nextStart = blockDefsString.length; } @@ -798,7 +791,7 @@ FactoryUtils.parseJsBlockDefinitions = function(blockDefsString) { * JSON objects. * @param {string} blockDefsString String containing JSON block * definition(s). - * @return {!Array.} Array of block definitions. + * @return {!Array} Array of block definitions. */ FactoryUtils.parseJsonBlockDefinitions = function(blockDefsString) { var blockDefArray = []; @@ -808,13 +801,13 @@ FactoryUtils.parseJsonBlockDefinitions = function(blockDefsString) { // are balanced. for (var i = 0; i < blockDefsString.length; i++) { var currentChar = blockDefsString[i]; - if (currentChar == '{') { + if (currentChar === '{') { unbalancedBracketCount++; } - else if (currentChar == '}') { + else if (currentChar === '}') { unbalancedBracketCount--; - if (unbalancedBracketCount == 0 && i > 0) { - // The brackets are balanced. We've got a complete block defintion. + if (unbalancedBracketCount === 0 && i > 0) { + // The brackets are balanced. We've got a complete block definition. var blockDef = blockDefsString.substring(defStart, i + 1); blockDefArray.push(blockDef); defStart = i + 1; @@ -828,13 +821,13 @@ FactoryUtils.parseJsonBlockDefinitions = function(blockDefsString) { * Define blocks from imported block definitions. * @param {string} blockDefsString Block definition(s). * @param {string} format Block definition format ('JSON' or 'JavaScript'). - * @return {!Array.} Array of block types defined. + * @return {!Array} Array of block types defined. */ FactoryUtils.defineAndGetBlockTypes = function(blockDefsString, format) { var blockTypes = []; // Define blocks and get block types. - if (format == 'JSON') { + if (format === 'JSON') { var blockDefArray = FactoryUtils.parseJsonBlockDefinitions(blockDefsString); // Populate array of blocktypes and define each block. @@ -849,7 +842,7 @@ FactoryUtils.defineAndGetBlockTypes = function(blockDefsString, format) { } }; } - } else if (format == 'JavaScript') { + } else if (format === 'JavaScript') { var blockDefArray = FactoryUtils.parseJsBlockDefinitions(blockDefsString); // Populate array of block types. @@ -874,9 +867,9 @@ FactoryUtils.defineAndGetBlockTypes = function(blockDefsString, format) { FactoryUtils.injectCode = function(code, id) { var pre = document.getElementById(id); pre.textContent = code; - code = pre.textContent; - code = PR.prettyPrintOne(code, 'js'); - pre.innerHTML = code; + // Remove the 'prettyprinted' class, so that Prettify will recalculate. + pre.className = pre.className.replace('prettyprinted', ''); + PR.prettyPrint(); }; /** @@ -891,33 +884,77 @@ FactoryUtils.injectCode = function(code, id) { */ FactoryUtils.sameBlockXml = function(blockXml1, blockXml2) { // Each XML element should contain a single child element with a 'block' tag - if (blockXml1.tagName.toLowerCase() != 'xml' || - blockXml2.tagName.toLowerCase() != 'xml') { - throw new Error('Expected two XML elements, recieved elements with tag ' + + if (blockXml1.tagName.toLowerCase() !== 'xml' || + blockXml2.tagName.toLowerCase() !== 'xml') { + throw Error('Expected two XML elements, received elements with tag ' + 'names: ' + blockXml1.tagName + ' and ' + blockXml2.tagName + '.'); } // Compare the block elements directly. The XML tags may include other meta - // information we want to igrore. + // information we want to ignore. var blockElement1 = blockXml1.getElementsByTagName('block')[0]; var blockElement2 = blockXml2.getElementsByTagName('block')[0]; if (!(blockElement1 && blockElement2)) { - throw new Error('Could not get find block element in XML.'); + throw Error('Could not get find block element in XML.'); } - var blockXmlText1 = Blockly.Xml.domToText(blockElement1); - var blockXmlText2 = Blockly.Xml.domToText(blockElement2); + var cleanBlockXml1 = FactoryUtils.cleanXml(blockElement1); + var cleanBlockXml2 = FactoryUtils.cleanXml(blockElement2); + + var blockXmlText1 = Blockly.Xml.domToText(cleanBlockXml1); + var blockXmlText2 = Blockly.Xml.domToText(cleanBlockXml2); // Strip white space. blockXmlText1 = blockXmlText1.replace(/\s+/g, ''); blockXmlText2 = blockXmlText2.replace(/\s+/g, ''); // Return whether or not changes have been saved. - return blockXmlText1 == blockXmlText2; + return blockXmlText1 === blockXmlText2; +}; + +/** + * Strips the provided xml of any attributes that don't describe the + * 'structure' of the blocks (i.e. block order, field values, etc). + * @param {Node} xml The xml to clean. + * @return {Node} + */ +FactoryUtils.cleanXml = function(xml) { + var newXml = xml.cloneNode(true); + var node = newXml; + while (node) { + // Things like text inside tags are still treated as nodes, but they + // don't have attributes (or the removeAttribute function) so we can + // skip removing attributes from them. + if (node.removeAttribute) { + node.removeAttribute('xmlns'); + node.removeAttribute('x'); + node.removeAttribute('y'); + node.removeAttribute('id'); + } + + // Try to go down the tree + var nextNode = node.firstChild || node.nextSibling; + // If we can't go down, try to go back up the tree. + if (!nextNode) { + nextNode = node.parentNode; + while (nextNode) { + // We are valid again! + if (nextNode.nextSibling) { + nextNode = nextNode.nextSibling; + break; + } + // Try going up again. If parentNode is null that means we have + // reached the top, and we will break out of both loops. + nextNode = nextNode.parentNode; + } + } + node = nextNode; + } + return newXml; }; -/* +/** * Checks if a block has a variable field. Blocks with variable fields cannot * be shadow blocks. * @param {Blockly.Block} block The block to check if a variable field exists. @@ -939,11 +976,11 @@ FactoryUtils.hasVariableField = function(block) { */ FactoryUtils.isProcedureBlock = function(block) { return block && - (block.type == 'procedures_defnoreturn' || - block.type == 'procedures_defreturn' || - block.type == 'procedures_callnoreturn' || - block.type == 'procedures_callreturn' || - block.type == 'procedures_ifreturn'); + (block.type === 'procedures_defnoreturn' || + block.type === 'procedures_defreturn' || + block.type === 'procedures_callnoreturn' || + block.type === 'procedures_callreturn' || + block.type === 'procedures_ifreturn'); }; /** @@ -978,7 +1015,7 @@ FactoryUtils.savedBlockChanges = function(blockLibraryController) { */ FactoryUtils.getTooltipFromRootBlock_ = function(rootBlock) { var tooltipBlock = rootBlock.getInputTargetBlock('TOOLTIP'); - if (tooltipBlock && !tooltipBlock.disabled) { + if (tooltipBlock && tooltipBlock.isEnabled()) { return tooltipBlock.getFieldValue('TEXT'); } return ''; @@ -992,7 +1029,7 @@ FactoryUtils.getTooltipFromRootBlock_ = function(rootBlock) { */ FactoryUtils.getHelpUrlFromRootBlock_ = function(rootBlock) { var helpUrlBlock = rootBlock.getInputTargetBlock('HELPURL'); - if (helpUrlBlock && !helpUrlBlock.disabled) { + if (helpUrlBlock && helpUrlBlock.isEnabled()) { return helpUrlBlock.getFieldValue('TEXT'); } return ''; diff --git a/demos/blockfactory/index.html b/demos/blockfactory/index.html index 2b49f7ef26c..3e532bc49a6 100644 --- a/demos/blockfactory/index.html +++ b/demos/blockfactory/index.html @@ -4,14 +4,16 @@ Blockly Demo: Blockly Developer Tools - - - - - + + + + + + + @@ -28,10 +30,13 @@ - + + -
    
    +              
    
                   
                 
               
    @@ -380,7 +405,7 @@ 

    Generator stub: -
    
    +              
    
                 
               
             
    @@ -390,7 +415,7 @@ 

    Generator stub:
    - + @@ -403,20 +428,17 @@

    Generator stub: + + - @@ -442,7 +464,7 @@

    Generator stub: - + diff --git a/demos/blockfactory/standard_categories.js b/demos/blockfactory/standard_categories.js index 6b4072680c5..c0e82d50357 100644 --- a/demos/blockfactory/standard_categories.js +++ b/demos/blockfactory/standard_categories.js @@ -1,21 +1,7 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** @@ -24,15 +10,13 @@ * the lower case name of the category, and contains the Category object for * that particular category. Also has a list of core block types provided * by Blockly. - * - * @author Emma Dauterman (evd2014) */ 'use strict'; /** * Namespace for StandardCategories */ -goog.provide('StandardCategories'); +var StandardCategories = StandardCategories || Object.create(null); // Map of standard category information necessary to add a standard category @@ -42,340 +26,345 @@ StandardCategories.categoryMap = Object.create(null); StandardCategories.categoryMap['logic'] = new ListElement(ListElement.TYPE_CATEGORY, 'Logic'); StandardCategories.categoryMap['logic'].xml = - Blockly.Xml.textToDom( - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + + Blockly.utils.xml.textToDom( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + ''); -StandardCategories.categoryMap['logic'].color ='#5C81A6'; +StandardCategories.categoryMap['logic'].hue = 210; StandardCategories.categoryMap['loops'] = new ListElement(ListElement.TYPE_CATEGORY, 'Loops'); StandardCategories.categoryMap['loops'].xml = - Blockly.Xml.textToDom( - '' + - '' + - '' + - '' + - '10' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '1' + - '' + - '' + - '' + - '' + - '10' + - '' + - '' + - '' + - '' + - '1' + - '' + - '' + - '' + - '' + - '' + + Blockly.utils.xml.textToDom( + '' + + '' + + '' + + '' + + '10' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '10' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '' + ''); -StandardCategories.categoryMap['loops'].color = '#5CA65C'; +StandardCategories.categoryMap['loops'].hue = 120; StandardCategories.categoryMap['math'] = new ListElement(ListElement.TYPE_CATEGORY, 'Math'); StandardCategories.categoryMap['math'].xml = - Blockly.Xml.textToDom( - '' + - '' + - '' + - '' + - '' + - '1' + - '' + - '' + - '' + - '' + - '1' + - '' + - '' + - '' + - '' + - '' + - '' + - '9' + - '' + - '' + - '' + - '' + - '' + - '' + - '45' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '0' + - '' + - '' + - '' + - '' + - '' + - '' + - '3.1' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '64' + - '' + - '' + - '' + - '' + - '10'+ - '' + - '' + - '' + - '' + - '' + - '' + - '50' + - '' + - '' + - '' + - '' + - '1' + - '' + - '' + - '' + - '' + - '100' + - '' + - '' + - '' + - '' + - '' + - '' + - '1' + - '' + - '' + - '' + - '' + - '100' + - '' + - '' + - '' + - '' + + Blockly.utils.xml.textToDom( + '' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '' + + '' + + '9' + + '' + + '' + + '' + + '' + + '' + + '' + + '45' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '0' + + '' + + '' + + '' + + '' + + '' + + '' + + '3.1' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '64' + + '' + + '' + + '' + + '' + + '10'+ + '' + + '' + + '' + + '' + + '' + + '' + + '50' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '100' + + '' + + '' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '100' + + '' + + '' + + '' + + '' + ''); -StandardCategories.categoryMap['math'].color = '#5C68A6'; +StandardCategories.categoryMap['math'].hue = 230; StandardCategories.categoryMap['text'] = new ListElement(ListElement.TYPE_CATEGORY, 'Text'); StandardCategories.categoryMap['text'].xml = - Blockly.Xml.textToDom( - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - 'abc' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - 'text' + - '' + - '' + - '' + - '' + - 'abc' + - '' + - '' + - '' + - '' + - '' + - '' + - 'text' + - '' + - '' + - '' + - '' + - '' + - '' + - 'text' + - '' + - '' + - '' + - '' + - '' + - '' + - 'abc' + - '' + - '' + - '' + - '' + - '' + - '' + - 'abc' + - '' + - '' + - '' + - '' + - '' + - '' + - 'abc' + - '' + - '' + - '' + - '' + - '' + - '' + - 'abc' + - '' + - '' + - '' + + Blockly.utils.xml.textToDom( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + 'text' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + + '' + + '' + + '' + + 'text' + + '' + + '' + + '' + + '' + + '' + + '' + + 'text' + + '' + + '' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + ''); -StandardCategories.categoryMap['text'].color = '#5CA68D'; +StandardCategories.categoryMap['text'].hue = 160; StandardCategories.categoryMap['lists'] = new ListElement(ListElement.TYPE_CATEGORY, 'Lists'); StandardCategories.categoryMap['lists'].xml = - Blockly.Xml.textToDom( - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '5' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - 'list' + - '' + - '' + - '' + - '' + - '' + - '' + - 'list' + - '' + - '' + - '' + - '' + - '' + - '' + - 'list' + - '' + - '' + - '' + - '' + - '' + - '' + - 'list' + - '' + - '' + - '' + - '' + - '' + - '' + - ',' + - '' + - '' + - '' + - '' + + Blockly.utils.xml.textToDom( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '5' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + 'list' + + '' + + '' + + '' + + '' + + '' + + '' + + 'list' + + '' + + '' + + '' + + '' + + '' + + '' + + 'list' + + '' + + '' + + '' + + '' + + '' + + '' + + 'list' + + '' + + '' + + '' + + '' + + '' + + '' + + ',' + + '' + + '' + + '' + + '' + ''); -StandardCategories.categoryMap['lists'].color = '#745CA6'; +StandardCategories.categoryMap['lists'].hue = 260; StandardCategories.categoryMap['colour'] = new ListElement(ListElement.TYPE_CATEGORY, 'Colour'); StandardCategories.categoryMap['colour'].xml = - Blockly.Xml.textToDom( - '' + - '' + - '' + - '' + - '' + - '' + - '100' + - '' + - '' + - '' + - '' + - '50' + - '' + - '' + - '' + - '' + - '0' + + Blockly.utils.xml.textToDom( + '' + + '' + + '' + + '' + + '' + + '' + + '100' + + '' + + '' + + '' + + '' + + '50' + + '' + + '' + + '' + + '' + + '0' + + '' + + '' + + '' + + '' + + '' + + '' + + '#ff0000' + + '' + + '' + + '' + + '' + + '#3333ff' + + '' + + '' + + '' + + '' + + '0.5' + '' + - '' + - '' + - '' + - '' + - '' + - '#ff0000' + - '' + - '' + - '' + - '' + - '#3333ff' + - '' + - '' + - '' + - '' + - '0.5' + - '' + - '' + - '' + + '' + + '' + ''); -StandardCategories.categoryMap['colour'].color = '#A6745C'; +StandardCategories.categoryMap['colour'].hue = 20; StandardCategories.categoryMap['functions'] = new ListElement(ListElement.TYPE_CATEGORY, 'Functions'); -StandardCategories.categoryMap['functions'].color = '#9A5CA6' +StandardCategories.categoryMap['functions'].hue = 290; StandardCategories.categoryMap['functions'].custom = 'PROCEDURE'; StandardCategories.categoryMap['variables'] = new ListElement(ListElement.TYPE_CATEGORY, 'Variables'); -StandardCategories.categoryMap['variables'].color = '#A65C81'; +StandardCategories.categoryMap['variables'].hue = 330; StandardCategories.categoryMap['variables'].custom = 'VARIABLE'; +StandardCategories.categoryMap['typedvariables'] = + new ListElement(ListElement.TYPE_CATEGORY, 'TypedVariables'); +StandardCategories.categoryMap['typedvariables'].custom = 'VARIABLE_DYNAMIC'; +StandardCategories.categoryMap['typedvariables'].hue = 290; + // All standard block types in provided in Blockly core. StandardCategories.coreBlockTypes = ["controls_if", "logic_compare", "logic_operation", "logic_negate", "logic_boolean", "logic_null", diff --git a/demos/blockfactory/workspacefactory/wfactory_controller.js b/demos/blockfactory/workspacefactory/wfactory_controller.js index 5d80f485858..385feede8ec 100644 --- a/demos/blockfactory/workspacefactory/wfactory_controller.js +++ b/demos/blockfactory/workspacefactory/wfactory_controller.js @@ -1,21 +1,7 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** @@ -30,14 +16,8 @@ * - updating the preview workspace * - changing a category name * - moving the position of a category. - * - * @author Emma Dauterman (evd2014) */ - goog.require('FactoryUtils'); - goog.require('StandardCategories'); - - /** * Class for a WorkspaceFactoryController * @param {string} toolboxName Name of workspace toolbox XML. @@ -68,7 +48,7 @@ WorkspaceFactoryController = function(toolboxName, toolboxDiv, previewDiv) { colour: '#ccc', snap: true}, media: '../../media/', - toolbox: '', + toolbox: '', zoom: {controls: true, wheel: true} @@ -94,7 +74,7 @@ WorkspaceFactoryController = function(toolboxName, toolboxDiv, previewDiv) { // toolbox. WorkspaceFactoryController.MODE_TOOLBOX = 'toolbox'; // Pre-loaded workspace editing mode. Changes the user makes to the workspace -// udpates the pre-loaded blocks. +// updates the pre-loaded blocks. WorkspaceFactoryController.MODE_PRELOAD = 'preload'; /** @@ -171,7 +151,7 @@ WorkspaceFactoryController.prototype.transferFlyoutBlocksToCategory = // Saves the user's blocks from the flyout in a category if there is no // toolbox and the user has dragged in blocks. if (!this.model.hasElements() && - this.toolboxWorkspace.getAllBlocks().length > 0) { + this.toolboxWorkspace.getAllBlocks(false).length > 0) { // Create the new category. this.createCategory('Category 1', true); // Set the new category as selected. @@ -265,7 +245,7 @@ WorkspaceFactoryController.prototype.switchElement = function(id) { Blockly.Events.disable(); // Caches information to reload or generate XML if switching to/from element. // Only saves if a category is selected. - if (this.model.getSelectedId() != null && id != null) { + if (this.model.getSelectedId() !== null && id !== null) { this.model.getSelected().saveFromWorkspace(this.toolboxWorkspace); } // Load element. @@ -281,13 +261,13 @@ WorkspaceFactoryController.prototype.switchElement = function(id) { */ WorkspaceFactoryController.prototype.clearAndLoadElement = function(id) { // Unselect current tab if switching to and from an element. - if (this.model.getSelectedId() != null && id != null) { + if (this.model.getSelectedId() !== null && id !== null) { this.view.setCategoryTabSelection(this.model.getSelectedId(), false); } // If switching to another category, set category selection in the model and // view. - if (id != null) { + if (id !== null) { // Set next category. this.model.setSelectedById(id); @@ -319,7 +299,7 @@ WorkspaceFactoryController.prototype.clearAndLoadElement = function(id) { */ WorkspaceFactoryController.prototype.exportXmlFile = function(exportMode) { // Get file name. - if (exportMode == WorkspaceFactoryController.MODE_TOOLBOX) { + if (exportMode === WorkspaceFactoryController.MODE_TOOLBOX) { var fileName = prompt('File Name for toolbox XML:', 'toolbox.xml'); } else { var fileName = prompt('File Name for pre-loaded workspace XML:', @@ -330,25 +310,37 @@ WorkspaceFactoryController.prototype.exportXmlFile = function(exportMode) { } // Generate XML. - if (exportMode == WorkspaceFactoryController.MODE_TOOLBOX) { + if (exportMode === WorkspaceFactoryController.MODE_TOOLBOX) { // Export the toolbox XML. - var configXml = Blockly.Xml.domToPrettyText - (this.generator.generateToolboxXml()); + var configXml = Blockly.Xml.domToPrettyText( + this.generator.generateToolboxXml()); this.hasUnsavedToolboxChanges = false; - } else if (exportMode == WorkspaceFactoryController.MODE_PRELOAD) { + } else if (exportMode === WorkspaceFactoryController.MODE_PRELOAD) { // Export the pre-loaded block XML. - var configXml = Blockly.Xml.domToPrettyText - (this.generator.generateWorkspaceXml()); + var configXml = Blockly.Xml.domToPrettyText( + this.generator.generateWorkspaceXml()); this.hasUnsavedPreloadChanges = false; } else { // Unknown mode. Throw error. - throw new Error ("Unknown export mode: " + exportMode); + var msg = 'Unknown export mode: ' + exportMode; + BlocklyDevTools.Analytics.onError(msg); + throw Error(msg); } // Download file. var data = new Blob([configXml], {type: 'text/xml'}); this.view.createAndDownloadFile(fileName, data); - }; + + if (exportMode === WorkspaceFactoryController.MODE_TOOLBOX) { + BlocklyDevTools.Analytics.onExport( + BlocklyDevTools.Analytics.TOOLBOX, + { format: BlocklyDevTools.Analytics.FORMAT_XML }); + } else if (exportMode === WorkspaceFactoryController.MODE_PRELOAD) { + BlocklyDevTools.Analytics.onExport( + BlocklyDevTools.Analytics.WORKSPACE_CONTENTS, + { format: BlocklyDevTools.Analytics.FORMAT_XML }); + } +}; /** * Export the options object to be used for the Blockly inject call. Gets a @@ -366,6 +358,13 @@ WorkspaceFactoryController.prototype.exportInjectFile = function() { var printableOptions = this.generator.generateInjectString() var data = new Blob([printableOptions], {type: 'text/javascript'}); this.view.createAndDownloadFile(fileName, data); + + BlocklyDevTools.Analytics.onExport( + BlocklyDevTools.Analytics.STARTER_CODE, + { + format: BlocklyDevTools.Analytics.FORMAT_JS, + platform: BlocklyDevTools.Analytics.PLATFORM_WEB + }); }; /** @@ -376,8 +375,7 @@ WorkspaceFactoryController.prototype.printConfig = function() { // Capture any changes made by user before generating XML. this.saveStateFromWorkspace(); // Print XML. - window.console.log(Blockly.Xml.domToPrettyText - (this.generator.generateToolboxXml())); + console.log(Blockly.Xml.domToPrettyText(this.generator.generateToolboxXml())); }; /** @@ -398,11 +396,11 @@ WorkspaceFactoryController.prototype.updatePreview = function() { // Only update the toolbox if not in read only mode. if (!this.model.options['readOnly']) { // Get toolbox XML. - var tree = Blockly.Options.parseToolboxTree( + var tree = Blockly.utils.toolbox.parseToolboxTree( this.generator.generateToolboxXml()); // No categories, creates a simple flyout. - if (tree.getElementsByTagName('category').length == 0) { + if (tree.getElementsByTagName('category').length === 0) { // No categories, creates a simple flyout. if (this.previewWorkspace.toolbox_) { this.reinjectPreview(tree); // Switch to simple flyout, expensive. @@ -436,20 +434,20 @@ WorkspaceFactoryController.prototype.updatePreview = function() { * be called after making changes to the workspace. */ WorkspaceFactoryController.prototype.saveStateFromWorkspace = function() { - if (this.selectedMode == WorkspaceFactoryController.MODE_TOOLBOX) { + if (this.selectedMode === WorkspaceFactoryController.MODE_TOOLBOX) { // If currently editing the toolbox. // Update flags if toolbox has been changed. - if (this.model.getSelectedXml() != + if (this.model.getSelectedXml() !== Blockly.Xml.workspaceToDom(this.toolboxWorkspace)) { this.hasUnsavedToolboxChanges = true; } this.model.getSelected().saveFromWorkspace(this.toolboxWorkspace); - } else if (this.selectedMode == WorkspaceFactoryController.MODE_PRELOAD) { + } else if (this.selectedMode === WorkspaceFactoryController.MODE_PRELOAD) { // If currently editing the pre-loaded workspace. // Update flags if preloaded blocks have been changed. - if (this.model.getPreloadXml() != + if (this.model.getPreloadXml() !== Blockly.Xml.workspaceToDom(this.toolboxWorkspace)) { this.hasUnsavedPreloadChanges = true; } @@ -476,26 +474,25 @@ WorkspaceFactoryController.prototype.reinjectPreview = function(tree) { }; /** - * Tied to "change name" button. Changes the name of the selected category. - * Continues prompting the user until they input a category name that is not - * currently in use, exits if user presses cancel. + * Changes the name and colour of the selected category. + * Return if selected element is a separator. + * @param {string} name New name for selected category. + * @param {?string} colour New colour for selected category, or null if none. + * Must be a valid CSS string, or '' for none. */ -WorkspaceFactoryController.prototype.changeCategoryName = function() { +WorkspaceFactoryController.prototype.changeSelectedCategory = function(name, + colour) { var selected = this.model.getSelected(); // Return if a category is not selected. - if (selected.type != ListElement.TYPE_CATEGORY) { - return; - } - // Get new name from user. - window.foo = selected; - var newName = this.promptForNewCategoryName('What do you want to change this' - + ' category\'s name to?', selected.name); - if (!newName) { // If cancelled. + if (selected.type !== ListElement.TYPE_CATEGORY) { return; } + // Change colour of selected category. + selected.changeColour(colour); + this.view.setBorderColour(this.model.getSelectedId(), colour); // Change category name. - selected.changeName(newName); - this.view.updateCategoryName(newName, this.model.getSelectedId()); + selected.changeName(name); + this.view.updateCategoryName(name, this.model.getSelectedId()); // Update preview. this.updatePreview(); }; @@ -542,24 +539,6 @@ WorkspaceFactoryController.prototype.moveElementToIndex = function(element, this.view.moveTabToIndex(element.id, newIndex, oldIndex); }; -/** - * Changes the color of the selected category. Return if selected element is - * a separator. - * @param {string} color The color to change the selected category. Must be - * a valid CSS string. - */ -WorkspaceFactoryController.prototype.changeSelectedCategoryColor = - function(color) { - // Return if category is not selected. - if (this.model.getSelected().type != ListElement.TYPE_CATEGORY) { - return; - } - // Change color of selected category. - this.model.getSelected().changeColor(color); - this.view.setBorderColor(this.model.getSelectedId(), color); - this.updatePreview(); -}; - /** * Tied to the "Standard Category" dropdown option, this function prompts * the user for a name of a standard Blockly category (case insensitive) and @@ -569,7 +548,8 @@ WorkspaceFactoryController.prototype.loadCategory = function() { // Prompt user for the name of the standard category to load. do { var name = prompt('Enter the name of the category you would like to import ' - + '(Logic, Loops, Math, Text, Lists, Colour, Variables, or Functions)'); + + '(Logic, Loops, Math, Text, Lists, Colour, Variables, TypedVariables ' + + 'or Functions)'); if (!name) { return; // Exit if cancelled. } @@ -589,12 +569,12 @@ WorkspaceFactoryController.prototype.loadCategoryByName = function(name) { if (!this.isStandardCategoryName(name)) { return; } - if (this.model.hasVariables() && name.toLowerCase() == 'variables') { + if (this.model.hasVariables() && name.toLowerCase() === 'variables') { alert('A Variables category already exists. You cannot create multiple' + ' variables categories.'); return; } - if (this.model.hasProcedures() && name.toLowerCase() == 'functions') { + if (this.model.hasProcedures() && name.toLowerCase() === 'functions') { alert('A Functions category already exists. You cannot create multiple' + ' functions categories.'); return; @@ -606,6 +586,11 @@ WorkspaceFactoryController.prototype.loadCategoryByName = function(name) { + '. Rename your category and try again.'); return; } + if (!standardCategory.colour && standardCategory.hue !== undefined) { + // Calculate the hex colour based on the hue. + standardCategory.colour = Blockly.utils.colour.hueToHex( + standardCategory.hue); + } // Transfers current flyout blocks to a category if it's the first category // created. this.transferFlyoutBlocksToCategory(); @@ -620,9 +605,9 @@ WorkspaceFactoryController.prototype.loadCategoryByName = function(name) { // Update the copy in the view. var tab = this.view.addCategoryRow(copy.name, copy.id); this.addClickToSwitch(tab, copy.id); - // Color the category tab in the view. - if (copy.color) { - this.view.setBorderColor(copy.id, copy.color); + // Colour the category tab in the view. + if (copy.colour) { + this.view.setBorderColour(copy.id, copy.colour); } // Switch to loaded category. this.switchElement(copy.id); @@ -664,12 +649,7 @@ WorkspaceFactoryController.prototype.loadStandardToolbox = function() { * @return {boolean} True if name is a standard category name, false otherwise. */ WorkspaceFactoryController.prototype.isStandardCategoryName = function(name) { - for (var category in StandardCategories.categoryMap) { - if (name.toLowerCase() == category) { - return true; - } - } - return false; + return !!StandardCategories.categoryMap[name.toLowerCase()]; }; /** @@ -721,41 +701,53 @@ WorkspaceFactoryController.prototype.importFile = function(file, importMode) { // Try to parse XML from file and load it into toolbox editing area. // Print error message if fail. try { - var tree = Blockly.Xml.textToDom(reader.result); - if (importMode == WorkspaceFactoryController.MODE_TOOLBOX) { + var tree = Blockly.utils.xml.textToDom(reader.result); + if (importMode === WorkspaceFactoryController.MODE_TOOLBOX) { // Switch mode. controller.setMode(WorkspaceFactoryController.MODE_TOOLBOX); // Confirm that the user wants to override their current toolbox. var hasToolboxElements = controller.model.hasElements() || - controller.toolboxWorkspace.getAllBlocks().length > 0; - if (hasToolboxElements && - !confirm('Are you sure you want to import? You will lose your ' + - 'current toolbox.')) { - return; + controller.toolboxWorkspace.getAllBlocks(false).length > 0; + if (hasToolboxElements) { + var msg = 'Are you sure you want to import? You will lose your ' + + 'current toolbox.'; + BlocklyDevTools.Analytics.onWarning(msg); + var continueAnyway = confirm(); + if (!continueAnyway) { + return; + } } // Import toolbox XML. controller.importToolboxFromTree_(tree); + BlocklyDevTools.Analytics.onImport('Toolbox.xml'); - } else if (importMode == WorkspaceFactoryController.MODE_PRELOAD) { + } else if (importMode === WorkspaceFactoryController.MODE_PRELOAD) { // Switch mode. controller.setMode(WorkspaceFactoryController.MODE_PRELOAD); // Confirm that the user wants to override their current blocks. - if (controller.toolboxWorkspace.getAllBlocks().length > 0 && - !confirm('Are you sure you want to import? You will lose your ' + - 'current workspace blocks.')) { + if (controller.toolboxWorkspace.getAllBlocks(false).length > 0) { + var msg = 'Are you sure you want to import? You will lose your ' + + 'current workspace blocks.'; + var continueAnyway = confirm(msg); + BlocklyDevTools.Analytics.onWarning(msg); + if (!continueAnyway) { return; + } } // Import pre-loaded workspace XML. controller.importPreloadFromTree_(tree); + BlocklyDevTools.Analytics.onImport('WorkspaceContents.xml'); } else { // Throw error if invalid mode. - throw new Error("Unknown import mode: " + importMode); + throw Error('Unknown import mode: ' + importMode); } } catch(e) { - alert('Cannot load XML from file.'); + var msg = 'Cannot load XML from file.'; + alert(msg); + BlocklyDevTools.Analytics.onError(msg); console.log(e); } finally { Blockly.Events.enable(); @@ -778,7 +770,7 @@ WorkspaceFactoryController.prototype.importToolboxFromTree_ = function(tree) { this.model.clearToolboxList(); this.view.clearToolboxTabs(); - if (tree.getElementsByTagName('category').length == 0) { + if (tree.getElementsByTagName('category').length === 0) { // No categories present. // Load all the blocks into a single category evenly spaced. Blockly.Xml.domToWorkspace(tree, this.toolboxWorkspace); @@ -794,7 +786,7 @@ WorkspaceFactoryController.prototype.importToolboxFromTree_ = function(tree) { // Categories/separators present. for (var i = 0, item; item = tree.children[i]; i++) { - if (item.tagName == 'category') { + if (item.tagName === 'category') { // If the element is a category, create a new category and switch to it. this.createCategory(item.getAttribute('name'), false); var category = this.model.getElementByIndex(i); @@ -812,10 +804,10 @@ WorkspaceFactoryController.prototype.importToolboxFromTree_ = function(tree) { // Convert actual shadow blocks to user-generated shadow blocks. this.convertShadowBlocks(); - // Set category color. + // Set category colour. if (item.getAttribute('colour')) { - category.changeColor(item.getAttribute('colour')); - this.view.setBorderColor(category.id, category.color); + category.changeColour(item.getAttribute('colour')); + this.view.setBorderColour(category.id, category.colour); } // Set any custom tags. if (item.getAttribute('custom')) { @@ -888,14 +880,15 @@ WorkspaceFactoryController.prototype.importPreloadFromTree_ = function(tree) { * "Clear" button. */ WorkspaceFactoryController.prototype.clearAll = function() { - if (!confirm('Are you sure you want to clear all of your work in Workspace' + - ' Factory?')) { + var msg = 'Are you sure you want to clear all of your work in Workspace' + + ' Factory?'; + BlocklyDevTools.Analytics.onWarning(msg); + if (!confirm(msg)) { return; } - var hasCategories = this.model.hasElements(); this.model.clearToolboxList(); this.view.clearToolboxTabs(); - this.model.savePreloadXml(Blockly.Xml.textToDom('')); + this.model.savePreloadXml(Blockly.utils.xml.createElement('xml')); this.view.addEmptyCategoryMessage(); this.view.updateState(-1, null); this.toolboxWorkspace.clear(); @@ -908,7 +901,7 @@ WorkspaceFactoryController.prototype.clearAll = function() { this.updatePreview(); }; -/* +/** * Makes the currently selected block a user-generated shadow block. These * blocks are not made into real shadow blocks, but recorded in the model * and visually marked as shadow blocks, allowing the user to move and edit @@ -917,14 +910,14 @@ WorkspaceFactoryController.prototype.clearAll = function() { */ WorkspaceFactoryController.prototype.addShadow = function() { // No block selected to make a shadow block. - if (!Blockly.selected) { + if (!Blockly.common.getSelected()) { return; } // Clear any previous warnings on the block (would only have warnings on // a non-shadow block if it was nested inside another shadow block). - Blockly.selected.setWarningText(null); + Blockly.common.getSelected().setWarningText(null); // Set selected block and all children as shadow blocks. - this.addShadowForBlockAndChildren_(Blockly.selected); + this.addShadowForBlockAndChildren_(Blockly.common.getSelected()); // Save and update the preview. this.saveStateFromWorkspace(); @@ -962,14 +955,14 @@ WorkspaceFactoryController.prototype.addShadowForBlockAndChildren_ = */ WorkspaceFactoryController.prototype.removeShadow = function() { // No block selected to modify. - if (!Blockly.selected) { + if (!Blockly.common.getSelected()) { return; } - this.model.removeShadowBlock(Blockly.selected.id); - this.view.unmarkShadowBlock(Blockly.selected); + this.model.removeShadowBlock(Blockly.common.getSelected().id); + this.view.unmarkShadowBlock(Blockly.common.getSelected()); // If turning invalid shadow block back to normal block, remove warning. - Blockly.selected.setWarningText(null); + Blockly.common.getSelected().setWarningText(null); this.saveStateFromWorkspace(); this.updatePreview(); @@ -993,7 +986,7 @@ WorkspaceFactoryController.prototype.isUserGenShadowBlock = function(blockId) { * shadow blocks in the view but are still editable and movable. */ WorkspaceFactoryController.prototype.convertShadowBlocks = function() { - var blocks = this.toolboxWorkspace.getAllBlocks(); + var blocks = this.toolboxWorkspace.getAllBlocks(false); for (var i = 0, block; block = blocks[i]; i++) { if (block.isShadow()) { block.setShadow(false); @@ -1021,12 +1014,12 @@ WorkspaceFactoryController.prototype.convertShadowBlocks = function() { */ WorkspaceFactoryController.prototype.setMode = function(mode) { // No work to change mode that's currently set. - if (this.selectedMode == mode) { + if (this.selectedMode === mode) { return; } // No work to change mode that's currently set. - if (this.selectedMode == mode) { + if (this.selectedMode === mode) { return; } @@ -1039,7 +1032,7 @@ WorkspaceFactoryController.prototype.setMode = function(mode) { // Update help text above workspace. this.view.updateHelpText(mode); - if (mode == WorkspaceFactoryController.MODE_TOOLBOX) { + if (mode === WorkspaceFactoryController.MODE_TOOLBOX) { // Open the toolbox editing space. this.model.savePreloadXml (Blockly.Xml.workspaceToDom(this.toolboxWorkspace)); @@ -1067,7 +1060,7 @@ WorkspaceFactoryController.prototype.clearAndLoadXml_ = function(xml) { this.toolboxWorkspace.clearUndo(); Blockly.Xml.domToWorkspace(xml, this.toolboxWorkspace); this.view.markShadowBlocks(this.model.getShadowBlocksInWorkspace - (this.toolboxWorkspace.getAllBlocks())); + (this.toolboxWorkspace.getAllBlocks(false))); this.warnForUndefinedBlocks_(); }; @@ -1090,8 +1083,8 @@ WorkspaceFactoryController.prototype.setStandardOptionsAndUpdate = function() { WorkspaceFactoryController.prototype.generateNewOptions = function() { this.model.setOptions(this.readOptions_()); - this.reinjectPreview(Blockly.Options.parseToolboxTree - (this.generator.generateToolboxXml())); + this.reinjectPreview(Blockly.utils.toolbox.parseToolboxTree( + this.generator.generateToolboxXml())); }; /** @@ -1120,7 +1113,7 @@ WorkspaceFactoryController.prototype.readOptions_ = function() { } else { var maxBlocksValue = document.getElementById('option_maxBlocks_number').value; - optionsObj['maxBlocks'] = typeof maxBlocksValue == 'string' ? + optionsObj['maxBlocks'] = typeof maxBlocksValue === 'string' ? parseInt(maxBlocksValue) : maxBlocksValue; } optionsObj['trashcan'] = @@ -1147,10 +1140,10 @@ WorkspaceFactoryController.prototype.readOptions_ = function() { var grid = Object.create(null); var spacingValue = document.getElementById('gridOption_spacing_number').value; - grid['spacing'] = typeof spacingValue == 'string' ? + grid['spacing'] = typeof spacingValue === 'string' ? parseInt(spacingValue) : spacingValue; var lengthValue = document.getElementById('gridOption_length_number').value; - grid['length'] = typeof lengthValue == 'string' ? + grid['length'] = typeof lengthValue === 'string' ? parseInt(lengthValue) : lengthValue; grid['colour'] = document.getElementById('gridOption_colour_text').value; if (!readonly) { @@ -1169,20 +1162,20 @@ WorkspaceFactoryController.prototype.readOptions_ = function() { document.getElementById('zoomOption_wheel_checkbox').checked; var startScaleValue = document.getElementById('zoomOption_startScale_number').value; - zoom['startScale'] = typeof startScaleValue == 'string' ? - parseFloat(startScaleValue) : startScaleValue; + zoom['startScale'] = typeof startScaleValue === 'string' ? + Number(startScaleValue) : startScaleValue; var maxScaleValue = document.getElementById('zoomOption_maxScale_number').value; - zoom['maxScale'] = typeof maxScaleValue == 'string' ? - parseFloat(maxScaleValue) : maxScaleValue; + zoom['maxScale'] = typeof maxScaleValue === 'string' ? + Number(maxScaleValue) : maxScaleValue; var minScaleValue = document.getElementById('zoomOption_minScale_number').value; - zoom['minScale'] = typeof minScaleValue == 'string' ? - parseFloat(minScaleValue) : minScaleValue; + zoom['minScale'] = typeof minScaleValue === 'string' ? + Number(minScaleValue) : minScaleValue; var scaleSpeedValue = document.getElementById('zoomOption_scaleSpeed_number').value; - zoom['scaleSpeed'] = typeof scaleSpeedValue == 'string' ? - parseFloat(scaleSpeedValue) : scaleSpeedValue; + zoom['scaleSpeed'] = typeof scaleSpeedValue === 'string' ? + Number(scaleSpeedValue) : scaleSpeedValue; optionsObj['zoom'] = zoom; } @@ -1213,18 +1206,22 @@ WorkspaceFactoryController.prototype.importBlocks = function(file, format) { // If an imported block type is already defined, check if the user wants // to override the current block definition. - if (controller.model.hasDefinedBlockTypes(blockTypes) && - !confirm('An imported block uses the same name as a block ' - + 'already in your toolbox. Are you sure you want to override the ' - + 'currently defined block?')) { + if (controller.model.hasDefinedBlockTypes(blockTypes)) { + var msg = 'An imported block uses the same name as a block ' + + 'already in your toolbox. Are you sure you want to override the ' + + 'currently defined block?'; + var continueAnyway = confirm(msg); + BlocklyDevTools.Analytics.onWarning(msg); + if (!continueAnyway) { return; + } } var blocks = controller.generator.getDefinedBlocks(blockTypes); // Generate category XML and append to toolbox. var categoryXml = FactoryUtils.generateCategoryXml(blocks, categoryName); - // Get random color for category between 0 and 360. Gives each imported - // category a different color. + // Get random colour for category between 0 and 360. Gives each imported + // category a different colour. var randomColor = Math.floor(Math.random() * 360); categoryXml.setAttribute('colour', randomColor); controller.toolbox.appendChild(categoryXml); @@ -1234,8 +1231,13 @@ WorkspaceFactoryController.prototype.importBlocks = function(file, format) { // Reload current category to possibly reflect any newly defined blocks. controller.clearAndLoadXml_ (Blockly.Xml.workspaceToDom(controller.toolboxWorkspace)); + + BlocklyDevTools.Analytics.onImport('BlockDefinitions' + + (format === 'JSON' ? '.json' : '.js')); } catch (e) { - alert('Cannot read blocks from file.'); + msg = 'Cannot read blocks from file.'; + alert(msg); + BlocklyDevTools.Analytics.onError(msg); window.console.log(e); } } @@ -1244,10 +1246,10 @@ WorkspaceFactoryController.prototype.importBlocks = function(file, format) { reader.readAsText(file); }; -/* +/** * Updates the block library category in the toolbox workspace toolbox. * @param {!Element} categoryXml XML for the block library category. - * @param {!Array.} libBlockTypes Array of block types from the block + * @param {!Array} libBlockTypes Array of block types from the block * library. */ WorkspaceFactoryController.prototype.setBlockLibCategory = @@ -1255,8 +1257,8 @@ WorkspaceFactoryController.prototype.setBlockLibCategory = var blockLibCategory = document.getElementById('blockLibCategory'); // Set category ID so that it can be easily replaced, and set a standard, - // arbitrary block library color. - categoryXml.setAttribute('id', 'blockLibCategory'); + // arbitrary block library colour. + categoryXml.id = 'blockLibCategory'; categoryXml.setAttribute('colour', 260); // Update the toolbox and toolboxWorkspace. @@ -1274,7 +1276,7 @@ WorkspaceFactoryController.prototype.setBlockLibCategory = /** * Return the block types used in the custom toolbox and pre-loaded workspace. - * @return {!Array.} Block types used in the custom toolbox and + * @return {!Array} Block types used in the custom toolbox and * pre-loaded workspace. */ WorkspaceFactoryController.prototype.getAllUsedBlockTypes = function() { @@ -1296,16 +1298,16 @@ WorkspaceFactoryController.prototype.isDefinedBlock = function(block) { * @private */ WorkspaceFactoryController.prototype.warnForUndefinedBlocks_ = function() { - var blocks = this.toolboxWorkspace.getAllBlocks(); + var blocks = this.toolboxWorkspace.getAllBlocks(false); for (var i = 0, block; block = blocks[i]; i++) { if (!this.isDefinedBlock(block)) { - block.setWarningText(block.type + ' is not defined (it is not a standard ' - + 'block, \nin your block library, or an imported block)'); + block.setWarningText(block.type + ' is not defined (it is not a ' + + 'standard block,\nin your block library, or an imported block)'); } } }; -/* +/** * Determines if a standard variable category is in the custom toolbox. * @return {boolean} True if a variables category is in use, false otherwise. */ diff --git a/demos/blockfactory/workspacefactory/wfactory_generator.js b/demos/blockfactory/workspacefactory/wfactory_generator.js index 3b06ac282e1..ca1a47b090e 100644 --- a/demos/blockfactory/workspacefactory/wfactory_generator.js +++ b/demos/blockfactory/workspacefactory/wfactory_generator.js @@ -1,21 +1,7 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** @@ -24,12 +10,8 @@ * Blockly.Xml and depends on information in the model (holds a reference). * Depends on a hidden workspace created in the generator to load saved XML in * order to generate toolbox XML. - * - * @author Emma Dauterman (evd2014) */ -goog.require('FactoryUtils'); - /** * Class for a WorkspaceFactoryGenerator @@ -42,7 +24,7 @@ WorkspaceFactoryGenerator = function(model) { var hiddenBlocks = document.createElement('div'); // Generate a globally unique ID for the hidden div element to avoid // collisions. - var hiddenBlocksId = Blockly.utils.genUid(); + var hiddenBlocksId = Blockly.utils.idGenerator.genUid(); hiddenBlocks.id = hiddenBlocksId; hiddenBlocks.style.display = 'none'; document.body.appendChild(hiddenBlocks); @@ -61,20 +43,19 @@ WorkspaceFactoryGenerator = function(model) { */ WorkspaceFactoryGenerator.prototype.generateToolboxXml = function() { // Create DOM for XML. - var xmlDom = goog.dom.createDom('xml', - { - 'id' : 'toolbox', - 'style' : 'display:none' - }); + var xmlDom = Blockly.utils.xml.createElement('xml'); + xmlDom.id = 'toolbox'; + xmlDom.setAttribute('style', 'display: none'); + if (!this.model.hasElements()) { // Toolbox has no categories. Use XML directly from workspace. this.loadToHiddenWorkspace_(this.model.getSelectedXml()); this.appendHiddenWorkspaceToDom_(xmlDom); } else { // Toolbox has categories. - // Assert that selected != null + // Assert that selected !== null if (!this.model.getSelected()) { - throw new Error('Selected is null when the toolbox is empty.'); + throw Error('Selected is null when the toolbox is empty.'); } var xml = this.model.getSelectedXml(); @@ -86,19 +67,19 @@ WorkspaceFactoryGenerator.prototype.generateToolboxXml = function() { // groups in the flyout. for (var i = 0; i < toolboxList.length; i++) { var element = toolboxList[i]; - if (element.type == ListElement.TYPE_SEPARATOR) { + if (element.type === ListElement.TYPE_SEPARATOR) { // If the next element is a separator. - var nextElement = goog.dom.createDom('sep'); - } else if (element.type == ListElement.TYPE_CATEGORY) { + var nextElement = Blockly.utils.xml.createElement('sep'); + } else if (element.type === ListElement.TYPE_CATEGORY) { // If the next element is a category. - var nextElement = goog.dom.createDom('category'); + var nextElement = Blockly.utils.xml.createElement('category'); nextElement.setAttribute('name', element.name); // Add a colour attribute if one exists. - if (element.color != null) { - nextElement.setAttribute('colour', element.color); + if (element.colour !== null) { + nextElement.setAttribute('colour', element.colour); } // Add a custom attribute if one exists. - if (element.custom != null) { + if (element.custom !== null) { nextElement.setAttribute('custom', element.custom); } // Load that category to hidden workspace, setting user-generated shadow @@ -128,10 +109,10 @@ WorkspaceFactoryGenerator.prototype.generateWorkspaceXml = function() { this.setShadowBlocksInHiddenWorkspace_(); // Generate XML and set attributes. - var generatedXml = Blockly.Xml.workspaceToDom(this.hiddenWorkspace); - generatedXml.setAttribute('id', 'workspaceBlocks'); - generatedXml.setAttribute('style', 'display:none'); - return generatedXml; + var xmlDom = Blockly.Xml.workspaceToDom(this.hiddenWorkspace); + xmlDom.id = 'workspaceBlocks'; + xmlDom.setAttribute('style', 'display: none'); + return xmlDom; }; /** @@ -146,10 +127,10 @@ WorkspaceFactoryGenerator.prototype.generateInjectString = function() { } var str = ''; for (var key in obj) { - if (key == 'grid' || key == 'zoom') { + if (key === 'grid' || key === 'zoom') { var temp = tabChar + key + ' : {\n' + addAttributes(obj[key], tabChar + '\t') + tabChar + '}, \n'; - } else if (typeof obj[key] == 'string') { + } else if (typeof obj[key] === 'string') { var temp = tabChar + key + ' : \'' + obj[key] + '\', \n'; } else { var temp = tabChar + key + ' : ' + obj[key] + ', \n'; @@ -178,7 +159,7 @@ WorkspaceFactoryGenerator.prototype.generateInjectString = function() { ' workspace blocks XML from Workspace Factory. */\n' + 'var workspaceBlocks = document.getElementById("workspaceBlocks"); \n\n' + '/* Load blocks to workspace. */\n' + - 'Blockly.Xml.domToWorkspace(workspace, workspaceBlocks);'; + 'Blockly.Xml.domToWorkspace(workspaceBlocks, workspace);'; return finalStr; }; @@ -218,7 +199,7 @@ WorkspaceFactoryGenerator.prototype.appendHiddenWorkspaceToDom_ = */ WorkspaceFactoryGenerator.prototype.setShadowBlocksInHiddenWorkspace_ = function() { - var blocks = this.hiddenWorkspace.getAllBlocks(); + var blocks = this.hiddenWorkspace.getAllBlocks(false); for (var i = 0; i < blocks.length; i++) { if (this.model.isShadowBlock(blocks[i].id)) { blocks[i].setShadow(true); @@ -229,8 +210,8 @@ WorkspaceFactoryGenerator.prototype.setShadowBlocksInHiddenWorkspace_ = /** * Given a set of block types, gets the Blockly.Block objects for each block * type. - * @param {!Array.} blockTypes Array of blocks that have been defined. - * @return {!Array.} Array of Blockly.Block objects corresponding + * @param {!Array} blockTypes Array of blocks that have been defined. + * @return {!Array} Array of Blockly.Block objects corresponding * to the array of blockTypes. */ WorkspaceFactoryGenerator.prototype.getDefinedBlocks = function(blockTypes) { diff --git a/demos/blockfactory/workspacefactory/wfactory_init.js b/demos/blockfactory/workspacefactory/wfactory_init.js index 6b619a76f8c..352c1d220d7 100644 --- a/demos/blockfactory/workspacefactory/wfactory_init.js +++ b/demos/blockfactory/workspacefactory/wfactory_init.js @@ -1,21 +1,7 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** @@ -23,12 +9,8 @@ * Adds click handlers to buttons and dropdowns, adds event listeners for * keydown events and Blockly events, and configures the initial setup of * the page. - * - * @author Emma Dauterman (evd2014) */ - goog.require('FactoryUtils'); - /** * Namespace for workspace factory initialization methods. * @namespace @@ -47,7 +29,7 @@ WorkspaceFactoryInit.initWorkspaceFactory = function(controller) { document.getElementById('button_down').disabled = true; document.getElementById('button_editCategory').disabled = true; - this.initColorPicker_(controller); + this.initColourPicker_(controller); this.addWorkspaceFactoryEventListeners_(controller); this.assignWorkspaceFactoryClickHandlers_(controller); this.addWorkspaceFactoryOptionsListeners_(controller); @@ -57,98 +39,37 @@ WorkspaceFactoryInit.initWorkspaceFactory = function(controller) { }; /** - * Initialize the color picker in workspace factory. + * Initialize the colour picker in workspace factory. * @param {!FactoryController} controller The controller for the workspace * factory tab. * @private */ -WorkspaceFactoryInit.initColorPicker_ = function(controller) { - // Array of Blockly category colours, consitent with the 15 degree default - // of the block factory's colour wheel. - var colours = []; - for (var hue = 0; hue < 360; hue += 15) { - colours.push(WorkspaceFactoryInit.hsvToHex_(hue, - Blockly.HSV_SATURATION, Blockly.HSV_VALUE)); +WorkspaceFactoryInit.initColourPicker_ = function(controller) { + // Array of Blockly category colours, consistent with the colour defaults. + var colours = [20, 65, 120, 160, 210, 230, 260, 290, 330, '']; + // Convert hue numbers to RRGGBB strings. + for (var i = 0; i < colours.length; i++) { + if (colours[i] !== '') { + colours[i] = Blockly.utils.colour.hueToHex(colours[i]).substring(1); + } } - - // Create color picker with specific set of Blockly colours. - var colourPicker = new goog.ui.ColorPicker(); - colourPicker.setSize(6); - colourPicker.setColors(colours); - - // Create and render the popup colour picker and attach to button. - var popupPicker = new goog.ui.PopupColorPicker(null, colourPicker); - popupPicker.render(); - popupPicker.attach(document.getElementById('dropdown_color')); - popupPicker.setFocusable(true); - goog.events.listen(popupPicker, 'change', function(e) { - controller.changeSelectedCategoryColor(popupPicker.getSelectedColor()); - blocklyFactory.closeModal(); - }); -}; - -/** - * 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, 1]. - * @return {string} hex representation of the color. - * @private - */ -WorkspaceFactoryInit.hsvToHex_ = function(h, s, v) { - var brightness = v * 255; - var red = 0; - var green = 0; - var blue = 0; - if (s == 0) { - red = brightness; - green = brightness; - blue = brightness; - } else { - var sextant = Math.floor(h / 60); - var remainder = (h / 60) - sextant; - var val1 = brightness * (1 - s); - var val2 = brightness * (1 - (s * remainder)); - var 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; + // Convert to 2D array. + var maxCols = Math.ceil(Math.sqrt(colours.length)); + var grid = []; + var row = []; + for (var i = 0; i < colours.length; i++) { + row.push(colours[i]); + if (row.length === maxCols) { + grid.push(row); + row = []; } } + if (row.length) { + grid.push(row); + } - var hexR = ('0' + Math.floor(red).toString(16)).slice(-2); - var hexG = ('0' + Math.floor(green).toString(16)).slice(-2); - var hexB = ('0' + Math.floor(blue).toString(16)).slice(-2); - return '#' + hexR + hexG + hexB; + // Override the default colours. + cp_grid = grid; }; /** @@ -310,12 +231,27 @@ WorkspaceFactoryInit.assignWorkspaceFactoryClickHandlers_ = document.getElementById('button_editCategory').addEventListener ('click', function() { + var selected = controller.model.getSelected(); + // Return if a category is not selected. + if (selected.type !== ListElement.TYPE_CATEGORY) { + return; + } + document.getElementById('categoryName').value = selected.name; + document.getElementById('categoryColour').value = selected.colour ? + selected.colour.substring(1).toLowerCase() : ''; + console.log(document.getElementById('categoryColour').value); + // Link the colour picker to the field. + cp_init('categoryColour'); blocklyFactory.openModal('dropdownDiv_editCategory'); }); - document.getElementById('dropdown_name').addEventListener + + document.getElementById('categorySave').addEventListener ('click', function() { - controller.changeCategoryName(); + var name = document.getElementById('categoryName').value.trim(); + var colour = document.getElementById('categoryColour').value; + colour = colour ? '#' + colour : null; + controller.changeSelectedCategory(name, colour); blocklyFactory.closeModal(); }); @@ -336,7 +272,7 @@ WorkspaceFactoryInit.assignWorkspaceFactoryClickHandlers_ = // Disable shadow editing button if turning invalid shadow block back // to normal block. - if (!Blockly.selected.getSurroundParent()) { + if (!Blockly.common.getSelected().getSurroundParent()) { document.getElementById('button_addShadow').disabled = true; } }); @@ -366,14 +302,14 @@ WorkspaceFactoryInit.addWorkspaceFactoryEventListeners_ = function(controller) { // Don't let arrow keys have any effect if not in Workspace Factory // editing the toolbox. if (!(controller.keyEventsEnabled && controller.selectedMode - == WorkspaceFactoryController.MODE_TOOLBOX)) { + === WorkspaceFactoryController.MODE_TOOLBOX)) { return; } - if (e.keyCode == 38) { + if (e.keyCode === 38) { // Arrow up. controller.moveElement(-1); - } else if (e.keyCode == 40) { + } else if (e.keyCode === 40) { // Arrow down. controller.moveElement(1); } @@ -396,9 +332,9 @@ WorkspaceFactoryInit.addWorkspaceFactoryEventListeners_ = function(controller) { // Not listening for Blockly create events because causes the user to drop // blocks when dragging them into workspace. Could cause problems if ever // load blocks into workspace directly without calling updatePreview. - if (e.type == Blockly.Events.BLOCK_MOVE || - e.type == Blockly.Events.BLOCK_DELETE || - e.type == Blockly.Events.BLOCK_CHANGE) { + if (e.type === Blockly.Events.BLOCK_MOVE || + e.type === Blockly.Events.BLOCK_DELETE || + e.type === Blockly.Events.BLOCK_CHANGE) { controller.saveStateFromWorkspace(); controller.updatePreview(); } @@ -407,9 +343,9 @@ WorkspaceFactoryInit.addWorkspaceFactoryEventListeners_ = function(controller) { // Only enable "Edit Block" when a block is selected and it has a // surrounding parent, meaning it is nested in another block (blocks that // are not nested in parents cannot be shadow blocks). - if (e.type == Blockly.Events.BLOCK_MOVE || (e.type == Blockly.Events.UI && - e.element == 'selected')) { - var selected = Blockly.selected; + if (e.type === Blockly.Events.BLOCK_MOVE || + e.type === Blockly.Events.SELECTED) { + var selected = Blockly.common.getSelected(); // Show shadow button if a block is selected. Show "Add Shadow" if // a block is not a shadow block, show "Remove Shadow" if it is a @@ -423,7 +359,7 @@ WorkspaceFactoryInit.addWorkspaceFactoryEventListeners_ = function(controller) { WorkspaceFactoryInit.displayRemoveShadow_(false); } - if (selected != null && selected.getSurroundParent() != null && + if (selected !== null && selected.getSurroundParent() !== null && !controller.isUserGenShadowBlock(selected.getSurroundParent().id)) { // Selected block is a valid shadow block or could be a valid shadow // block. @@ -440,7 +376,7 @@ WorkspaceFactoryInit.addWorkspaceFactoryEventListeners_ = function(controller) { } else { // Selected block cannot be a valid shadow block. - if (selected != null && isInvalidBlockPlacement(selected)) { + if (selected !== null && isInvalidBlockPlacement(selected)) { // Selected block breaks shadow block rules. // Invalid shadow block if (1) a shadow block no longer has a valid // parent, or (2) a normal block is inside of a shadow block. @@ -465,7 +401,7 @@ WorkspaceFactoryInit.addWorkspaceFactoryEventListeners_ = function(controller) { // be a shadow block. // Remove possible 'invalid shadow block placement' warning. - if (selected != null && controller.isDefinedBlock(selected) && + if (selected !== null && controller.isDefinedBlock(selected) && (!FactoryUtils.hasVariableField(selected) || !controller.isUserGenShadowBlock(selected.id))) { selected.setWarningText(null); @@ -481,7 +417,7 @@ WorkspaceFactoryInit.addWorkspaceFactoryEventListeners_ = function(controller) { // Convert actual shadow blocks added from the toolbox to user-generated // shadow blocks. - if (e.type == Blockly.Events.BLOCK_CREATE) { + if (e.type === Blockly.Events.BLOCK_CREATE) { controller.convertShadowBlocks(); // Let the user create a Variables or Functions category if they use diff --git a/demos/blockfactory/workspacefactory/wfactory_model.js b/demos/blockfactory/workspacefactory/wfactory_model.js index 7b60810d38a..b0e1ab9d4f3 100644 --- a/demos/blockfactory/workspacefactory/wfactory_model.js +++ b/demos/blockfactory/workspacefactory/wfactory_model.js @@ -1,33 +1,17 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Stores and updates information about state and categories * in workspace factory. Each list element is either a separator or a category, - * and each category stores its name, XML to load that category, color, + * and each category stores its name, XML to load that category, colour, * custom tags, and a unique ID making it possible to change category names and * move categories easily. Keeps track of the currently selected list * element. Also keeps track of all the user-created shadow blocks and * manipulates them as necessary. - * - * @author Emma Dauterman (evd2014) */ /** @@ -50,7 +34,7 @@ WorkspaceFactoryModel = function() { // Boolean for if a Procedure category has been added. this.hasProcedureCategory = false; // XML to be pre-loaded to workspace. Empty on default; - this.preloadXml = Blockly.Xml.textToDom(''); + this.preloadXml = Blockly.utils.xml.createElement('xml'); // Options object to be configured for Blockly inject call. this.options = new Object(null); // Block Library block types. @@ -68,8 +52,8 @@ WorkspaceFactoryModel = function() { */ WorkspaceFactoryModel.prototype.hasCategoryByName = function(name) { for (var i = 0; i < this.toolboxList.length; i++) { - if (this.toolboxList[i].type == ListElement.TYPE_CATEGORY && - this.toolboxList[i].name == name) { + if (this.toolboxList[i].type === ListElement.TYPE_CATEGORY && + this.toolboxList[i].name === name) { return true; } } @@ -109,9 +93,9 @@ WorkspaceFactoryModel.prototype.hasElements = function() { */ WorkspaceFactoryModel.prototype.addElementToList = function(element) { // Update state if the copied category has a custom tag. - this.hasVariableCategory = element.custom == 'VARIABLE' ? true : + this.hasVariableCategory = element.custom === 'VARIABLE' ? true : this.hasVariableCategory; - this.hasProcedureCategory = element.custom == 'PROCEDURE' ? true : + this.hasProcedureCategory = element.custom === 'PROCEDURE' ? true : this.hasProcedureCategory; // Add element to toolboxList. this.toolboxList.push(element); @@ -129,9 +113,9 @@ WorkspaceFactoryModel.prototype.deleteElementFromList = function(index) { return; // No entry to delete. } // Check if need to update flags. - this.hasVariableCategory = this.toolboxList[index].custom == 'VARIABLE' ? + this.hasVariableCategory = this.toolboxList[index].custom === 'VARIABLE' ? false : this.hasVariableCategory; - this.hasProcedureCategory = this.toolboxList[index].custom == 'PROCEDURE' ? + this.hasProcedureCategory = this.toolboxList[index].custom === 'PROCEDURE' ? false : this.hasProcedureCategory; // Remove element. this.toolboxList.splice(index, 1); @@ -144,7 +128,7 @@ WorkspaceFactoryModel.prototype.deleteElementFromList = function(index) { * of blocks displayed. */ WorkspaceFactoryModel.prototype.createDefaultSelectedIfEmpty = function() { - if (this.toolboxList.length == 0) { + if (this.toolboxList.length === 0) { this.flyout = new ListElement(ListElement.TYPE_FLYOUT); this.selected = this.flyout; } @@ -164,7 +148,7 @@ WorkspaceFactoryModel.prototype.moveElementToIndex = function(element, newIndex, // Check that indexes are in bounds. if (newIndex < 0 || newIndex >= this.toolboxList.length || oldIndex < 0 || oldIndex >= this.toolboxList.length) { - throw new Error('Index out of bounds when moving element in the model.'); + throw Error('Index out of bounds when moving element in the model.'); } this.deleteElementFromList(oldIndex); this.toolboxList.splice(newIndex, 0, element); @@ -172,7 +156,7 @@ WorkspaceFactoryModel.prototype.moveElementToIndex = function(element, newIndex, /** * Returns the ID of the currently selected element. Returns null if there are - * no categories (if selected == null). + * no categories (if selected === null). * @return {string} The ID of the element currently selected. */ WorkspaceFactoryModel.prototype.getSelectedId = function() { @@ -181,7 +165,7 @@ WorkspaceFactoryModel.prototype.getSelectedId = function() { /** * Returns the name of the currently selected category. Returns null if there - * are no categories (if selected == null) or the selected element is not + * are no categories (if selected === null) or the selected element is not * a category (in which case its name is null). * @return {string} The name of the category currently selected. */ @@ -214,7 +198,7 @@ WorkspaceFactoryModel.prototype.setSelectedById = function(id) { */ WorkspaceFactoryModel.prototype.getIndexByElementId = function(id) { for (var i = 0; i < this.toolboxList.length; i++) { - if (this.toolboxList[i].id == id) { + if (this.toolboxList[i].id === id) { return i; } } @@ -229,7 +213,7 @@ WorkspaceFactoryModel.prototype.getIndexByElementId = function(id) { */ WorkspaceFactoryModel.prototype.getElementById = function(id) { for (var i = 0; i < this.toolboxList.length; i++) { - if (this.toolboxList[i].id == id) { + if (this.toolboxList[i].id === id) { return this.toolboxList[i]; } } @@ -260,7 +244,7 @@ WorkspaceFactoryModel.prototype.getSelectedXml = function() { /** * Return ordered list of ListElement objects. - * @return {!Array.} ordered list of ListElement objects + * @return {!Array} ordered list of ListElement objects */ WorkspaceFactoryModel.prototype.getToolboxList = function() { return this.toolboxList; @@ -273,7 +257,7 @@ WorkspaceFactoryModel.prototype.getToolboxList = function() { */ WorkspaceFactoryModel.prototype.getCategoryIdByName = function(name) { for (var i = 0; i < this.toolboxList.length; i++) { - if (this.toolboxList[i].name == name) { + if (this.toolboxList[i].name === name) { return this.toolboxList[i].id; } } @@ -288,7 +272,7 @@ WorkspaceFactoryModel.prototype.clearToolboxList = function() { this.hasVariableCategory = false; this.hasProcedureCategory = false; this.shadowBlocks = []; - this.selected.xml = Blockly.Xml.textToDom(''); + this.selected.xml = Blockly.utils.xml.createElement('xml'); }; /** @@ -307,7 +291,7 @@ WorkspaceFactoryModel.prototype.addShadowBlock = function(blockId) { */ WorkspaceFactoryModel.prototype.removeShadowBlock = function(blockId) { for (var i = 0; i < this.shadowBlocks.length; i++) { - if (this.shadowBlocks[i] == blockId) { + if (this.shadowBlocks[i] === blockId) { this.shadowBlocks.splice(i, 1); return; } @@ -322,7 +306,7 @@ WorkspaceFactoryModel.prototype.removeShadowBlock = function(blockId) { */ WorkspaceFactoryModel.prototype.isShadowBlock = function(blockId) { for (var i = 0; i < this.shadowBlocks.length; i++) { - if (this.shadowBlocks[i] == blockId) { + if (this.shadowBlocks[i] === blockId) { return true; } } @@ -355,14 +339,14 @@ WorkspaceFactoryModel.prototype.getShadowBlocksInWorkspace = */ WorkspaceFactoryModel.prototype.addCustomTag = function(category, tag) { // Only update list elements that are categories. - if (category.type != ListElement.TYPE_CATEGORY) { + if (category.type !== ListElement.TYPE_CATEGORY) { return; } // Only update the tag to be 'VARIABLE' or 'PROCEDURE'. - if (tag == 'VARIABLE') { + if (tag === 'VARIABLE') { this.hasVariableCategory = true; category.custom = 'VARIABLE'; - } else if (tag == 'PROCEDURE') { + } else if (tag === 'PROCEDURE') { this.hasProcedureCategory = true; category.custom = 'PROCEDURE'; } @@ -374,7 +358,7 @@ WorkspaceFactoryModel.prototype.addCustomTag = function(category, tag) { * @param {!Element} xml The XML to be saved. */ WorkspaceFactoryModel.prototype.savePreloadXml = function(xml) { - this.preloadXml = xml + this.preloadXml = xml; }; /** @@ -393,11 +377,11 @@ WorkspaceFactoryModel.prototype.setOptions = function(options) { this.options = options; }; -/* +/** * Returns an array of all the block types currently being used in the toolbox * and the pre-loaded blocks. No duplicates. * TODO(evd2014): Move pushBlockTypesToList to FactoryUtils. - * @return {!Array.} Array of block types currently being used. + * @return {!Array} Array of block types currently being used. */ WorkspaceFactoryModel.prototype.getAllUsedBlockTypes = function() { var blockTypeList = []; @@ -411,7 +395,7 @@ WorkspaceFactoryModel.prototype.getAllUsedBlockTypes = function() { // Add block types if not already in list. for (var i = 0; i < blocks.length; i++) { var type = blocks[i].getAttribute('type'); - if (list.indexOf(type) == -1) { + if (!list.includes(type)) { list.push(type); } } @@ -424,7 +408,7 @@ WorkspaceFactoryModel.prototype.getAllUsedBlockTypes = function() { // If has categories, add block types for each category. for (var i = 0, category; category = this.toolboxList[i]; i++) { - if (category.type == ListElement.TYPE_CATEGORY) { + if (category.type === ListElement.TYPE_CATEGORY) { pushBlockTypesToList(category.xml, blockTypeList); } } @@ -438,7 +422,7 @@ WorkspaceFactoryModel.prototype.getAllUsedBlockTypes = function() { /** * Adds new imported block types to the list of current imported block types. - * @param {!Array.} blockTypes Array of block types imported. + * @param {!Array} blockTypes Array of block types imported. */ WorkspaceFactoryModel.prototype.addImportedBlockTypes = function(blockTypes) { this.importedBlockTypes = this.importedBlockTypes.concat(blockTypes); @@ -446,7 +430,7 @@ WorkspaceFactoryModel.prototype.addImportedBlockTypes = function(blockTypes) { /** * Updates block types in block library. - * @param {!Array.} blockTypes Array of block types in block library. + * @param {!Array} blockTypes Array of block types in block library. */ WorkspaceFactoryModel.prototype.updateLibBlockTypes = function(blockTypes) { this.libBlockTypes = blockTypes; @@ -459,16 +443,16 @@ WorkspaceFactoryModel.prototype.updateLibBlockTypes = function(blockTypes) { * @return {boolean} True if blockType is defined, false otherwise. */ WorkspaceFactoryModel.prototype.isDefinedBlockType = function(blockType) { - var isStandardBlock = StandardCategories.coreBlockTypes.indexOf(blockType) - != -1; - var isLibBlock = this.libBlockTypes.indexOf(blockType) != -1; - var isImportedBlock = this.importedBlockTypes.indexOf(blockType) != -1; + var isStandardBlock = + StandardCategories.coreBlockTypes.includes(blockType); + var isLibBlock = this.libBlockTypes.includes(blockType); + var isImportedBlock = this.importedBlockTypes.includes(blockType); return (isStandardBlock || isLibBlock || isImportedBlock); }; /** * Checks if any of the block types are already defined. - * @param {!Array.} blockTypes Array of block types. + * @param {!Array} blockTypes Array of block types. * @return {boolean} True if a block type in the array is already defined, * false if none of the blocks are already defined. */ @@ -488,13 +472,13 @@ WorkspaceFactoryModel.prototype.hasDefinedBlockTypes = function(blockTypes) { ListElement = function(type, opt_name) { this.type = type; // XML DOM element to load the element. - this.xml = Blockly.Xml.textToDom(''); + this.xml = Blockly.utils.xml.createElement('xml'); // Name of category. Can be changed by user. Null if separator. this.name = opt_name ? opt_name : null; // Unique ID of element. Does not change. - this.id = Blockly.utils.genUid(); - // Color of category. Default is no color. Null if separator. - this.color = null; + this.id = Blockly.utils.idGenerator.genUid(); + // Colour of category. Default is no colour. Null if separator. + this.colour = null; // Stores a custom tag, if necessary. Null if no custom tag or separator. this.custom = null; }; @@ -512,8 +496,8 @@ ListElement.TYPE_FLYOUT = 'flyout'; */ ListElement.prototype.saveFromWorkspace = function(workspace) { // Only save XML for categories and flyouts. - if (this.type == ListElement.TYPE_FLYOUT || - this.type == ListElement.TYPE_CATEGORY) { + if (this.type === ListElement.TYPE_FLYOUT || + this.type === ListElement.TYPE_CATEGORY) { this.xml = Blockly.Xml.workspaceToDom(workspace); } }; @@ -524,24 +508,25 @@ ListElement.prototype.saveFromWorkspace = function(workspace) { * not a category. * @param {string} name New name of category. */ -ListElement.prototype.changeName = function (name) { +ListElement.prototype.changeName = function(name) { // Only update list elements that are categories. - if (this.type != ListElement.TYPE_CATEGORY) { + if (this.type !== ListElement.TYPE_CATEGORY) { return; } this.name = name; }; /** - * Sets the color of a category. If tries to set the color of something other + * Sets the colour of a category. If tries to set the colour of something other * than a category, returns. - * @param {string} color The color that should be used for that category. + * @param {?string} colour The colour that should be used for that category, + * or null if none. */ -ListElement.prototype.changeColor = function (color) { - if (this.type != ListElement.TYPE_CATEGORY) { +ListElement.prototype.changeColour = function(colour) { + if (this.type !== ListElement.TYPE_CATEGORY) { return; } - this.color = color; + this.colour = colour; }; /** @@ -552,11 +537,11 @@ ListElement.prototype.changeColor = function (color) { ListElement.prototype.copy = function() { copy = new ListElement(this.type); // Generate a unique ID for the element. - copy.id = Blockly.utils.genUid(); + copy.id = Blockly.utils.idGenerator.genUid(); // Copy all attributes except ID. copy.name = this.name; copy.xml = this.xml; - copy.color = this.color; + copy.colour = this.colour; copy.custom = this.custom; // Return copy. return copy; diff --git a/demos/blockfactory/workspacefactory/wfactory_view.js b/demos/blockfactory/workspacefactory/wfactory_view.js index bf7463eb98f..f98b1353049 100644 --- a/demos/blockfactory/workspacefactory/wfactory_view.js +++ b/demos/blockfactory/workspacefactory/wfactory_view.js @@ -1,21 +1,7 @@ /** * @license - * Blockly Demos: Block Factory - * - * Copyright 2016 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** @@ -24,11 +10,8 @@ * Depends on WorkspaceFactoryController (for adding mouse listeners). Tabs for * each category are stored in tab map, which associates a unique ID for a * category with a particular tab. - * - * @author Emma Dauterman (edauterman) */ -goog.require('FactoryUtils'); /** * Class for a WorkspaceFactoryView @@ -50,7 +33,7 @@ WorkspaceFactoryView.prototype.addCategoryRow = function(name, id) { var count = table.rows.length; // Delete help label and enable category buttons if it's the first category. - if (count == 0) { + if (count === 0) { document.getElementById('categoryHeader').textContent = 'Your categories:'; } @@ -110,7 +93,7 @@ WorkspaceFactoryView.prototype.addEmptyCategoryMessage = function() { WorkspaceFactoryView.prototype.updateState = function(selectedIndex, selected) { // Disable/enable editing buttons as necessary. document.getElementById('button_editCategory').disabled = selectedIndex < 0 || - selected.type != ListElement.TYPE_CATEGORY; + selected.type !== ListElement.TYPE_CATEGORY; document.getElementById('button_remove').disabled = selectedIndex < 0; document.getElementById('button_up').disabled = selectedIndex <= 0; var table = document.getElementById('categoryTable'); @@ -138,7 +121,7 @@ WorkspaceFactoryView.prototype.createCategoryIdName = function(name) { WorkspaceFactoryView.prototype.setCategoryTabSelection = function(id, selected) { if (!this.tabMap[id]) { - return; // Exit if tab does not exist. + return; // Exit if tab does not exist. } this.tabMap[id].className = selected ? 'tabon' : 'taboff'; }; @@ -150,7 +133,7 @@ WorkspaceFactoryView.prototype.setCategoryTabSelection = * @param {!Function} func Function to be executed on click. */ WorkspaceFactoryView.prototype.bindClick = function(el, func) { - if (typeof el == 'string') { + if (typeof el === 'string') { el = document.getElementById(el); } el.addEventListener('click', func, true); @@ -160,8 +143,8 @@ WorkspaceFactoryView.prototype.bindClick = function(el, func) { /** * Creates a file and downloads it. In some browsers downloads, and in other * browsers, opens new tab with contents. - * @param {string} filename Name of file - * @param {!Blob} data Blob containing contents to download + * @param {string} filename Name of file. + * @param {!Blob} data Blob containing contents to download. */ WorkspaceFactoryView.prototype.createAndDownloadFile = function(filename, data) { @@ -180,8 +163,8 @@ WorkspaceFactoryView.prototype.createAndDownloadFile = /** * Given the ID of a certain category, updates the corresponding tab in * the DOM to show a new name. - * @param {string} newName Name of string to be displayed on tab - * @param {string} id ID of category to be updated + * @param {string} newName Name of string to be displayed on tab. + * @param {string} id ID of category to be updated. */ WorkspaceFactoryView.prototype.updateCategoryName = function(newName, id) { this.tabMap[id].textContent = newName; @@ -202,7 +185,7 @@ WorkspaceFactoryView.prototype.moveTabToIndex = // Check that indexes are in bounds. if (newIndex < 0 || newIndex >= table.rows.length || oldIndex < 0 || oldIndex >= table.rows.length) { - throw new Error('Index out of bounds when moving tab in the view.'); + throw Error('Index out of bounds when moving tab in the view.'); } if (newIndex < oldIndex) { @@ -219,17 +202,23 @@ WorkspaceFactoryView.prototype.moveTabToIndex = }; /** - * Given a category ID and color, use that color to color the left border of the - * tab for that category. - * @param {string} id The ID of the category to color. - * @param {string} color The color for to be used for the border of the tab. - * Must be a valid CSS string. + * Given a category ID and colour, use that colour to colour the left border of + * the tab for that category. + * @param {string} id The ID of the category to colour. + * @param {?string} colour The colour for to be used for the border of the tab, + * or null if none. Must be a valid CSS string. */ -WorkspaceFactoryView.prototype.setBorderColor = function(id, color) { - var tab = this.tabMap[id]; - tab.style.borderLeftWidth = '8px'; - tab.style.borderLeftStyle = 'solid'; - tab.style.borderColor = color; +WorkspaceFactoryView.prototype.setBorderColour = function(id, colour) { + var style = this.tabMap[id].style; + if (colour) { + style.borderLeftWidth = '8px'; + style.borderLeftStyle = 'solid'; + style.borderColor = colour; + } else { + style.borderLeftWidth = ''; + style.borderLeftStyle = ''; + style.borderColor = ''; + } }; /** @@ -242,7 +231,7 @@ WorkspaceFactoryView.prototype.addSeparatorTab = function(id) { var table = document.getElementById('categoryTable'); var count = table.rows.length; - if (count == 0) { + if (count === 0) { document.getElementById('categoryHeader').textContent = 'Your categories:'; } // Create separator. @@ -280,9 +269,9 @@ WorkspaceFactoryView.prototype.disableWorkspace = function(disable) { * @return {boolean} True if the workspace should be disabled, false otherwise. */ WorkspaceFactoryView.prototype.shouldDisableWorkspace = function(category) { - return category != null && category.type != ListElement.TYPE_FLYOUT && - (category.type == ListElement.TYPE_SEPARATOR || - category.custom == 'VARIABLE' || category.custom == 'PROCEDURE'); + return category !== null && category.type !== ListElement.TYPE_FLYOUT && + (category.type === ListElement.TYPE_SEPARATOR || + category.custom === 'VARIABLE' || category.custom === 'PROCEDURE'); }; /** @@ -303,7 +292,7 @@ WorkspaceFactoryView.prototype.clearToolboxTabs = function() { * Given a set of blocks currently loaded user-generated shadow blocks, visually * marks them without making them actual shadow blocks (allowing them to still * be editable and movable). - * @param {!Array.} blocks Array of user-generated shadow blocks + * @param {!Array} blocks Array of user-generated shadow blocks * currently loaded. */ WorkspaceFactoryView.prototype.markShadowBlocks = function(blocks) { @@ -321,7 +310,7 @@ WorkspaceFactoryView.prototype.markShadowBlocks = function(blocks) { */ WorkspaceFactoryView.prototype.markShadowBlock = function(block) { // Add Blockly CSS for user-generated shadow blocks. - Blockly.utils.addClass(block.svgGroup_, 'shadowBlock'); + block.getSvgRoot().classList.add('shadowBlock'); // If not a valid shadow block, add a warning message. if (!block.getSurroundParent()) { block.setWarningText('Shadow blocks must be nested inside' + @@ -339,23 +328,23 @@ WorkspaceFactoryView.prototype.markShadowBlock = function(block) { */ WorkspaceFactoryView.prototype.unmarkShadowBlock = function(block) { // Remove Blockly CSS for user-generated shadow blocks. - Blockly.utils.removeClass(block.svgGroup_, 'shadowBlock'); + block.getSvgRoot().classList.remove('shadowBlock'); }; /** - * Sets the tabs for modes according to which mode the user is currenly + * Sets the tabs for modes according to which mode the user is currently * editing in. * @param {string} mode The mode being switched to * (WorkspaceFactoryController.MODE_TOOLBOX or WorkspaceFactoryController.MODE_PRELOAD). */ WorkspaceFactoryView.prototype.setModeSelection = function(mode) { - document.getElementById('tab_preload').className = mode == + document.getElementById('tab_preload').className = mode === WorkspaceFactoryController.MODE_PRELOAD ? 'tabon' : 'taboff'; - document.getElementById('preload_div').style.display = mode == + document.getElementById('preload_div').style.display = mode === WorkspaceFactoryController.MODE_PRELOAD ? 'block' : 'none'; - document.getElementById('tab_toolbox').className = mode == + document.getElementById('tab_toolbox').className = mode === WorkspaceFactoryController.MODE_TOOLBOX ? 'tabon' : 'taboff'; - document.getElementById('toolbox_div').style.display = mode == + document.getElementById('toolbox_div').style.display = mode === WorkspaceFactoryController.MODE_TOOLBOX ? 'block' : 'none'; }; @@ -365,7 +354,7 @@ WorkspaceFactoryView.prototype.setModeSelection = function(mode) { * WorkspaceFactoryController.MODE_PRELOAD). */ WorkspaceFactoryView.prototype.updateHelpText = function(mode) { - if (mode == WorkspaceFactoryController.MODE_TOOLBOX) { + if (mode === WorkspaceFactoryController.MODE_TOOLBOX) { var helpText = 'Drag blocks into the workspace to configure the toolbox ' + 'in your custom workspace.'; } else { @@ -397,8 +386,7 @@ WorkspaceFactoryView.prototype.setBaseOptions = function() { // Check infinite blocks and hide suboption. document.getElementById('option_infiniteBlocks_checkbox').checked = true; - document.getElementById('maxBlockNumber_option').style.display = - 'none'; + document.getElementById('maxBlockNumber_option').style.display = 'none'; // Uncheck grid and zoom options and hide suboptions. document.getElementById('option_grid_checkbox').checked = false; diff --git a/demos/blockfactory_old/blocks.js b/demos/blockfactory_old/blocks.js deleted file mode 100644 index 856780a526a..00000000000 --- a/demos/blockfactory_old/blocks.js +++ /dev/null @@ -1,826 +0,0 @@ -/** - * Blockly Demos: Block Factory Blocks - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 Blocks for Blockly's Block Factory application. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -Blockly.Blocks['factory_base'] = { - // Base of new block. - init: function() { - this.setColour(120); - this.appendDummyInput() - .appendField('name') - .appendField(new Blockly.FieldTextInput('block_type'), 'NAME'); - this.appendStatementInput('INPUTS') - .setCheck('Input') - .appendField('inputs'); - var dropdown = new Blockly.FieldDropdown([ - ['automatic inputs', 'AUTO'], - ['external inputs', 'EXT'], - ['inline inputs', 'INT']]); - this.appendDummyInput() - .appendField(dropdown, 'INLINE'); - dropdown = new Blockly.FieldDropdown([ - ['no connections', 'NONE'], - ['← left output', 'LEFT'], - ['↕ top+bottom connections', 'BOTH'], - ['↑ top connection', 'TOP'], - ['↓ bottom connection', 'BOTTOM']], - function(option) { - this.sourceBlock_.updateShape_(option); - // Connect a shadow block to this new input. - this.sourceBlock_.spawnOutputShadow_(option); - }); - this.appendDummyInput() - .appendField(dropdown, 'CONNECTIONS'); - this.appendValueInput('COLOUR') - .setCheck('Colour') - .appendField('colour'); - this.setTooltip('Build a custom block by plugging\n' + - 'fields, inputs and other blocks here.'); - this.setHelpUrl( - 'https://developers.google.com/blockly/guides/create-custom-blocks/block-factory'); - }, - mutationToDom: function() { - var container = document.createElement('mutation'); - container.setAttribute('connections', this.getFieldValue('CONNECTIONS')); - return container; - }, - domToMutation: function(xmlElement) { - var connections = xmlElement.getAttribute('connections'); - this.updateShape_(connections); - }, - spawnOutputShadow_: function(option) { - // Helper method for deciding which type of outputs this block needs - // to attach shaddow blocks to. - switch (option) { - case 'LEFT': - this.connectOutputShadow_('OUTPUTTYPE'); - break; - case 'TOP': - this.connectOutputShadow_('TOPTYPE'); - break; - case 'BOTTOM': - this.connectOutputShadow_('BOTTOMTYPE'); - break; - case 'BOTH': - this.connectOutputShadow_('TOPTYPE'); - this.connectOutputShadow_('BOTTOMTYPE'); - break; - } - }, - connectOutputShadow_: function(outputType) { - // Helper method to create & connect shadow block. - var type = this.workspace.newBlock('type_null'); - type.setShadow(true); - type.outputConnection.connect(this.getInput(outputType).connection); - type.initSvg(); - type.render(); - }, - updateShape_: function(option) { - var outputExists = this.getInput('OUTPUTTYPE'); - var topExists = this.getInput('TOPTYPE'); - var bottomExists = this.getInput('BOTTOMTYPE'); - if (option == 'LEFT') { - if (!outputExists) { - this.addTypeInput_('OUTPUTTYPE', 'output type'); - } - } else if (outputExists) { - this.removeInput('OUTPUTTYPE'); - } - if (option == 'TOP' || option == 'BOTH') { - if (!topExists) { - this.addTypeInput_('TOPTYPE', 'top type'); - } - } else if (topExists) { - this.removeInput('TOPTYPE'); - } - if (option == 'BOTTOM' || option == 'BOTH') { - if (!bottomExists) { - this.addTypeInput_('BOTTOMTYPE', 'bottom type'); - } - } else if (bottomExists) { - this.removeInput('BOTTOMTYPE'); - } - }, - addTypeInput_: function(name, label) { - this.appendValueInput(name) - .setCheck('Type') - .appendField(label); - this.moveInputBefore(name, 'COLOUR'); - } -}; - -var FIELD_MESSAGE = 'fields %1 %2'; -var FIELD_ARGS = [ - { - "type": "field_dropdown", - "name": "ALIGN", - "options": [['left', 'LEFT'], ['right', 'RIGHT'], ['centre', 'CENTRE']], - }, - { - "type": "input_statement", - "name": "FIELDS", - "check": "Field" - } -]; - -var TYPE_MESSAGE = 'type %1'; -var TYPE_ARGS = [ - { - "type": "input_value", - "name": "TYPE", - "check": "Type", - "align": "RIGHT" - } -]; - -Blockly.Blocks['input_value'] = { - // Value input. - init: function() { - this.jsonInit({ - "message0": "value input %1 %2", - "args0": [ - { - "type": "field_input", - "name": "INPUTNAME", - "text": "NAME" - }, - { - "type": "input_dummy" - } - ], - "message1": FIELD_MESSAGE, - "args1": FIELD_ARGS, - "message2": TYPE_MESSAGE, - "args2": TYPE_ARGS, - "previousStatement": "Input", - "nextStatement": "Input", - "colour": 210, - "tooltip": "A value socket for horizontal connections.", - "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=71" - }); - }, - onchange: function() { - inputNameCheck(this); - } -}; - -Blockly.Blocks['input_statement'] = { - // Statement input. - init: function() { - this.jsonInit({ - "message0": "statement input %1 %2", - "args0": [ - { - "type": "field_input", - "name": "INPUTNAME", - "text": "NAME" - }, - { - "type": "input_dummy" - }, - ], - "message1": FIELD_MESSAGE, - "args1": FIELD_ARGS, - "message2": TYPE_MESSAGE, - "args2": TYPE_ARGS, - "previousStatement": "Input", - "nextStatement": "Input", - "colour": 210, - "tooltip": "A statement socket for enclosed vertical stacks.", - "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=246" - }); - }, - onchange: function() { - inputNameCheck(this); - } -}; - -Blockly.Blocks['input_dummy'] = { - // Dummy input. - init: function() { - this.jsonInit({ - "message0": "dummy input", - "message1": FIELD_MESSAGE, - "args1": FIELD_ARGS, - "previousStatement": "Input", - "nextStatement": "Input", - "colour": 210, - "tooltip": "For adding fields on a separate row with no " + - "connections. Alignment options (left, right, centre) " + - "apply only to multi-line fields.", - "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=293" - }); - } -}; - -Blockly.Blocks['field_static'] = { - // Text value. - init: function() { - this.setColour(160); - this.appendDummyInput() - .appendField('text') - .appendField(new Blockly.FieldTextInput(''), 'TEXT'); - this.setPreviousStatement(true, 'Field'); - this.setNextStatement(true, 'Field'); - this.setTooltip('Static text that serves as a label.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=88'); - } -}; - -Blockly.Blocks['field_input'] = { - // Text input. - init: function() { - this.setColour(160); - this.appendDummyInput() - .appendField('text input') - .appendField(new Blockly.FieldTextInput('default'), 'TEXT') - .appendField(',') - .appendField(new Blockly.FieldTextInput('NAME'), 'FIELDNAME'); - this.setPreviousStatement(true, 'Field'); - this.setNextStatement(true, 'Field'); - this.setTooltip('An input field for the user to enter text.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=319'); - }, - onchange: function() { - fieldNameCheck(this); - } -}; - -Blockly.Blocks['field_number'] = { - // Numeric input. - init: function() { - this.setColour(160); - this.appendDummyInput() - .appendField('numeric input') - .appendField(new Blockly.FieldNumber(0), 'VALUE') - .appendField(',') - .appendField(new Blockly.FieldTextInput('NAME'), 'FIELDNAME'); - this.appendDummyInput() - .appendField('min') - .appendField(new Blockly.FieldNumber(-Infinity), 'MIN') - .appendField('max') - .appendField(new Blockly.FieldNumber(Infinity), 'MAX') - .appendField('precision') - .appendField(new Blockly.FieldNumber(0, 0), 'PRECISION'); - this.setPreviousStatement(true, 'Field'); - this.setNextStatement(true, 'Field'); - this.setTooltip('An input field for the user to enter a number.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=319'); - }, - onchange: function() { - fieldNameCheck(this); - } -}; - -Blockly.Blocks['field_angle'] = { - // Angle input. - init: function() { - this.setColour(160); - this.appendDummyInput() - .appendField('angle input') - .appendField(new Blockly.FieldAngle('90'), 'ANGLE') - .appendField(',') - .appendField(new Blockly.FieldTextInput('NAME'), 'FIELDNAME'); - this.setPreviousStatement(true, 'Field'); - this.setNextStatement(true, 'Field'); - this.setTooltip('An input field for the user to enter an angle.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=372'); - }, - onchange: function() { - fieldNameCheck(this); - } -}; - -Blockly.Blocks['field_dropdown'] = { - // Dropdown menu. - init: function() { - this.appendDummyInput() - .appendField('dropdown') - .appendField(new Blockly.FieldTextInput('NAME'), 'FIELDNAME'); - this.optionCount_ = 3; - this.updateShape_(); - this.setPreviousStatement(true, 'Field'); - this.setNextStatement(true, 'Field'); - this.setMutator(new Blockly.Mutator(['field_dropdown_option'])); - this.setColour(160); - this.setTooltip('Dropdown menu with a list of options.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=386'); - }, - mutationToDom: function(workspace) { - // Create XML to represent menu options. - var container = document.createElement('mutation'); - container.setAttribute('options', this.optionCount_); - return container; - }, - domToMutation: function(container) { - // Parse XML to restore the menu options. - this.optionCount_ = parseInt(container.getAttribute('options'), 10); - this.updateShape_(); - }, - decompose: function(workspace) { - // Populate the mutator's dialog with this block's components. - var containerBlock = workspace.newBlock('field_dropdown_container'); - containerBlock.initSvg(); - var connection = containerBlock.getInput('STACK').connection; - for (var i = 0; i < this.optionCount_; i++) { - var optionBlock = workspace.newBlock('field_dropdown_option'); - optionBlock.initSvg(); - connection.connect(optionBlock.previousConnection); - connection = optionBlock.nextConnection; - } - return containerBlock; - }, - compose: function(containerBlock) { - // Reconfigure this block based on the mutator dialog's components. - var optionBlock = containerBlock.getInputTargetBlock('STACK'); - // Count number of inputs. - var data = []; - while (optionBlock) { - data.push([optionBlock.userData_, optionBlock.cpuData_]); - optionBlock = optionBlock.nextConnection && - optionBlock.nextConnection.targetBlock(); - } - this.optionCount_ = data.length; - this.updateShape_(); - // Restore any data. - for (var i = 0; i < this.optionCount_; i++) { - this.setFieldValue(data[i][0] || 'option', 'USER' + i); - this.setFieldValue(data[i][1] || 'OPTIONNAME', 'CPU' + i); - } - }, - saveConnections: function(containerBlock) { - // Store names and values for each option. - var optionBlock = containerBlock.getInputTargetBlock('STACK'); - var i = 0; - while (optionBlock) { - optionBlock.userData_ = this.getFieldValue('USER' + i); - optionBlock.cpuData_ = this.getFieldValue('CPU' + i); - i++; - optionBlock = optionBlock.nextConnection && - optionBlock.nextConnection.targetBlock(); - } - }, - updateShape_: function() { - // Modify this block to have the correct number of options. - // Add new options. - for (var i = 0; i < this.optionCount_; i++) { - if (!this.getInput('OPTION' + i)) { - this.appendDummyInput('OPTION' + i) - .appendField(new Blockly.FieldTextInput('option'), 'USER' + i) - .appendField(',') - .appendField(new Blockly.FieldTextInput('OPTIONNAME'), 'CPU' + i); - } - } - // Remove deleted options. - while (this.getInput('OPTION' + i)) { - this.removeInput('OPTION' + i); - i++; - } - }, - onchange: function() { - if (this.workspace && this.optionCount_ < 1) { - this.setWarningText('Drop down menu must\nhave at least one option.'); - } else { - fieldNameCheck(this); - } - } -}; - -Blockly.Blocks['field_dropdown_container'] = { - // Container. - init: function() { - this.setColour(160); - this.appendDummyInput() - .appendField('add options'); - this.appendStatementInput('STACK'); - this.setTooltip('Add, remove, or reorder options\n' + - 'to reconfigure this dropdown menu.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=386'); - this.contextMenu = false; - } -}; - -Blockly.Blocks['field_dropdown_option'] = { - // Add option. - init: function() { - this.setColour(160); - this.appendDummyInput() - .appendField('option'); - this.setPreviousStatement(true); - this.setNextStatement(true); - this.setTooltip('Add a new option to the dropdown menu.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=386'); - this.contextMenu = false; - } -}; - -Blockly.Blocks['field_checkbox'] = { - // Checkbox. - init: function() { - this.setColour(160); - this.appendDummyInput() - .appendField('checkbox') - .appendField(new Blockly.FieldCheckbox('TRUE'), 'CHECKED') - .appendField(',') - .appendField(new Blockly.FieldTextInput('NAME'), 'FIELDNAME'); - this.setPreviousStatement(true, 'Field'); - this.setNextStatement(true, 'Field'); - this.setTooltip('Checkbox field.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=485'); - }, - onchange: function() { - fieldNameCheck(this); - } -}; - -Blockly.Blocks['field_colour'] = { - // Colour input. - init: function() { - this.setColour(160); - this.appendDummyInput() - .appendField('colour') - .appendField(new Blockly.FieldColour('#ff0000'), 'COLOUR') - .appendField(',') - .appendField(new Blockly.FieldTextInput('NAME'), 'FIELDNAME'); - this.setPreviousStatement(true, 'Field'); - this.setNextStatement(true, 'Field'); - this.setTooltip('Colour input field.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=495'); - }, - onchange: function() { - fieldNameCheck(this); - } -}; - -Blockly.Blocks['field_date'] = { - // Date input. - init: function() { - this.setColour(160); - this.appendDummyInput() - .appendField('date') - .appendField(new Blockly.FieldDate(), 'DATE') - .appendField(',') - .appendField(new Blockly.FieldTextInput('NAME'), 'FIELDNAME'); - this.setPreviousStatement(true, 'Field'); - this.setNextStatement(true, 'Field'); - this.setTooltip('Date input field.'); - }, - onchange: function() { - fieldNameCheck(this); - } -}; - -Blockly.Blocks['field_variable'] = { - // Dropdown for variables. - init: function() { - this.setColour(160); - this.appendDummyInput() - .appendField('variable') - .appendField(new Blockly.FieldTextInput('item'), 'TEXT') - .appendField(',') - .appendField(new Blockly.FieldTextInput('NAME'), 'FIELDNAME'); - this.setPreviousStatement(true, 'Field'); - this.setNextStatement(true, 'Field'); - this.setTooltip('Dropdown menu for variable names.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=510'); - }, - onchange: function() { - fieldNameCheck(this); - } -}; - -Blockly.Blocks['field_image'] = { - // Image. - init: function() { - this.setColour(160); - var src = 'https://www.gstatic.com/codesite/ph/images/star_on.gif'; - this.appendDummyInput() - .appendField('image') - .appendField(new Blockly.FieldTextInput(src), 'SRC'); - this.appendDummyInput() - .appendField('width') - .appendField(new Blockly.FieldNumber('15', 0, NaN, 1), 'WIDTH') - .appendField('height') - .appendField(new Blockly.FieldNumber('15', 0, NaN, 1), 'HEIGHT') - .appendField('alt text') - .appendField(new Blockly.FieldTextInput('*'), 'ALT'); - this.setPreviousStatement(true, 'Field'); - this.setNextStatement(true, 'Field'); - this.setTooltip('Static image (JPEG, PNG, GIF, SVG, BMP).\n' + - 'Retains aspect ratio regardless of height and width.\n' + - 'Alt text is for when collapsed.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=567'); - } -}; - -Blockly.Blocks['type_group'] = { - // Group of types. - init: function() { - this.typeCount_ = 2; - this.updateShape_(); - this.setOutput(true, 'Type'); - this.setMutator(new Blockly.Mutator(['type_group_item'])); - this.setColour(230); - this.setTooltip('Allows more than one type to be accepted.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=677'); - }, - mutationToDom: function(workspace) { - // Create XML to represent a group of types. - var container = document.createElement('mutation'); - container.setAttribute('types', this.typeCount_); - return container; - }, - domToMutation: function(container) { - // Parse XML to restore the group of types. - this.typeCount_ = parseInt(container.getAttribute('types'), 10); - this.updateShape_(); - for (var i = 0; i < this.typeCount_; i++) { - this.removeInput('TYPE' + i); - } - for (var i = 0; i < this.typeCount_; i++) { - var input = this.appendValueInput('TYPE' + i) - .setCheck('Type'); - if (i == 0) { - input.appendField('any of'); - } - } - }, - decompose: function(workspace) { - // Populate the mutator's dialog with this block's components. - var containerBlock = workspace.newBlock('type_group_container'); - containerBlock.initSvg(); - var connection = containerBlock.getInput('STACK').connection; - for (var i = 0; i < this.typeCount_; i++) { - var typeBlock = workspace.newBlock('type_group_item'); - typeBlock.initSvg(); - connection.connect(typeBlock.previousConnection); - connection = typeBlock.nextConnection; - } - return containerBlock; - }, - compose: function(containerBlock) { - // Reconfigure this block based on the mutator dialog's components. - var typeBlock = containerBlock.getInputTargetBlock('STACK'); - // Count number of inputs. - var connections = []; - while (typeBlock) { - connections.push(typeBlock.valueConnection_); - typeBlock = typeBlock.nextConnection && - typeBlock.nextConnection.targetBlock(); - } - // Disconnect any children that don't belong. - for (var i = 0; i < this.typeCount_; i++) { - var connection = this.getInput('TYPE' + i).connection.targetConnection; - if (connection && connections.indexOf(connection) == -1) { - connection.disconnect(); - } - } - this.typeCount_ = connections.length; - this.updateShape_(); - // Reconnect any child blocks. - for (var i = 0; i < this.typeCount_; i++) { - Blockly.Mutator.reconnect(connections[i], this, 'TYPE' + i); - } - }, - saveConnections: function(containerBlock) { - // Store a pointer to any connected child blocks. - var typeBlock = containerBlock.getInputTargetBlock('STACK'); - var i = 0; - while (typeBlock) { - var input = this.getInput('TYPE' + i); - typeBlock.valueConnection_ = input && input.connection.targetConnection; - i++; - typeBlock = typeBlock.nextConnection && - typeBlock.nextConnection.targetBlock(); - } - }, - updateShape_: function() { - // Modify this block to have the correct number of inputs. - // Add new inputs. - for (var i = 0; i < this.typeCount_; i++) { - if (!this.getInput('TYPE' + i)) { - var input = this.appendValueInput('TYPE' + i); - if (i == 0) { - input.appendField('any of'); - } - } - } - // Remove deleted inputs. - while (this.getInput('TYPE' + i)) { - this.removeInput('TYPE' + i); - i++; - } - } -}; - -Blockly.Blocks['type_group_container'] = { - // Container. - init: function() { - this.jsonInit({ - "message0": "add types %1 %2", - "args0": [ - {"type": "input_dummy"}, - {"type": "input_statement", "name": "STACK"} - ], - "colour": 230, - "tooltip": "Add, or remove allowed type.", - "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=677" - }); - } -}; - -Blockly.Blocks['type_group_item'] = { - // Add type. - init: function() { - this.jsonInit({ - "message0": "type", - "previousStatement": null, - "nextStatement": null, - "colour": 230, - "tooltip": "Add a new allowed type.", - "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=677" - }); - } -}; - -Blockly.Blocks['type_null'] = { - // Null type. - valueType: null, - init: function() { - this.jsonInit({ - "message0": "any", - "output": "Type", - "colour": 230, - "tooltip": "Any type is allowed.", - "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=602" - }); - } -}; - -Blockly.Blocks['type_boolean'] = { - // Boolean type. - valueType: 'Boolean', - init: function() { - this.jsonInit({ - "message0": "Boolean", - "output": "Type", - "colour": 230, - "tooltip": "Booleans (true/false) are allowed.", - "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=602" - }); - } -}; - -Blockly.Blocks['type_number'] = { - // Number type. - valueType: 'Number', - init: function() { - this.jsonInit({ - "message0": "Number", - "output": "Type", - "colour": 230, - "tooltip": "Numbers (int/float) are allowed.", - "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=602" - }); - } -}; - -Blockly.Blocks['type_string'] = { - // String type. - valueType: 'String', - init: function() { - this.jsonInit({ - "message0": "String", - "output": "Type", - "colour": 230, - "tooltip": "Strings (text) are allowed.", - "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=602" - }); - } -}; - -Blockly.Blocks['type_list'] = { - // List type. - valueType: 'Array', - init: function() { - this.jsonInit({ - "message0": "Array", - "output": "Type", - "colour": 230, - "tooltip": "Arrays (lists) are allowed.", - "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=602" - }); - } -}; - -Blockly.Blocks['type_other'] = { - // Other type. - init: function() { - this.jsonInit({ - "message0": "other %1", - "args0": [{"type": "field_input", "name": "TYPE", "text": ""}], - "output": "Type", - "colour": 230, - "tooltip": "Custom type to allow.", - "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=702" - }); - } -}; - -Blockly.Blocks['colour_hue'] = { - // Set the colour of the block. - init: function() { - this.appendDummyInput() - .appendField('hue:') - .appendField(new Blockly.FieldAngle('0', this.validator), 'HUE'); - this.setOutput(true, 'Colour'); - this.setTooltip('Paint the block with this colour.'); - this.setHelpUrl('https://www.youtube.com/watch?v=s2_xaEvcVI0#t=55'); - }, - validator: function(text) { - // Update the current block's colour to match. - var hue = parseInt(text, 10); - if (!isNaN(hue)) { - this.sourceBlock_.setColour(hue); - } - }, - mutationToDom: function(workspace) { - var container = document.createElement('mutation'); - container.setAttribute('colour', this.getColour()); - return container; - }, - domToMutation: function(container) { - this.setColour(container.getAttribute('colour')); - } -}; - -/** - * Check to see if more than one field has this name. - * Highly inefficient (On^2), but n is small. - * @param {!Blockly.Block} referenceBlock Block to check. - */ -function fieldNameCheck(referenceBlock) { - if (!referenceBlock.workspace) { - // Block has been deleted. - return; - } - var name = referenceBlock.getFieldValue('FIELDNAME').toLowerCase(); - var count = 0; - var blocks = referenceBlock.workspace.getAllBlocks(); - for (var i = 0, block; block = blocks[i]; i++) { - var otherName = block.getFieldValue('FIELDNAME'); - if (!block.disabled && !block.getInheritedDisabled() && - otherName && otherName.toLowerCase() == name) { - count++; - } - } - var msg = (count > 1) ? - 'There are ' + count + ' field blocks\n with this name.' : null; - referenceBlock.setWarningText(msg); -} - -/** - * Check to see if more than one input has this name. - * Highly inefficient (On^2), but n is small. - * @param {!Blockly.Block} referenceBlock Block to check. - */ -function inputNameCheck(referenceBlock) { - if (!referenceBlock.workspace) { - // Block has been deleted. - return; - } - var name = referenceBlock.getFieldValue('INPUTNAME').toLowerCase(); - var count = 0; - var blocks = referenceBlock.workspace.getAllBlocks(); - for (var i = 0, block; block = blocks[i]; i++) { - var otherName = block.getFieldValue('INPUTNAME'); - if (!block.disabled && !block.getInheritedDisabled() && - otherName && otherName.toLowerCase() == name) { - count++; - } - } - var msg = (count > 1) ? - 'There are ' + count + ' input blocks\n with this name.' : null; - referenceBlock.setWarningText(msg); -} diff --git a/demos/blockfactory_old/factory.js b/demos/blockfactory_old/factory.js deleted file mode 100644 index 9db58bd6dbe..00000000000 --- a/demos/blockfactory_old/factory.js +++ /dev/null @@ -1,850 +0,0 @@ -/** - * Blockly Demos: Block Factory - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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 JavaScript for Blockly's Block Factory application. - * @author fraser@google.com (Neil Fraser) - */ -'use strict'; - -/** - * Workspace for user to build block. - * @type {Blockly.Workspace} - */ -var mainWorkspace = null; - -/** - * Workspace for preview of block. - * @type {Blockly.Workspace} - */ -var previewWorkspace = null; - -/** - * Name of block if not named. - */ -var UNNAMED = 'unnamed'; - -/** - * Change the language code format. - */ -function formatChange() { - var mask = document.getElementById('blocklyMask'); - var languagePre = document.getElementById('languagePre'); - var languageTA = document.getElementById('languageTA'); - if (document.getElementById('format').value == 'Manual') { - Blockly.hideChaff(); - mask.style.display = 'block'; - languagePre.style.display = 'none'; - languageTA.style.display = 'block'; - var code = languagePre.textContent.trim(); - languageTA.value = code; - languageTA.focus(); - updatePreview(); - } else { - mask.style.display = 'none'; - languageTA.style.display = 'none'; - languagePre.style.display = 'block'; - updateLanguage(); - } - disableEnableLink(); -} - -/** - * Update the language code based on constructs made in Blockly. - */ -function updateLanguage() { - var rootBlock = getRootBlock(); - if (!rootBlock) { - return; - } - var blockType = rootBlock.getFieldValue('NAME').trim().toLowerCase(); - if (!blockType) { - blockType = UNNAMED; - } - blockType = blockType.replace(/\W/g, '_').replace(/^(\d)/, '_\\1'); - switch (document.getElementById('format').value) { - case 'JSON': - var code = formatJson_(blockType, rootBlock); - break; - case 'JavaScript': - var code = formatJavaScript_(blockType, rootBlock); - break; - } - injectCode(code, 'languagePre'); - updatePreview(); -} - -/** - * Update the language code as JSON. - * @param {string} blockType Name of block. - * @param {!Blockly.Block} rootBlock Factory_base block. - * @return {string} Generanted language code. - * @private - */ -function formatJson_(blockType, rootBlock) { - var JS = {}; - // Type is not used by Blockly, but may be used by a loader. - JS.type = blockType; - // Generate inputs. - var message = []; - var args = []; - var contentsBlock = rootBlock.getInputTargetBlock('INPUTS'); - var lastInput = null; - while (contentsBlock) { - if (!contentsBlock.disabled && !contentsBlock.getInheritedDisabled()) { - var fields = getFieldsJson_(contentsBlock.getInputTargetBlock('FIELDS')); - for (var i = 0; i < fields.length; i++) { - if (typeof fields[i] == 'string') { - message.push(fields[i].replace(/%/g, '%%')); - } else { - args.push(fields[i]); - message.push('%' + args.length); - } - } - - var input = {type: contentsBlock.type}; - // Dummy inputs don't have names. Other inputs do. - if (contentsBlock.type != 'input_dummy') { - input.name = contentsBlock.getFieldValue('INPUTNAME'); - } - var check = JSON.parse(getOptTypesFrom(contentsBlock, 'TYPE') || 'null'); - if (check) { - input.check = check; - } - var align = contentsBlock.getFieldValue('ALIGN'); - if (align != 'LEFT') { - input.align = align; - } - args.push(input); - message.push('%' + args.length); - lastInput = contentsBlock; - } - contentsBlock = contentsBlock.nextConnection && - contentsBlock.nextConnection.targetBlock(); - } - // Remove last input if dummy and not empty. - if (lastInput && lastInput.type == 'input_dummy') { - var fields = lastInput.getInputTargetBlock('FIELDS'); - if (fields && getFieldsJson_(fields).join('').trim() != '') { - var align = lastInput.getFieldValue('ALIGN'); - if (align != 'LEFT') { - JS.lastDummyAlign0 = align; - } - args.pop(); - message.pop(); - } - } - JS.message0 = message.join(' '); - if (args.length) { - JS.args0 = args; - } - // Generate inline/external switch. - if (rootBlock.getFieldValue('INLINE') == 'EXT') { - JS.inputsInline = false; - } else if (rootBlock.getFieldValue('INLINE') == 'INT') { - JS.inputsInline = true; - } - // Generate output, or next/previous connections. - switch (rootBlock.getFieldValue('CONNECTIONS')) { - case 'LEFT': - JS.output = - JSON.parse(getOptTypesFrom(rootBlock, 'OUTPUTTYPE') || 'null'); - break; - case 'BOTH': - JS.previousStatement = - JSON.parse(getOptTypesFrom(rootBlock, 'TOPTYPE') || 'null'); - JS.nextStatement = - JSON.parse(getOptTypesFrom(rootBlock, 'BOTTOMTYPE') || 'null'); - break; - case 'TOP': - JS.previousStatement = - JSON.parse(getOptTypesFrom(rootBlock, 'TOPTYPE') || 'null'); - break; - case 'BOTTOM': - JS.nextStatement = - JSON.parse(getOptTypesFrom(rootBlock, 'BOTTOMTYPE') || 'null'); - break; - } - // Generate colour. - var colourBlock = rootBlock.getInputTargetBlock('COLOUR'); - if (colourBlock && !colourBlock.disabled) { - var hue = parseInt(colourBlock.getFieldValue('HUE'), 10); - JS.colour = hue; - } - JS.tooltip = ''; - JS.helpUrl = 'http://www.example.com/'; - return JSON.stringify(JS, null, ' '); -} - -/** - * Update the language code as JavaScript. - * @param {string} blockType Name of block. - * @param {!Blockly.Block} rootBlock Factory_base block. - * @return {string} Generanted language code. - * @private - */ -function formatJavaScript_(blockType, rootBlock) { - var code = []; - code.push("Blockly.Blocks['" + blockType + "'] = {"); - code.push(" init: function() {"); - // Generate inputs. - var TYPES = {'input_value': 'appendValueInput', - 'input_statement': 'appendStatementInput', - 'input_dummy': 'appendDummyInput'}; - var contentsBlock = rootBlock.getInputTargetBlock('INPUTS'); - while (contentsBlock) { - if (!contentsBlock.disabled && !contentsBlock.getInheritedDisabled()) { - var name = ''; - // Dummy inputs don't have names. Other inputs do. - if (contentsBlock.type != 'input_dummy') { - name = escapeString(contentsBlock.getFieldValue('INPUTNAME')); - } - code.push(' this.' + TYPES[contentsBlock.type] + '(' + name + ')'); - var check = getOptTypesFrom(contentsBlock, 'TYPE'); - if (check) { - code.push(' .setCheck(' + check + ')'); - } - var align = contentsBlock.getFieldValue('ALIGN'); - if (align != 'LEFT') { - code.push(' .setAlign(Blockly.ALIGN_' + align + ')'); - } - var fields = getFieldsJs_(contentsBlock.getInputTargetBlock('FIELDS')); - for (var i = 0; i < fields.length; i++) { - code.push(' .appendField(' + fields[i] + ')'); - } - // Add semicolon to last line to finish the statement. - code[code.length - 1] += ';'; - } - contentsBlock = contentsBlock.nextConnection && - contentsBlock.nextConnection.targetBlock(); - } - // Generate inline/external switch. - if (rootBlock.getFieldValue('INLINE') == 'EXT') { - code.push(' this.setInputsInline(false);'); - } else if (rootBlock.getFieldValue('INLINE') == 'INT') { - code.push(' this.setInputsInline(true);'); - } - // Generate output, or next/previous connections. - switch (rootBlock.getFieldValue('CONNECTIONS')) { - case 'LEFT': - code.push(connectionLineJs_('setOutput', 'OUTPUTTYPE')); - break; - case 'BOTH': - code.push(connectionLineJs_('setPreviousStatement', 'TOPTYPE')); - code.push(connectionLineJs_('setNextStatement', 'BOTTOMTYPE')); - break; - case 'TOP': - code.push(connectionLineJs_('setPreviousStatement', 'TOPTYPE')); - break; - case 'BOTTOM': - code.push(connectionLineJs_('setNextStatement', 'BOTTOMTYPE')); - break; - } - // Generate colour. - var colourBlock = rootBlock.getInputTargetBlock('COLOUR'); - if (colourBlock && !colourBlock.disabled) { - var hue = parseInt(colourBlock.getFieldValue('HUE'), 10); - if (!isNaN(hue)) { - code.push(' this.setColour(' + hue + ');'); - } - } - code.push(" this.setTooltip('');"); - code.push(" this.setHelpUrl('http://www.example.com/');"); - code.push(' }'); - code.push('};'); - return code.join('\n'); -} - -/** - * Create JS code required to create a top, bottom, or value connection. - * @param {string} functionName JavaScript function name. - * @param {string} typeName Name of type input. - * @return {string} Line of JavaScript code to create connection. - * @private - */ -function connectionLineJs_(functionName, typeName) { - var type = getOptTypesFrom(getRootBlock(), typeName); - if (type) { - type = ', ' + type; - } else { - type = ''; - } - return ' this.' + functionName + '(true' + type + ');'; -} - -/** - * Returns field strings and any config. - * @param {!Blockly.Block} block Input block. - * @return {!Array.} Field strings. - * @private - */ -function getFieldsJs_(block) { - var fields = []; - while (block) { - if (!block.disabled && !block.getInheritedDisabled()) { - switch (block.type) { - case 'field_static': - // Result: 'hello' - fields.push(escapeString(block.getFieldValue('TEXT'))); - break; - case 'field_input': - // Result: new Blockly.FieldTextInput('Hello'), 'GREET' - fields.push('new Blockly.FieldTextInput(' + - escapeString(block.getFieldValue('TEXT')) + '), ' + - escapeString(block.getFieldValue('FIELDNAME'))); - break; - case 'field_number': - // Result: new Blockly.FieldNumber(10, 0, 100, 1), 'NUMBER' - var args = [ - Number(block.getFieldValue('VALUE')), - Number(block.getFieldValue('MIN')), - Number(block.getFieldValue('MAX')), - Number(block.getFieldValue('PRECISION')) - ]; - // Remove any trailing arguments that aren't needed. - if (args[3] == 0) { - args.pop(); - if (args[2] == Infinity) { - args.pop(); - if (args[1] == -Infinity) { - args.pop(); - } - } - } - fields.push('new Blockly.FieldNumber(' + args.join(', ') + '), ' + - escapeString(block.getFieldValue('FIELDNAME'))); - break; - case 'field_angle': - // Result: new Blockly.FieldAngle(90), 'ANGLE' - fields.push('new Blockly.FieldAngle(' + - Number(block.getFieldValue('ANGLE')) + '), ' + - escapeString(block.getFieldValue('FIELDNAME'))); - break; - case 'field_checkbox': - // Result: new Blockly.FieldCheckbox('TRUE'), 'CHECK' - fields.push('new Blockly.FieldCheckbox(' + - escapeString(block.getFieldValue('CHECKED')) + '), ' + - escapeString(block.getFieldValue('FIELDNAME'))); - break; - case 'field_colour': - // Result: new Blockly.FieldColour('#ff0000'), 'COLOUR' - fields.push('new Blockly.FieldColour(' + - escapeString(block.getFieldValue('COLOUR')) + '), ' + - escapeString(block.getFieldValue('FIELDNAME'))); - break; - case 'field_date': - // Result: new Blockly.FieldDate('2015-02-04'), 'DATE' - fields.push('new Blockly.FieldDate(' + - escapeString(block.getFieldValue('DATE')) + '), ' + - escapeString(block.getFieldValue('FIELDNAME'))); - break; - case 'field_variable': - // Result: new Blockly.FieldVariable('item'), 'VAR' - var varname = escapeString(block.getFieldValue('TEXT') || null); - fields.push('new Blockly.FieldVariable(' + varname + '), ' + - escapeString(block.getFieldValue('FIELDNAME'))); - break; - case 'field_dropdown': - // Result: - // new Blockly.FieldDropdown([['yes', '1'], ['no', '0']]), 'TOGGLE' - var options = []; - for (var i = 0; i < block.optionCount_; i++) { - options[i] = '[' + escapeString(block.getFieldValue('USER' + i)) + - ', ' + escapeString(block.getFieldValue('CPU' + i)) + ']'; - } - if (options.length) { - fields.push('new Blockly.FieldDropdown([' + - options.join(', ') + ']), ' + - escapeString(block.getFieldValue('FIELDNAME'))); - } - break; - case 'field_image': - // Result: new Blockly.FieldImage('http://...', 80, 60, '*') - var src = escapeString(block.getFieldValue('SRC')); - var width = Number(block.getFieldValue('WIDTH')); - var height = Number(block.getFieldValue('HEIGHT')); - var alt = escapeString(block.getFieldValue('ALT')); - fields.push('new Blockly.FieldImage(' + - src + ', ' + width + ', ' + height + ', ' + alt + ')'); - break; - } - } - block = block.nextConnection && block.nextConnection.targetBlock(); - } - return fields; -} - -/** - * Returns field strings and any config. - * @param {!Blockly.Block} block Input block. - * @return {!Array.} Array of static text and field configs. - * @private - */ -function getFieldsJson_(block) { - var fields = []; - while (block) { - if (!block.disabled && !block.getInheritedDisabled()) { - switch (block.type) { - case 'field_static': - // Result: 'hello' - fields.push(block.getFieldValue('TEXT')); - break; - case 'field_input': - fields.push({ - type: block.type, - name: block.getFieldValue('FIELDNAME'), - text: block.getFieldValue('TEXT') - }); - break; - case 'field_number': - var obj = { - type: block.type, - name: block.getFieldValue('FIELDNAME'), - value: parseFloat(block.getFieldValue('VALUE')) - }; - var min = parseFloat(block.getFieldValue('MIN')); - if (min > -Infinity) { - obj.min = min; - } - var max = parseFloat(block.getFieldValue('MAX')); - if (max < Infinity) { - obj.max = max; - } - var precision = parseFloat(block.getFieldValue('PRECISION')); - if (precision) { - obj.precision = precision; - } - fields.push(obj); - break; - case 'field_angle': - fields.push({ - type: block.type, - name: block.getFieldValue('FIELDNAME'), - angle: Number(block.getFieldValue('ANGLE')) - }); - break; - case 'field_checkbox': - fields.push({ - type: block.type, - name: block.getFieldValue('FIELDNAME'), - checked: block.getFieldValue('CHECKED') == 'TRUE' - }); - break; - case 'field_colour': - fields.push({ - type: block.type, - name: block.getFieldValue('FIELDNAME'), - colour: block.getFieldValue('COLOUR') - }); - break; - case 'field_date': - fields.push({ - type: block.type, - name: block.getFieldValue('FIELDNAME'), - date: block.getFieldValue('DATE') - }); - break; - case 'field_variable': - fields.push({ - type: block.type, - name: block.getFieldValue('FIELDNAME'), - variable: block.getFieldValue('TEXT') || null - }); - break; - case 'field_dropdown': - var options = []; - for (var i = 0; i < block.optionCount_; i++) { - options[i] = [block.getFieldValue('USER' + i), - block.getFieldValue('CPU' + i)]; - } - if (options.length) { - fields.push({ - type: block.type, - name: block.getFieldValue('FIELDNAME'), - options: options - }); - } - break; - case 'field_image': - fields.push({ - type: block.type, - src: block.getFieldValue('SRC'), - width: Number(block.getFieldValue('WIDTH')), - height: Number(block.getFieldValue('HEIGHT')), - alt: block.getFieldValue('ALT') - }); - break; - } - } - block = block.nextConnection && block.nextConnection.targetBlock(); - } - return fields; -} - -/** - * Escape a string. - * @param {string} string String to escape. - * @return {string} Escaped string surrouned by quotes. - */ -function escapeString(string) { - return JSON.stringify(string); -} - -/** - * Fetch the type(s) defined in the given input. - * Format as a string for appending to the generated code. - * @param {!Blockly.Block} block Block with input. - * @param {string} name Name of the input. - * @return {?string} String defining the types. - */ -function getOptTypesFrom(block, name) { - var types = getTypesFrom_(block, name); - if (types.length == 0) { - return undefined; - } else if (types.indexOf('null') != -1) { - return 'null'; - } else if (types.length == 1) { - return types[0]; - } else { - return '[' + types.join(', ') + ']'; - } -} - -/** - * Fetch the type(s) defined in the given input. - * @param {!Blockly.Block} block Block with input. - * @param {string} name Name of the input. - * @return {!Array.} List of types. - * @private - */ -function getTypesFrom_(block, name) { - var typeBlock = block.getInputTargetBlock(name); - var types; - if (!typeBlock || typeBlock.disabled) { - types = []; - } else if (typeBlock.type == 'type_other') { - types = [escapeString(typeBlock.getFieldValue('TYPE'))]; - } else if (typeBlock.type == 'type_group') { - types = []; - for (var i = 0; i < typeBlock.typeCount_; i++) { - types = types.concat(getTypesFrom_(typeBlock, 'TYPE' + i)); - } - // Remove duplicates. - var hash = Object.create(null); - for (var n = types.length - 1; n >= 0; n--) { - if (hash[types[n]]) { - types.splice(n, 1); - } - hash[types[n]] = true; - } - } else { - types = [escapeString(typeBlock.valueType)]; - } - return types; -} - -/** - * Update the generator code. - * @param {!Blockly.Block} block Rendered block in preview workspace. - */ -function updateGenerator(block) { - function makeVar(root, name) { - name = name.toLowerCase().replace(/\W/g, '_'); - return ' var ' + root + '_' + name; - } - var language = document.getElementById('language').value; - var code = []; - code.push("Blockly." + language + "['" + block.type + - "'] = function(block) {"); - - // Generate getters for any fields or inputs. - for (var i = 0, input; input = block.inputList[i]; i++) { - for (var j = 0, field; field = input.fieldRow[j]; j++) { - var name = field.name; - if (!name) { - continue; - } - if (field instanceof Blockly.FieldVariable) { - // Subclass of Blockly.FieldDropdown, must test first. - code.push(makeVar('variable', name) + - " = Blockly." + language + - ".variableDB_.getName(block.getFieldValue('" + name + - "'), Blockly.Variables.NAME_TYPE);"); - } else if (field instanceof Blockly.FieldAngle) { - // Subclass of Blockly.FieldTextInput, must test first. - code.push(makeVar('angle', name) + - " = block.getFieldValue('" + name + "');"); - } else if (Blockly.FieldDate && field instanceof Blockly.FieldDate) { - // Blockly.FieldDate may not be compiled into Blockly. - code.push(makeVar('date', name) + - " = block.getFieldValue('" + name + "');"); - } else if (field instanceof Blockly.FieldColour) { - code.push(makeVar('colour', name) + - " = block.getFieldValue('" + name + "');"); - } else if (field instanceof Blockly.FieldCheckbox) { - code.push(makeVar('checkbox', name) + - " = block.getFieldValue('" + name + "') == 'TRUE';"); - } else if (field instanceof Blockly.FieldDropdown) { - code.push(makeVar('dropdown', name) + - " = block.getFieldValue('" + name + "');"); - } else if (field instanceof Blockly.FieldNumber) { - code.push(makeVar('number', name) + - " = block.getFieldValue('" + name + "');"); - } else if (field instanceof Blockly.FieldTextInput) { - code.push(makeVar('text', name) + - " = block.getFieldValue('" + name + "');"); - } - } - var name = input.name; - if (name) { - if (input.type == Blockly.INPUT_VALUE) { - code.push(makeVar('value', name) + - " = Blockly." + language + ".valueToCode(block, '" + name + - "', Blockly." + language + ".ORDER_ATOMIC);"); - } else if (input.type == Blockly.NEXT_STATEMENT) { - code.push(makeVar('statements', name) + - " = Blockly." + language + ".statementToCode(block, '" + - name + "');"); - } - } - } - // Most languages end lines with a semicolon. Python does not. - var lineEnd = { - 'JavaScript': ';', - 'Python': '', - 'PHP': ';', - 'Dart': ';' - }; - code.push(" // TODO: Assemble " + language + " into code variable."); - if (block.outputConnection) { - code.push(" var code = '...';"); - code.push(" // TODO: Change ORDER_NONE to the correct strength."); - code.push(" return [code, Blockly." + language + ".ORDER_NONE];"); - } else { - code.push(" var code = '..." + (lineEnd[language] || '') + "\\n';"); - code.push(" return code;"); - } - code.push("};"); - - injectCode(code.join('\n'), 'generatorPre'); -} - -/** - * Existing direction ('ltr' vs 'rtl') of preview. - */ -var oldDir = null; - -/** - * Update the preview display. - */ -function updatePreview() { - // Toggle between LTR/RTL if needed (also used in first display). - var newDir = document.getElementById('direction').value; - if (oldDir != newDir) { - if (previewWorkspace) { - previewWorkspace.dispose(); - } - var rtl = newDir == 'rtl'; - previewWorkspace = Blockly.inject('preview', - {rtl: rtl, - media: '../../media/', - scrollbars: true}); - oldDir = newDir; - } - previewWorkspace.clear(); - - // Fetch the code and determine its format (JSON or JavaScript). - var format = document.getElementById('format').value; - if (format == 'Manual') { - var code = document.getElementById('languageTA').value; - // If the code is JSON, it will parse, otherwise treat as JS. - try { - JSON.parse(code); - format = 'JSON'; - } catch (e) { - format = 'JavaScript'; - } - } else { - var code = document.getElementById('languagePre').textContent; - } - if (!code.trim()) { - // Nothing to render. Happens while cloud storage is loading. - return; - } - - // Backup Blockly.Blocks object so that main workspace and preview don't - // collide if user creates a 'factory_base' block, for instance. - var backupBlocks = Blockly.Blocks; - try { - // Make a shallow copy. - Blockly.Blocks = {}; - for (var prop in backupBlocks) { - Blockly.Blocks[prop] = backupBlocks[prop]; - } - - if (format == 'JSON') { - var json = JSON.parse(code); - Blockly.Blocks[json.type || UNNAMED] = { - init: function() { - this.jsonInit(json); - } - }; - } else if (format == 'JavaScript') { - eval(code); - } else { - throw 'Unknown format: ' + format; - } - - // Look for a block on Blockly.Blocks that does not match the backup. - var blockType = null; - for (var type in Blockly.Blocks) { - if (typeof Blockly.Blocks[type].init == 'function' && - Blockly.Blocks[type] != backupBlocks[type]) { - blockType = type; - break; - } - } - if (!blockType) { - return; - } - - // Create the preview block. - var previewBlock = previewWorkspace.newBlock(blockType); - previewBlock.initSvg(); - previewBlock.render(); - previewBlock.setMovable(false); - previewBlock.setDeletable(false); - previewBlock.moveBy(15, 10); - previewWorkspace.clearUndo(); - - updateGenerator(previewBlock); - } finally { - Blockly.Blocks = backupBlocks; - } -} - -/** - * Inject code into a pre tag, with syntax highlighting. - * Safe from HTML/script injection. - * @param {string} code Lines of code. - * @param {string} id ID of
     element to inject into.
    - */
    -function injectCode(code, id) {
    -  var pre = document.getElementById(id);
    -  pre.textContent = code;
    -  code = pre.textContent;
    -  code = PR.prettyPrintOne(code, 'js');
    -  pre.innerHTML = code;
    -}
    -
    -/**
    - * Return the uneditable container block that everything else attaches to.
    - * @return {Blockly.Block}
    - */
    -function getRootBlock() {
    -  var blocks = mainWorkspace.getTopBlocks(false);
    -  for (var i = 0, block; block = blocks[i]; i++) {
    -    if (block.type == 'factory_base') {
    -      return block;
    -    }
    -  }
    -  return null;
    -}
    -
    -/**
    - * Disable the link button if the format is 'Manual', enable otherwise.
    - */
    -function disableEnableLink() {
    -  var linkButton = document.getElementById('linkButton');
    -  linkButton.disabled = document.getElementById('format').value == 'Manual';
    -}
    -
    -/**
    - * Initialize Blockly and layout.  Called on page load.
    - */
    -function init() {
    -  if ('BlocklyStorage' in window) {
    -    BlocklyStorage.HTTPREQUEST_ERROR =
    -        'There was a problem with the request.\n';
    -    BlocklyStorage.LINK_ALERT =
    -        'Share your blocks with this link:\n\n%1';
    -    BlocklyStorage.HASH_ERROR =
    -        'Sorry, "%1" doesn\'t correspond with any saved Blockly file.';
    -    BlocklyStorage.XML_ERROR = 'Could not load your saved file.\n'+
    -        'Perhaps it was created with a different version of Blockly?';
    -    var linkButton = document.getElementById('linkButton');
    -    linkButton.style.display = 'inline-block';
    -    linkButton.addEventListener('click',
    -        function() {BlocklyStorage.link(mainWorkspace);});
    -    disableEnableLink();
    -  }
    -
    -  document.getElementById('helpButton').addEventListener('click',
    -    function() {
    -      open('https://developers.google.com/blockly/guides/create-custom-blocks/block-factory',
    -           'BlockFactoryHelp');
    -    });
    -
    -  var expandList = [
    -    document.getElementById('blockly'),
    -    document.getElementById('blocklyMask'),
    -    document.getElementById('preview'),
    -    document.getElementById('languagePre'),
    -    document.getElementById('languageTA'),
    -    document.getElementById('generatorPre')
    -  ];
    -  var onresize = function(e) {
    -    for (var i = 0, expand; expand = expandList[i]; i++) {
    -      expand.style.width = (expand.parentNode.offsetWidth - 2) + 'px';
    -      expand.style.height = (expand.parentNode.offsetHeight - 2) + 'px';
    -    }
    -  };
    -  onresize();
    -  window.addEventListener('resize', onresize);
    -
    -  var toolbox = document.getElementById('toolbox');
    -  mainWorkspace = Blockly.inject('blockly',
    -      {collapse: false,
    -       toolbox: toolbox,
    -       media: '../../media/'});
    -
    -  // Create the root block.
    -  if ('BlocklyStorage' in window && window.location.hash.length > 1) {
    -    BlocklyStorage.retrieveXml(window.location.hash.substring(1),
    -                               mainWorkspace);
    -  } else {
    -    var xml = '';
    -    Blockly.Xml.domToWorkspace(Blockly.Xml.textToDom(xml), mainWorkspace);
    -  }
    -  mainWorkspace.clearUndo();
    -
    -  mainWorkspace.addChangeListener(Blockly.Events.disableOrphans);
    -  mainWorkspace.addChangeListener(updateLanguage);
    -  document.getElementById('direction')
    -      .addEventListener('change', updatePreview);
    -  document.getElementById('languageTA')
    -      .addEventListener('change', updatePreview);
    -  document.getElementById('languageTA')
    -      .addEventListener('keyup', updatePreview);
    -  document.getElementById('format')
    -      .addEventListener('change', formatChange);
    -  document.getElementById('language')
    -      .addEventListener('change', updatePreview);
    -}
    -window.addEventListener('load', init);
    diff --git a/demos/blockfactory_old/icon.png b/demos/blockfactory_old/icon.png
    deleted file mode 100644
    index d4d19b45768..00000000000
    Binary files a/demos/blockfactory_old/icon.png and /dev/null differ
    diff --git a/demos/blockfactory_old/index.html b/demos/blockfactory_old/index.html
    deleted file mode 100644
    index f4fd4f6c788..00000000000
    --- a/demos/blockfactory_old/index.html
    +++ /dev/null
    @@ -1,229 +0,0 @@
    -
    -
    -
    -  
    -  
    -  Blockly Demo: Block Factory
    -  
    -  
    -  
    -  
    -  
    -  
    -  
    -
    -
    -  
    -    
    -      
    -      
    -    
    -    
    -      
    -      
    -    
    -  
    -

    Blockly > - Demos > Block Factory

    -
    - - - - - -
    -

    Preview: - -

    -
    - - - -
    -
    -
    -
    -
    - - - - - - - - - - - - - - - - -
    -
    -
    -

    Language code: - -

    -
    -
    
    -              
    -            
    -

    Generator stub: - -

    -
    -
    
    -            
    -
    - - - diff --git a/demos/blockfactory_old/link.png b/demos/blockfactory_old/link.png deleted file mode 100644 index 11dfd82845e..00000000000 Binary files a/demos/blockfactory_old/link.png and /dev/null differ diff --git a/demos/code/code.js b/demos/code/code.js index 8ad4d166736..c264ff2dc9d 100644 --- a/demos/code/code.js +++ b/demos/code/code.js @@ -1,25 +1,11 @@ /** - * Blockly Demos: Code - * - * Copyright 2012 Google Inc. - * https://developers.google.com/blockly/ - * - * 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. + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview JavaScript for Blockly's Code demo. - * @author fraser@google.com (Neil Fraser) */ 'use strict'; @@ -46,12 +32,14 @@ Code.LANGUAGE_NAME = { 'fa': 'فارسی', 'fr': 'Français', 'he': 'עברית', + 'hr': 'Hrvatski', 'hrx': 'Hunsrik', 'hu': 'Magyar', 'ia': 'Interlingua', 'is': 'Íslenska', 'it': 'Italiano', 'ja': '日本語', + 'kab': 'Kabyle', 'ko': '한국어', 'mk': 'Македонски', 'ms': 'Bahasa Melayu', @@ -92,7 +80,7 @@ Code.workspace = null; * Extracts a parameter from the URL. * If the parameter is absent default_value is returned. * @param {string} name The name of the parameter. - * @param {string} defaultValue Value to return if paramater not found. + * @param {string} defaultValue Value to return if parameter not found. * @return {string} The parameter value or the default value if not found. */ Code.getStringParamFromUrl = function(name, defaultValue) { @@ -118,7 +106,7 @@ Code.getLang = function() { * @return {boolean} True if RTL, false if LTR. */ Code.isRtl = function() { - return Code.LANGUAGE_RTL.indexOf(Code.LANG) != -1; + return Code.LANGUAGE_RTL.includes(Code.LANG); }; /** @@ -139,11 +127,11 @@ Code.loadBlocks = function(defaultXml) { } else if (loadOnce) { // Language switching stores the blocks during the reload. delete window.sessionStorage.loadOnceBlocks; - var xml = Blockly.Xml.textToDom(loadOnce); + var xml = Blockly.utils.xml.textToDom(loadOnce); Blockly.Xml.domToWorkspace(xml, Code.workspace); } else if (defaultXml) { // Load the editor with default starting blocks. - var xml = Blockly.Xml.textToDom(defaultXml); + var xml = Blockly.utils.xml.textToDom(defaultXml); Blockly.Xml.domToWorkspace(xml, Code.workspace); } else if ('BlocklyStorage' in window) { // Restore saved blocks in a separate thread so that subsequent @@ -157,10 +145,8 @@ Code.loadBlocks = function(defaultXml) { */ Code.changeLanguage = function() { // Store the blocks for the duration of the reload. - // This should be skipped for the index page, which has no blocks and does - // not load Blockly. // MSIE 11 does not support sessionStorage on file:// URLs. - if (typeof Blockly != 'undefined' && window.sessionStorage) { + if (window.sessionStorage) { var xml = Blockly.Xml.workspaceToDom(Code.workspace); var text = Blockly.Xml.domToText(xml); window.sessionStorage.loadOnceBlocks = text; @@ -182,6 +168,15 @@ Code.changeLanguage = function() { window.location.host + window.location.pathname + search; }; +/** + * Changes the output language by clicking the tab matching + * the selected language in the codeMenu. + */ +Code.changeCodingLanguage = function() { + var codeMenu = document.getElementById('code_menu'); + Code.tabClick(codeMenu.options[codeMenu.selectedIndex].value); +} + /** * Bind a function to a button's click event. * On touch enabled browsers, ontouchend is treated as equivalent to onclick. @@ -189,11 +184,16 @@ Code.changeLanguage = function() { * @param {!Function} func Event handler to bind. */ Code.bindClick = function(el, func) { - if (typeof el == 'string') { + if (typeof el === 'string') { el = document.getElementById(el); } el.addEventListener('click', func, true); - el.addEventListener('touchend', func, true); + function touchFunc(e) { + // Prevent code from being executed twice on touchscreens. + e.preventDefault(); + func(e); + } + el.addEventListener('touchend', touchFunc, true); }; /** @@ -201,7 +201,7 @@ Code.bindClick = function(el, func) { */ Code.importPrettify = function() { var script = document.createElement('script'); - script.setAttribute('src', 'https://cdn.rawgit.com/google/code-prettify/master/loader/run_prettify.js'); + script.setAttribute('src', 'https://cdn.jsdelivr.net/gh/google/code-prettify@master/loader/run_prettify.js'); document.head.appendChild(script); }; @@ -239,7 +239,17 @@ Code.LANG = Code.getLang(); * List of tab names. * @private */ -Code.TABS_ = ['blocks', 'javascript', 'php', 'python', 'dart', 'lua', 'xml']; +Code.TABS_ = [ + 'blocks', 'javascript', 'php', 'python', 'dart', 'lua', 'xml', 'json' +]; + +/** + * List of tab names with casing, for display in the UI. + * @private + */ +Code.TABS_DISPLAY_ = [ + 'Blocks', 'JavaScript', 'PHP', 'Python', 'Dart', 'Lua', 'XML', 'JSON' +]; Code.selected = 'blocks'; @@ -249,15 +259,15 @@ Code.selected = 'blocks'; */ Code.tabClick = function(clickedName) { // If the XML tab was open, save and render the content. - if (document.getElementById('tab_xml').className == 'tabon') { + if (document.getElementById('tab_xml').classList.contains('tabon')) { var xmlTextarea = document.getElementById('content_xml'); var xmlText = xmlTextarea.value; var xmlDom = null; try { - xmlDom = Blockly.Xml.textToDom(xmlText); + xmlDom = Blockly.utils.xml.textToDom(xmlText); } catch (e) { - var q = - window.confirm(MSG['badXml'].replace('%1', e)); + var q = window.confirm( + MSG['parseError'].replace(/%1/g, 'XML').replace('%2', e)); if (!q) { // Leave the user on the XML tab. return; @@ -269,25 +279,61 @@ Code.tabClick = function(clickedName) { } } - if (document.getElementById('tab_blocks').className == 'tabon') { + if (document.getElementById('tab_json').classList.contains('tabon')) { + var jsonTextarea = document.getElementById('content_json'); + var jsonText = jsonTextarea.value; + var json = null; + try { + json = JSON.parse(jsonText); + } catch (e) { + var q = window.confirm( + MSG['parseError'].replace(/%1/g, 'JSON').replace('%2', e)); + if (!q) { + // Leave the user on the JSON tab. + return; + } + } + if (json) { + Blockly.serialization.workspaces.load(json, Code.workspace); + } + } + + if (document.getElementById('tab_blocks').classList.contains('tabon')) { Code.workspace.setVisible(false); } // Deselect all tabs and hide all panes. for (var i = 0; i < Code.TABS_.length; i++) { var name = Code.TABS_[i]; - document.getElementById('tab_' + name).className = 'taboff'; + var tab = document.getElementById('tab_' + name); + tab.classList.add('taboff'); + tab.classList.remove('tabon'); document.getElementById('content_' + name).style.visibility = 'hidden'; } // Select the active tab. Code.selected = clickedName; - document.getElementById('tab_' + clickedName).className = 'tabon'; + var selectedTab = document.getElementById('tab_' + clickedName); + selectedTab.classList.remove('taboff'); + selectedTab.classList.add('tabon'); // Show the selected pane. document.getElementById('content_' + clickedName).style.visibility = 'visible'; Code.renderContent(); - if (clickedName == 'blocks') { + // The code menu tab is on if the blocks tab is off. + var codeMenuTab = document.getElementById('tab_code'); + if (clickedName === 'blocks') { Code.workspace.setVisible(true); + codeMenuTab.className = 'taboff'; + } else { + codeMenuTab.className = 'tabon'; + } + // Sync the menu's value with the clicked tab value if needed. + var codeMenu = document.getElementById('code_menu'); + for (var i = 0; i < codeMenu.options.length; i++) { + if (codeMenu.options[i].value === clickedName) { + codeMenu.selectedIndex = i; + break; + } } Blockly.svgResize(Code.workspace); }; @@ -298,53 +344,71 @@ Code.tabClick = function(clickedName) { Code.renderContent = function() { var content = document.getElementById('content_' + Code.selected); // Initialize the pane. - if (content.id == 'content_xml') { + if (content.id === 'content_xml') { var xmlTextarea = document.getElementById('content_xml'); var xmlDom = Blockly.Xml.workspaceToDom(Code.workspace); var xmlText = Blockly.Xml.domToPrettyText(xmlDom); xmlTextarea.value = xmlText; xmlTextarea.focus(); - } else if (content.id == 'content_javascript') { - var code = Blockly.JavaScript.workspaceToCode(Code.workspace); - content.textContent = code; - if (typeof PR.prettyPrintOne == 'function') { - code = content.textContent; - code = PR.prettyPrintOne(code, 'js'); - content.innerHTML = code; - } - } else if (content.id == 'content_python') { - code = Blockly.Python.workspaceToCode(Code.workspace); - content.textContent = code; - if (typeof PR.prettyPrintOne == 'function') { - code = content.textContent; - code = PR.prettyPrintOne(code, 'py'); - content.innerHTML = code; - } - } else if (content.id == 'content_php') { - code = Blockly.PHP.workspaceToCode(Code.workspace); - content.textContent = code; - if (typeof PR.prettyPrintOne == 'function') { - code = content.textContent; - code = PR.prettyPrintOne(code, 'php'); - content.innerHTML = code; - } - } else if (content.id == 'content_dart') { - code = Blockly.Dart.workspaceToCode(Code.workspace); - content.textContent = code; - if (typeof PR.prettyPrintOne == 'function') { - code = content.textContent; - code = PR.prettyPrintOne(code, 'dart'); - content.innerHTML = code; - } - } else if (content.id == 'content_lua') { - code = Blockly.Lua.workspaceToCode(Code.workspace); + } else if (content.id === 'content_json') { + var jsonTextarea = document.getElementById('content_json'); + jsonTextarea.value = JSON.stringify( + Blockly.serialization.workspaces.save(Code.workspace), null, 2); + jsonTextarea.focus(); + } else if (content.id === 'content_javascript') { + Code.attemptCodeGeneration(javascript.javascriptGenerator); + } else if (content.id === 'content_python') { + Code.attemptCodeGeneration(python.pythonGenerator); + } else if (content.id === 'content_php') { + Code.attemptCodeGeneration(php.phpGenerator); + } else if (content.id === 'content_dart') { + Code.attemptCodeGeneration(dart.dartGenerator); + } else if (content.id === 'content_lua') { + Code.attemptCodeGeneration(lua.luaGenerator); + } + if (typeof PR === 'object') { + PR.prettyPrint(); + } +}; + +/** + * Attempt to generate the code and display it in the UI, pretty printed. + * @param generator {!Blockly.CodeGenerator} The generator to use. + */ +Code.attemptCodeGeneration = function(generator) { + var content = document.getElementById('content_' + Code.selected); + content.textContent = ''; + if (Code.checkAllGeneratorFunctionsDefined(generator)) { + var code = generator.workspaceToCode(Code.workspace); content.textContent = code; - if (typeof PR.prettyPrintOne == 'function') { - code = content.textContent; - code = PR.prettyPrintOne(code, 'lua'); - content.innerHTML = code; + // Remove the 'prettyprinted' class, so that Prettify will recalculate. + content.className = content.className.replace('prettyprinted', ''); + } +}; + +/** + * Check whether all blocks in use have generator functions. + * @param generator {!Blockly.CodeGenerator} The generator to use. + */ +Code.checkAllGeneratorFunctionsDefined = function(generator) { + var blocks = Code.workspace.getAllBlocks(false); + var missingBlockGenerators = []; + for (var i = 0; i < blocks.length; i++) { + var blockType = blocks[i].type; + if (!generator.forBlock[blockType]) { + if (!missingBlockGenerators.includes(blockType)) { + missingBlockGenerators.push(blockType); + } } } + + var valid = missingBlockGenerators.length === 0; + if (!valid) { + var msg = 'The generator code for the following blocks not specified for ' + + generator.name_ + ':\n - ' + missingBlockGenerators.join('\n - '); + Blockly.dialog.alert(msg); // Assuming synchronous. No callback. + } + return valid; }; /** @@ -369,9 +433,9 @@ Code.init = function() { el.style.width = (2 * bBox.width - el.offsetWidth) + 'px'; } // Make the 'Blocks' tab line up with the toolbox. - if (Code.workspace && Code.workspace.toolbox_.width) { + if (Code.workspace && Code.workspace.getToolbox().width) { document.getElementById('tab_blocks').style.minWidth = - (Code.workspace.toolbox_.width - 38) + 'px'; + (Code.workspace.getToolbox().width - 38) + 'px'; // Account for the 19 pixel margin and on each side. } }; @@ -386,14 +450,16 @@ Code.init = function() { // TODO: Clean up the message files so this is done explicitly instead of // through this for-loop. for (var messageKey in MSG) { - if (goog.string.startsWith(messageKey, 'cat')) { + if (messageKey.startsWith('cat')) { Blockly.Msg[messageKey.toUpperCase()] = MSG[messageKey]; } } - // Construct the toolbox XML. + // Construct the toolbox XML, replacing translated variable names. var toolboxText = document.getElementById('toolbox').outerHTML; - var toolboxXml = Blockly.Xml.textToDom(toolboxText); + toolboxText = toolboxText.replace(/(^|[^%]){(\w+)}/g, + function(m, p1, p2) {return p1 + MSG[p2];}); + var toolboxXml = Blockly.utils.xml.textToDom(toolboxText); Code.workspace = Blockly.inject('content_blocks', {grid: @@ -411,7 +477,7 @@ Code.init = function() { // Add to reserved word list: Local variables in execution environment (runJS) // and the infinite loop detection function. - Blockly.JavaScript.addReservedWords('code,timeouts,checkTimeout'); + javascript.javascriptGenerator.addReservedWords('code,timeouts,checkTimeout'); Code.loadBlocks(''); @@ -431,7 +497,7 @@ Code.init = function() { BlocklyStorage['HTTPREQUEST_ERROR'] = MSG['httpRequestError']; BlocklyStorage['LINK_ALERT'] = MSG['linkAlert']; BlocklyStorage['HASH_ERROR'] = MSG['hashError']; - BlocklyStorage['XML_ERROR'] = MSG['xmlError']; + BlocklyStorage['XML_ERROR'] = MSG['loadError']; Code.bindClick(linkButton, function() {BlocklyStorage.link(Code.workspace);}); } else if (linkButton) { @@ -443,6 +509,14 @@ Code.init = function() { Code.bindClick('tab_' + name, function(name_) {return function() {Code.tabClick(name_);};}(name)); } + Code.bindClick('tab_code', function(e) { + if (e.target !== document.getElementById('tab_code')) { + // Prevent clicks on child codeMenu from triggering a tab click. + return; + } + Code.changeCodingLanguage(); + }); + onresize(); Blockly.svgResize(Code.workspace); @@ -478,13 +552,21 @@ Code.initLanguage = function() { var tuple = languages[i]; var lang = tuple[tuple.length - 1]; var option = new Option(tuple[0], lang); - if (lang == Code.LANG) { + if (lang === Code.LANG) { option.selected = true; } languageMenu.options.add(option); } languageMenu.addEventListener('change', Code.changeLanguage, true); + // Populate the coding language selection menu. + var codeMenu = document.getElementById('code_menu'); + codeMenu.options.length = 0; + for (var i = 1; i < Code.TABS_.length; i++) { + codeMenu.options.add(new Option(Code.TABS_DISPLAY_[i], Code.TABS_[i])); + } + codeMenu.addEventListener('change', Code.changeCodingLanguage); + // Inject language strings. document.title += ' ' + MSG['title']; document.getElementById('title').textContent = MSG['title']; @@ -498,17 +580,23 @@ Code.initLanguage = function() { /** * Execute the user's code. * Just a quick and dirty eval. Catch infinite loops. + * @param {Event} event Event created from listener bound to the function. */ -Code.runJS = function() { - Blockly.JavaScript.INFINITE_LOOP_TRAP = ' checkTimeout();\n'; +Code.runJS = function(event) { + // Prevent code from being executed twice on touchscreens. + if (event.type === 'touchend') { + event.preventDefault(); + } + + javascript.javascriptGenerator.INFINITE_LOOP_TRAP = 'checkTimeout();\n'; var timeouts = 0; var checkTimeout = function() { if (timeouts++ > 1000000) { throw MSG['timeout']; } }; - var code = Blockly.JavaScript.workspaceToCode(Code.workspace); - Blockly.JavaScript.INFINITE_LOOP_TRAP = null; + var code = javascript.javascriptGenerator.workspaceToCode(Code.workspace); + javascript.javascriptGenerator.INFINITE_LOOP_TRAP = null; try { eval(code); } catch (e) { @@ -520,9 +608,9 @@ Code.runJS = function() { * Discard all blocks from the workspace. */ Code.discard = function() { - var count = Code.workspace.getAllBlocks().length; + var count = Code.workspace.getAllBlocks(false).length; if (count < 2 || - window.confirm(Blockly.Msg.DELETE_ALL_BLOCKS.replace('%1', count))) { + window.confirm(Blockly.Msg['DELETE_ALL_BLOCKS'].replace('%1', count))) { Code.workspace.clear(); if (window.location.hash) { window.location.hash = ''; @@ -533,6 +621,6 @@ Code.discard = function() { // Load the Code demo's language strings. document.write('\n'); // Load Blockly's language strings. -document.write('\n'); +document.write('\n'); window.addEventListener('load', Code.init); diff --git a/demos/code/icon.png b/demos/code/icon.png index e2f23bd8304..feaa92996a4 100644 Binary files a/demos/code/icon.png and b/demos/code/icon.png differ diff --git a/demos/code/index.html b/demos/code/index.html index 84a35ab50c2..d8f894607f7 100644 --- a/demos/code/index.html +++ b/demos/code/index.html @@ -2,17 +2,18 @@ + Blockly Demo: - - - - - - - + + + + + + + @@ -26,6 +27,7 @@

    Blockly‏ > + Privacy @@ -33,18 +35,24 @@

    Blockly‏ > + + + + + + + + + + + + + + - - - - - - - - - - - +
    ... JavaScript Python PHP Lua Dart XML JSON  JavaScript Python PHP Lua Dart XML + +
    -
    
    -  
    
    -  
    
    -  
    
    -  
    
    +  
    
    +  
    
    +  
    
    +  
    
    +  
    
       
    +  
     
    -