diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 74e92d193..000000000 --- a/.babelrc +++ /dev/null @@ -1,16 +0,0 @@ -{ - "presets": ["@babel/preset-env", "@babel/preset-react"], - "plugins": [ - "@babel/plugin-proposal-object-rest-spread", - "@babel/plugin-proposal-class-properties", - "babel-plugin-emotion" - ], - "env": { - "esm": { - "presets": [ - ["@babel/preset-env", { "modules": false }], - "@babel/preset-react" - ] - } - } -} diff --git a/.babelrc.build.js b/.babelrc.build.js new file mode 100644 index 000000000..9fda3f491 --- /dev/null +++ b/.babelrc.build.js @@ -0,0 +1,7 @@ +/** + * Configurations used for building core library files. + */ +module.exports = { + extends: './.babelrc.js', + ignore: ['/__snapshots__/', /^.*(.test.)[j|t]sx?$/] +}; diff --git a/.babelrc.js b/.babelrc.js new file mode 100644 index 000000000..146e7a9cf --- /dev/null +++ b/.babelrc.js @@ -0,0 +1,22 @@ +/** + * General configuration. + */ +module.exports = { + presets: [ + '@babel/preset-typescript', + ['@babel/preset-env', { modules: false }], + ['@babel/preset-react', { runtime: 'automatic' }] + ], + plugins: [ + '@babel/plugin-proposal-object-rest-spread', + '@babel/plugin-proposal-class-properties' + ], + env: { + cjs: { + presets: ['@babel/preset-env', '@babel/preset-react'] + }, + test: { + presets: ['@babel/preset-env', '@babel/preset-react'] + } + } +}; diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 000000000..a42ae819c --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", + "changelog": [ + "@svitejs/changesets-changelog-github-compact", + { + "repo": "FormidableLabs/spectacle" + } + ], + "access": "public", + "baseBranch": "main" +} diff --git a/.eslintignore b/.eslintignore index 22a3224b2..1b1a7874c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,6 @@ -dist/ -lib/ -node_modules/* +dist +lib +es +node_modules +coverage +.wireit diff --git a/.eslintrc b/.eslintrc index e660aeff7..55c24f64d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,63 +1,51 @@ -{ - "root": true, - "parser": "babel-eslint", - "extends": [ - "formidable/configurations/es6-react", - "prettier", - "prettier/react" - ], - "env": { - "browser": true, - "commonjs": true, - "es6": true, - "node": true, - "jest": true - }, - "globals": { - "expect": true - }, - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module", - "ecmaFeatures": { - "jsx": true, - "generators": true, - "experimentalObjectRestSpread": true - } - }, - "rules": { - "quotes": [ - "error", - "single", - { - "allowTemplateLiterals": true - } - ], - "comma-dangle": "off", - "indent": "off", - "space-before-function-paren": "off", - "react/jsx-indent-props": "off", - "max-len": "off", - "no-magic-numbers": "off", - "func-style": "off", - "arrow-parens": "off", - "no-use-before-define": "off", - "no-undef": ["error", { "typeof": true }], - "react/jsx-filename-extension": "off", - "react/require-extension": "off", - "react/no-multi-comp": "off", - "react/prop-types": "warn", - "react/sort-comp": "warn", - "react/sort-prop-types": "warn", - "react/jsx-handler-names": "off", - "react/no-find-dom-node": "off", - "no-invalid-this": "off", - "complexity": "off", - "no-unused-vars": [ - "error", - { - "argsIgnorePattern": "^_+$" - } - ] - } -} +{ + "root": true, + "parser": "@typescript-eslint/parser", + "extends": [ + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "prettier" + ], + "plugins": ["prettier", "react", "react-hooks", "@typescript-eslint"], + "env": { + "browser": true, + "commonjs": true, + "es6": true, + "node": true, + "jest": true + }, + "settings": { + "react": { + "version": "detect" + } + }, + "globals": { + "expect": true + }, + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true, + "generators": true, + "experimentalObjectRestSpread": true + } + }, + "rules": { + "react/jsx-uses-react": "off", + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "prettier/prettier": ["error"], + "quotes": ["error", "single", { "allowTemplateLiterals": true }], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error"] + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "rules": { + "react/prop-types": "off" + } + } + ] +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 3dc22656f..a74f6279b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -5,41 +5,48 @@ about: Create a report to help us improve ### Prerequisites -**Feel free to delete this section if you have checked off all of the following.** + -- [ ] I've searched open [issues](https://www.github.com/FormidableLabs/spectacle/issues) to make sure I'm not opening a duplicate issue -- [ ] This issue not specific to `spectacle-boilerplate` (those issues should be opened [here](https://github.com/FormidableLabs/spectacle-boilerplate/issues/new)). -- [ ] I have read through the [docs](https://formidable.com/open-source/spectacle/docs/) before asking a question +- [ ] I have searched the open [issues](https://www.github.com/FormidableLabs/spectacle/issues) to make sure I'm not opening a duplicate issue +- [ ] I have read through the [docs](https://www.formidable.com/open-source/spectacle/docs) before asking a question - [ ] I am using the latest version of Spectacle ### Describe Your Environment -What version of Spectacle are you using? (can be found by running `npm list spectacle`) +**What version of Spectacle are you using?** (can be found by running `npm list --depth 0 spectacle`) -What version of React are you using? (can be found by running `npm list react`) +**What version of React are you using?** (can be found by running `npm list --depth 0 react`) -What browser are you using? +**What browser are you using?** (e.g., Chrome 105.0.5195.102, Safari 16.0) -What machine are you on? +**What platform are you on?** (e.g., Windows, macOS, iOS, Android) ### Describe the Problem + + **Expected behavior:** [What you expect to happen] **Actual behavior:** [What actually happens] ### Additional Information -Any additional information, configuration or data that might be necessary to reproduce the issue. + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index b22ad2ab1..510abf4df 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -5,18 +5,18 @@ about: Suggest an idea for this project ### Description -Including the problem you want to address, use cases, benefits, and/or goals. + ### Proposal -How do you propose we implement this change? + ### Links / References -Any resources you want to point to for reference or more information. + diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 71d4ed1a1..13cedc00f 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -5,14 +5,14 @@ about: Ask a question about using Spectacle. ### Question -What question do you have about using Spectacle? + ### Background Info/Attempts -Any background information that might help us answer your questions, or a code snippet or link to a code example if you have an implementation question. + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index be23bb1d3..70c7c2501 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ @@ -12,7 +12,7 @@ Fixes # (issue) #### Type of Change -Please delete options that are not relevant. + - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) @@ -21,16 +21,15 @@ Please delete options that are not relevant. ### How Has This Been Tested? -Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration + ### Checklist: (Feel free to delete this section upon completion) -- [ ] My code follows the style guidelines of this project (I have run `yarn prettier-fix && yarn lint`) -- [ ] I have added tests that prove my fix is effective or that my feature works -- [ ] New and existing unit tests pass locally with my changes (I have run `yarn test`) +- [ ] I have included a [changeset](../CONTRIBUTING.md#changesets) if this change will require a version change to one of the packages. - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation +- [ ] I have run `pnpm run check:ci` and all checks pass +- [ ] I have added tests that prove my fix is effective or that my feature works - [ ] My changes generate no new warnings - [ ] Any dependent changes have been merged and published in downstream modules -- [ ] I have updated type definitions in `index.d.ts` for any breaking API changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..d0099e882 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +# Runs build and test on: +# every push that has a change in a file not in the docs folder +# every pull request with main branch as the base that has a change +# in a file not in the docs folder +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + name: Check and build codebase + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [14.x, 16.x, 18.x] + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + # Wireit cache + - uses: google/wireit@setup-github-actions-caching/v1 + + - uses: pnpm/action-setup@v2.2.2 + with: + version: 7 + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('./pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + # If you hare having issues post-merge with wireit improperly caching, + # comment this out, push a commit, then re-comment. + # - name: Clear all caches + # run: pnpm clean:cache + + - name: Build Code and Examples ${{ matrix.node-version }} + run: pnpm run build + + # We build in-source files like `examples/one-page/index.html`. + # This check ensures we don't build changes that need committing. + - name: Check generated in-source files + run: git diff --no-ext-diff --quiet --exit-code + + - name: Check Code ${{ matrix.node-version }} + run: pnpm run check:ci diff --git a/.github/workflows/create-spectacle.yml b/.github/workflows/create-spectacle.yml new file mode 100644 index 000000000..982502bea --- /dev/null +++ b/.github/workflows/create-spectacle.yml @@ -0,0 +1,77 @@ +name: create-spectacle + +on: + push: + branches: + - main + paths: + - ".github/workflows/create-spectacle.yml" + - "packages/create-spectacle/**" + pull_request: + branches: + - main + paths: + - ".github/workflows/create-spectacle.yml" + - "packages/create-spectacle/**" + +jobs: + build: + name: Create, build, and install + timeout-minutes: 5 + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x] + create-type: ['jsx', 'tsx', 'onepage'] + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + # Wireit cache + - uses: google/wireit@setup-github-actions-caching/v1 + + - uses: pnpm/action-setup@v2.2.2 + with: + version: 7 + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('./pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Build create-spectacle + run: pnpm run --filter ./packages/create-spectacle build + + # Create, build, isntall a full example. + # Then, start a background dev server. + - name: Create example - ${{ matrix.create-type }} + working-directory: ./packages/create-spectacle + run: pnpm run examples:${{ matrix.create-type }}:create + + - name: Install example - ${{ matrix.create-type }} + working-directory: ./packages/create-spectacle + run: pnpm run examples:${{ matrix.create-type }}:install + + - name: Build example - ${{ matrix.create-type }} + working-directory: ./packages/create-spectacle + run: pnpm run examples:${{ matrix.create-type }}:build + + # Wait until the dev server is full up and running and then test. + - name: Start and test example - ${{ matrix.create-type }} + working-directory: ./packages/create-spectacle + run: | + pnpm run examples:${{ matrix.create-type }}:start & \ + pnpm exec wait-on http-get://localhost:3000 && \ + pnpm run examples:test diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..f256f91a9 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,87 @@ +name: Deploy Website + +on: + push: + paths: + - '.github/workflows/docs.yml' + - 'docs/**' + - 'website/**' + pull_request: + branches: + - main + paths: + - '.github/workflows/docs.yml' + - 'docs/**' + - 'website/**' + +jobs: + deploy-website: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: AWS CLI version + run: "aws --version" + + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + # Wireit cache + - uses: google/wireit@setup-github-actions-caching/v1 + + - uses: pnpm/action-setup@v2.2.2 + with: + version: 7 + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('./pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Build the website + working-directory: ./website + run: pnpm build + + # Use `gh` tool to infer more information about the pull request. + # The underlying issue here is pushes to a non-mergeable/main target branch + # don't have the PR number easily available. + # https://stackoverflow.com/a/70102700 + - name: Get pull request info + id: pr_info + run: echo "::set-output name=pull_request_number::$(gh pr view --json number -q .number || echo "")" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy docs (staging) + if: github.ref != 'refs/heads/main' + working-directory: ./website + run: pnpm deploy:stage + env: + # GH actions have a PR merge commit that _isn't_ our actual commits. + # Manually infer correct branch and sha for pull requests. + FORMIDEPLOY_GIT_SHA: ${{ github.event.pull_request.head.sha }} + FORMIDEPLOY_PULL_REQUEST: ${{ steps.pr_info.outputs.pull_request_number }} + GITHUB_DEPLOYMENT_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }} + SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} + + - name: Deploy docs (production) + if: github.ref == 'refs/heads/main' + working-directory: ./website + run: pnpm deploy:prod + env: + GITHUB_DEPLOYMENT_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..643cb04b8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Spectacle Release Workflow + +on: + push: + branches: + - main + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: 18.x + + # Wireit cache + - uses: google/wireit@setup-github-actions-caching/v1 + + - uses: pnpm/action-setup@v2.2.2 + with: + version: 7 + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Build packages + run: pnpm run build + + - name: PR or Publish + id: changesets + uses: changesets/action@v1 + with: + # Note: Our `package.json:scripts.version` currently doesn't have `--fix-lockfile` for + # `pnpm install` because of a PNPM bug of some kind. + # https://github.com/FormidableLabs/spectacle/issues/1156 + version: pnpm run version + publish: pnpm changeset publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 53d28d112..d3cc3d0e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,46 @@ -node_modules +# dependencies +/node_modules +/**/node_modules package-lock.json + +# testing +/coverage +test/screenshots + +# production +/dist +/tmp +/docs/tmp + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local +\.hg +.idea +.vscode +.nova + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.log + +# build +.wireit +.eslintcache +coverage +Procfile +build dist lib es -*.log -.DS_Store -.vscode +bin +.puppeteer +.examples + +# Pack-ing artifacts +packages/**/package +**/*.tgz diff --git a/.npmignore b/.npmignore index 410139a2b..12f55c4a3 100644 --- a/.npmignore +++ b/.npmignore @@ -1,11 +1,7 @@ -/* -/dist/* -!/dist/spectacle* -/dist/*.map -!/es -!/lib -!/src -!/docs +* +!/dist/spectacle*.js +!/es/**/*.js +!/lib/**/*.js __snapshots__ __mocks__ *.test.js diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..519f90d57 --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +strict-peer-dependencies=false +prefer-workspace-packages=true + +# Docusaurus has some phantom dependencies, so specifically hoist those. +public-hoist-pattern[]=@docusaurus/theme-classic diff --git a/.prettierignore b/.prettierignore index aa5c6df5d..68cf25d9a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,19 @@ -package-lock.json +yarn.lock package.json +tsconfig.json node_modules -dist es lib -example/assets +dist +docs +bin +build +.nova +.vscode +.wireit +.examples +.changeset/*.md + +# Allow us to manually format this. +examples/md/slides.md +examples/one-page/index.html diff --git a/.prettierrc b/.prettierrc index 544138be4..a6448d885 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,7 @@ { - "singleQuote": true + "singleQuote": true, + "printWidth": 80, + "trailingComma": "none", + "endOfLine": "lf", + "semi": true } diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e7e376b76..000000000 --- a/.travis.yml +++ /dev/null @@ -1,26 +0,0 @@ -language: node_js - -node_js: - - "8" - - "10" - -# Use container-based Travis infrastructure. -sudo: false - -branches: - only: - - master - -install: - # Fail if lockfile outdated. - # https://yarnpkg.com/lang/en/docs/cli/install/#toc-yarn-install-frozen-lockfile - - yarn install --frozen-lockfile - -notifications: - email: - on_success: change - on_failure: always - -script: - - yarn run build - - yarn run check-ci diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b78f3573f..6dbe567d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,53 +1,267 @@ -Thanks for contributing! +# Contributing + +Thank you for contributing! + + + Maintenance Status + + +Spectacle is actively maintained by @[carlos-kelly][] for [@FormidableLabs][formidable-github]. ## Development ### Installing dependencies +We use [`pnpm`][pnpm-docs]. + +Install all dependencies by running: + ```sh -yarn install +$ pnpm install ``` -You will find all building blocks that make up Spectacle in the [`src`](src) folder. +### Examples + +We have various deck scenarios in `examples` in this repository that are part of the development process. + +We follow the convention of `start:NAME` to run an in-memory dev server for a specific example, but we also have a `pnpm build` script task to make sure we're actually producing non-broken sample presentations as a CI / assurance test. + +- `spectacle` + - [`examples/js`](https://github.com/FormidableLabs/spectacle/tree/main/examples/js) + - [`examples/md`](https://github.com/FormidableLabs/spectacle/tree/main/examples/md) + - [`examples/typescript`](https://github.com/FormidableLabs/spectacle/tree/main/examples/typescript) + - [`examples/one-page`](https://github.com/FormidableLabs/spectacle/tree/main/examples/one-page) +- `spectacle-mdx-loader` + - [`examples/mdx`](https://github.com/FormidableLabs/spectacle-mdx-loader/tree/main/examples/mdx) + +Here's how you can run the various examples: + +```sh +# JavaScript demo app (in two different terminals) +$ pnpm start:js +$ open http://localhost:3000/ -### Testing +# TypeScript demo app (in two different terminals) +$ pnpm start:ts +$ open http://localhost:3100/ -You will find tests for files colocated with `*.test.js` suffixes. Whenever making any changes, ensure that all existing tests pass by running `yarn run test`. +# Markdown demo app (in two different terminals) +$ pnpm start:md +$ open http://localhost:3200/ -If you are adding a new feature or some extra functionality, you should also make sure to accompany those changes with appropriate tests. +# One-page (no build, HTML page only) demo app (in two different terminals) +$ pnpm start:one-page +$ open examples/one-page/index.html -### Linting and Formatting +# Start **ALL** the example watchers at the same time! +$ pnpm start:examples +``` -Before committing any changes, be sure to do `yarn run lint`; this will lint all relevant files using [ESLint](http://eslint.org/) and report on any changes that you need to make. +You can also live watch the CLI and execute the built script on command with: -You will also want to ensure your code meets the prettier formatting guidelines by running `yarn run prettier -l ` on a specific file. If there are differences the script errors out. You can also specify a glob `yarn run prettier -l "src/**/*.js"` which will return a list of files that do not conform. +```sh +# Watch create-spectacle code and test out (in two different terminals) +$ pnpm start:create-spectacle +$ node packages/create-spectacle/bin/cli.js -h +``` -Alternatively, install the Prettier [editor plugin](https://prettier.io/docs/en/editors.html) in your favorite editor. This is the preferred method. +These run appropriate file watchers, so you can just start developing source files and wait for the various dev servers to pick up the new changes. -There is also a pre-commit hook in place to lint all staged files. If any of the staged files do not conform to the eslint rules or the [prettier](https://prettier.io/) formatting guidelines, your commit will fail until you resolve all outstanding issues. +### Build and checks -To resolve/fix prettier formatting problems from the CLI: +Our task system mostly takes care of all task dependencies and things you need. When you first clone this repo or a new branch, run: ```sh -yarn prettier-fix && yarn lint-fix +# Run all checks. Re-run this command for your normal workflow. +$ pnpm run check +# ... or add in a `--watch` to watch & re-run checks for only what you change! +$ pnpm run check --watch + +# Build libraries and UMD distributions. +# Really only needed to double-check the webpack build still works. +$ pnpm run build +# ... or add in a `--watch` to watch & re-run the parts of the build that changed! +$ pnpm run build --watch ``` -This will modify your file in place. You will need to `git add` the file again and re-commit. +This will do all the build, seeding the task cache so subsequent tasks are fast, and checks that everything is correctly working. Your Spectacle workflow could reasonably just be (1) making some changes to files + tests, and then (2) re-running `pnpm run check`! + +Here are some other useful tasks (with or without a `--watch` flag): -### Before submitting a PR... +```sh +# Quality checks +$ pnpm run prettier +$ pnpm run prettier --watch +$ pnpm run lint +$ pnpm run lint --watch +$ pnpm run types:check +$ pnpm run types:check --watch + +# Tests +$ pnpm run test +$ pnpm run test --watch +``` + +We also have some helper tasks to fix issues that are fixable. + +```sh +$ pnpm run prettier:fix +$ pnpm run lint:fix +``` + +If you have issues with tasks failing erroneously, you can clear our tooling caches: + +```sh +# Clean out everything +$ yarn clean:cache + +# Individually +$ yarn clean:cache:lint # eslint cache +$ yarn clean:cache:wireit # wireit task cache +$ yarn clean:cache:modules # caches in node_modules (prettier, etc.) +``` + +### Checking `create-spectacle` + +We have slower checks for the outputs created by our `create-spectacle` package that are run in CI, but you generally won't need to run unless you are developing that package. + +First, you can install Chromium to use in `puppeteer` or use a local Chrome instance. We only presently have Mac instructions and will get to Windows/Linux support when we get demand. You only need to do the following step once. + +```sh +# Option 1 -- Do nothing! If you have the Mac Chrome app, you can skip this step! +# Option 2 -- Install chromium +# Option 2.a -- Normal binary +$ pnpm puppeteer:install +# Option 2.b -- If you are on an M1/2 Mac, do this instead: +$ PUPPETEER_EXPERIMENTAL_CHROMIUM_MAC_ARM=true pnpm puppeteer:install +``` + +After that, you'll want to either build or watch the `create-spectacle` files: + +```sh +$ pnpm run --filter ./packages/create-spectacle build +$ pnpm run --filter ./packages/create-spectacle build --watch +``` + +From there, here are sample collections of commands to create new example applications from scratch with full installation and ending with firing up a dev server: + +```sh +# JavaScript +$ pnpm run --filter ./packages/create-spectacle examples:jsx:clean && \ + pnpm run --filter ./packages/create-spectacle examples:jsx:create && \ + pnpm run --filter ./packages/create-spectacle examples:jsx:install && \ + pnpm run --filter ./packages/create-spectacle examples:jsx:build && \ + pnpm run --filter ./packages/create-spectacle examples:jsx:start + +# TypeScript +$ pnpm run --filter ./packages/create-spectacle examples:tsx:clean && \ + pnpm run --filter ./packages/create-spectacle examples:tsx:create && \ + pnpm run --filter ./packages/create-spectacle examples:tsx:install && \ + pnpm run --filter ./packages/create-spectacle examples:tsx:build && \ + pnpm run --filter ./packages/create-spectacle examples:tsx:start + +# One Page (HTML-only, no build step) +$ pnpm run --filter ./packages/create-spectacle examples:onepage:clean && \ + pnpm run --filter ./packages/create-spectacle examples:onepage:create && \ + pnpm run --filter ./packages/create-spectacle examples:onepage:start +``` + +The dev server in each of these examples runs on port 3000 by default, and you can run a simple Puppeteer test against that port with the following: + +```sh +$ pnpm run --filter ./packages/create-spectacle examples:test +``` + +### Before submitting a PR Thanks for taking the time to help us make Spectacle even better! Before you go ahead and submit a PR, make sure that you have done the following: -- Run the tests using `yarn run test`. -- Run lint and flow using `yarn run lint` -- Update the [type definitions](./index.d.ts) for anything that modifies the Spectacle API, like breaking changes or new features. -- Everything else included in our [pull request checklist](https://github.com/FormidableLabs/spectacle/blob/master/.github/PULL_REQUEST_TEMPLATE.md#checklist-feel-free-to-delete-this-section-upon-completion) +- Run all checks using `pnpm run check:ci`. +- Run `pnpm run build` and check + commit changes to `examples/one-page/index.html` +- Add a [changeset](#changeset) if your PR requires a version change for any of the packages in this repo. +- Everything else included in our [pull request checklist](.github/PULL_REQUEST_TEMPLATE.md). + +### Changesets + +We use [changesets](https://github.com/changesets/changesets) to create package versions and publish them. + +If your work contributes changes that require a change in version to any of the packages, add a changeset by running: + +```sh +$ pnpm changeset +``` + +which will open an interactive CLI menu. Use this menu to select which packages need versioning, which semantic version changes are needed, and add appropriate messages accordingly. + +After this, you'll see a new uncommitted file in `.changesets` that looks something like: + +``` +$ git status +# .... +Untracked files: + (use "git add ..." to include in what will be committed) + .changeset/flimsy-pandas-marry.md +``` + +Review this file, make any necessary adjustments, and commit the file to source. During the next package release, the changes (and changeset notes) will be automatically incorporated based on these changeset files. + +### Releasing a new version to NPM -## Releasing a new version to NPM (only for project administrators) +
+ +Only for project administrators + -1. Run `npm version patch` (or `minor`, `major` as appropriate) to run tests and lint, build the `lib` and `dist` directories, and automatically update the `package.json` with a new git tag. -2. Run `npm publish` and publish to npm if all is well. -3. Run `git push && git push --tags` +We use [changesets](https://github.com/changesets/changesets) to create package versions and publish them. + +Our official release path is to use automation (via GitHub actions) to perform the actual publishing of our packages. The steps are: + +1. Developers add changesets, ideally as part of their PR that have version impacts. +2. On merge of a PR with a changeset file, our automation opens a "Version Packages" PR. +3. On merging the "Version Packages" PR, the automation system publishes the packages. + +This streamlines releasing too: ensuring PRs have changeset files added as necessary, and approving the "Version Packages" PR generated from GitHub actions to publish a release to all affected packages. + +#### Manual Releases + +For exceptional circumstances, here is a quick guide to manually publish from a local machine using changesets. + +1. Add a changeset with `pnpm changeset`. Generate the changeset file, review it, and commit it. +2. Make a version. Due to our changelog formatting package you will need to create a personal token and pass it to the environment. + + ```sh + $ GITHUB_TOKEN= pnpm run version + ``` + + Review git changes, tweak, and commit. + +3. Publish. + + First, build necessary files: + + ```sh + $ pnpm run build + ``` + + Then publish: + + ```sh + # Test things out first + $ pnpm -r publish --dry-run + + # The real publish + $ pnpm changeset publish --otp= + ``` + + Note that publishing multiple pacakges via `changeset` to npm with an OTP code can often fail with `429 Too Many Requests` rate limiting error. Take a 5+ minute coffee break, then come back and try again. + + Then issue the following to also push git tags: + + ```sh + $ git push && git push --tags + ``` + +
## Contributor Covenant Code of Conduct @@ -118,8 +332,13 @@ members of the project's leadership. ### Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [http://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the [Contributor Covenant][cc-homepage], version 2.0, +available at [https://www.contributor-covenant.org/version/2/0][cc-latest-version] + + -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ +[carlos-kelly]: https://www.github.com/carlos-kelly +[cc-homepage]: http://contributor-covenant.org +[cc-latest-version]: https://www.contributor-covenant.org/version/2/0/code_of_conduct +[formidable-github]: https://www.github.com/FormidableLabs +[pnpm-docs]: https://pnpm.io/ diff --git a/LICENSE b/LICENSE index cd46f55b2..d39574007 100644 --- a/LICENSE +++ b/LICENSE @@ -2,9 +2,6 @@ The MIT License (MIT) Copyright (c) 2013-2018 Formidable Labs, Inc. -Copyright (c) 2016-2018 Zachary Maybury, Kylie Stewart, and potentially other -DefinitelyTyped contributors - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to diff --git a/README.md b/README.md index 59e868d9d..cce4baa76 100644 --- a/README.md +++ b/README.md @@ -1,1059 +1,36 @@ -# Spectacle - -[![Travis Status][trav_img]][trav_site] -[![Maintenance Status][maintenance-image]](#maintenance-status) - -ReactJS based Presentation Library - -[Spectacle Boilerplate MDX](https://github.com/FormidableLabs/spectacle-boilerplate-mdx/) -[Spectacle Boilerplate](https://github.com/FormidableLabs/spectacle-boilerplate/) - -Looking for a quick preview of what you can do with Spectacle? Check out our live Demo Deck [here](https://raw.githack.com/FormidableLabs/spectacle/master/one-page.html#/). - -Have a question about Spectacle? Submit an issue in this repository using the "Question" template. - -## Contents - - - -- [Getting Started](#getting-started) - - [Classic Spectacle](#classic-spectacle) - - [Spectacle MDX](#spectacle-mdx) - - [One Page](#one-page) -- [Development](#development) -- [Build & Deployment](#build--deployment) -- [Presenting](#presenting) -- [Controls](#controls) -- [Fullscreen](#fullscreen) -- [PDF Export](#pdf-export) -- [Basic Concepts](#basic-concepts) - - [Main file](#main-file) - - [Themes](#themes) - - [createTheme(colors, fonts)](#createthemecolors-fonts) -- [FAQ](#faq) -- [Tag API](#tag-api) - - [Main Tags](#main-tags) - - [Deck](#deck) - - [Slide (Base)](#slide-base) - - [Notes](#notes) - - [MarkdownSlides](#markdown-slides) - - [Layout Tags](#layout-tags) - - [Layout](#layout) - - [Fit](#fit) - - [Fill](#fill) - - [Markdown Tag](#markdown-tag) - - [Markdown](#markdown) - - [Magic Tag](#magic-tag) - - [Magic](#magic) - - [Element Tags](#element-tags) - - [Appear](#appear) - - [Anim](#anim) - - [BlockQuote, Quote and Cite (Base)](#blockquote-quote-and-cite-base) - - [CodePane (Base)](#codepane-base) - - [Code (Base)](#code-base) - - [ComponentPlayground](#component-playground) - - [GoToAction (Base)](#go-to-action) - - [Heading (Base)](#heading-base) - - [Image (Base)](#image-base) - - [Link (Base)](#link-base) - - [List & ListItem (Base)](#list--listitem-base) - - [S (Base)](#s-base) - - [Table, TableRow, TableBody, TableHeader, TableHeaderItem and TableItem (Base)](#table-tablerow-tableheaderitem-and-tableitem-base) - - [Text (Base)](#text-base) - - [Typeface](#typeface) - - [Base Props](#base-props) -- [Third Party Extensions](#third-party) - - - - +

+

Spectacle

+

+✨ A ReactJS based Presentation Library ✨ +

+ + + + Maintenance Status + +

## Getting Started -First, decide whether you want to use [classic Spectacle](#classic-spectacle), [Spectacle MDX](#spectacle-mdx), which has all the same functionality but allows you to write your Spectacle presentation in markdown, or using only [one HTML page](#one-page). - -### Classic Spectacle - -There are four ways to get started building your presentation. - -1. **Option #1:** Run the following command in your terminal: - - `npx create-react-app my-presentation --scripts-version spectacle-scripts` - -2. **Option #2:** Using the [Spectacle Boilerplate](https://github.com/FormidableLabs/spectacle-boilerplate). - -3. **Option #3:** Following along the [Spectacle Tutorial](./docs/tutorial.md), which also involves downloading the [Spectacle Boilerplate](https://github.com/FormidableLabs/spectacle-boilerplate). - -All three of the above ways will give you everything you'll need to get started, including a sample presentation in the `presentation` folder. You can change the props and tags as needed for your presentation or delete everything in `presentation/index.js` to start from scratch. From here you can go to [Development](#development) to get started. - -3. **Option #4:** Run `npm install spectacle` in your terminal and writing your own build configurations. We also provide full UMD builds (with a `Spectacle` global variable) of the library at `dist/spectacle.js` and `dist/spectacle.min.js` for more general use cases. You could, for example, include the library via a script tag with: `https://unpkg.com/spectacle@VERSION/dist/spectacle.min.js`. - -### Spectacle MDX - -Download the [Spectacle MDX Boilerplate](https://github.com/FormidableLabs/spectacle-boilerplate-mdx). - -This repository will give you everything you'll need to get started, including a sample presentation in the `presentation` folder. You can change the props and tags as needed for your presentation or delete everything in the `index.mdx` file to start from scratch. From here you can go to [Development](#development) to get started. - -_NOTE: We have webpack externals for `react`, `react-dom`, and `prop-types`, so you will need to provide them in your upstream build or something like linking in via `script` tags in your HTML page for all three libraries. This comports with our project dependencies which place these three libraries in `peerDependencies`._ - - - -### One Page - -To aid with speedy development we've provided a simple boilerplate HTML page with a bespoke script tag that contains your entire presentation. The rest of the setup will take care of transpiling your React/ESnext code, providing Spectacle, React, and ReactDOM libraries, and being raring to go with a minimum of effort. - -We can start with this project's sample at [`one-page.html`](./one-page.html). It's the same presentation as the fully-built-from-source version, with a few notable exceptions: - -1. There are no `import`s or `require`s. Everything must come from the global namespace. This includes `Spectacle`, `React`, `ReactDOM` and all the Spectacle exports from [`./src/index.js`](./src/index.js) -- `Deck`, `Slide`, `themes`, etc. - -2. The presentation must include exactly **one** script tag with the type `text/spectacle` that is a function. Presently, that function is directly inserted inline into a wrapper code boilerplate as a React Component `render` function. The wrapper is transpiled. There should not be any extraneous content around it like outer variables or comments. - - **Good** examples: - - ```html - - ``` - - ```html - - ``` - - **Bad** examples of what not to do: - - ```html - - ``` - -3. If you want to create your own theme settings, you can use the following code snippet to change the [themes](#createthemecolors-fonts) default settings. - - ```html - - ``` - -... with those guidelines in mind, here's the boilerplate that you can copy-and-paste into an HTML file and start a Spectacle presentation that works from the get go! - -```html - - - - - - Spectacle - - - - - -
- - - - - - - - - -``` - - - -## Development - -After downloading the boilerplate, run the following commands on the project's root directory... - -- `npm install` (you can also use `yarn`) -- `rm -R .git` to remove the existing version control -- `npm start` to start up the local server or visit [http://localhost:3000/#/](http://localhost:3000/#/) - -... and we are ready to roll - - - -## Build & Deployment - -Building the dist version of the slides is as easy as running `npm run build:dist` - -If you want to deploy the slideshow to [surge](https://surge.sh/), run `npm run deploy` - -_⚠️ WARNING: If you are deploying the dist version to [GitHub Pages](https://pages.github.com/ 'GitHub Pages'), note that the built bundle uses an absolute path to the `/dist/` directory while GitHub Pages requires the relative `./dist/` to find any embedded assets and/or images. A very hacky way to fix this is to edit one place in the produced bundle, as shown [in this GitHub issue](https://github.com/FormidableLabs/spectacle/issues/326#issue-233283633 'GitHub: spectacle issue #326')._ - - - -## Presenting - -Spectacle comes with a built in presenter mode. It shows you a slide lookahead, current time and your current slide: - -![http://i.imgur.com/jW8uMYY.png](http://i.imgur.com/jW8uMYY.png) - -You also have the option of a stopwatch to count the elapsed time: - -![http://i.imgur.com/VDltgmZ.png](http://i.imgur.com/VDltgmZ.png) - -To present: - -- Run `npm start`. You will be redirected to a URL containing your presentation or visit [http://localhost:3000/#/](http://localhost:3000/#/) -- Open a second browser window on a different screen -- Add `?presenter` or `?presenter&timer` immediately after the `/`, e.g.: [http://localhost:3000/#/0?presenter](http://localhost:3000/#/0?presenter) or [http://localhost:3000/#/?presenter&timer](http://localhost:3000/#/?presenter&timer) -- Give an amazingly stylish presentation - -_NOTE: Any windows/tabs in the same browser that are running Spectacle will sync to one another, even if you don't want to use presentation mode_ - -Check it out: - -![http://i.imgur.com/H7o2qHI.gif](http://i.imgur.com/H7o2qHI.gif_) - -You can toggle the presenter or overview mode by pressing respectively `alt+p` and `alt+o`. - - - -## Controls - -| Key Combination | Function | -| --------------- | ------------------------------ | -| Right Arrow | Next Slide | -| Left Arrow | Previous Slide | -| Space | Next Slide | -| Shift+Space | Previous Slide | -| Alt/Option + O | Toggle Overview Mode | -| Alt/Option + P | Toggle Presenter Mode | -| Alt/Option + T | Toggle Timer in Presenter Mode | -| Alt/Option + A | Toggle autoplay (if enabled) | -| Alt/Option + F | Toggle Fullscreen Mode | - - - -## Fullscreen - -Fullscreen can be toggled via browser options, Alt/Option + F, or by pressing the button in the bottom right corner of your window. - -Note: Right now, this works well when browser window itself is not full screen. When the browser is in fullscreen, there is an issue [#654](https://github.com/FormidableLabs/spectacle/issues/654). This is because we use the browser's FullScreen API methods. It still works but has some inconstiency. - - - -## PDF Export - -You can export a PDF from your Spectacle presentation either from the command line or browser: - -#### CLI - -- Run `npm install spectacle-renderer -g` -- Run `npm start` on your project and wait for it to build and be available -- Run `spectacle-renderer` - -A PDF is created in your project directory. For more options and configuration of this tool, check out: - -[https://github.com/FormidableLabs/spectacle-renderer](https://github.com/FormidableLabs/spectacle-renderer) - -#### Browser - -After running `npm start` and opening [http://localhost:3000/#/](http://localhost:3000/#/) in your browser... - -- Add `?export` after the `/` on the URL of the page you are redirected to, e.g.: [http://localhost:3000/#/?export](http://localhost:3000/#/?export) -- Bring up the print dialog `(ctrl or cmd + p)` -- Change destination to "Save as PDF", as shown below: - -![https://i.imgur.com/fLeYrZC.png](https://i.imgur.com/fLeYrZC.png) - -If you want a printer friendly version, repeat the above process but instead print from [http://localhost:3000/#/?export&print](http://localhost:3000/#/?export&print). - -If you want to export your slides with your [notes](#notes) included, repeat the above process but instead print from [http://localhost:3000/#/?export¬es](http://localhost:3000/#/?export¬es). - -#### Query Parameters - -Here is a list of all valid query parameters that can be placed after `/#/` on the URL. - -| Query | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------------- | -| 0, 1, 2, 3... etc. | Will take you to the corresponding slide, with `0` being the first slide in the presentation. | -| ?export | Creates a single-page overview of your slides, that you can then print. | -| ?export¬es | Creates a single-page overview of your slides, including any [notes](#notes), that you can then print. | -| ?export&print | Creates a black & white single-page overview of your slides. | -| ?export&print¬es | Creates a black & white single-page overview of your slides, including any [notes](#notes), that you can then print. | -| ?presenter | Takes you to presenter mode where you’ll see current slide, next slide, current time, and your [notes](#notes). | -| ?presenter&timer | Takes you to presenter mode where you’ll see current slide, next slide, timer, and your [notes](#notes). | -| ?overview | Take you to overview mode where you’ll see all your slides. | - -_NOTE: If you add a non-valid query parameter, you will be taken to a blank page. Removing or replacing the query parameter with a valid query parameter and refreshing the page will return you to the correct destination._ - - - -## Basic Concepts - - - -### Main file - -Your presentation files & assets will live in the `presentation` folder. - -The main `.js` file you write your deck in is `/presentation/index.js` - -Check it out [here](https://github.com/FormidableLabs/spectacle-boilerplate/blob/master/presentation/index.js) in the boilerplate. - -```jsx -// index.js - -import React, { Component } from 'react'; -import { - Appear, - BlockQuote, - Cite, - CodePane, - Code, - Deck, - Fill, - Fit, - Heading, - Image, - Layout, - ListItem, - List, - Quote, - Slide, - Text -} from 'spectacle'; - -export default class extends Component { - render() { - return ( - - - Hello - - - ); - } -} -``` - -Here is where you can use the library's tags to compose your presentation. While you can use any JSX syntax here, building your presentation with the supplied tags allows for theming to work properly. - -The bare minimum you need to start is a `Deck` element and a `Slide` element. Each `Slide` element represents a slide inside of your slideshow. - - - -### Themes - -In Spectacle, themes are functions that return style objects for `screen` & `print`. - -You can import the default theme from: - -```jsx -import createTheme from 'spectacle/lib/themes/default'; -``` - -Or create your own based upon the source. - -`index.js` is what you would edit in order to create a custom theme of your own, using object based styles. - -You will want to edit `index.html` to include any web fonts or additional CSS that your theme requires. - - - -#### createTheme(colors, fonts) - -Spectacle's functional theme system allows you to pass in color and font variables that you can use on your elements. The fonts configuration object can take a string for a system font or an object that specifies it‘s a Google Font. If you use a Google Font you can provide a styles array for loading different weights and variations. Google Font tags will be automatically created. See the example below: - -```jsx -const theme = createTheme( - { - primary: 'red', - secondary: 'blue' - }, - { - primary: 'Helvetica', - secondary: { - name: 'Droid Serif', - googleFont: true, - styles: ['400', '700i'] - } - } -); -``` - -The returned theme object can then be passed to the `Deck` tag via the `theme` prop, and will override the default styles. - - - -## FAQ - -**_How can I easily style the base components for my presentation?_** - -Historically, custom styling in Spectacle has meant screwing with a theme file, or using `!important` overrides. We fixed that. Spectacle is now driven by [emotion](https://github.com/emotion-js/emotion), so you can bring your own styling library, whether it's emotion itself, or something like styled-components or glamorous. For example, if you want to create a custom Heading style: - -```javascript -import styled from 'react-emotion'; -import { Heading } from 'spectacle'; - -const CustomHeading = styled(Heading)` - font-size: 1.2em; - color: papayawhip; -`; -``` - - - -**_Can I write my presentation in TypeScript?_** - -Yes, you can! Type definitions are shipped with the library, so you can import Spectacle components into any `.tsx` presentation without additional installation steps. - -Updated type definitions for the Spectacle API can be found [at the root of this repository](./index.d.ts). - -## Tag API - -In Spectacle, presentations are composed of a set of base tags. We can separate these into three categories: Main tags, Layout tags & Element tags. - - - -### Main Tags - - - -#### Deck - -The Deck tag is the root level tag for your presentation. It supports the following props: - -| Name | PropType | Description | Default | -| ----------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | -| autoplay | PropTypes.bool | Automatically advance slides. | `false` | -| autoplayDuration | PropTypes.number | Accepts integer value in milliseconds for global autoplay duration. | `7000` | -| autoplayLoop | PropTypes.bool | Keep slides in loop. | `true` | -| autoplayOnStart | PropTypes.bool | Start presentation with autoplay on/not paused (if autoplay is enabled). | `true` | -| controls | PropTypes.bool | Show control arrows when not in fullscreen. | `true` | -| contentHeight | PropTypes.numbers | Baseline content area height. | `700px` | -| contentWidth | PropTypes.numbers | Baseline content area width. | `1000px` | -| disableKeyboardControls | PropTypes.bool | Toggle keyboard control. | `false` | -| onStateChange | PropTypes.func | Called whenever a new slide becomes visible with the arguments `(previousState, nextState)` where state refers to the outgoing and incoming ``'s `state` props, respectively. The default implementation attaches the current state as a class to the document root. | see description | -| history | PropTypes.object | Accepts custom configuration for [history](https://github.com/ReactTraining/history). | | -| progress | PropTypes.string | Accepts `pacman`, `bar`, `number` or `none`. To override the color, change the 'quaternary' color in the theme. | `pacman` | -| showFullscreenControl | PropTypes.bool | Show the fullscreen control button in bottom right of the screen. | `true` | -| theme | PropTypes.object | Accepts a theme object for styling your presentation. | | -| transition | PropTypes.array | Accepts `slide`, `zoom`, `fade` or `spin`, and can be combined. Sets global slide transitions. **Note: If you use the 'scale' transition, fitted text won't work in Safari.** | | -| transitionDuration | PropTypes.number | Accepts integer value in milliseconds for global transition duration. | `500` | - - - -#### Slide ([Base](#base-props)) - -The slide tag represents each slide in the presentation. Giving a slide tag an `id` attribute will replace its number based navigation hash with the `id` provided. It supports the following props, in addition to any of the props outlined in the [Base](#base-props) class props listing: - -| Name | PropType | Description | Default | -| ------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| align | PropTypes.string | Accepts a space delimited value for positioning interior content. The first value can be `flex-start` (left), `center` (middle), or `flex-end` (right). The second value can be `flex-start` (top) , `center` (middle), or `flex-end` (bottom). | `align="center center"` | -| controlColor | PropTypes.string | Used to override color of control arrows on a per slide basis, accepts color aliases, or valid color values. | Set by `Deck`'s `control` prop | -| goTo | PropTypes.number | Used to navigate to a slide for out-of-order presenting. Slide numbers start at `1`. This can also be used to skip slides as well. | | -| id | PropTypes.string | Used to create a string based hash. | -| notes | PropTypes.string | Text which will appear in the presenter mode. Can be HTML. | | -| onActive | PropTypes.func | Optional function that is called with the slide index when the slide comes into view. | | -| progressColor | PropTypes.string | Used to override color of progress elements on a per slide basis, accepts color aliases, or valid color values. | `quaternary` color set by theme | -| state | PropTypes.string | Used to indicate that the deck is in a specific state. Inspired by [Reveal.js](https://github.com/hakimel/reveal.js)'s `data-state` attribute | | -| transition | PropTypes.array | Used to override transition prop on a per slide basis, accepts `slide`, `zoom`, `fade`, `spin`, or a [function](#transition-function), and can be combined. This will affect both enter and exit transitions. **Note: If you use the 'scale' transition, fitted text won't work in Safari.** | Set by `Deck`'s `transition` prop | -| transitionIn | PropTypes.array | Specifies the slide transition when the slide comes into view. Accepts the same values as transition. | -| transitionOut | PropTypes.array | Specifies the slide transition when the slide exits. Accepts the same values as transition. | Set by `Deck`'s `transition` prop | -| transitionDuration | PropTypes.number | Accepts integer value in milliseconds for slide transition duration. | Set by `Deck`'s `transition` prop | - -#### SlideSet - -With `SlideSet`, you can wrap multiple slide in it to apply the same style. - -```jsx - - Slide1 - Slide2 - Slide3 - -``` - - - -##### Transition Function - -Spectacle now supports defining custom transitions. The function prototype is `(transitioning: boolean, forward: boolean) => Object`. The `transitioning` param is true when the slide enters and exits. The `forward` param is `true` when the slide is entering, `false` when the slide is exiting. The function returns a style object. You can mix string-based transitions and functions. Styles provided when `transitioning` is `false` will appear during the lifecyle of the slide. An example is shown below: - -```jsx - { - const angle = forward ? -180 : 180; - return { - transform: ` - translate3d(0%, ${transitioning ? 100 : 0}%, 0) - rotate(${transitioning ? angle : 0}deg) - `, - backgroundColor: transitioning ? '#26afff' : '#000' - }; - } - ]} -> -``` - - - -#### Notes - -The notes tag allows to use any tree of react elements as the notes of a slide. It is used as a child node of a slide tag and its children override any value given as the `notes` attribute of its parent slide. - -```jsx - - -

Slide notes

-
    -
  1. First note
  2. -
  3. Second note
  4. -
-
- {/* Slide content */} -
-``` - - - -### MarkdownSlides - -The MarkdownSlides function lets you create a single or multiple slides using Markdown. It can be used as a tagged template literal or a function. Three dashes (`---` are used as a delimiter between slides. - -**Tagged Template Literal Usage** - -```jsx - - {MarkdownSlides` -## Slide One Title -Slide Content ---- -## Slide Two Title -Slide Content - `} - -``` - -**Function Usage** - -```jsx -const slidesMarkdown = ` -## Slide One Title -Slide Content ---- -## Slide Two Title -Slide Content - `; - - .... -import slidesMarkdown from "!raw-loader!markdown.md"; - - - {MarkdownSlides(slidesMarkdown)} - -``` - - - -### Layout Tags - -Layout tags are used for layout using Flexbox within your slide. They are `Layout`, `Fit` & `Fill`. - - - -#### Layout - -The layout tag is used to wrap `Fit` and `Fill` tags to provide a row. - - - -#### Fit - -The fit tag only takes up as much space as its bounds provide. - - - -#### Fill - -The fill tag takes up all the space available to it. For example, if you have a `Fill` tag next to a `Fit` tag, the `Fill` tag will take up the rest of the space. Adjacent `Fill` tags split the difference and form an equidistant grid. - - - -### Markdown Tag - - - -#### Markdown ([Base](#base-props)) - -The Markdown tag is used to add inline markdown to your slide. You can provide markdown source via the `source` prop, or as children. You can also provide a custom [mdast configuration](https://github.com/wooorm/mdast) via the `mdastConfig` prop. - -Markdown generated tags aren't prop configurable, and instead render with your theme defaults. - -| Name | PropType | Description | Default | -| ------ | ---------------- | --------------- | ------- | -| source | PropTypes.string | Markdown source | | - - - -### Magic Tag - - - -#### Magic - -_NOTE: The Magic tag uses the Web Animations API. If you use the Magic tag and want it to work places other than Chrome, you will need to include the polyfill [https://github.com/web-animations/web-animations-js](https://github.com/web-animations/web-animations-js)_ - -The Magic Tag recreates Magic Move behavior that slide authors might be accustomed to coming from Keynote. It wraps slides and transitions between positional values for child elements. This means that if you have two similar strings, we will transition common characters to their new positions. This does not transition on non positional values such as slide background color or font size. - -_⚠️ WARNING: Do not use a `transition` prop on your slides if you are wrapping them with a Magic tag since it will take care of the transition for you._ - -```javascript - - - First Heading - - - Second Heading - - -``` - -Transitioning between similar states will vary based upon the input content. It will look better when there are more common elements. An upcoming patch will allow for custom keys, which will provide greater control over which elements are identified as common for reuse. - -Until then, feedback is very welcome, as this is a non-trivial feature and we anticipate iterating on the behind the scenes mechanics of how it works, so that we can accommodate most use cases. - - - -### Element Tags - -The element tags are the bread and butter of your slide content. Most of these tags derive their props from the Base class, but the ones that have special options will have them listed: - - - -#### Appear - -This tag does not extend from Base. It's special. Wrapping elements in the appear tag makes them appear/disappear in order in response to navigation. - -For best performance, wrap the contents of this tag in a native DOM element like a `
` or ``. - -_NOTE: When using `CodePane` tag inside an `Appear` tag you must wrap it inside a `
`_ - -```jsx -.... - -
- -
- -.... -``` - -| Name | PropType | Description | Default | -| ------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| order | PropTypes.number | An optional integer starting at 1 for the presentation order of the Appear tags within a slide. If a slide contains ordered and unordered Appear tags, the unordered will show first. | -| transitionDuration | PropTypes.number | An optional duration (in milliseconds) for the Appear animation. | `300` | -| startValue | PropTypes.object | An optional style object that defines the starting, inactive state of the Appear tag. The default animation is a fade-in. | `{ opacity: 0 }` | -| endValue | PropTypes.object | An optional style object that defines the ending, active state of the Appear tag. The default animation is a simple fade-in. | `{ opacity: 1 }` | -| easing | PropTypes.string | An optional victory easing curve for the Appear animation. The various options are documented in the [Victory Animation easing docs](https://formidable.com/open-source/victory/docs/victory-animation/#easing). | `quadInOut` | - - - -#### Anim - -If you want extra flexibility with animated animation, you can use the Anim component instead of Appear. It will let you have multi-step animations for each individual fragment. You can use this to create fancy animated intros, in-slide carousels, and many other fancy things. This tag does not extend from Base. It's special. - -For best performance, wrap the contents of this tag in a native DOM element like a `
` or ``. - -_NOTE: `CodePane` tag can not be used inside a `Anim` tag._ - -| Name | PropType | Description | Default | -| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- | -| order | PropTypes.number | An optional integer for the presentation order of the Appear tags within a slide. If a slide contains ordered and unordered Appear tags, the unordered will show first. | Starting at `1` | -| transitionDuration | PropTypes.number | A duration (in milliseconds) for the animation. | `300` | -| fromStyle | PropTypes.object | A style object that defines the starting, inactive state of the Anim tag. | | -| toStyle | PropTypes.array | An array of style objects that define each step in the animation. They will step from one toStyle object to another, until that fragment is finished with its animations. | | -| easing | PropTypes.string | A victory easing curve for the Appear animation. The various options are documented in the [Victory Animation easing docs](https://formidable.com/open-source/victory/docs/victory-animation/#easing). | | -| onAnim | PropTypes.fun | This function is called every time the Anim component plays an animation. It'll be called with two arguments, forwards, a boolean indicating if it was stepped forwards or backwards, and the index of the animation that was just played. | | - - - -#### BlockQuote, Quote and Cite ([Base](#base-props)) - -These tags create a styled blockquote. Use them as follows: - -```jsx -
- Ken Wheeler is amazing - Everyone -
-``` - -_NOTE: By default the text color of the `Quote` tag is the same as the background color and may not show up. Use the `bgColor` and/or `textColor` props on the `Slide` or `Quote` tags to make it visible._ - -```jsx - -
- Example Quote - Author -
-
-``` - -```jsx - -
- Example Quote - Author -
-
-``` - - - -#### CodePane ([Base](#base-props)) - -This tag displays a styled, highlighted code preview. I prefer putting my code samples in external `.example` files and requiring them using `raw-loader` as shown in the demo. Here are the props: - -| Name | PropType | Description | Default | -| --------- | ---------------- | ----------------------------------------------------------------------------------- | ------- | -| lang | PropTypes.string | Prism compatible language name. i.e: 'javascript' | | -| source | PropTypes.string | String of code to be shown | | -| className | PropTypes.string | String of a className to be appended to the CodePane | | -| theme | PropTypes.string | Accepts `light`, `dark`, or `external` for the source editor's syntax highlighting. | `dark` | - -If you want to change the theme used here, you can include a prism theme in index.html via a style or a link tag. For your theme to be actually applied -correctly you need to set the `theme` prop to `"external"`, which disables our builtin light and dark themes. -Please note that including a theme can actually influence all CodePane and Playground components, even if you don't set this prop, since some Prism -themes use very generic CSS selectors. - -CodePane and Playground both use the prism library under the hood, which has several themes that are available to include. - - - -#### Code ([Base](#base-props)) - -A simple tag for wrapping inline text that you want lightly styled in a monospace font. - - - -#### Component Playground - -This tag displays a two-pane view with a ES6 source code editor on the right and a preview pane on the left for showing off custom React components. `React` and `render` are supplied as variables. To render a component call `render` with some JSX code. Any `console` output will be forwarded to the main console in the browser. - -For more information on the playground read the docs over at [react-live](https://github.com/FormidableLabs/react-live). - -| Name | PropType | Description | Default | -| ---------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------- | -| code | PropTypes.string | The code block you want to initially supply to the component playground. If none is supplied a demo component will be displayed. | | -| previewBackgroundColor | PropTypes.string | The background color you want for the preview pane. | `#fff` | -| theme | PropTypes.string | Accepts `light`, `dark`, or `external` for the source editor's syntax highlighting. | `dark` | -| scope | PropTypes.object | Defines any outside modules or components to expose to the playground. React, Component, and render are supplied for you. | | - -Example code blocks: - -```jsx -const Button = ({ title }) => ; -render( -
: - Easy there pal - } -
- ); - } -} diff --git a/example/assets/kat.gif b/example/assets/kat.gif deleted file mode 100644 index 198f016e1..000000000 Binary files a/example/assets/kat.gif and /dev/null differ diff --git a/example/assets/kat.png b/example/assets/kat.png deleted file mode 100644 index 68c79470d..000000000 Binary files a/example/assets/kat.png and /dev/null differ diff --git a/example/assets/markdown.png b/example/assets/markdown.png deleted file mode 100644 index b0e592be5..000000000 Binary files a/example/assets/markdown.png and /dev/null differ diff --git a/example/src/index.js b/example/src/index.js deleted file mode 100644 index 3608c37b4..000000000 --- a/example/src/index.js +++ /dev/null @@ -1,463 +0,0 @@ -import React, { Component } from 'react'; - -import { - Anim, - Appear, - BlockQuote, - Cite, - CodePane, - ComponentPlayground, - Deck, - Fill, - Heading, - Image, - Layout, - Link, - ListItem, - List, - Markdown, - MarkdownSlides, - Notes, - Quote, - Slide, - SlideSet, - TableBody, - TableHeader, - TableHeaderItem, - TableItem, - TableRow, - Table, - Text, - GoToAction -} from '../../src'; -import preloader from '../../src/utils/preloader'; -import createTheme from '../../src/themes/default'; -import Interactive from '../assets/interactive'; - -require('normalize.css'); - -const images = { - city: require('../assets/city.jpg'), - kat: require('../assets/kat.gif'), - logo: require('../assets/formidable-logo.svg'), - markdown: require('../assets/markdown.png') -}; - -preloader(images); - -const theme = createTheme({ - primary: '#ff4081' -}); - -export default class Presentation extends Component { - constructor() { - super(...arguments); - - this.updateSteps = this.updateSteps.bind(this); - } - - state = { - steps: 0 - }; - - updateSteps(steps) { - if (this.state.steps !== steps) { - this.setState({ steps }); - } - } - - render() { - return ( - - - - Spectacle - - - A ReactJS Presentation Library - - - Where You Can Write Your Decks In JSX - - - - View on Github - - - - Hit Your Right Arrow To Begin! - - Let's get started! - - { - console.info(`Viewing slide index: ${slideIndex}.`); // eslint-disable-line no-console - }} - id="wait-what" - goTo={4} - transition={[ - 'fade', - (transitioning, forward) => { - const angle = forward ? -180 : 180; - return { - transform: ` - translate3d(0%, ${transitioning ? 100 : 0}%, 0) - rotate(${transitioning ? angle : 0}deg) - `, - backgroundColor: transitioning ? '#26afff' : '#000' - }; - } - ]} - bgColor="black" - > - - - Wait what? - - - You can even put notes on your slide. How awesome is that? - - - - - - - talk about that - and that - and then this - - - - - - - - - - Full Width - - - - - Adjustable Darkness - - - - - Background Imagery - - - - - { - /* eslint-disable */ - console.log('forwards ', forwards); - console.log('animIndex ', animIndex); - /* eslint-enable */ - }} - fromStyle={{ - opacity: 0, - transform: 'translate3d(0px, -100px, 0px) scale(1) rotate(0deg)' - }} - toStyle={[ - { - opacity: 1, - transform: 'translate3d(0px, 0px, 0px) scale(1) rotate(0deg)' - }, - { - opacity: 1, - transform: - 'translate3d(0px, 0px, 0px) scale(1.6) rotate(-15deg)' - }, - { - opacity: 1, - transform: 'translate3d(0px, 0px, 0px) scale(0.8) rotate(0deg)' - }, - { - opacity: 1, - transform: - 'translate3d(0px, -200px, 0px) scale(0.8) rotate(0deg)' - }, - { - opacity: 1, - transform: - 'translate3d(200px, 0px, 0px) scale(0.8) rotate(0deg)' - }, - { - opacity: 1, - transform: - 'translate3d(0px, 200px, 0px) scale(0.8) rotate(0deg)' - }, - { - opacity: 1, - transform: - 'translate3d(-200px, 0px, 0px) scale(0.8) rotate(0deg)' - } - ]} - easing={'bounceOut'} - transitionDuration={500} - > -
- - Flexible -
- animations -
-
-
- Much animation, very style -
- - - Mix it up! - - - You can even jump to different slides with a standard button or - custom component! - - - Jump to Slide 8 - - ( - - )} - /> - Doesn't work in export view, though - - - - - Can - - - - - Count - - - - - Steps - - - - Steps: {this.state.steps} - - - - - Flexible Layouts - - - - - Left - - - - - Right - - - - - Use layout to fill or fit{' '} - your content - - - -
- Wonderfully formatted quotes - Ken Wheeler -
-
- - - Inline Markdown - - - {` - ![Markdown Logo](${images.markdown.replace('/', '')}) - - You can write inline images, [Markdown Links](http://commonmark.org), paragraph text and most other markdown syntax - * Lists too! - * With ~~strikethrough~~ and _italic_ - * And let's not forget **bold** - * Add some \`inline code\` to your sldes! - `} - - Who doesn't love markdown? - - {MarkdownSlides` -#### Create Multiple Slides in Markdown -All the same tags and elements supported in are supported in MarkdownSlides. ---- -Slides are separated with **three dashes** and can be used _anywhere_ in the deck. The markdown can either be: -* A Tagged Template Literal -* Imported Markdown from another file ---- -Add some inline code to your markdown! - -\`\`\`js -const myCode = (is, great) => 'for' + 'sharing'; -\`\`\` - `} - - - Smooth - - - Combinable Transitions - - So smooth - - - - - - Inline style based theme system - - - Autofit text - - - Flexbox layout system - - - PDF export - - - Customized bullets - - - And... - - - - - - Your presentations are interactive - - - - - - - Pizza Toppings - - - - - - - 2011 - 2013 - 2015 - - - - - None - 61.8% - 39.6% - 35.0% - - - Pineapple - 28.3% - 54.5% - 61.5% - - - Pepperoni - - 50.2% - 77.2% - - - Olives - - 24.9% - 55.9% - - -
-
- Hard to find cities without any pizza -
- - - Made with love in Seattle by - - - - - Check us out → https://www.formidable.com - -
- ); - } -} diff --git a/examples/js/.babelrc b/examples/js/.babelrc new file mode 100644 index 000000000..18d203321 --- /dev/null +++ b/examples/js/.babelrc @@ -0,0 +1,3 @@ +{ + "extends": "../../.babelrc.js" +} diff --git a/examples/js/index.html b/examples/js/index.html new file mode 100644 index 000000000..f02666f1f --- /dev/null +++ b/examples/js/index.html @@ -0,0 +1,15 @@ + + + + + + <%= htmlWebpackPlugin.options.title %> + + + +
+ + diff --git a/examples/js/index.js b/examples/js/index.js new file mode 100644 index 000000000..0ab3a57c5 --- /dev/null +++ b/examples/js/index.js @@ -0,0 +1,274 @@ +import React from 'react'; +import { + FlexBox, + Heading, + SpectacleLogo, + UnorderedList, + CodeSpan, + OrderedList, + ListItem, + FullScreen, + AnimatedProgress, + Appear, + Slide, + Deck, + Text, + Grid, + Box, + Image, + CodePane, + MarkdownSlide, + MarkdownSlideSet, + Notes, + SlideLayout +} from 'spectacle'; +import ReactDOM from 'react-dom'; + +const formidableLogo = + 'https://avatars2.githubusercontent.com/u/5078602?s=280&v=4'; + +// SPECTACLE_CLI_THEME_START +const theme = { + fonts: { + header: '"Open Sans Condensed", Helvetica, Arial, sans-serif', + text: '"Open Sans Condensed", Helvetica, Arial, sans-serif' + } +}; +// SPECTACLE_CLI_THEME_END + +// SPECTACLE_CLI_TEMPLATE_START +const template = () => ( + + + + + + + + +); +// SPECTACLE_CLI_TEMPLATE_END + +const SlideFragments = () => ( + <> + + This is a slide fragment. + + + This is also a slide fragment. + + This item shows up! + + + This item also shows up! + + + +); + +const Presentation = () => ( + + + + + + + Spectacle supports notes per slide. +
    +
  1. Notes can now be HTML markup!
  2. +
  3. Lists can make it easier to make points.
  4. +
+
+
+ + + + ✨Spectacle ✨ + + + A ReactJS Presentation Library + + + Where you can write your decks in JSX, Markdown, or MDX! + + + + + Custom Backgrounds + + + backgroundColor + + + backgroundImage + + + backgroundOpacity + + + backgroundSize + + + backgroundPosition + + + backgroundRepeat + + + + + Animated Elements + + + Elements can animate in! + + + Out of order + + + + Just identify the order with the prop priority! + + + + + + + These + Text + Items + Flex + + + + Single-size Grid Item + + + Double-size Grid Item + + + + {Array(9) + .fill('') + .map((_, index) => ( + + + + ))} + + + + + {` + import { createClient, Provider } from 'urql'; + + const client = createClient({ url: 'https://0ufyz.sse.codesandbox.io' }); + + const App = () => ( + + + + ); + `} + + {` + public class NoLineNumbers { + public static void main(String[] args) { + System.out.println("Hello"); + } + } + `} + +
+ + This is a slide embedded in a div + +
+ + {` + # This is a Markdown Slide + + - You can pass props down to all elements on the slide. + - Just use the \`componentProps\` prop. + `} + + + {` + # This is also a Markdown Slide + + It uses the \`animateListItems\` prop. + + - Its list items... + - ...will appear... + - ...one at a time. + `} + + + + + This is a 4x4 Grid + + + + With all the content aligned and justified center. + + + + + It uses Spectacle {''} and{' '} + {''} components. + + + + + + + + + {` + # This is the first slide of a Markdown Slide Set + --- + # This is the second slide of a Markdown Slide Set + `} + + +
+); + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(); diff --git a/examples/js/package.json b/examples/js/package.json new file mode 100644 index 000000000..5c7ab396f --- /dev/null +++ b/examples/js/package.json @@ -0,0 +1,83 @@ +{ + "name": "spectacle-example-js", + "private": true, + "dependencies": { + "react": "^18.1.0", + "react-dom": "^18.1.0", + "spectacle": "*" + }, + "devDependencies": {}, + "scripts": { + "start": "webpack-dev-server --port=3000 --hot --config ./webpack.config.js", + "build": "wireit", + "lint": "wireit", + "lint:fix": "wireit", + "prettier": "wireit", + "prettier:fix": "wireit" + }, + "wireit": { + "build": { + "command": "nps webpack", + "files": [ + "*.{js,jsx,ts,tsx,html}" + ], + "output": [ + "dist/*" + ], + "dependencies": [ + "../../packages/spectacle:build:lib:esm" + ], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint": { + "command": "nps \"lint:base *.js\"", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "*.{js,jsx,ts,tsx}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint:fix": { + "command": "pnpm run lint || nps \"lint:base --fix *.js\"", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "*.{js,jsx,ts,tsx}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier": { + "command": "nps prettier:pkg -- -- \"*\"", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.{js,html}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier:fix": { + "command": "pnpm run prettier || nps prettier:pkg:fix -- -- \"*\"", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.{js,html}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + } + } +} diff --git a/examples/js/webpack.config.js b/examples/js/webpack.config.js new file mode 100644 index 000000000..ffc5872b0 --- /dev/null +++ b/examples/js/webpack.config.js @@ -0,0 +1,22 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const base = require('../../webpack.config.base'); + +module.exports = { + ...base, + mode: 'development', + context: __dirname, + entry: './index.js', + output: { + path: path.join(__dirname, 'dist'), + filename: 'example.js' + }, + externals: {}, + plugins: [ + ...base.plugins, + new HtmlWebpackPlugin({ + title: 'Spectacle JavaScript Demo', + template: `./index.html` + }) + ] +}; diff --git a/examples/md/.babelrc b/examples/md/.babelrc new file mode 100644 index 000000000..18d203321 --- /dev/null +++ b/examples/md/.babelrc @@ -0,0 +1,3 @@ +{ + "extends": "../../.babelrc.js" +} diff --git a/examples/md/index.html b/examples/md/index.html new file mode 100644 index 000000000..f02666f1f --- /dev/null +++ b/examples/md/index.html @@ -0,0 +1,15 @@ + + + + + + <%= htmlWebpackPlugin.options.title %> + + + +
+ + diff --git a/examples/md/index.js b/examples/md/index.js new file mode 100644 index 000000000..850a28e9f --- /dev/null +++ b/examples/md/index.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import { + Box, + Deck, + FlexBox, + FullScreen, + AnimatedProgress, + MarkdownSlideSet +} from 'spectacle'; + +// SPECTACLE_CLI_MD_START +import mdContent from './slides.md'; +// SPECTACLE_CLI_MD_END + +// SPECTACLE_CLI_THEME_START +const theme = {}; +// SPECTACLE_CLI_THEME_END + +// SPECTACLE_CLI_TEMPLATE_START +const template = () => ( + + + + + + + + +); +// SPECTACLE_CLI_TEMPLATE_END + +const Presentation = () => ( + + {mdContent} + +); + +const root = createRoot(document.getElementById('root')); +root.render(); diff --git a/examples/md/package.json b/examples/md/package.json new file mode 100644 index 000000000..cdce404dc --- /dev/null +++ b/examples/md/package.json @@ -0,0 +1,83 @@ +{ + "name": "spectacle-example-md", + "private": true, + "dependencies": { + "spectacle": "*", + "react": "^18.1.0", + "react-dom": "^18.1.0" + }, + "devDependencies": {}, + "scripts": { + "start": "webpack-dev-server --port=3200 --hot --config ./webpack.config.js", + "build": "wireit", + "lint": "wireit", + "lint:fix": "wireit", + "prettier": "wireit", + "prettier:fix": "wireit" + }, + "wireit": { + "build": { + "command": "nps webpack", + "files": [ + "*.{js,jsx,ts,tsx,html}" + ], + "output": [ + "dist/*" + ], + "dependencies": [ + "../../packages/spectacle:build:lib:esm" + ], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint": { + "command": "nps \"lint:base *.js\"", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "*.{js,jsx,ts,tsx}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint:fix": { + "command": "pnpm run lint || nps \"lint:base --fix *.js\"", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "*.{js,jsx,ts,tsx}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier": { + "command": "nps prettier:pkg -- -- \"*\"", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.{js,html}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier:fix": { + "command": "pnpm run prettier || nps prettier:pkg:fix -- -- \"*\"", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.{js,html}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + } + } +} diff --git a/examples/md/slides.md b/examples/md/slides.md new file mode 100644 index 000000000..a5245d078 --- /dev/null +++ b/examples/md/slides.md @@ -0,0 +1,54 @@ +# Spectacle Presentation (MD) 👋 + +These slides are bare Markdown with nothing special. + +- `one` +- "two" +- 'three' + +--- + +# Write your Spectacle Presentations in Markdown + +## And seamlessly use React Components + +**How sweet is that** +**(super sweet)** + +--- + +![datboi](https://media.giphy.com/media/xohHbwcnOhqbS/giphy.gif) + +--- + +Typography + +# Heading 1 + +## Heading 2 + +### Heading 3 + +#### Heading 4 + +##### Heading 5 + +###### Heading 6 + +--- + +> Example Quote + +--- + +```jsx +import { createClient, Provider } from 'urql'; + +const client = createClient({ url: 'https://0ufyz.sse.codesandbox.io' }); + +const App = () => ( + + + +); +``` diff --git a/examples/md/webpack.config.js b/examples/md/webpack.config.js new file mode 100644 index 000000000..6b5760955 --- /dev/null +++ b/examples/md/webpack.config.js @@ -0,0 +1,31 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +const base = require('../../webpack.config.base'); + +module.exports = { + ...base, + mode: 'development', + context: __dirname, + entry: './index.js', + output: { + path: path.join(__dirname, 'dist'), + filename: 'example.js' + }, + externals: {}, + module: { + rules: base.module.rules.concat([ + { + test: /\.md$/, + use: [require.resolve('raw-loader')] + } + ]) + }, + plugins: [ + ...base.plugins, + new HtmlWebpackPlugin({ + title: 'Spectacle MD Development Example', + template: `./index.html` + }) + ] +}; diff --git a/examples/one-page/index.html b/examples/one-page/index.html new file mode 100644 index 000000000..c07e3b271 --- /dev/null +++ b/examples/one-page/index.html @@ -0,0 +1,262 @@ + + + + + + + Spectacle One-Page Example + + +
+ + + + + + + + + + + diff --git a/examples/one-page/package.json b/examples/one-page/package.json new file mode 100644 index 000000000..e58a1f59d --- /dev/null +++ b/examples/one-page/package.json @@ -0,0 +1,81 @@ +{ + "name": "spectacle-example-one-page", + "private": "true", + "devDependencies": { + "pretty": "^2.0.0" + }, + "scripts": { + "build": "wireit", + "lint": "wireit", + "lint:fix": "wireit", + "prettier": "wireit", + "prettier:fix": "wireit" + }, + "wireit": { + "build": { + "command": "node ./scripts/one-page.js", + "clean": false, + "files": [ + "index.html", + "../examples/js/index.js", + "scripts/one-page.js" + ], + "output": [ + "index.html" + ], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint": { + "command": "nps \"lint:base scripts\"", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "scripts" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint:fix": { + "command": "pnpm run lint || nps \"lint:base --fix scripts\"", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "scripts" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier": { + "command": "nps prettier:pkg -- -- \"*\" scripts", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.html", + "scripts" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier:fix": { + "command": "pnpm run prettier || nps prettier:pkg:fix -- -- \"*\" scripts", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.html", + "scripts" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + } + } +} diff --git a/examples/one-page/scripts/one-page.js b/examples/one-page/scripts/one-page.js new file mode 100644 index 000000000..2b689c022 --- /dev/null +++ b/examples/one-page/scripts/one-page.js @@ -0,0 +1,121 @@ +'use strict'; + +/** + * Generate the JS `index.html` from `examples/js/index.js` + */ +const fs = require('fs').promises; +const path = require('path'); + +const { transformFileAsync } = require('@babel/core'); +const pretty = require('pretty'); + +const EXAMPLES = path.resolve(__dirname, '../..'); +const SRC_FILE = path.join(EXAMPLES, 'js/index.js'); +const DEST_FILE = path.join(EXAMPLES, 'one-page/index.html'); + +const htmImport = ` +import htm from 'https://unpkg.com/htm@^3?module'; +const html = htm.bind(React.createElement); +` + .replace(/ /gm, '') + .trim(); + +const spectacleImportReplacer = (match, imports) => { + // Prettify imports + imports = imports + .split(',') + .map((i) => ` ${i.trim()}`) + .join(`,\n`); + + return `const {\n${imports}\n} = Spectacle;\n\n${htmImport}`; +}; + +const getSrcContent = async (src) => { + let { code } = await transformFileAsync(src, { + babelrc: false, + configFile: false, + plugins: ['babel-plugin-transform-jsx-to-htm'] + }); + + // Mutate exports and comments. + code = code + // Mutate exports to our global imports. + .replace(/import React(|DOM) from 'react(|-dom)';[\n]*/gm, '') + .replace(/import {[ ]*(.*)} from 'spectacle';/, spectacleImportReplacer) + // Hackily fix / undo babel's poor control comment placment. + .replace(/\/\/ SPECTACLE_CLI/gm, '\n// SPECTACLE_CLI') + .replace(/(\/\/ SPECTACLE_CLI[^\n]*)[\n]{2}/gm, '$1\n') + .replace(/(\/\/ SPECTACLE_CLI[^\n]*_START)/gm, '\n$1'); + + // Beautify htm snippets. + code = code.replace( + /(html`)(<\${[\s\S]*?}>)(`;)/gm, + (match, open, htm, close) => { + // Initial cleanup for inline strings and functions + htm = htm.replace(/>\${/gm, '>\n${').replace(/}<\/\${/gm, '}\n + htm = htm + .replace(/\n\${`/gm, '\n
SPECTACLE_ONE_PAGE_TEMP_MARKER${`')
+        .replace(/`}\n/gm, '`}SPECTACLE_ONE_PAGE_TEMP_MARKER
\n'); + + // Make the HTML pretty: + htm = pretty(htm).replace( + /
SPECTACLE_ONE_PAGE_TEMP_MARKER|SPECTACLE_ONE_PAGE_TEMP_MARKER<\/pre>/g,
+        ''
+      );
+
+      // Final tweaks:
+      htm = `${open}${htm}${close}`
+        // Initial newline
+        .replace('html`<${', 'html`\n<${')
+        // Indent
+        .split('\n')
+        .join('\n  ')
+        // Handle pretty() erroneous newline after string literal
+        .replace(/\${\"\n[ ]*/gm, '${"')
+        // Final newline
+        .replace('}>`;', '}>\n`;');
+
+      return htm;
+    }
+  );
+
+  return code;
+};
+
+const writeDestContent = async (destFile, code) => {
+  // Format for indentation in index.html.
+  const indent = '      ';
+  code = `${indent}${code}`;
+  code = code.split('\n').join(`\n${indent}`);
+
+  // Get destination content.
+  let destContent = (await fs.readFile(destFile)).toString();
+
+  // Mutate in our updated code.
+  destContent = destContent
+    .replace(
+      /(
 
+  
+    
+ + diff --git a/examples/typescript/index.tsx b/examples/typescript/index.tsx new file mode 100644 index 000000000..45f126af3 --- /dev/null +++ b/examples/typescript/index.tsx @@ -0,0 +1,274 @@ +import React from 'react'; +import { + FlexBox, + Heading, + SpectacleLogo, + UnorderedList, + CodeSpan, + OrderedList, + ListItem, + FullScreen, + AnimatedProgress, + Appear, + Slide, + Deck, + Text, + Grid, + Box, + Image, + CodePane, + MarkdownSlide, + MarkdownSlideSet, + Notes, + SlideLayout +} from 'spectacle'; +import { createRoot } from 'react-dom/client'; + +const formidableLogo = + 'https://avatars2.githubusercontent.com/u/5078602?s=280&v=4'; + +// SPECTACLE_CLI_THEME_START +const theme = { + fonts: { + header: '"Open Sans Condensed", Helvetica, Arial, sans-serif', + text: '"Open Sans Condensed", Helvetica, Arial, sans-serif' + } +}; +// SPECTACLE_CLI_THEME_END + +// SPECTACLE_CLI_TEMPLATE_START +const template = () => ( + + + + + + + + +); +// SPECTACLE_CLI_TEMPLATE_END + +const SlideFragments = () => ( + <> + + This is a slide fragment. + + + This is also a slide fragment. + + This item shows up! + + + This item also shows up! + + + +); + +const Presentation = () => ( + + + + + + + Spectacle supports notes per slide. +
    +
  1. Notes can now be HTML markup!
  2. +
  3. Lists can make it easier to make points.
  4. +
+
+
+ + + + ✨Spectacle ✨ + + + A ReactJS Presentation Library + + + Where you can write your decks in JSX, Markdown, or MDX! + + + + + Custom Backgrounds + + + backgroundColor + + + backgroundImage + + + backgroundOpacity + + + backgroundSize + + + backgroundPosition + + + backgroundRepeat + + + + + Animated Elements + + + Elements can animate in! + + + Out of order + + + + Just identify the order with the prop priority! + + + + + + + These + Text + Items + Flex + + + + Single-size Grid Item + + + Double-size Grid Item + + + + {Array(9) + .fill('') + .map((_, index) => ( + + + + ))} + + + + + {` + import { createClient, Provider } from 'urql'; + + const client = createClient({ url: 'https://0ufyz.sse.codesandbox.io' }); + + const App = () => ( + + + + ); + `} + + {` + public class NoLineNumbers { + public static void main(String[] args) { + System.out.println("Hello"); + } + } + `} + +
+ + This is a slide embedded in a div + +
+ + {` + # This is a Markdown Slide + + - You can pass props down to all elements on the slide. + - Just use the \`componentProps\` prop. + `} + + + {` + # This is also a Markdown Slide + + It uses the \`animateListItems\` prop. + + - Its list items... + - ...will appear... + - ...one at a time. + `} + + + + + This is a 4x4 Grid + + + + With all the content aligned and justified center. + + + + + It uses Spectacle {''} and{' '} + {''} components. + + + + + + + + + {` + # This is the first slide of a Markdown Slide Set + --- + # This is the second slide of a Markdown Slide Set + `} + + +
+); + +const root = createRoot(document.getElementById('root')!); +root.render(); diff --git a/examples/typescript/package.json b/examples/typescript/package.json new file mode 100644 index 000000000..4ceb93a51 --- /dev/null +++ b/examples/typescript/package.json @@ -0,0 +1,100 @@ +{ + "name": "spectacle-example-ts", + "private": true, + "dependencies": { + "spectacle": "*", + "react": "^18.1.0", + "react-dom": "^18.1.0" + }, + "devDependencies": { + "@types/react": "^18.0.12", + "@types/react-dom": "^18.0.5" + }, + "scripts": { + "start": "webpack-dev-server --port=3100 --hot --config ./webpack.config.js", + "build": "wireit", + "types:check": "wireit", + "lint": "wireit", + "lint:fix": "wireit", + "prettier": "wireit", + "prettier:fix": "wireit" + }, + "wireit": { + "build": { + "command": "nps webpack", + "files": [ + "*.{js,jsx,ts,tsx,html}" + ], + "output": [ + "dist/*" + ], + "dependencies": [ + "../../packages/spectacle:build:lib:esm" + ], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "types:check": { + "command": "nps types:check", + "files": [ + "index.{ts,tsx}", + "../../tsconfig.json", + "tsconfig.json" + ], + "dependencies": [], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint": { + "command": "nps \"lint:base *.js *.tsx\"", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "*.{js,jsx,ts,tsx}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint:fix": { + "command": "pnpm run lint || nps \"lint:base --fix *.js *.tsx\"", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "*.{js,jsx,ts,tsx}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier": { + "command": "nps prettier:pkg -- -- \"*\"", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.{js,jsx,ts,tsx,html}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier:fix": { + "command": "pnpm run prettier || nps prettier:pkg:fix -- -- \"*\"", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.{js,jsx,ts,tsx,html}" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + } + } +} diff --git a/examples/typescript/tsconfig.json b/examples/typescript/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/examples/typescript/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/examples/typescript/webpack.config.js b/examples/typescript/webpack.config.js new file mode 100644 index 000000000..d81303f85 --- /dev/null +++ b/examples/typescript/webpack.config.js @@ -0,0 +1,22 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const base = require('../../webpack.config.base'); + +module.exports = { + ...base, + mode: 'development', + context: __dirname, + entry: './index.tsx', + output: { + path: path.join(__dirname, 'dist'), + filename: 'example.js' + }, + externals: {}, + plugins: [ + ...base.plugins, + new HtmlWebpackPlugin({ + title: 'Spectacle TypeScript Demo', + template: `./index.html` + }) + ] +}; diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index acbfb7abb..000000000 --- a/index.d.ts +++ /dev/null @@ -1,339 +0,0 @@ -// Definitions by: Zachary Maybury -// Kylie Stewart - -declare module 'spectacle' { - import * as CSS from 'csstype'; - import * as React from 'react'; - - /** - * Alignment Types for Spectacle - */ - type alignType = - | 'flex-start flex-start' - | 'flex-start center' - | 'flex-start flex-end' - | 'center flex-start' - | 'center center' - | 'center flex-end' - | 'flex-end flex-start' - | 'flex-end center' - | 'flex-end flex-end'; - - /** - * Bullet Style Types for Spectacle - */ - type bulletStyleType = - | 'arrow' - | 'classicCheck' - | 'cross' - | 'greenCheck' - | 'star'; - - /** - * Animation Types for Spectacle - */ - type easeType = - | 'back' - | 'backIn' - | 'backOut' - | 'backInOut' - | 'bounce' - | 'bounceIn' - | 'bounceOut' - | 'bounceInOut' - | 'circle' - | 'circleIn' - | 'circleOut' - | 'circleInOut' - | 'linear' - | 'linearIn' - | 'linearOut' - | 'linearInOut' - | 'cubic' - | 'cubicIn' - | 'cubicOut' - | 'cubicInOut' - | 'elastic' - | 'elasticIn' - | 'elasticOut' - | 'elasticInOut' - | 'exp' - | 'expIn' - | 'expOut' - | 'expInOut' - | 'poly' - | 'polyIn' - | 'polyOut' - | 'polyInOut' - | 'quad' - | 'quadIn' - | 'quadOut' - | 'quadInOut' - | 'sin' - | 'sinIn' - | 'sinOut' - | 'sinInOut'; - - /** - * Progress Types for Spectacle - */ - type progressType = 'pacman' | 'bar' | 'number' | 'none'; - - /** - * S Types for StyledS in Spectacle - */ - type sType = 'italic' | 'bold' | 'line-through' | 'underline'; - - /** - * Target Types for links - */ - type targetType = '_blank' | '_self' | '_parent' | '_top'; - - /** - * Theme Types for CodePane in Spectacle - */ - type themeType = 'dark' | 'light' | 'external'; - - /** - * Transition Types for Spectacle - */ - type transitionType = 'slide' | 'zoom' | 'fade' | 'spin'; - - /** - * All available DOM style properties and their types - * https://www.npmjs.com/package/csstype - */ - interface CSSProperties extends CSS.Properties {} - - interface AnimProps { - easing: easeType; - fromStyle: CSSProperties | CSSProperties[]; - onAnim?: (forwards?: boolean, animIndex?: number) => void; - order?: number; - route?: object; - style?: CSSProperties; - toStyle: CSSProperties | CSSProperties[]; - transitionDuration: number; - } - - interface AppearProps { - easing?: easeType; - endValue?: object; - fid?: string; - order?: number; - startValue?: object; - style?: BaseProps['style']; - transitionDuration?: number; - } - - /** - * Base props for many Spectacle components - */ - interface BaseProps { - bgColor?: string; - bgDarken?: number; - bgImage?: string; - bold?: boolean; - caps?: boolean; - className?: string; - italic?: boolean; - margin?: number | string; - padding?: number | string; - style?: CSSProperties; - textAlign?: string; - textColor?: string; - textFont?: string; - textSize?: string; - } - - interface CodePaneProps { - className?: BaseProps['className']; - contentEditable?: boolean; - lang?: string; - source?: string; - style?: BaseProps['style']; - theme?: themeType; - } - - interface ComponentPlaygroundProps { - code?: string; - previewBackgroundColor?: string; - scope?: object; - theme?: themeType; - transformCode?: (code: string) => string; - } - - interface DeckProps { - autoplay?: boolean; - autoplayDuration?: number; - autoplayLoop?: boolean; - autoplayOnStart?: boolean; - controls?: boolean; - globalStyles?: boolean; - history?: any; // Needs a type, see https://github.com/ReactTraining/history - showFullscreenControl?: boolean; - onStateChange?: (previousState?: string, nextState?: string) => void; - progress?: progressType; - theme?: Theme; - transition?: transitionType[]; - transitionDuration?: number; - contentWidth?: string; - contentHeight?: string; - } - - interface FillProps { - className?: string; - style?: CSSProperties; - } - - interface FitProps extends FillProps {} // tslint:disable-line:no-empty-interface - - interface GoToActionProps { - margin?: BaseProps['margin']; - padding?: BaseProps['padding']; - render?: (goToSlide?: (slide: number | string) => void) => void; - slide?: number | string; - style?: BaseProps['style']; - } - - interface HeadingProps extends BaseProps { - fit?: boolean; - lineHeight?: number; - size?: number; - } - - interface ImageProps { - alt?: string; - className?: BaseProps['className']; - display?: string; - height?: number | string; - margin?: BaseProps['margin']; - padding?: BaseProps['padding']; - src?: string; - width?: number | string; - } - - interface LayoutProps { - style?: CSSProperties; - } - - interface LinkProps extends BaseProps { - href?: string; - target?: targetType; - } - - interface ListProps extends BaseProps { - bulletStyle?: bulletStyleType; - } - - interface MarkdownProps { - mdastConfig?: { [key: string]: number | string }; - source?: string; - } - - interface SlideProps extends BaseProps { - align?: alignType; - contentStyles?: CSSProperties; - controlColor?: string; - dispatch?: () => void; - hash?: number | string; - progressColor?: string; - history?: any; // Needs a type, see https://github.com/ReactTraining/history - id?: string; - lastSlideIndex?: number; - notes?: string; - onActive?: (slideIndex: string | number) => void; - slideIndex?: number; - state?: string; - transition?: transitionType[]; - transitionDuration?: number; - transitionIn?: transitionType[]; - transitionOut?: transitionType[]; - } - - interface SProps extends BaseProps { - type?: sType | sType[]; - } - - interface TextProps extends BaseProps { - fit?: boolean; - lineHeight?: number; - } - - interface Theme { - [key: string]: number | string; - } - - class Anim extends React.Component {} - - class Appear extends React.Component {} - - class BlockQuote extends React.Component {} - - class Cite extends React.Component {} - - class Code extends React.Component {} - - class CodePane extends React.Component {} - - class ComponentPlayground extends React.Component {} - - class Deck extends React.Component {} - - class Fill extends React.Component {} - - class Fit extends React.Component {} - - class GoToAction extends React.Component {} - - class Heading extends React.Component {} - - class Image extends React.Component {} - - class Layout extends React.Component {} - - class Link extends React.Component {} - - class List extends React.Component {} - - class ListItem extends React.Component {} - - class Markdown extends React.Component {} - - class Notes extends React.Component {} - - class Quote extends React.Component {} - - class S extends React.Component {} - - class Slide extends React.Component {} - - class SlideSet extends React.Component {} - - class Table extends React.Component {} - - class TableBody extends React.Component {} - - class TableHeader extends React.Component {} - - class TableHeaderItem extends React.Component {} - - class TableItem extends React.Component {} - - class TableRow extends React.Component {} - - class Text extends React.Component {} - - class UnfitText extends React.Component {} -} - -declare module 'spectacle/lib/utils/preloader' { - const preloader: (obj: object) => void; - export default preloader; -} - -declare module 'spectacle/lib/themes/default' { - import { Theme } from 'spectacle'; - const createTheme: (...args: object[]) => Theme; - export default createTheme; -} diff --git a/index.html b/index.html deleted file mode 100644 index ffe71e406..000000000 --- a/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - Spectacle - - - - -
- - - diff --git a/index.js b/index.js deleted file mode 100644 index 8e011d7aa..000000000 --- a/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; -import { render } from 'react-dom'; - -import Presentation from './example/src'; - -render(, document.getElementById('root')); diff --git a/jest-setup.js b/jest-setup.js deleted file mode 100644 index 4537f3bb9..000000000 --- a/jest-setup.js +++ /dev/null @@ -1,7 +0,0 @@ -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; - -Enzyme.configure({ adapter: new Adapter() }); -document.requestAnimationFrame = function(callback) { - setTimeout(callback, 0); -}; diff --git a/one-page.html b/one-page.html deleted file mode 100644 index c6c8e58b0..000000000 --- a/one-page.html +++ /dev/null @@ -1,304 +0,0 @@ - - - - - - Spectacle - - - - - -
- - - - - - - - - diff --git a/package-scripts.js b/package-scripts.js new file mode 100644 index 000000000..335b8f8cf --- /dev/null +++ b/package-scripts.js @@ -0,0 +1,54 @@ +/** + * We generally use `nps` for scripts that we: + * 1. define at the root of the monorepo + * 2. that are meant to execute _within_ a workspace + * + * ... or ... + * + * - That could use a little JS magic that we don't want to write a full + * node script for 😂 + * + * For more cases, if you have an actual root task, define it in root + * `package.json:scripts`. + */ + +module.exports = { + scripts: { + // Build + // - Typescript + 'types:create': 'tsc --emitDeclarationOnly', + 'types:check': 'tsc --noEmit', + + // - Babel + 'babel:pkg:base': + 'babel src --config-file ../../.babelrc.build.js --extensions .tsx,.ts,.jsx,.js', + 'babel:pkg:lib:esm': + 'cross-env BABEL_ENV=es nps "babel:pkg:base src --out-dir es"', + 'babel:pkg:lib:cjs': + 'cross-env BABEL_ENV=commonjs nps "babel:pkg:base src --out-dir lib"', + + // - Webpack + webpack: 'webpack', + + // Test + jest: 'jest', + + // Quality. + // - Format + 'prettier:base': 'prettier --list-different', + 'prettier:base:fix': 'prettier --write', + 'prettier:pkg': + 'nps prettier:base -- -- --config ../../.prettierrc --ignore-path ../../.prettierignore', + 'prettier:pkg:fix': + 'nps prettier:base:fix -- -- --config ../../.prettierrc --ignore-path ../../.prettierignore', + 'prettier:website': + 'nps prettier:base -- -- --config ../.prettierrc --ignore-path ../.prettierignore', + 'prettier:website:fix': + 'nps prettier:base:fix -- -- --config ../.prettierrc --ignore-path ../.prettierignore', + + // - Lint + 'lint:base': 'eslint --cache --color', + 'lint:pkg': 'nps lint:base -- -- src', + 'lint:pkg:fix': 'nps lint:base -- -- --fix src' + } +}; diff --git a/package.json b/package.json index 6b45b652e..5e51fcc47 100644 --- a/package.json +++ b/package.json @@ -1,147 +1,256 @@ { - "name": "spectacle", - "version": "5.7.0", - "description": "ReactJS Powered Presentation Framework", - "main": "lib/index.js", - "module": "es/index.js", - "types": "index.d.ts", - "jsnext:main": "es/index.js", - "scripts": { - "preversion": "npm run check", - "version": "npm run build:publish", - "clean:lib": "rimraf lib", - "clean:dist": "rimraf dist", - "clean": "npm run clean:lib && npm run clean:dist", - "build-babel": "babel src --ignore \"/__snapshots__/,/**/*.test.js/\"", - "build:es": "builder run --env \"{\\\"BABEL_ENV\\\":\\\"esm\\\"}\" build-babel -- -d es", - "build:lib": "builder run build-babel -- -d lib", - "build-wds-base": "webpack-dev-server", - "build-webpack-base": "webpack", - "build-webpack": "builder run --env \"{\\\"BABEL_ENV\\\":\\\"esm\\\"}\" build-webpack-base", - "build:dist": "builder run build-webpack -- --config webpack.config.production.js", - "build:dist-umd": "builder run build-webpack -- --config webpack.config.umd.js", - "build:dist-umd-prod": "builder run build-webpack -- --config webpack.config.umd.production.js", - "build": "builder concurrent --buffer build:es build:lib build:dist build:dist-umd build:dist-umd-prod", - "build:publish": "npm run clean && npm run build", - "lint": "npm run lint-js && npm run lint-dts", - "lint-js": "eslint src example *.js", - "lint-fix": "npm run lint-js -- --fix && npm run lint-dts -- --fix", - "lint-dts": "tslint index.d.ts --format verbose", - "prettier": "prettier \"**/*.{js,json,ts,css,md}\"", - "prettier-fix": "npm run prettier -- --write", - "prettier-check": "npm run prettier -- --list-different", - "deploy": "npm run build:dist && surge -p .", - "start": "builder run --env \"{\\\"BABEL_ENV\\\":\\\"esm\\\"}\" build-wds-base -- --port=3000 --hot", - "test": "jest --verbose", - "test-debug": "node --inspect-brk node_modules/jest/bin/jest.js --runInBand", - "check-typescript": "tsc index.d.ts && npm run lint-dts", - "check": "npm run lint-js && npm run check-typescript && npm run test", - "check-ci": "npm run check && npm run prettier-check" - }, - "author": "", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/FormidableLabs/spectacle.git" - }, - "dependencies": { - "csstype": "^2.6.0", - "deep-object-diff": "^1.1.0", - "emotion": "^8.0.8", - "history": "^4.7.2", - "lodash": "^4.17.15", - "marksy": "^6.1.0", - "normalize.css": "^8.0.1", - "prismjs": "^1.17.1", - "react-emotion": "^8.0.8", - "react-live": "^1.6", - "react-redux": "^5.1.1", - "react-transition-group": "1.2.1", - "react-typography": "^0.16.18", - "redux": "^4.0.1", - "redux-actions": "^2.6.4", - "to-style": "^1.3.3", - "victory-core": "^31.1.0" - }, - "peerDependencies": { - "prop-types": "^15.6.2", - "react": "^16.7.0", - "react-dom": "^16.7.0" - }, + "name": "spectacle-monorepo", "devDependencies": { - "@babel/cli": "^7.2.3", - "@babel/core": "^7.2.2", - "@babel/plugin-proposal-class-properties": "^7.2.3", - "@babel/plugin-proposal-object-rest-spread": "^7.2.0", - "@babel/polyfill": "^7.2.5", - "@babel/preset-env": "^7.2.3", - "@babel/preset-react": "^7.0.0", - "babel-eslint": "^10.0.1", - "babel-jest": "^23.6.0", - "babel-loader": "^8.0.5", - "babel-plugin-emotion": "^9.0.1", - "builder": "^4.0.0", - "css-loader": "^3.1.0", - "enzyme": "^3.8.0", - "enzyme-adapter-react-16": "^1.7.1", - "enzyme-to-json": "3.3.5", - "eslint": "^4.19.0", - "eslint-config-formidable": "^3.0.0", - "eslint-config-prettier": "^2.9.0", - "eslint-plugin-filenames": "^1.2.0", - "eslint-plugin-import": "^2.9.0", - "eslint-plugin-react": "^7.7.0", - "file-loader": "^3.0.1", - "husky": "^1.3.1", - "jest": "^23.6.0", - "jest-serializer-enzyme": "^1.0.0", - "lint-staged": "^8.1.0", - "prettier": "^1.15.3", - "prop-types": "^15.6.2", - "raw-loader": "^1.0.0", - "react": "^16.7.0", - "react-dom": "^16.7.0", - "react-test-renderer": "^16.7.0", - "redbox-react": "1.6.0", - "rimraf": "^2.6.3", - "style-loader": "^0.23.1", - "surge": "^0.21.3", - "tslint": "^5.12.1", - "typescript": "^3.2.2", - "url-loader": "^1.1.2", - "webpack": "^4.28.4", - "webpack-cli": "^3.2.1", - "webpack-dev-server": "^3.1.14" + "@babel/cli": "^7.12.8", + "@babel/core": "^7.17.2", + "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-object-rest-spread": "^7.12.1", + "@babel/preset-env": "^7.12.7", + "@babel/preset-react": "^7.16.7", + "@babel/preset-typescript": "^7.16.0", + "@changesets/cli": "^2.23.1", + "@svitejs/changesets-changelog-github-compact": "^0.1.1", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^13.3.0", + "@types/jest": "^27.5.2", + "@types/testing-library__jest-dom": "^5.14.5", + "@typescript-eslint/eslint-plugin": "^5.4.0", + "@typescript-eslint/parser": "^5.4.0", + "babel-eslint": "^10.1.0", + "babel-loader": "^8.0.6", + "babel-plugin-transform-jsx-to-htm": "^2.0.0", + "concurrently": "^7.3.0", + "cross-env": "^7.0.3", + "eslint": "^8.2.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-react": "^7.27.0", + "eslint-plugin-prettier": "^3.3.1", + "eslint-plugin-react-hooks": "^4.3.0", + "html-webpack-plugin": "^5.5.0", + "jest": "^28.1.3", + "jest-environment-jsdom": "^28.1.3", + "jest-puppeteer": "^6.1.1", + "mkdirp": "^1.0.4", + "nps": "^5.10.0", + "prettier": "^2.4.1", + "puppeteer": "^16.0.0", + "raw-loader": "^4.0.0", + "rimraf": "^3.0.0", + "serve": "^14.0.1", + "ts-jest": "^28.0.7", + "typescript": "^4.7.4", + "wait-on": "^6.0.1", + "webpack": "^5.68.0", + "webpack-cli": "^4.10.0", + "webpack-dev-server": "^4.7.4", + "wireit": "^0.7.1" }, - "resolutions": { - "babel-core": "^7.0.0-0", - "source-map": "^0.5.0" + "pnpm": { + "neverBuiltDependencies": [ + "puppeteer" + ] }, - "jest": { - "moduleNameMapper": { - "\\.(css)$": "/__mocks__/styleMock.js" - }, - "snapshotSerializers": [ - "enzyme-to-json/serializer" - ], - "setupFiles": [ - "raf/polyfill", - "/jest-setup.js" - ], - "testURL": "http://localhost" + "scripts": { + "version": "pnpm changeset version && pnpm install --no-frozen-lockfile", + "changeset": "changeset", + "start:js": "concurrently --raw pnpm:build:spectacle:esm:watch pnpm:build:spectacle:types:watch \"pnpm run --filter ./examples/js start\"", + "start:md": "concurrently --raw pnpm:build:spectacle:esm:watch pnpm:build:spectacle:types:watch \"pnpm run --filter ./examples/md start\"", + "start:ts": "concurrently --raw pnpm:build:spectacle:esm:watch pnpm:build:spectacle:types:watch \"pnpm run --filter ./examples/typescript start\"", + "start:one-page": "concurrently --raw pnpm:build:spectacle:dev:watch pnpm:build:spectacle:dev:watch", + "start:examples": "concurrently --raw pnpm:build:spectacle:esm:watch pnpm:build:spectacle:dev:watch pnpm:build:spectacle:dev:watch \"pnpm run --parallel --filter \\\"./examples/*\\\" start\"", + "start:create-spectacle": "pnpm run --filter ./packages/create-spectacle build --watch", + "clean:build": "rimraf \"{packages,examples}/*/{es,lib,dist}\" packages/create-spectacle/bin", + "clean:cache": "wireit", + "clean:cache:lint": "rimraf .eslintcache \"{packages,examples}/*/.eslintcache\"", + "clean:cache:wireit": "rimraf .wireit \"{packages,examples}/*/.wireit\"", + "clean:cache:modules": "rimraf node_modules/.cache \"{packages,examples}/*/node_modules/.cache\"", + "check": "wireit", + "check:ci": "wireit", + "build": "wireit", + "build:one-page:watch": "pnpm run --filter ./examples/one-page build --watch", + "build:spectacle:dev:watch": "pnpm run --filter ./packages/spectacle build:dist:dev --watch", + "build:spectacle:esm:watch": "pnpm run --filter ./packages/spectacle build:lib:esm --watch", + "build:spectacle:types:watch": "pnpm run --filter ./packages/spectacle types:create --watch", + "types:check": "wireit", + "lint": "wireit", + "lint:fix": "wireit", + "lint:root": "wireit", + "lint:root:fix": "wireit", + "lint:pkgs": "wireit", + "lint:pkgs:fix": "wireit", + "prettier": "wireit", + "prettier:fix": "wireit", + "prettier:root": "wireit", + "prettier:root:fix": "wireit", + "prettier:pkgs": "wireit", + "prettier:pkgs:fix": "wireit", + "test": "wireit", + "puppeteer:install": "rimraf .puppeteer && cross-env PUPPETEER_DOWNLOAD_PATH=.puppeteer node ./node_modules/puppeteer/install.js" }, - "sideEffects": false, - "husky": { - "hooks": { - "pre-commit": "lint-staged" + "wireit": { + "clean:cache": { + "dependencies": [ + "clean:cache:wireit", + "clean:cache:lint", + "clean:cache:modules" + ] + }, + "check": { + "dependencies": [ + "prettier", + "lint", + "types:check", + "test" + ] + }, + "check:ci": { + "dependencies": [ + "check" + ] + }, + "build": { + "dependencies": [ + "./packages/create-spectacle:build", + "./packages/spectacle:build", + "./examples/js:build", + "./examples/md:build", + "./examples/one-page:build", + "./examples/typescript:build" + ] + }, + "types:check": { + "dependencies": [ + "./packages/create-spectacle:types:check", + "./packages/spectacle:types:check", + "./examples/typescript:types:check", + "./website:types:check" + ] + }, + "lint": { + "dependencies": [ + "lint:root", + "lint:pkgs" + ] + }, + "lint:fix": { + "dependencies": [ + "lint:root:fix", + "lint:pkgs:fix" + ] + }, + "lint:root": { + "command": "nps \"lint:base *.js\"", + "files": [ + ".eslintrc", + ".eslintignore", + "*.js", + "!**/node_modules/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint:root:fix": { + "command": "pnpm run lint:root || nps \"lint:base --fix *.js\"", + "files": [ + ".eslintrc", + ".eslintignore", + "*.js", + "!**/node_modules/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint:pkgs": { + "dependencies": [ + "./packages/spectacle:lint", + "./packages/create-spectacle:lint", + "./website:lint", + "./examples/js:lint", + "./examples/md:lint", + "./examples/one-page:lint", + "./examples/typescript:lint" + ] + }, + "lint:pkgs:fix": { + "dependencies": [ + "./packages/spectacle:lint:fix", + "./packages/create-spectacle:lint:fix", + "./website:lint:fix", + "./examples/js:lint:fix", + "./examples/md:lint:fix", + "./examples/one-page:lint:fix", + "./examples/typescript:lint:fix" + ] + }, + "prettier": { + "dependencies": [ + "prettier:root", + "prettier:pkgs" + ] + }, + "prettier:fix": { + "dependencies": [ + "prettier:root:fix", + "prettier:pkgs:fix" + ] + }, + "prettier:root": { + "command": "nps prettier:base -- -- \"*.{js,json,md}\"", + "files": [ + ".prettierrc", + ".prettierignore", + "*.{js,json,md}", + "!**/node_modules/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier:root:fix": { + "command": "pnpm run prettier:root || nps prettier:base:fix -- -- \"*.{js,json,md}\"", + "files": [ + ".prettierrc", + ".prettierignore", + "*.{js,json,md}", + "!**/node_modules/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier:pkgs": { + "dependencies": [ + "./packages/spectacle:prettier", + "./packages/create-spectacle:prettier", + "./website:prettier", + "./examples/js:prettier", + "./examples/md:prettier", + "./examples/one-page:prettier", + "./examples/typescript:prettier" + ] + }, + "prettier:pkgs:fix": { + "dependencies": [ + "./packages/spectacle:prettier:fix", + "./packages/create-spectacle:prettier:fix", + "./website:prettier:fix", + "./examples/js:prettier:fix", + "./examples/md:prettier:fix", + "./examples/one-page:prettier:fix", + "./examples/typescript:prettier:fix" + ] + }, + "test": { + "dependencies": [ + "./packages/spectacle:test", + "./packages/create-spectacle:test" + ] } - }, - "lint-staged": { - "*.js": [ - "eslint" - ], - "*.{js,json,css,md}": [ - "prettier --list-different" - ] } } diff --git a/packages/create-spectacle/.gitignore b/packages/create-spectacle/.gitignore new file mode 100644 index 000000000..a9a5aecf4 --- /dev/null +++ b/packages/create-spectacle/.gitignore @@ -0,0 +1 @@ +tmp diff --git a/packages/create-spectacle/CHANGELOG.md b/packages/create-spectacle/CHANGELOG.md new file mode 100644 index 000000000..4ec0122e7 --- /dev/null +++ b/packages/create-spectacle/CHANGELOG.md @@ -0,0 +1,31 @@ +# create-spectacle + +## 0.3.0 + +### Minor Changes + +- Add README and .gitignore to generated projects ([#1200](https://github.com/FormidableLabs/spectacle/pull/1200)) + +* Add vite starter kits to create-spectacle. Add tests to test expected files were generated. ([#1210](https://github.com/FormidableLabs/spectacle/pull/1210)) + +### Patch Changes + +- Better overwrite logic based on whether HTML file or directory will be created ([#1205](https://github.com/FormidableLabs/spectacle/pull/1205)) + +## 0.2.0 + +### Minor Changes + +- Added interactive CLI prompt ([#1195](https://github.com/FormidableLabs/spectacle/pull/1195)) + +## 0.1.1 + +### Patch Changes + +- Fix spectacle version import ([#1192](https://github.com/FormidableLabs/spectacle/pull/1192)) + +## 0.1.0 + +### Minor Changes + +- Initial release of create-spectacle ([#1189](https://github.com/FormidableLabs/spectacle/pull/1189)) diff --git a/packages/create-spectacle/README.md b/packages/create-spectacle/README.md new file mode 100644 index 000000000..dec1f2d2e --- /dev/null +++ b/packages/create-spectacle/README.md @@ -0,0 +1,29 @@ +# `create-spectacle` + +This package contains `create-spectacle`, the boilerplate-generator for Spectacle. The simplest usage is to run one of the following commands (based on your package manager of choice): + +```shell +yarn create spectacle # yarn +npm create spectacle # npm +npx create-spectacle # using npx +pnpm create spectacle # using pnpm +``` + +Once running the respective command, you will be prompted to provide information about the spectacle project you'd like to create. Once you provide necessary information, a new spectacle project will be created in the directory derived from the project name you provided. + +## Flags + +`create-spectacle`'s core usage is via the interactive prompts. However, there are a handful of arguments/flags that you can provide to pre-fill prompt options: + +- Pass a project name as the main argument to specify a project name, e.g. `yarn create spectacle my-presentation`. +- Pass the `--type` or `-t` flag to specify the type of spectacle project you'd like to create. Options are `jsx`, `tsx`, or `onepage`. Example: `yarn create spectacle -t onepage my-presentation`. +- Pass the `--lang` or `-l` flag to specify the HTML lang attribute for your presentation. Example: `yarn create spectacle -l en my-presentation`. +- Pass the `--port` or `-p` flag to specify the port to run the presentation on. Example: `yarn create spectacle -p 8080 my-presentation`. + +### Bypassing Prompts + +If you want to bypass the prompts entirely, pass the `-t`, `-l`, and `-p` flags as well as the project name as the main argument. For example: + +```shell +yarn create spectacle -t jsx -l en -p 8080 my-presentation +``` diff --git a/packages/create-spectacle/jest.config.js b/packages/create-spectacle/jest.config.js new file mode 100644 index 000000000..5c88aeebb --- /dev/null +++ b/packages/create-spectacle/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest/presets/js-with-ts', + testPathIgnorePatterns: [ + '/node_modules/', + '/bin/', + '/.wireit/' + ] +}; diff --git a/packages/create-spectacle/package.json b/packages/create-spectacle/package.json new file mode 100644 index 000000000..5e94b95fc --- /dev/null +++ b/packages/create-spectacle/package.json @@ -0,0 +1,158 @@ +{ + "name": "create-spectacle", + "version": "0.3.0", + "description": "Project generator for Spectacle", + "main": "bin/cli.js", + "files": [ + "bin/" + ], + "bin": "bin/cli.js", + "author": "Formidable Labs ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/FormidableLabs/spectacle.git" + }, + "dependencies": { + "@types/yargs": "^17.0.11", + "chalk": "^4.1.2", + "clear": "^0.1.0", + "cli-spinners": "^2.6.1", + "log-update": "4.0.0", + "prompts": "^2.4.2", + "yargs": "^17.5.1" + }, + "devDependencies": { + "@types/node": "^18.0.3", + "@types/prompts": "^2.0.14", + "spectacle": "workspace:*" + }, + "resolutions": {}, + "scripts": { + "build": "wireit", + "types:check": "wireit", + "lint": "wireit", + "lint:fix": "wireit", + "prettier": "wireit", + "prettier:fix": "wireit", + "test": "wireit", + "examples:clean": "rimraf .examples", + "examples:test": "nps jest", + "examples:jsx:clean": "rimraf .examples/jsx", + "examples:jsx:create": "mkdirp .examples && cd .examples && node ../bin/cli.js jsx -t jsx -l en -p 3000", + "examples:jsx:install": "cd .examples/jsx && npm install", + "examples:jsx:build": "cd .examples/jsx && npm run build", + "examples:jsx:start": "cd .examples/jsx && npm start", + "examples:tsx:clean": "rimraf .examples/tsx", + "examples:tsx:create": "mkdirp .examples && cd .examples && node ../bin/cli.js tsx -t tsx -l en -p 3000", + "examples:tsx:install": "cd .examples/tsx && npm install", + "examples:tsx:build": "cd .examples/tsx && npm run build", + "examples:tsx:start": "cd .examples/tsx && npm start", + "examples:onepage:clean": "rimraf .examples/onepage", + "examples:onepage:create": "mkdirp .examples/onepage && cd .examples/onepage && node ../../bin/cli.js index -t onepage -l en", + "examples:onepage:install": "echo unused", + "examples:onepage:build": "echo unused", + "examples:onepage:start": "pnpm exec serve .examples/onepage" + }, + "wireit": { + "build": { + "command": "tsc --p tsconfig.build.json", + "files": [ + "src/**", + "!src/**/*.test.*", + "tsconfig.json", + "tsconfig.build.json" + ], + "output": [ + "bin/**/*.js" + ], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "types:check": { + "command": "nps types:check -- -- --p tsconfig.typecheck.json", + "files": [ + "src/**/*.{ts,tsx}", + "test/**/*.{ts,tsx}", + "tsconfig.json", + "tsconfig.typecheck.json" + ], + "dependencies": [], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint": { + "command": "nps lint:pkg -- -- test", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "*.js", + "src/**", + "test/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint:fix": { + "command": "pnpm run lint || nps lint:pkg:fix -- -- test", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "*.js", + "src/**", + "test/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier": { + "command": "nps prettier:pkg -- -- src test", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.js", + "src/**", + "test/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier:fix": { + "command": "pnpm run prettier || nps prettier:pkg:fix -- -- src test", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.js", + "src/**", + "test/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "test": { + "dependencies": [ + "build" + ], + "command": "jest --testMatch=\"/src/*.test.ts\"", + "files": [ + "src/**", + "../../.babelrc.js" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + } + } +} diff --git a/packages/create-spectacle/src/cli.test.ts b/packages/create-spectacle/src/cli.test.ts new file mode 100644 index 000000000..9ce77f7e7 --- /dev/null +++ b/packages/create-spectacle/src/cli.test.ts @@ -0,0 +1,260 @@ +import path from 'node:path'; +import { exec } from 'node:child_process'; +import fs from 'node:fs/promises'; + +const CLI_PATH = path.resolve(__dirname, '../bin/cli.js'); +const TMP_PATH = path.resolve(__dirname, '../tmp'); +const OUT_NAME = 'my-deck'; +const OUT_PATH = path.join(TMP_PATH, OUT_NAME); + +describe('create-spectacle', () => { + /** + * Some file/dir setup: + * - Make tmp dir before suite + * - Clean up tmp dir after suite + * - After each test, try to clean up the generated files from that test. + */ + beforeAll(async () => { + await fs.mkdir(TMP_PATH, { recursive: true }); + }); + afterAll(async () => { + await fs.rm(TMP_PATH, { recursive: true }); + }); + afterEach(async () => { + await fs.rm(OUT_PATH, { recursive: true }).catch(() => {}); + }); + + it('generates tsx (webpack) deck with expected files', async () => { + await runCliWithArgs({ type: 'tsx', lang: 'foobar', port: 6969 }); + + expect(await listFiles()).toEqual([ + '.babelrc', + '.gitignore', + 'README.md', + 'index.html', + 'index.tsx', + 'package.json', + 'tsconfig.json', + 'webpack.config.js' + ]); + + // package.json fields + const pak = JSON.parse(await peakFile('package.json')); + expect(Object.keys(pak)).toEqual([ + 'name', + 'private', + 'scripts', + 'dependencies', + 'devDependencies' + ]); + expect(Object.keys(pak.dependencies)).toEqual( + expect.arrayContaining(['spectacle', 'react', 'react-dom']) + ); + expect(Object.keys(pak.devDependencies)).toEqual( + expect.arrayContaining([ + '@babel/core', + '@babel/preset-env' /* ignoring a lot... */, + 'typescript', + '@types/react' + ]) + ); + + // README instructions for changing dist directory + expect(await peakFile('README.md')).toContain( + `\`output.path\` in \`webpack.config.js\`` + ); + + // Custom lang/port + expect(await peakFile('index.html')).toContain(`lang="foobar"`); + expect(await peakFile('webpack.config.js')).toContain('6969'); + }); + + it('generates jsx (webpack) deck with expected files', async () => { + await runCliWithArgs({ type: 'jsx' }); + + const files = await listFiles(); + expect(files).toEqual([ + '.babelrc', + '.gitignore', + 'README.md', + 'index.html', + 'index.jsx', + 'package.json', + 'webpack.config.js' + ]); + expect(files).not.toContain('tsconfig.json'); + + // package.json fields + const pak = JSON.parse(await peakFile('package.json')); + expect(Object.keys(pak)).toEqual([ + 'name', + 'private', + 'scripts', + 'dependencies', + 'devDependencies' + ]); + expect(Object.keys(pak.dependencies)).toEqual( + expect.arrayContaining(['spectacle', 'react', 'react-dom']) + ); + expect(Object.keys(pak.devDependencies)).not.toContain([ + 'typescript', + '@types/react' + ]); + + // Custom lang/port + expect(await peakFile('index.html')).toContain(`lang="en"`); + expect(await peakFile('webpack.config.js')).toContain('3000'); + }); + + it('generates tsx (vite) deck with expected files', async () => { + await runCliWithArgs({ type: 'tsx-vite', lang: 'foobar', port: 6969 }); + + expect(await listFiles()).toEqual([ + '.gitignore', + 'README.md', + 'index.html', + 'index.tsx', + 'package.json', + 'tsconfig.json', + 'vite.config.ts' + ]); + + // package.json fields + const pak = JSON.parse(await peakFile('package.json')); + expect(Object.keys(pak)).toEqual([ + 'name', + 'private', + 'scripts', + 'dependencies', + 'devDependencies' + ]); + expect(Object.keys(pak.dependencies)).toEqual( + expect.arrayContaining(['spectacle', 'react', 'react-dom']) + ); + expect(Object.keys(pak.devDependencies)).toEqual( + expect.arrayContaining([ + '@types/react', + '@types/react-dom', + '@vitejs/plugin-react', + 'typescript' + ]) + ); + + // Vite config should have react plugin + expect(await peakFile('vite.config.ts')).toContain('plugins: [react()]'); + // Vite index.html should have entry point + expect(await peakFile('index.html')).toContain( + `` + ); + + // README instructions for changing dist directory + expect(await peakFile('README.md')).toContain( + `\`build.outDir\` in \`vite.config.ts` + ); + + // Custom lang/port + expect(await peakFile('index.html')).toContain(`lang="foobar"`); + expect(pak.scripts.start).toContain('--port 6969'); + }); + + it('generates jsx (vite) deck with expected files', async () => { + await runCliWithArgs({ type: 'jsx-vite' }); + + expect(await listFiles()).toEqual([ + '.gitignore', + 'README.md', + 'index.html', + 'index.jsx', + 'package.json', + 'vite.config.js' + ]); + + // package.json fields + const pak = JSON.parse(await peakFile('package.json')); + expect(Object.keys(pak)).toEqual([ + 'name', + 'private', + 'scripts', + 'dependencies', + 'devDependencies' + ]); + expect(Object.keys(pak.dependencies)).toEqual( + expect.arrayContaining(['spectacle', 'react', 'react-dom']) + ); + expect(Object.keys(pak.devDependencies)).toEqual( + expect.arrayContaining([ + '@types/react', + '@types/react-dom', + '@vitejs/plugin-react' + ]) + ); + + // Vite config should have react plugin + expect(await peakFile('vite.config.js')).toContain('plugins: [react()]'); + // Vite index.html should have entry point + expect(await peakFile('index.html')).toContain( + `` + ); + + // README instructions for changing dist directory + expect(await peakFile('README.md')).toContain( + `\`build.outDir\` in \`vite.config.js` + ); + + // Custom lang/port + expect(await peakFile('index.html')).toContain(`lang="en"`); + expect(pak.scripts.start).toContain('--port 3000'); + }); + + it('generates a onepage file', async () => { + await runCliWithArgs({ type: 'onepage' }); + + const HTML_PATH = path.join(TMP_PATH, `${OUT_NAME}.html`); + const contents = await fs + .readFile(HTML_PATH, 'utf8') + .then((buffer) => buffer.toString()); + + // Should have deps + const deps = [ + 'https://unpkg.com/react@18.1.0/umd/react.production.min.js', + 'https://unpkg.com/react-dom@18.1.0/umd/react-dom.production.min.js', + 'https://unpkg.com/react-is@18.1.0/umd/react-is.production.min.js', + 'https://unpkg.com/prop-types@15.7.2/prop-types.min.js', + 'https://unpkg.com/spectacle@^9/dist/spectacle.min.js' + ]; + deps.forEach((dep) => { + expect(contents).toContain(``); + }); + }); +}); + +/** + * Run the cli with certain args + */ +type Type = 'tsx' | 'jsx' | 'tsx-vite' | 'jsx-vite' | 'onepage'; +const runCliWithArgs = ({ + type, + lang = 'en', + port = 3000 +}: { + type: Type; + lang?: string; + port?: number; +}) => { + return new Promise((res) => { + const cp = exec( + `node ${CLI_PATH} ${OUT_NAME} -t ${type} -l ${lang} -p ${port}`, + { + cwd: TMP_PATH + } + ); + cp.on('exit', () => res(true)); + }); +}; + +const listFiles = (targetDir = OUT_PATH) => fs.readdir(targetDir); + +const peakFile = (filename: string) => + fs + .readFile(path.join(OUT_PATH, filename)) + .then((buffer) => buffer.toString()); diff --git a/packages/create-spectacle/src/cli.ts b/packages/create-spectacle/src/cli.ts new file mode 100644 index 000000000..9dd41e9c8 --- /dev/null +++ b/packages/create-spectacle/src/cli.ts @@ -0,0 +1,224 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import chalk from 'chalk'; +import cliSpinners from 'cli-spinners'; +import logUpdate from 'log-update'; +import prompts from 'prompts'; +import { + FileOptions, + writeWebpackProjectFiles, + writeOnePageHTMLFile, + writeViteProjectFiles +} from './templates/file-writers'; +// @ts-ignore +import { devDependencies } from '../package.json'; + +const argv = yargs(hideBin(process.argv)).argv; +const cwd = process.cwd(); + +enum ArgName { + type = 'type', + name = 'name', + lang = 'lang', + port = 'port', + overwrite = 'overwrite' +} + +const DeckTypeOptions = [ + { title: 'tsx (webpack)', value: 'tsx' }, + { title: 'jsx (webpack)', value: 'jsx' }, + { title: 'tsx (vite)', value: 'tsx-vite' }, + { title: 'jsx (vite)', value: 'jsx-vite' }, + { title: 'One Page', value: 'onepage' } +]; + +let progressInterval: NodeJS.Timer; +const log = console.log; +const printConsoleError = (message: string) => + chalk.whiteBright.bgRed.bold(' ! ') + chalk.red.bold(' ' + message + '\n'); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const main = async () => { + log(chalk.whiteBright.bgMagenta.bold(' Spectacle CLI ')); + + let i = 0; + let type = argv[ArgName.type] || argv['t']; + let name = argv['_']?.[0]; + let lang = argv[ArgName.lang] || argv['l']; + let port = argv[ArgName.port] || argv['p']; + + const isTryingToOverwrite = + Boolean(name) && !isOutputPathAvailable(name, type === 'onepage'); + + /** + * If type/name not both provided via CLI flags, prompt for them. + */ + const hasType = Boolean(type); + const hasName = Boolean(name); + const hasLang = Boolean(lang); + const hasPort = type === 'onepage' || Boolean(port); // onepage has no port + + if (!(hasType && hasName && hasLang && hasPort) || isTryingToOverwrite) { + try { + const response = await prompts( + [ + // Name prompt + { + type: 'text', + name: ArgName.name as string, + message: 'What is the name of the presentation?', + initial: name, + validate: async (val) => { + return val.trim().length > 0 ? true : 'Name is required'; + } + }, + /** + * Type of deck. + * Needs to be before overwrite confirmation so we can determine if folder/file already exists. + */ + { + type: 'select', + name: ArgName.type as string, + message: 'What type of deck do you want to create?', + choices: DeckTypeOptions, + initial: (() => { + const ind = DeckTypeOptions.findIndex((o) => o.value === type); + return ind > -1 ? ind : 0; + })() + }, + // If output directory already exists, prompt to overwrite + { + type: (_, answers) => + isOutputPathAvailable( + answers?.[ArgName.name], + answers?.[ArgName.type] === 'onepage' + ) + ? null + : 'confirm', + name: ArgName.overwrite as string, + message: (_, answers) => { + const name = answers?.[ArgName.name]; + const type = answers?.[ArgName.type]; + if (type === 'onepage') { + return `File ${formatProjectOutputPath( + name + )}.html already exists. Overwrite and continue?`; + } else { + return `Target directory ${formatProjectOutputPath( + name + )} already exists. Overwrite and continue?`; + } + } + }, + // Check overwrite comes back false, we need to abort. + { + type: (_, answers) => { + if (answers?.[ArgName.overwrite] === false) { + throw new Error('❌ Operation cancelled'); + } + return null; + }, + name: 'overwriteAborter' + }, + // Language prompt + { + type: 'text', + name: ArgName.lang as string, + message: + 'What is the language code for the generated HTML document?', + initial: lang || 'en', + validate: async (val) => { + return val.trim().length > 0 ? true : 'Language code is required'; + } + }, + { + // Don't prompt for this if onepage + type: (_, answers) => + answers?.[ArgName.type] === 'onepage' ? null : 'text', + name: ArgName.port as string, + message: 'What port should the webpack dev server run on?', + initial: port || '3000' + } + ], + { + onCancel: () => { + throw new Error('❌ Operation cancelled'); + } + } + ); + + if (response.type) type = response.type; + if (response.name) name = response.name; + lang = response.lang; + port = response.port; + } catch (err) { + console.log(chalk.red(err.message)); + return; + } + } + + progressInterval = setInterval(() => { + const { frames } = cliSpinners.aesthetic; + logUpdate( + chalk.whiteBright.bgBlue.bold(` ${frames[(i = ++i % frames.length)]} `), + chalk.blue.bold('Building Deck') + ); + }, cliSpinners.dots.interval); + + await sleep(750); + + const fileOptions: FileOptions = { + snakeCaseName: formatProjectOutputPath(name), + name, + lang, + port, + enableTypeScriptSupport: /^tsx/.test(type), + isVite: /vite$/.test(type), + spectacleVersion: devDependencies.spectacle + }; + + switch (type) { + case 'jsx': + case 'tsx': + await writeWebpackProjectFiles(fileOptions); + break; + case 'jsx-vite': + case 'tsx-vite': + await writeViteProjectFiles(fileOptions); + break; + case 'onepage': + await writeOnePageHTMLFile(fileOptions); + break; + } + + clearInterval(progressInterval); + const atLocation = + type === 'onepage' + ? `at ${fileOptions.snakeCaseName}.html` + : `in ${fileOptions.snakeCaseName}/`; + logUpdate( + chalk.whiteBright.bgGreen.bold(` \u2714 `), + chalk.green.bold( + `A new ${type} deck named ${name} was created ${atLocation}.\n` + ) + ); +}; + +const formatProjectOutputPath = (name: string) => + name.toLowerCase().replace(/([^a-z0-9]+)/gi, '-'); + +const isOutputPathAvailable = (name: string, isHTMLFile = false) => { + const outputPath = isHTMLFile + ? path.join(cwd, `${formatProjectOutputPath(name)}.html`) + : path.join(cwd, formatProjectOutputPath(name)); + return !fs.existsSync(outputPath); +}; + +main().catch((err) => { + clearInterval(progressInterval); + logUpdate(printConsoleError(err.message)); +}); diff --git a/packages/create-spectacle/src/templates/babel.ts b/packages/create-spectacle/src/templates/babel.ts new file mode 100644 index 000000000..3ef7a2921 --- /dev/null +++ b/packages/create-spectacle/src/templates/babel.ts @@ -0,0 +1,25 @@ +type BabelTemplateOptions = { + enableTypeScriptSupport: boolean; +}; + +export const babelTemplate = (options: BabelTemplateOptions) => + `{ + "presets": [ + ${options.enableTypeScriptSupport ? '"@babel/preset-typescript",' : ''} + ["@babel/preset-env", { "modules": false }], + ["@babel/preset-react", { "runtime": "automatic" }] + ], + "plugins": [ + "@babel/plugin-proposal-object-rest-spread", + "@babel/plugin-proposal-class-properties" + ], + "env": { + "cjs": { + "presets": ["@babel/preset-env", "@babel/preset-react"] + }, + "test": { + "presets": ["@babel/preset-env", "@babel/preset-react"] + } + } +} +`; diff --git a/packages/create-spectacle/src/templates/file-writers.ts b/packages/create-spectacle/src/templates/file-writers.ts new file mode 100644 index 000000000..1114d84c3 --- /dev/null +++ b/packages/create-spectacle/src/templates/file-writers.ts @@ -0,0 +1,126 @@ +import path from 'path'; +import { mkdir, writeFile, rm } from 'fs/promises'; +import { htmlTemplate } from './html'; +import { onePageTemplate } from './one-page'; +import { webpackTemplate } from './webpack'; +import { babelTemplate } from './babel'; +import { packageTemplate, vitePackageTemplate } from './package'; +import { indexTemplate } from './index'; +import { tsconfigTemplate } from './tsconfig'; +import { gitignoreTemplate } from './gitignore'; +import { readmeTemplate } from './readme'; +import { viteConfigTemplate } from './viteConfig'; + +export type FileOptions = { + snakeCaseName: string; + name: string; + lang: string; + port: number; + enableTypeScriptSupport: boolean; + spectacleVersion: string; + isVite: boolean; +}; + +const prepForProjectWrite = async (fileOptions: FileOptions) => { + const { name, lang, snakeCaseName, enableTypeScriptSupport, isVite } = + fileOptions; + + const outPath = path.resolve(process.cwd(), snakeCaseName); + const pathFor = (file: string) => path.join(outPath, file); + + // Clear out the directory if it already exists. + await rm(outPath, { recursive: true, force: true }); + + // Make new directory, and add some base files (shared between webpack and vite). + await mkdir(outPath, { recursive: true }); + await writeFile( + pathFor('index.html'), + htmlTemplate({ + name, + lang, + entryFile: isVite + ? `/index.${enableTypeScriptSupport ? 'tsx' : 'jsx'}` + : undefined + }) + ); + await writeFile( + pathFor(`index.${enableTypeScriptSupport ? 'tsx' : 'jsx'}`), + indexTemplate({ + usesTypeScript: enableTypeScriptSupport, + name + }) + ); + await writeFile(pathFor('.gitignore'), gitignoreTemplate()); + await writeFile( + pathFor('README.md'), + readmeTemplate({ name, enableTypeScriptSupport, isVite }) + ); + enableTypeScriptSupport && + (await writeFile(pathFor('tsconfig.json'), tsconfigTemplate())); + + return { outPath, pathFor }; +}; + +/** + * Generate a webpack-based project + */ +export const writeWebpackProjectFiles = async (options: FileOptions) => { + const { port, enableTypeScriptSupport, snakeCaseName, spectacleVersion } = + options; + const { pathFor } = await prepForProjectWrite(options); + + await writeFile( + pathFor('webpack.config.js'), + webpackTemplate({ port, usesTypeScript: enableTypeScriptSupport }) + ); + await writeFile( + pathFor('.babelrc'), + babelTemplate({ enableTypeScriptSupport }) + ); + await writeFile( + pathFor('package.json'), + packageTemplate({ + usesTypeScript: enableTypeScriptSupport, + name: snakeCaseName, + spectacleVersion + }) + ); +}; + +/** + * Generate a vite-based project + */ +export const writeViteProjectFiles = async (options: FileOptions) => { + const { enableTypeScriptSupport, snakeCaseName, spectacleVersion, port } = + options; + const { pathFor } = await prepForProjectWrite(options); + + await writeFile( + pathFor('package.json'), + vitePackageTemplate({ + usesTypeScript: enableTypeScriptSupport, + name: snakeCaseName, + spectacleVersion, + port + }) + ); + + await writeFile( + pathFor(`vite.config.${enableTypeScriptSupport ? 'ts' : 'js'}`), + viteConfigTemplate() + ); +}; + +/** + * Generate a one-page project + */ +export const writeOnePageHTMLFile = async ({ + snakeCaseName, + name, + lang +}: FileOptions) => { + await writeFile( + path.resolve(process.cwd(), `${snakeCaseName}.html`), + onePageTemplate({ name, lang }) + ); +}; diff --git a/packages/create-spectacle/src/templates/gitignore.ts b/packages/create-spectacle/src/templates/gitignore.ts new file mode 100644 index 000000000..9b115eae6 --- /dev/null +++ b/packages/create-spectacle/src/templates/gitignore.ts @@ -0,0 +1,20 @@ +export const gitignoreTemplate = () => + ` +# Deps +node_modules + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Editor/FS configs +.vscode +.idea +.DS_Store + +# Build artifacts +dist +`.trim(); diff --git a/packages/create-spectacle/src/templates/html.ts b/packages/create-spectacle/src/templates/html.ts new file mode 100644 index 000000000..75b33fcc1 --- /dev/null +++ b/packages/create-spectacle/src/templates/html.ts @@ -0,0 +1,24 @@ +type HTMLTemplateOptions = { + name: string; + lang: string; + entryFile?: string; +}; + +export const htmlTemplate = (options: HTMLTemplateOptions) => ` + + + + + + ${options.name} + + +
+ ${ + options.entryFile + ? `` + : '' + } + + +`; diff --git a/packages/create-spectacle/src/templates/index.ts b/packages/create-spectacle/src/templates/index.ts new file mode 100644 index 000000000..edd6284e5 --- /dev/null +++ b/packages/create-spectacle/src/templates/index.ts @@ -0,0 +1,46 @@ +type IndexTemplateOptions = { + name: string; + usesTypeScript: boolean; +}; + +export const indexTemplate = (options: IndexTemplateOptions) => + `import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { Slide, Deck, FlexBox, Heading, SpectacleLogo, Box, FullScreen, AnimatedProgress } from 'spectacle'; + +const template = () => ( + + + + + + + + +); + +const Presentation = () => ( + + + + ${options.name} + + + + + Made with + + + + +); + +createRoot(document.getElementById('app')${ + options.usesTypeScript ? '!' : '' + }).render(); +`; diff --git a/packages/create-spectacle/src/templates/one-page.ts b/packages/create-spectacle/src/templates/one-page.ts new file mode 100644 index 000000000..2f04aeb83 --- /dev/null +++ b/packages/create-spectacle/src/templates/one-page.ts @@ -0,0 +1,56 @@ +type OnePageTemplateOptions = { + name: string; + lang: string; +}; + +export const onePageTemplate = ({ name, lang }: OnePageTemplateOptions) => ` + + + + + + + ${name} + + +
+ + + + + + + + + + +`; diff --git a/packages/create-spectacle/src/templates/package.ts b/packages/create-spectacle/src/templates/package.ts new file mode 100644 index 000000000..9fbb651a4 --- /dev/null +++ b/packages/create-spectacle/src/templates/package.ts @@ -0,0 +1,86 @@ +type PackageTemplateOptions = { + name: string; + spectacleVersion: string; + usesTypeScript: boolean; + port?: number; +}; + +export const packageTemplate = (options: PackageTemplateOptions) => + JSON.stringify( + { + name: options.name, + private: true, + scripts: { + start: 'webpack-dev-server --hot --config ./webpack.config.js', + clean: 'rimraf dist', + build: 'webpack --config ./webpack.config.js --mode production' + }, + dependencies: { + react: '^18.1.0', + 'react-dom': '^18.1.0', + spectacle: + options.spectacleVersion === 'workspace:*' + ? '*' + : `^${options.spectacleVersion}` + }, + devDependencies: { + '@babel/core': '^7.17.2', + '@babel/plugin-proposal-class-properties': '^7.12.1', + '@babel/plugin-proposal-object-rest-spread': '^7.12.1', + '@babel/preset-env': '^7.12.7', + '@babel/preset-react': '^7.16.7', + 'babel-loader': '^8.0.6', + 'html-webpack-plugin': '^5.3.1', + 'style-loader': '^3.3.1', + 'css-loader': '^5.1.3', + 'file-loader': '^6.2.0', + rimraf: '^3.0.0', + webpack: '^5.68.0', + 'webpack-cli': '^4.5.0', + 'webpack-dev-server': '^4.7.4', + ...(options.usesTypeScript + ? { + typescript: '^4.5.2', + '@babel/preset-typescript': '^7.16.0', + '@types/react': '^18.0.12', + '@types/react-dom': '^18.0.5' + } + : {}) + } + }, + null, + 2 + ); + +export const vitePackageTemplate = (options: PackageTemplateOptions) => + JSON.stringify( + { + name: options.name, + private: true, + scripts: { + start: `vite dev --port ${options.port || 3000}`, + build: 'vite build' + }, + dependencies: { + react: '^18.1.0', + 'react-dom': '^18.1.0', + spectacle: + options.spectacleVersion === 'workspace:*' + ? '*' + : `^${options.spectacleVersion}` + }, + devDependencies: { + '@types/react': '^18.0.17', + '@types/react-dom': '^18.0.6', + '@vitejs/plugin-react': '^2.0.1', + ...(options.usesTypeScript + ? { + typescript: '^4.6.4' + } + : {}), + vite: '^3.0.7' + } + }, + null, + 2 + ); diff --git a/packages/create-spectacle/src/templates/readme.ts b/packages/create-spectacle/src/templates/readme.ts new file mode 100644 index 000000000..05a51c554 --- /dev/null +++ b/packages/create-spectacle/src/templates/readme.ts @@ -0,0 +1,36 @@ +type ReadmeTemplateOptions = { + name: string; + enableTypeScriptSupport: boolean; + isVite?: boolean; +}; + +export const readmeTemplate = ({ + name, + enableTypeScriptSupport, + isVite +}: ReadmeTemplateOptions) => + ` +# ${name} + +Made with ❤️ and [Spectacle](https://github.com/FormidableLabs/spectacle/). + +## Running your presentation + +- Run \`yarn install\` (or \`npm install\` or \`pnpm install\`) to install dependencies. +- Run \`yarn start\` (or \`npm start\` or \`pnpm start\`) to start the presentation. +- Edit \`index.${ + enableTypeScriptSupport ? 'tsx' : 'jsx' + }\` to add your presentation content. + +## Building you presentation + +To build your presentation for a production deploy, run \`yarn build\` (or \`npm build\` or \`pnpm build\`). + +The build artifacts will be placed in the \`dist\` directory. If you'd like to change this location, edit ${ + isVite + ? `\`build.outDir\` in \`vite.config.${ + enableTypeScriptSupport ? 'ts' : 'js' + }\`` + : `\`output.path\` in \`webpack.config.js\`` + }. +`.trim(); diff --git a/packages/create-spectacle/src/templates/tsconfig.ts b/packages/create-spectacle/src/templates/tsconfig.ts new file mode 100644 index 000000000..ba40dcea1 --- /dev/null +++ b/packages/create-spectacle/src/templates/tsconfig.ts @@ -0,0 +1,21 @@ +export const tsconfigTemplate = () => + `{ + "compilerOptions": { + "target": "ES6", + "lib": [ + "DOM", + "ES2019" + ], + "jsx": "react-jsx", + "module": "commonjs", + "moduleResolution": "node", + "allowJs": true, + "allowUmdGlobalAccess": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} +`; diff --git a/packages/create-spectacle/src/templates/viteConfig.ts b/packages/create-spectacle/src/templates/viteConfig.ts new file mode 100644 index 000000000..e9a46dbd2 --- /dev/null +++ b/packages/create-spectacle/src/templates/viteConfig.ts @@ -0,0 +1,12 @@ +export const viteConfigTemplate = () => + ` +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + } +}); +`.trim(); diff --git a/packages/create-spectacle/src/templates/webpack.ts b/packages/create-spectacle/src/templates/webpack.ts new file mode 100644 index 000000000..70c99c7a6 --- /dev/null +++ b/packages/create-spectacle/src/templates/webpack.ts @@ -0,0 +1,32 @@ +type WebpackTemplateOptions = { + port: number; + usesTypeScript: boolean; +}; + +export const webpackTemplate = (options: WebpackTemplateOptions) => + `const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +module.exports = { + mode: 'development', + context: __dirname, + entry: './index.${options.usesTypeScript ? 'tsx' : 'jsx'}', + output: { + path: path.join(__dirname, '/dist'), + filename: 'app.bundle.js' + }, + devServer: { + port: ${options.port} + }, + module: { + rules: [ + { test: /\\.[tj]sx?$/, use: ['babel-loader'] }, + { test: /\\.(png|svg|jpg|gif)$/, use: ['file-loader'] }, + { test: /\\.css$/, use: ['style-loader', 'css-loader'] } + ] + }, + plugins: [ + new HtmlWebpackPlugin({ template: './index.html' }), + ] +}; +`; diff --git a/packages/create-spectacle/test/e2e.test.ts b/packages/create-spectacle/test/e2e.test.ts new file mode 100644 index 000000000..c3a89fa94 --- /dev/null +++ b/packages/create-spectacle/test/e2e.test.ts @@ -0,0 +1,33 @@ +import type { Browser, Page } from 'puppeteer'; +import puppeteer from 'puppeteer'; +import { getLaunchOptions } from './util'; + +// Make the test timeout longer to accomodate Puppeteer startup +jest.setTimeout(20000); + +describe('App.js', () => { + let browser: Browser; + let page: Page; + + beforeAll(async () => { + const launchOpts = await getLaunchOptions(); + browser = await puppeteer.launch(launchOpts); + page = await browser.newPage(); + }); + + it('contains last slide text', async () => { + await page.goto('http://localhost:3000'); + + // TODO: GET BETTER SELECTORS + const sel = `div[font-family='header'][font-size='h2']`; + await page.waitForSelector(sel); + const text = await page.$eval(sel, (e) => e.textContent); + expect(text).toContain('Made with'); + }); + + afterAll(() => { + if (browser) { + browser.close(); + } + }); +}); diff --git a/packages/create-spectacle/test/util.ts b/packages/create-spectacle/test/util.ts new file mode 100644 index 000000000..7a1843a12 --- /dev/null +++ b/packages/create-spectacle/test/util.ts @@ -0,0 +1,66 @@ +import path from 'path'; +import { promises as fs } from 'fs'; + +const ROOT = path.resolve(__dirname, '../../..'); +const DL_DIR = path.join(ROOT, '.puppeteer'); +const IS_MAC = process.platform.startsWith('darwin'); + +// Infer local chrome/chromium location and set options. +// TODO: Abstract this to a common root file if multiple packages use. +export const getLaunchOptions = async () => { + // CI: Assume GH Actions environment and use local chrome. + if (process.env.CI === 'true') { + return { + executablePath: '/usr/bin/google-chrome-stable', + headless: true, + args: [ + '--ignore-certificate-errors', + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-accelerated-2d-canvas', + '--disable-gpu' + ] + }; + } + + // Localdev. + // Check if `pnpm puppeteer:install` was run. + const chromeDirs = await fs.readdir(DL_DIR).catch((err) => { + if (err.code === 'ENOENT') { + return []; + } + throw err; + }); + if (chromeDirs.length > 1) { + throw new Error( + `Found multiple chrome installs in ${DL_DIR}. Please run \`pnpm puppeteer:install\` to install only 1.` + ); + } + const chromeDir = chromeDirs[0] || null; + let executablePath; + if (IS_MAC) { + if (chromeDir) { + // Downloaded chrome + executablePath = path.join( + DL_DIR, + chromeDir, + 'chrome-mac/Chromium.app/Contents/MacOS/Chromium' + ); + } else { + // System chrome + executablePath = + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + } + } + + // Bail on unsupported platforms (and then implement later). + if (!executablePath) { + throw new Error(`Unsupported platform: ${process.platform}`); + } + + return { + ...(executablePath ? { executablePath } : {}), + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }; +}; diff --git a/packages/create-spectacle/tsconfig.build.json b/packages/create-spectacle/tsconfig.build.json new file mode 100644 index 000000000..00a2adaa1 --- /dev/null +++ b/packages/create-spectacle/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "outDir": "./bin", + "rootDir": "./src" + } +} diff --git a/packages/create-spectacle/tsconfig.json b/packages/create-spectacle/tsconfig.json new file mode 100644 index 000000000..cef13f04b --- /dev/null +++ b/packages/create-spectacle/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["src/**/*"], + "compilerOptions": { + "moduleResolution": "node", + "module": "commonjs", + // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping#node-14 + "target": "ES2020", + "esModuleInterop": true, + "resolveJsonModule": true + } +} diff --git a/packages/create-spectacle/tsconfig.typecheck.json b/packages/create-spectacle/tsconfig.typecheck.json new file mode 100644 index 000000000..30fa406dc --- /dev/null +++ b/packages/create-spectacle/tsconfig.typecheck.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/spectacle/.gitignore b/packages/spectacle/.gitignore new file mode 100644 index 000000000..b43bf86b5 --- /dev/null +++ b/packages/spectacle/.gitignore @@ -0,0 +1 @@ +README.md diff --git a/packages/spectacle/CHANGELOG.md b/packages/spectacle/CHANGELOG.md new file mode 100644 index 000000000..6b82d0c85 --- /dev/null +++ b/packages/spectacle/CHANGELOG.md @@ -0,0 +1,307 @@ +# Changelog + +## 9.6.0 + +### Minor Changes + +- exports the `useSteps` hook ([#1225](https://github.com/FormidableLabs/spectacle/pull/1225)) + +## 9.5.1 + +### Patch Changes + +- Fixed Notes node tree generation inside Markdown component. ([#1219](https://github.com/FormidableLabs/spectacle/pull/1219)) + +## 9.5.0 + +### Minor Changes + +- feat: Add new Slide Layouts: Section, Statement, Big fact, Quote to expand basic layout creation. ([#1209](https://github.com/FormidableLabs/spectacle/pull/1209)) + +* feat: Add single and multiple code pane Slide Layouts with options. ([#1217](https://github.com/FormidableLabs/spectacle/pull/1217)) + +## 9.4.0 + +### Minor Changes + +- Utilize `Kbar` to allow users to quickly search and use the current commands Spectacle supports within presentations. Fixes #1115 ([#1161](https://github.com/FormidableLabs/spectacle/pull/1161)) + +### Patch Changes + +- (fix [#1171](https://github.com/FormidableLabs/spectacle/issues/1171)) Fix for URL state being overwritten (see [#1171](https://github.com/FormidableLabs/spectacle/issues/1171)) ([#1188](https://github.com/FormidableLabs/spectacle/pull/1188)) + +## 9.3.0 + +### Minor Changes + +- Fixed lineNumberStyle prop for CodePane. (fixes [#1150](https://github.com/FormidableLabs/spectacle/issues/1150)) ([#1154](https://github.com/FormidableLabs/spectacle/pull/1154)) + +## 9.2.1 + +- Fix `use-broadcast-channel` to be compatible with React 18 strict mode, via [#1131](https://github.com/FormidableLabs/spectacle/pull/1131). +- Upgrade `react-syntax-highlighter` so that Spectacle works with Vite out of the box, via [#1132](https://github.com/FormidableLabs/spectacle/pull/1132). + +## 9.2.0 + +- Fix deck level templates not displayed in presenter, print and export modes +- Persist deck template between slides in default and presenter modes [#1106](https://github.com/FormidableLabs/spectacle/issues/1106) +- Upgrade dependencies to be React 18-friendly, removing Enzyme in favor of RTL via [#1119](https://github.com/FormidableLabs/spectacle/pull/1119) +- Fix only the first page showing in print and export modes in Firefox [#1077](https://github.com/FormidableLabs/spectacle/issues/1077) +- Add AnimatedProgress component [#1105](https://github.com/FormidableLabs/spectacle/issues/1105) +- Add SlideLayout helper for quick-creation of slides with basic layout needs via [#1123](https://github.com/FormidableLabs/spectacle/pull/1123). + +## 9.1.1 + +- Newlines in markdown slides correctly render line breaks [#1114](https://github.com/FormidableLabs/spectacle/pull/1114) + +## 9.1.0 + +- Added support for deck-wide background images [#1112](https://github.com/FormidableLabs/spectacle/pull/1112) + +## 9.0.2 + +- Fixed disappearing slides with background images in export and overview mode [#1100](https://github.com/FormidableLabs/spectacle/issues/1100) + +## 9.0.1 + +- Fix export mode. [#1097](https://github.com/FormidableLabs/spectacle/issues/1097) + +## 9.0.0 + +- Migrated Spectacle core to TypeScript + +## 9.0.0-beta.7 + +- Ensured types for styled-system based components are included with Spectacle core + +## 9.0.0-beta.6 + +- Fixed path for emitting type declarations + +## 9.0.0-beta.5 + +- Removed dependency on Node assert + +## 9.0.0-beta.3 + +- Added TypeScript development example + +## 9.0.0-beta.2 + +- webpack 5 upgrade (@carlos-kelly) + +## 9.0.0-beta.1 + +- Finished initial pass of TypeScript conversion (@scottrippey) + +## 8.5.0 + +- Add styled-system position and layout functions to CodePane, Progress, Markdown components [#1079](https://github.com/FormidableLabs/spectacle/pull/1079) + +## 8.4.1 + +- Fix highlight ranges bug [#1070](https://github.com/FormidableLabs/spectacle/pull/1070) +- Update code samples with correct indentation +- Added Google Tag Manager to documentation + +## 8.4.0 + +- Fix script reference in `examples/one-page.html`. +- Docs: Update Formidable logo. +- Add `Stepper` component and update docs for `useSteps` and `Appear`. + +## 8.3.0 + +- Added ref-forwarding for `CodePane`, `FullScreen`, `Markdown`, and `Progress`. +- Fixed `showLineNumbers` and theme sizing for `` + +## 8.2.0 + +- Added support for custom slide and deck transitions +- Added Fade and Slide transition objects as built-in transitions +- Updated JS example with spinning custom slide transition + +## 8.1.0 + +- Allow raw HTML in Markdown content. +- Added missing export and type for `useMousetrap` +- Removed unused `useAnimatedSteps` hook. + +## 8.0.1 + +- Fixed navigation by swiping on touch-devices + +## 8.0.0 + +- Removed auto-importing every Prism theme for CodePane to reduce overall bundle size +- The CodePane theme prop now accepts a pre-defined or custom Prism object + +## 7.1.5 + +- Added missing type for `indentNormalizer` function. + +## 7.1.4 + +- Fixed reference for `FlexBox` type in ambient type declarations. + +## 7.1.3 + +- Add support for `listStyleType` in `UnorderedList` and `OrderedList` components. + +## 7.1.2 + +- Fixed issue with `animateListItems` in Markdown components that was causing lists to not show up at all. + +## 7.1.1 + +- Exports `SlideContext`, `DeckContext`, and `defaultTheme` +- Fixes required child prop for `Notes` + +## 7.1.0 + +- Added animateListItems prop for Markdown, MarkdownSlide, and MarkdownSlideSet components, enabling animating list items via Markdown components. +- Added componentProps prop for Markdown, MarkdownSlide, and MarkdownSlideSet components, enabling passing a set of props down to each component rendered within a Markdown component. + +## 7.0.4 + +- Fixed page-size for export to PDF mode + +## 7.0.3 + +- Fixed margin sizing for `Link` when using Markdown. + +## 7.0.2 + +- Fixed usage of Node API, `setImmediate`, and replaced with a browser API. + +## 7.0.1 + +- Update one-page to use v7 of Spectacle + +## 7.0.0 + +- Updated TypeScript type definitions +- Fixed duplication final slide in Presenter Mode + +## 7.0.0-beta.5 (1/21/2021) + +- Fixed Notes for Markdown components + +## 7.0.0-beta.4 (1/13/2021) + +- Props for Page Layout, Orientation, and PPI for Print and Export mode + +## 7.0.0-beta.3 (12/28/2020) + +- Support for Print and Export Modes +- Support for keyboard shortcuts to change between modes + +## 7.0.0-beta.2 (12/9/2020) + +- Support for multiple lines or single lines for highlight ranges in +- Overview Mode now allows for selecting or tabbing through slides + +## 7.0.0-beta.1 (11/24/2020) + +- Core refactor of navigation and slide management to fix bugs around nested slide sets +- Rewrite of to fix line-by-line highlighting +- Supports nested slides inside JSX fragments and HTML tags like div +- New and for MD-based content +- Slides in Markdown are now tightly integrated within the deck and can be mixed within regular slides +- Presenter Mode defaults back to Spectacle 5.0 experience of dual-browser mode +- Notes can now contain HTML markup + +## 6.2.0 (06-17-2020) + +- Fix presenter mode so the next element is displayed, not just the next Slide. + [#924](https://github.com/FormidableLabs/spectacle/pull/924) +- Allow for user-supplied indentation size in the CodePane. + [#908](https://github.com/FormidableLabs/spectacle/pull/908) + +## 6.1.0 (05-28-2020) + +- Remove references to PropTypes from type definitions. + [#907](https://github.com/FormidableLabs/spectacle/pull/907) +- Enhance hot key support for Linux users. + [#905](https://github.com/FormidableLabs/spectacle/pull/905) + +## 6.0.3 (05-11-2020) + +- Fix keyboard toggles for both MacOS and Windows. + [#893](https://github.com/FormidableLabs/spectacle/pull/893) +- Documentation updates. + [#894](https://github.com/FormidableLabs/spectacle/pull/894) +- Add support for mobile navigation of slides. + [#876](https://github.com/FormidableLabs/spectacle/pull/876) + +## 6.0.2 (04-10-2020) + +- Allow for `props.fontSize` to override the theme's monospace font size in `CodePane`. + [#875](https://github.com/FormidableLabs/spectacle/pull/875) +- Surface `textDecoration` prop from styled-system for `Link`s. + [#869](https://github.com/FormidableLabs/spectacle/pull/869) + +## 6.0.1 (03-18-2020) + +- Fix broken doc links. + [#859](https://github.com/FormidableLabs/spectacle/pull/859), [#862](https://github.com/FormidableLabs/spectacle/pull/862) +- Add a live preview to the docs lander. + [#860](https://github.com/FormidableLabs/spectacle/pull/860) +- Fix CodePane so user-supplied themes are surfaced. + [#866](https://github.com/FormidableLabs/spectacle/pull/866) +- Fix nested Appears. + [#864](https://github.com/FormidableLabs/spectacle/pull/864) + +## 6.0.0 (03-06-2020) + +- Expand custom background support by adding `Background` props to the `Slide` component, along with `backgroundOpacity`. + [#849](https://github.com/FormidableLabs/spectacle/pull/849) +- Add `Stepper`, allowing for code range highlighting/scrolling within CodePane. + [#843](https://github.com/FormidableLabs/spectacle/pull/843) + +## 6.0.0-alpha.8 (02-20-2020) + +- Update `examples/one-page.html` to `examples/js/index.js` with new script helper. +- Add support for Deck or Slide-level transitions. +- Add default transitions for Fade, Slide, and None. +- Fixes Full Screen component for Chrome/FF, adds support for Safari. +- Added support for dual-browser tab mode for presenter mode in all browsers. + +## 6.0.0-alpha.7 (01-27-2020) + +- Fix `one-page.html` closing tags. + +## 6.0.0-alpha.6 (01-27-2020) + +- Fix `one-page.html` unpkg script links. + +## 6.0.0-alpha.5 (01-27-2020) + +- Add color props support to Flex Box. + [#816](https://github.com/FormidableLabs/spectacle/issues/816) + +## 6.0.0-alpha.4 (01-27-2020) + +- Move around internal examples, and publish some for `spectacle-cli` usage. +- Use top-center layout defaults for Spectacle. Drop `autoLayout` prop. +- Add `border` styled-system props to `FlexBox` and `Box`. + +## 6.0.0-alpha.3 (11-04-2019) + +- Fixes overflow issue for presenter mode in Chrome. + +## 6.0.0-alpha.1 (11-02-2019) + +- First release of the Spectacle rewrite MVP. +- Support for: + - Overview mode. [#731](https://github.com/FormidableLabs/spectacle/pull/731) + - Presenter mode. [#724](https://github.com/FormidableLabs/spectacle/pull/724) + - Presenter notes. [#762](https://github.com/FormidableLabs/spectacle/pull/762) + - Base themeing. [#717](https://github.com/FormidableLabs/spectacle/pull/717) + - In-browser resizing. [#721](https://github.com/FormidableLabs/spectacle/pull/721) + - CodePane. [#712](https://github.com/FormidableLabs/spectacle/pull/712) + - URL-based navigation. [#711](https://github.com/FormidableLabs/spectacle/pull/711) + - MDX slides. [#689](https://github.com/FormidableLabs/spectacle/pull/689) + - Keyboard controls. [#760](https://github.com/FormidableLabs/spectacle/pull/760) + - Print & Export mode. [#758](https://github.com/FormidableLabs/spectacle/pull/758) + - A Progress indicator. [#726](https://github.com/FormidableLabs/spectacle/pull/726) diff --git a/packages/spectacle/README.md b/packages/spectacle/README.md new file mode 100644 index 000000000..a63fcaee5 --- /dev/null +++ b/packages/spectacle/README.md @@ -0,0 +1,23 @@ +

+

Spectacle

+

+✨ A ReactJS based Presentation Library ✨ +

+ + + + + Maintenance Status + +

+ +Looking for a quick preview of what you can do with Spectacle? Check out our Live Demo deck +[here](https://raw.githack.com/FormidableLabs/spectacle/main/examples/one-page/index.html). + +## Documentation + +Spectacle's documentation lives on our [docs site](https://www.formidable.com/open-source/spectacle). + +### Maintenance Status + +**Active:** Formidable is actively working on this project, and we expect to continue for work for the foreseeable future. Bug reports, feature requests and pull requests are welcome. diff --git a/packages/spectacle/jest.config.js b/packages/spectacle/jest.config.js new file mode 100644 index 000000000..f4ba08032 --- /dev/null +++ b/packages/spectacle/jest.config.js @@ -0,0 +1,191 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // Respect "browser" field in package.json when resolving modules + // browser: false, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/private/var/folders/0x/271k4p353hld3l1wn2l2pzv40000gn/T/jest_dx", + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: null, + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: null, + + // A path to a custom dependency extractor + // dependencyExtractor: null, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: null, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: null, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + moduleFileExtensions: [ + 'tsx', + 'ts', + 'jsx', + 'js', + 'json' + // "node" + ], + + // A map from regular expressions to module names that allow to stub out resources with a single module + moduleNameMapper: { + // Jest doesn't like the ESM exports from prism. + // They aren't needed for current test suite, + // so just map to an empty module. + 'react-syntax-highlighter/dist/esm/styles/prism': + '/src/test-utils/empty-module.ts' + }, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + preset: 'ts-jest/presets/js-with-ts', + + // Run tests from one or more projects + // projects: null, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: null, + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: null, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + setupFiles: ['/src/test-utils/test-setup.ts'], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + setupFilesAfterEnv: ['/src/jest-setup.ts'], + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + + // The test environment that will be used for testing + testEnvironment: 'jest-environment-jsdom', + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + testPathIgnorePatterns: ['/node_modules/', '/es/', '/lib/', '/dist/'], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: null, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: { + // '^.+test\\.*[jt]sx?$': BABEL_TRANSFORM, + // }, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + transformIgnorePatterns: ['/node_modules/', '/es/', '/lib/'] + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: null, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/packages/spectacle/package.json b/packages/spectacle/package.json new file mode 100644 index 000000000..6974d5932 --- /dev/null +++ b/packages/spectacle/package.json @@ -0,0 +1,260 @@ +{ + "name": "spectacle", + "version": "9.6.0", + "description": "ReactJS Powered Presentation Framework", + "types": "lib/index.d.ts", + "main": "lib/index.js", + "files": [ + "lib/", + "es/", + "dist/" + ], + "module": "es/index.js", + "author": "Formidable Labs ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/FormidableLabs/spectacle.git" + }, + "browserslist": "> 0.25%, not dead", + "dependencies": { + "broadcast-channel": "^3.2.0", + "history": "^4.9.0", + "kbar": "0.1.0-beta.36", + "mdast-builder": "^1.1.1", + "mdast-zone": "^4.0.0", + "merge-anything": "^3.0.3", + "mousetrap": "^1.6.5", + "query-string": "^6.8.2", + "react-fast-compare": "^3.2.0", + "react-is": "^18.1.0", + "react-spring": "^8.0.25", + "react-swipeable": "^6.1.0", + "react-syntax-highlighter": "^15.5.0", + "rehype-raw": "^5.1.0", + "rehype-react": "^6.0.0", + "remark-parse": "^8.0.3", + "remark-rehype": "^7.0.0", + "styled-components": "^4.3.2", + "styled-system": "5.1.5", + "ulid": "^2.3.0", + "unified": "^9.0.0", + "unist-util-visit": "^2.0.3", + "use-resize-observer": "^6.1.0" + }, + "peerDependencies": { + "react": ">=17.0.2", + "react-dom": ">=17.0.2" + }, + "devDependencies": { + "@types/history": "^4.7.9", + "@types/mousetrap": "^1.6.8", + "@types/react": "^18.0.12", + "@types/react-dom": "^18.0.5", + "@types/react-is": "^17.0.3", + "@types/react-syntax-highlighter": "^15.5.2", + "@types/styled-components": "^5.1.15", + "@types/styled-system": "^5.1.5", + "@types/unist": "^2.0.6", + "csstype": "^3.1.0", + "process": "^0.11.10", + "react": "^18.1.0", + "react-dom": "^18.1.0" + }, + "resolutions": { + "@types/react": "^18.0.12" + }, + "scripts": { + "build": "wireit", + "build:lib": "wireit", + "build:lib:esm": "wireit", + "build:lib:cjs": "wireit", + "build:dist": "wireit", + "build:dist:dev": "wireit", + "build:dist:min": "wireit", + "types:check": "wireit", + "types:create": "wireit", + "lint": "wireit", + "lint:fix": "wireit", + "prettier": "wireit", + "prettier:fix": "wireit", + "test": "wireit" + }, + "wireit": { + "build": { + "dependencies": [ + "build:lib", + "build:dist", + "types:create" + ] + }, + "build:lib": { + "dependencies": [ + "build:lib:esm", + "build:lib:cjs" + ] + }, + "build:lib:esm": { + "command": "nps babel:pkg:lib:esm", + "files": [ + "src/**", + "!src/**/*.test.*", + "../../.babelrc.js", + "../../.babelrc.build.js" + ], + "output": [ + "es/**/*.js" + ], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "build:lib:cjs": { + "command": "nps babel:pkg:lib:cjs", + "files": [ + "src/**", + "!src/**/*.test.*", + "../../.babelrc.js", + "../../.babelrc.build.js" + ], + "output": [ + "lib/**/*.js" + ], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "build:dist": { + "dependencies": [ + "build:dist:dev", + "build:dist:min" + ] + }, + "build:dist:dev": { + "command": "nps \"webpack --config webpack.config.dev.js\"", + "files": [ + "src/**", + "!src/**/*.test.*", + "../../.babelrc.js", + "../../.babelrc.build.js", + "webpack.config.js", + "webpack.config.dev.js" + ], + "output": [ + "dist/spectacle.js*", + "!dist/spectacle.min.js*" + ], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "build:dist:min": { + "command": "nps webpack", + "files": [ + "src/**", + "!src/**/*.test.*", + "../../.babelrc.js", + "../../.babelrc.build.js", + "webpack.config.dev.js" + ], + "output": [ + "dist/spectacle.min.js*" + ], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "types:check": { + "command": "nps types:check", + "files": [ + "src/**/*.{ts,tsx}", + "../../tsconfig.json", + "tsconfig.json" + ], + "dependencies": [], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "types:create": { + "command": "nps types:create", + "files": [ + "src/**", + "!src/**/*.test.*", + "../../tsconfig.json" + ], + "output": [ + "lib/**/*.d.ts", + "lib/**/*.d.ts.map" + ], + "dependencies": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint": { + "command": "nps lint:pkg -- -- \"*.js\" src", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "*.js", + "src/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "lint:fix": { + "command": "pnpm run lint || nps lint:pkg:fix -- -- \"*.js\" src", + "files": [ + "../../.eslintignore", + "../../.eslintrc", + "*.js", + "src/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier": { + "command": "nps prettier:pkg -- -- \"*.js\" src", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.js", + "src/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "prettier:fix": { + "command": "pnpm run prettier || nps prettier:pkg:fix -- -- \"*.js\" src", + "files": [ + "../../.prettierignore", + "../../.prettierrc", + "*.js", + "src/**" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + }, + "test": { + "command": "nps jest", + "files": [ + "src/**", + "../../.babelrc.js" + ], + "output": [], + "packageLocks": [ + "pnpm-lock.yaml" + ] + } + } +} diff --git a/packages/spectacle/src/components/animated-progress.test.tsx b/packages/spectacle/src/components/animated-progress.test.tsx new file mode 100644 index 000000000..96dfd85cd --- /dev/null +++ b/packages/spectacle/src/components/animated-progress.test.tsx @@ -0,0 +1,38 @@ +import { PropsWithChildren, ReactElement } from 'react'; +import { ThemeProvider } from 'styled-components'; + +import { DeckContext, DeckContextType } from './deck/deck'; +import defaultTheme from '../theme/default-theme'; +import AnimatedProgress from './animated-progress'; +import { DeepPartial } from '../types/deep-partial'; +import { render } from '@testing-library/react'; + +const mountWithContext = ( + tree: ReactElement, + context: DeepPartial +) => { + const WrappingThemeProvider = (props: PropsWithChildren<{}>) => ( + + {props.children} + + ); + return render(tree, { wrapper: WrappingThemeProvider }); +}; + +describe('', () => { + it('should render the right amount of circles', () => { + const { queryAllByTestId } = mountWithContext(, { + slideCount: 5, + activeView: { + slideIndex: 0 + } + }); + + expect(queryAllByTestId('animated-progress-circle')).toHaveLength(5); + }); +}); diff --git a/packages/spectacle/src/components/animated-progress.tsx b/packages/spectacle/src/components/animated-progress.tsx new file mode 100644 index 000000000..3a25434fb --- /dev/null +++ b/packages/spectacle/src/components/animated-progress.tsx @@ -0,0 +1,191 @@ +import { + forwardRef, + useContext, + useEffect, + useState, + useCallback +} from 'react'; +import styled, { keyframes } from 'styled-components'; +import { DeckContext } from './deck/deck'; +import { + ProgressContainer, + Circle, + PROGRESS_CIRCLE_BORDER_WIDTH, + DEFAULT_PROGRESS_CIRCLE_WIDTH_INCLUDING_MARGIN +} from './progress'; + +interface PacmanBaseProps { + pacmanSize: number; + top: number; + left: number; +} +export const PacmanBase = styled.div` + position: absolute; + top: ${({ top }) => top}px; + left: ${({ left }) => left}px; + height: ${({ pacmanSize }) => pacmanSize}px; + width: ${({ pacmanSize }) => pacmanSize}px; + transition: left 0.3s ease-in-out 0.2s; + transform: translate(-50%, -50%); +`; + +const pacmanTopFrames = keyframes` + 0% { transform: rotateZ(0deg) } + 100% { transform: rotateZ(-30deg) } +`; +const pacmanBottomFrames = keyframes` + 0% { transform: rotateZ(0deg) } + 100% { transform: rotateZ(30deg) } +`; +// NOTE: rotateZ is 0.1 to generate two different animation names (styled components deduplication) +const pacmanTopFramesAlternate = keyframes` + 0% { transform: rotateZ(0.1deg) } + 100% { transform: rotateZ(-30deg) } +`; +// NOTE: rotateZ is 0.1 to generate two different animation names (styled components deduplication) +const pacmanBottomFramesAlternate = keyframes` + 0% { transform: rotateZ(0.1deg) } + 100% { transform: rotateZ(30deg) } +`; +interface PacmanBodyProps { + color: string; + pacmanSize: number; + alternate: boolean; +} +const PacmanBodyTop = styled.div` + position: absolute; + top: 0; + height: ${({ pacmanSize }) => pacmanSize / 2}px; + width: ${({ pacmanSize }) => pacmanSize}px; + background: ${({ color }) => color}; + border-top-left-radius: ${({ pacmanSize }) => pacmanSize / 2}px; + border-top-right-radius: ${({ pacmanSize }) => pacmanSize / 2}px; + // NOTE: So the top and bottom always overlap when sizes are in decimals. + box-shadow: 0 0 0 0.5px ${({ color }) => color}; + animation-name: ${({ alternate }) => + alternate ? pacmanTopFrames : pacmanTopFramesAlternate}; + animation-duration: 0.12s; + animation-timing-function: linear; + animation-iteration-count: 10; + animation-direction: alternate; + animation-fill-mode: both; +`; +const PacmanBodyBottom = styled(PacmanBodyTop)` + top: 50%; + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: ${({ pacmanSize }) => pacmanSize / 2}px; + border-bottom-right-radius: ${({ pacmanSize }) => pacmanSize / 2}px; + animation-name: ${({ alternate }) => + alternate ? pacmanBottomFrames : pacmanBottomFramesAlternate}; +`; + +const DEFAULT_ANIMATED_PROGRESS_CIRCLE_SIZE = 7.5; + +export type AnimatedProgressProps = { + color?: string; + size?: number; + pacmanColor?: string; +}; + +const AnimatedProgress = forwardRef( + ( + { + color: circleColor = '#fff', + size: circleSize = DEFAULT_ANIMATED_PROGRESS_CIRCLE_SIZE, + ...props + }, + ref + ) => { + const { + slideCount, + skipTo, + activeView: { slideIndex } + } = useContext(DeckContext); + + const [pacmanOffsetLeft, setPacmanOffsetLeft] = useState( + null + ); + const [pacmanOffsetTop, setPacmanOffsetTop] = useState(null); + const [alternateAnimation, setAlternateAnimation] = useState(false); + + const [activeCircleNode, setActiveCircleNode] = + useState(null); + const activeCircleCallbackRef = useCallback( + (activeCircleNode: HTMLDivElement) => { + setActiveCircleNode(activeCircleNode); + }, + [] + ); + + useEffect(() => { + if (activeCircleNode?.offsetParent) { + const { offsetLeft, offsetTop } = activeCircleNode; + const halfOfCircleOccupiedSpace = + circleSize / 2 + PROGRESS_CIRCLE_BORDER_WIDTH; + setPacmanOffsetLeft(offsetLeft + halfOfCircleOccupiedSpace); + setPacmanOffsetTop(offsetTop + halfOfCircleOccupiedSpace); + setAlternateAnimation((alternateAnimation) => !alternateAnimation); + } else { + setPacmanOffsetLeft(null); + setPacmanOffsetTop(null); + } + }, [circleSize, activeCircleNode]); + + const circleMargin = + (DEFAULT_PROGRESS_CIRCLE_WIDTH_INCLUDING_MARGIN - + DEFAULT_ANIMATED_PROGRESS_CIRCLE_SIZE - + PROGRESS_CIRCLE_BORDER_WIDTH * 2) / + 2; + const pacmanColor = props.pacmanColor || circleColor; + const pacmanSize = + circleSize + PROGRESS_CIRCLE_BORDER_WIDTH + circleMargin * 2; + + return ( + + {typeof pacmanOffsetTop === 'number' && + typeof pacmanOffsetLeft === 'number' && ( + + + + + )} + {Array(slideCount) + .fill(0) + .map((_, idx) => ( + + skipTo({ + slideIndex: idx, + stepIndex: 0 + }) + } + data-testid="animated-progress-circle" + /> + ))} + + ); + } +); + +AnimatedProgress.displayName = 'AnimatedProgress'; + +export default AnimatedProgress; diff --git a/packages/spectacle/src/components/appear.tsx b/packages/spectacle/src/components/appear.tsx new file mode 100644 index 000000000..6c4bc4d6b --- /dev/null +++ b/packages/spectacle/src/components/appear.tsx @@ -0,0 +1,122 @@ +import { ReactNode, useContext } from 'react'; +import { animated, useSpring } from 'react-spring'; +import { useSteps } from '../hooks/use-steps'; +import { SlideContext } from './slide/slide'; + +const SteppedComponent = (props: SteppedComponentProps): JSX.Element => { + const { + id, + className, + children: childrenOrRenderFunction, + tagName = 'div', + priority, + stepIndex, + numSteps = 1, + alwaysAppearActive = false, + activeStyle = { opacity: '1' }, + inactiveStyle = { opacity: '0' } + } = props; + const { immediate } = useContext(SlideContext); + + const { isActive, step, placeholder } = useSteps(numSteps, { + id, + priority, + stepIndex + }); + + const AnimatedEl = animated[tagName]; + + let children: ReactNode; + if (typeof childrenOrRenderFunction === 'function') { + children = childrenOrRenderFunction(step, isActive); + } else { + children = childrenOrRenderFunction; + } + + const springStyle = useSpring({ + to: isActive ? activeStyle : inactiveStyle, + immediate + }); + + return ( + <> + {placeholder} + + {children} + + + ); +}; + +type SteppedComponentProps = { + id?: string | number; + priority?: number; + /** @deprecated use priority prop instead */ + stepIndex?: number; + children: ReactNode | ((step: number, isActive: boolean) => ReactNode); + className?: string; + tagName?: keyof JSX.IntrinsicElements; + activeStyle?: Partial; + inactiveStyle?: Partial; + numSteps?: number; + alwaysAppearActive?: boolean; +}; + +type AppearProps = Omit< + SteppedComponentProps, + 'numSteps' | 'alwaysAppearActive' +>; +export const Appear = (props: AppearProps): JSX.Element => { + const { children, ...restProps } = props; + return ( + + {children} + + ); +}; + +export const Stepper = (props: StepperProps): JSX.Element => { + const { + values, + render: renderFn, + children: renderChildrenFn, + alwaysVisible = false, + ...restProps + } = props; + if (renderFn !== undefined && renderChildrenFn !== undefined) { + throw new Error( + ' component specified both `render` prop and a render function as its `children`.' + ); + } + + return ( + + {(step, isActive) => + (renderFn || renderChildrenFn!)(values[step], step, isActive) + } + + ); +}; + +type StepperProps = { + id?: string | number; + priority?: number; + /** @deprecated use priority prop instead */ + stepIndex?: number; + render?: (value: T[number], step: number, isActive: boolean) => ReactNode; + children?: (value: T[number], step: number, isActive: boolean) => ReactNode; + className?: string; + tagName?: keyof JSX.IntrinsicElements; + values: T; + alwaysVisible?: boolean; + activeStyle?: Partial; + inactiveStyle?: Partial; +}; diff --git a/packages/spectacle/src/components/code-pane.tsx b/packages/spectacle/src/components/code-pane.tsx new file mode 100644 index 000000000..000f546a4 --- /dev/null +++ b/packages/spectacle/src/components/code-pane.tsx @@ -0,0 +1,194 @@ +import { + forwardRef, + useMemo, + useContext, + useRef, + useCallback, + useEffect, + CSSProperties +} from 'react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { useSteps } from '../hooks/use-steps'; +import indentNormalizer from '../utils/indent-normalizer'; +import styled, { ThemeContext } from 'styled-components'; +import { compose, layout, position } from 'styled-system'; +import dark from 'react-syntax-highlighter/dist/cjs/styles/prism/vs-dark'; + +type Ranges = Array; + +const checkForNumberValues = (ranges: Ranges) => { + return ranges.every((element) => typeof element === 'number'); +}; + +const checkForInvalidValues = (ranges: Ranges) => { + return ranges.every((element) => element === null || element === undefined); +}; + +const getRangeFormat = ( + numberOfSteps: number, + highlightRanges: Ranges, + step: number +): Ranges => { + // If the value passed to highlightRanges is: + // a single array containing only two numbers e.g. [3, 5] + if (numberOfSteps === 1) { + return highlightRanges; + } + + // a 2D array containing null/undefined values e.g. [1, null, 5, [7, 9]] + if (highlightRanges[step] === null || highlightRanges[step] === undefined) { + return []; + } + + // a 2D array and some of its elements contain numbers e.g. [[1, 3], 5, 7, 9, [10, 15]] + if (typeof highlightRanges[step] === 'number') { + return [highlightRanges[step]]; + } + + // a 2D array e.g. [[1], [3], [5, 9], [15], [20, 25], [30]] + return highlightRanges[step] as Ranges; +}; + +const getStyleForLineNumber = (lineNumber: number, activeRange: Ranges) => { + const isOneLineNumber = activeRange.length === 1; + if (isOneLineNumber) { + const [activeLineNumber] = activeRange; + if (activeLineNumber === lineNumber) { + return { opacity: 1 }; + } else { + return { opacity: 0.5 }; + } + } + + const [from, to] = activeRange; + return { opacity: from <= lineNumber && lineNumber <= to ? 1 : 0.5 }; +}; + +const Container = styled('div')(compose(position, layout)); + +const CodePane = forwardRef( + ( + { + highlightRanges = [], + language, + showLineNumbers = true, + children: rawCodeString, + stepIndex, + theme: syntaxTheme = dark, + ...props + }, + ref + ) => { + const numberOfSteps = useMemo(() => { + if ( + highlightRanges.length === 0 || + // Prevents e.g. [null, null] to be used to count the number of steps + checkForInvalidValues(highlightRanges) + ) { + return 0; + } + + // Checks if the value passed to highlightRanges is a single array containing only two numbers e.g. [3, 5] + const isSingleRange = + highlightRanges.length <= 2 && + // Prevents e.g. [3, [5]] from being considered a single array range + checkForNumberValues(highlightRanges); + + if (isSingleRange) { + return 1; + } + + return highlightRanges.length; + }, [highlightRanges]); + + const theme = useContext(ThemeContext); + const { isActive, step, placeholder } = useSteps(numberOfSteps, { + stepIndex + }); + + const children = useMemo(() => { + return indentNormalizer(rawCodeString); + }, [rawCodeString]); + + const scrollTarget = useRef(null); + + const getLineNumberStyle = useCallback( + (lineNumber: number) => { + if (!isActive) return {}; + const range = getRangeFormat(numberOfSteps, highlightRanges, step); + return getStyleForLineNumber(lineNumber, range); + }, + [isActive, highlightRanges, numberOfSteps, step] + ); + + const getLineProps = useCallback( + (lineNumber: number) => { + if (!isActive) return {}; + const range = getRangeFormat(numberOfSteps, highlightRanges, step); + return { + ref: lineNumber === (range as number[])[0] ? scrollTarget : null, + style: getStyleForLineNumber(lineNumber, range) + }; + }, + [isActive, highlightRanges, numberOfSteps, step] + ); + + useEffect(() => { + window.requestAnimationFrame(() => { + if (!scrollTarget.current) return; + scrollTarget.current.scrollIntoView({ + block: 'center', + behavior: 'smooth' + }); + }); + }, [isActive, step]); + + const customStyle = useMemo(() => { + /** + * Provide fallback values if the user intentionally overrides the + * default theme with no valid values. + */ + const { + space = [0, 0, 0], + fontSizes: { monospace = '20px' } + } = theme; + + return { + padding: space[0], + margin: 0, + fontSize: monospace + }; + }, [theme]); + + return ( + <> + {placeholder} + + + {children} + + + + ); + } +); +CodePane.displayName = 'CodePane'; + +export type CodePaneProps = { + children: string; + language: string | undefined; + theme?: Record; + stepIndex?: number; + highlightRanges?: Ranges; + showLineNumbers?: boolean; +}; + +export default CodePane; diff --git a/packages/spectacle/src/components/command-bar/command-bar-actions.tsx b/packages/spectacle/src/components/command-bar/command-bar-actions.tsx new file mode 100644 index 000000000..de0ccdc25 --- /dev/null +++ b/packages/spectacle/src/components/command-bar/command-bar-actions.tsx @@ -0,0 +1,72 @@ +import { + KEYBOARD_SHORTCUTS_IDS, + SpectacleMode, + SPECTACLE_MODES +} from '../../utils/constants'; +import useModes from '../../hooks/use-modes'; + +/** + * Kbar default actions, those that do not depend on dynamic logic, can be added here. + * To register actions dynamically use 'useRegisterActions' and make sure the action + * is registed within the KBarProvider. + * @see https://kbar.vercel.app/docs/concepts/actions + * Kbar action shortcuts dont seem to support all keybindings. If you need to utilize + * keybindings that are not supported you'll have to implement the keybinding seperately. + * @see useMousetrap + * To display keybindings that are not supported in the Kbar results, please use + * KEYBOARD_SHORTCUTS instead of Kbar actions 'shortcut' property. + * @see CommandBarResults getShortcutKeys + */ + +const spectacleModeDisplay = { + [SPECTACLE_MODES.DEFAULT_MODE]: 'Default Mode', + [SPECTACLE_MODES.PRESENTER_MODE]: 'Presenter Mode', + [SPECTACLE_MODES.OVERVIEW_MODE]: 'Overview Mode', + [SPECTACLE_MODES.PRINT_MODE]: 'Print Mode', + [SPECTACLE_MODES.EXPORT_MODE]: 'Export Mode' +}; + +const getName = (currentMode: string, mode: SpectacleMode) => { + const defaultMode = SPECTACLE_MODES.DEFAULT_MODE; + + return currentMode === mode + ? `← Back to ${spectacleModeDisplay[defaultMode]}` + : spectacleModeDisplay[mode]; +}; + +const useCommandBarActions = () => { + const { toggleMode, getCurrentMode } = useModes(); + const currentMode = getCurrentMode(); + return [ + { + id: KEYBOARD_SHORTCUTS_IDS.PRESENTER_MODE, + name: getName(currentMode, SPECTACLE_MODES.PRESENTER_MODE), + keywords: 'presenter', + perform: () => toggleMode({ newMode: SPECTACLE_MODES.PRESENTER_MODE }), + section: 'Mode' + }, + { + id: KEYBOARD_SHORTCUTS_IDS.OVERVIEW_MODE, + name: getName(currentMode, SPECTACLE_MODES.OVERVIEW_MODE), + keywords: 'overview', + perform: () => toggleMode({ newMode: SPECTACLE_MODES.OVERVIEW_MODE }), + section: 'Mode' + }, + { + id: KEYBOARD_SHORTCUTS_IDS.PRINT_MODE, + name: getName(currentMode, SPECTACLE_MODES.PRINT_MODE), + keywords: 'export', + perform: () => toggleMode({ newMode: SPECTACLE_MODES.PRINT_MODE }), + section: 'Mode' + }, + { + id: KEYBOARD_SHORTCUTS_IDS.EXPORT_MODE, + name: getName(currentMode, SPECTACLE_MODES.EXPORT_MODE), + keywords: 'export', + perform: () => toggleMode({ newMode: SPECTACLE_MODES.EXPORT_MODE }), + section: 'Mode' + } + ]; +}; + +export default useCommandBarActions; diff --git a/packages/spectacle/src/components/command-bar/index.tsx b/packages/spectacle/src/components/command-bar/index.tsx new file mode 100644 index 000000000..a4fe83f28 --- /dev/null +++ b/packages/spectacle/src/components/command-bar/index.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from 'react'; +import { KBarProvider } from 'kbar'; +import useCommandBarActions from './command-bar-actions'; +import CommandBarSearch from './search'; + +const CommandBar = ({ children }: CommandBarProps): JSX.Element => { + const actions = useCommandBarActions(); + return ( + + + {children} + + ); +}; + +export type CommandBarProps = { + children: ReactNode; +}; + +export default CommandBar; diff --git a/packages/spectacle/src/components/command-bar/results/index.tsx b/packages/spectacle/src/components/command-bar/results/index.tsx new file mode 100644 index 000000000..d2a6e3fc5 --- /dev/null +++ b/packages/spectacle/src/components/command-bar/results/index.tsx @@ -0,0 +1,90 @@ +import styled from 'styled-components'; +import { ActionImpl, KBarResults, useMatches } from 'kbar'; +import { prettifyShortcut } from '../../../utils/platform-keys'; +import { + KeyboardShortcutTypes, + KEYBOARD_SHORTCUTS, + SYSTEM_FONT +} from '../../../utils/constants'; +import { Text } from '../../typography'; + +type RenderParams = { + item: ActionImpl | string; + active: boolean; +}; + +function getShortcutKeys({ id, shortcut = [] }: ActionImpl): string[] { + if (id in KEYBOARD_SHORTCUTS && !shortcut?.length) { + const _id = id as KeyboardShortcutTypes; + return prettifyShortcut(KEYBOARD_SHORTCUTS[_id]); + } + return prettifyShortcut(shortcut); +} + +const ResultCommand = styled.div>` + display: flex; + justify-content: space-between; + align-items: center; + background-color: ${(p) => (p.active ? 'lightsteelblue' : 'transparent')}; + padding: 0.5rem 1rem; + cursor: pointer; + height: 30px; +`; + +const ResultSectionHeader = styled(Text)` + background-color: white; + color: gray; + margin: 0 2rem; + padding: 0.5rem 0; + font-size: small; + font-weight: bold; + font-family: ${SYSTEM_FONT}; +`; + +const ResultShortcut = styled.span` + display: flex; + gap: 5px; +`; + +const ResultShortcutKey = styled.kbd` + display: flex; + justify-content: center; + align-items: center; + background-color: #eee; + border-radius: 5px; + border: 1px solid #b4b4b4; + padding: 5px 10px; + min-width: 20px; + height: 25px; + white-space: nowrap; + font-family: ${SYSTEM_FONT}; +`; + +function onRender({ item, active }: RenderParams) { + if (typeof item === 'string') { + return {item}; + } else { + return ( + + {item.name} + + {getShortcutKeys(item).map( + (key) => + key && ( + + {key} + + ) + )} + + + ); + } +} + +const CommandBarResults = () => { + const { results } = useMatches(); + return ; +}; + +export default CommandBarResults; diff --git a/packages/spectacle/src/components/command-bar/search/index.tsx b/packages/spectacle/src/components/command-bar/search/index.tsx new file mode 100644 index 000000000..0dccbda77 --- /dev/null +++ b/packages/spectacle/src/components/command-bar/search/index.tsx @@ -0,0 +1,36 @@ +import styled from 'styled-components'; +import { KBarPortal, KBarPositioner, KBarAnimator, KBarSearch } from 'kbar'; +import CommandBarResults from '../results'; + +const KBarSearchStyled = styled(KBarSearch)` + padding: 12px 16px; + font-size: 16px; + width: 100%; + box-sizing: border-box; + outline: none; + border: none; +`; + +const KBarAnimatorStyled = styled(KBarAnimator)` + max-width: 600px; + width: 100%; + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: rgb(0 0 0 / 50%) 0px 16px 70px; +`; + +const CommandBarSearch = () => { + return ( + + + + + + + + + ); +}; + +export default CommandBarSearch; diff --git a/packages/spectacle/src/components/deck/deck-styles.ts b/packages/spectacle/src/components/deck/deck-styles.ts new file mode 100644 index 000000000..e996a7f41 --- /dev/null +++ b/packages/spectacle/src/components/deck/deck-styles.ts @@ -0,0 +1,73 @@ +import { CSSProperties } from 'react'; + +export function overviewFrameStyle({ + overviewScale, + nativeSlideWidth, + nativeSlideHeight +}: { + overviewScale: number; + nativeSlideWidth: number; + nativeSlideHeight: number; +}): CSSProperties { + return { + margin: '1rem', + width: `${overviewScale * nativeSlideWidth}px`, + height: `${ + (overviewScale / (nativeSlideWidth / nativeSlideHeight)) * + nativeSlideWidth + }px`, + display: 'block', + transform: 'none', + position: 'relative' + }; +} + +export function overviewWrapperStyle({ + overviewScale +}: { + overviewScale: number; +}): CSSProperties { + return { + width: `${100 / overviewScale}%`, + height: `${100 / overviewScale}%`, + transform: `scale(${overviewScale})`, + transformOrigin: '0px 0px', + position: 'absolute' + }; +} + +export function printFrameStyle({ + nativeSlideWidth, + nativeSlideHeight, + printScale +}: { + nativeSlideWidth: number; + nativeSlideHeight: number; + printScale: number; +}): CSSProperties { + return { + margin: '0', + width: `${printScale * nativeSlideWidth}px`, + height: `${ + (printScale / (nativeSlideWidth / nativeSlideHeight)) * nativeSlideWidth + }px`, + display: 'block', + transform: 'none', + position: 'relative', + breakAfter: 'page' + }; +} + +export function printWrapperStyle({ + printScale +}: { + printScale: number; +}): CSSProperties { + return { + width: `${100 / printScale}%`, + height: `${100 / printScale}%`, + transform: `scale(${printScale})`, + transformOrigin: '0px 0px', + position: 'absolute' + }; +} diff --git a/packages/spectacle/src/components/deck/deck.tsx b/packages/spectacle/src/components/deck/deck.tsx new file mode 100644 index 000000000..7f7d7aee5 --- /dev/null +++ b/packages/spectacle/src/components/deck/deck.tsx @@ -0,0 +1,581 @@ +import { + useState, + useEffect, + forwardRef, + useMemo, + useCallback, + createContext, + ElementType, + useImperativeHandle, + FC, + RefAttributes, + ReactNode, + CSSProperties +} from 'react'; +import styled, { CSSObject, ThemeProvider } from 'styled-components'; +import { ulid } from 'ulid'; +import { useCollectSlides } from '../../hooks/use-slides'; +import useAspectRatioFitting from '../../hooks/use-aspect-ratio-fitting'; +import useDeckState, { + DeckStateAndActions, + DeckView +} from '../../hooks/use-deck-state'; +import useMousetrap from '../../hooks/use-mousetrap'; +import useLocationSync from '../../hooks/use-location-sync'; +import { mergeTheme } from '../../theme'; +import * as queryStringMapFns from '../../location-map-fns/query-string'; +import { + overviewFrameStyle, + overviewWrapperStyle, + printFrameStyle, + printWrapperStyle +} from './deck-styles'; +import { useAutoPlay } from '../../utils/use-auto-play'; +import defaultTheme, { + SpectacleThemeOverrides +} from '../../theme/default-theme'; +import { defaultTransition, SlideTransition } from '../transitions'; +import { SwipeEventData } from 'react-swipeable'; +import { MarkdownComponentMap } from '../../utils/mdx-component-mapper'; +import TemplateWrapper from '../template-wrapper'; +import { useRegisterActions } from 'kbar'; +import { KEYBOARD_SHORTCUTS_IDS } from '../../utils/constants'; + +export type DeckContextType = { + deckId: string | number; + slideCount: number; + useAnimations: boolean; + slidePortalNode: HTMLDivElement; + onSlideClick(e: MouseEvent, slideId: SlideId): void; + onMobileSlide(eventData: SwipeEventData): void; + theme?: SpectacleThemeOverrides & MarkdownThemeOverrides; + frameOverrideStyle: CSSProperties; + wrapperOverrideStyle: CSSProperties; + backdropNode: HTMLDivElement; + notePortalNode: HTMLDivElement; + initialized: boolean; + passedSlideIds: Set; + upcomingSlideIds: Set; + activeView: { + slideId: SlideId; + slideIndex: number; + stepIndex: number; + }; + pendingView: { + slideId: SlideId; + slideIndex: number; + stepIndex: number; + }; + skipTo(options: { slideIndex: number; stepIndex: number }): void; + stepForward(): void; + stepBackward(): void; + advanceSlide(): void; + regressSlide(): void; + commitTransition(newView?: { stepIndex: number }): void; + cancelTransition(): void; + template: TemplateFn | ReactNode; + transition: SlideTransition; + backgroundImage?: string; + inOverviewMode: boolean; + inPrintMode: boolean; +}; + +export const DeckContext = createContext(null as any); +DeckContext.displayName = 'DeckContext'; +const noop = () => {}; + +/** + * The PDF DPI is 96. We want to scale the slide down because it's a 1:1 px to 1/100th of an inch. + * However there are some unchangeable margins that make 0.96 too big, so we use 0.959 to prevent overflow. + */ +const DEFAULT_PRINT_SCALE = 0.959; +const DEFAULT_OVERVIEW_SCALE = 0.25; + +type PortalProps = { + fitAspectRatioStyle: CSSObject; + overviewMode: boolean; + printMode: boolean; +}; +const Portal = styled.div( + ({ fitAspectRatioStyle, overviewMode, printMode }) => [ + !printMode && { overflow: 'hidden' }, + !printMode && fitAspectRatioStyle, + overviewMode && { + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'flex-start', + alignItems: 'flex-start', + alignContent: 'flex-start', + transform: 'scale(1)', + overflowY: 'scroll', + width: '100%', + height: '100%' + }, + printMode && { + display: 'block' + } + ] +); + +export const DeckInternal = forwardRef( + ( + { + id: userProvidedId, + className = '', + backdropStyle: userProvidedBackdropStyle, + overviewMode = false, + printMode = false, + exportMode = false, + overviewScale = DEFAULT_OVERVIEW_SCALE, + printScale = DEFAULT_PRINT_SCALE, + template, + theme: { + Backdrop: UserProvidedBackdropComponent, + backdropStyle: themeProvidedBackdropStyle = { + position: 'fixed', + top: 0, + left: 0, + width: '100vw', + height: '100vh' + } as CSSObject, + suppressBackdropFallback: themeSuppressBackdropFallback, + ...restTheme + } = {}, + + onSlideClick = noop, + onMobileSlide = noop, + + disableInteractivity = false, + notePortalNode, + useAnimations = true, + children, + onActiveStateChange: onActiveStateChangeExternal = noop, + initialState: initialDeckState = { + slideIndex: 0, + stepIndex: 0 + }, + suppressBackdropFallback = false, + autoPlay = false, + autoPlayLoop = false, + autoPlayInterval = 1000, + transition = defaultTransition, + backgroundImage + }, + ref + ) => { + const [deckId] = useState(userProvidedId || ulid); + const { + width: nativeSlideWidth = defaultTheme.size.width, + height: nativeSlideHeight = defaultTheme.size.height + } = restTheme.size || {}; + + const { + initialized, + pendingView, + activeView, + + initializeTo, + skipTo, + stepForward, + stepBackward, + advanceSlide, + regressSlide, + commitTransition, + cancelTransition + } = useDeckState(initialDeckState); + + const [ + setPlaceholderContainer, + slideIds, + slideIdsWithTemplates, + slideIdsInitialized + ] = useCollectSlides(); + + // It really is much easier to just expose methods to the outside world that + // drive the presentation through its state rather than trying to implement a + // declarative API. + useImperativeHandle( + ref, + () => ({ + initialized, + activeView, + initializeTo, + skipTo, + stepForward, + stepBackward, + advanceSlide, + regressSlide, + numberOfSlides: slideIds.length + }), + [ + initialized, + activeView, + initializeTo, + skipTo, + stepForward, + stepBackward, + advanceSlide, + regressSlide, + slideIds + ] + ); + + useRegisterActions( + !disableInteractivity + ? [ + { + id: KEYBOARD_SHORTCUTS_IDS.NEXT_SLIDE, + name: 'Next Slide', + keywords: 'next', + perform: () => stepForward(), + section: 'Slide' + }, + { + id: KEYBOARD_SHORTCUTS_IDS.PREVIOUS_SLIDE, + name: 'Previous Slide', + keywords: 'previous', + perform: () => stepBackward(), + section: 'Slide' + }, + { + id: 'Restart Presentation', + name: 'Restart Presentation', + keywords: 'restart', + perform: () => + skipTo({ + slideIndex: 0, + stepIndex: 0 + }), + section: 'Slide' + } + ] + : [] + ); + useMousetrap( + disableInteractivity + ? {} + : { + left: () => stepBackward(), + right: () => stepForward() + }, + [] + ); + + const [syncLocation, onActiveStateChange] = useLocationSync({ + disableInteractivity, + setState: skipTo, + ...queryStringMapFns + }); + + useEffect(() => { + if (!initialized) return; + onActiveStateChange(activeView); + onActiveStateChangeExternal(activeView); + }, [ + initialized, + activeView, + onActiveStateChange, + onActiveStateChangeExternal + ]); + + useEffect(() => { + const initialView = syncLocation({ + slideIndex: 0, + stepIndex: 0 + }); + initializeTo(initialView); + }, [initializeTo, syncLocation]); + + useAutoPlay({ + enabled: autoPlay, + loop: autoPlayLoop, + interval: autoPlayInterval, + navigation: { + skipTo, + stepForward, + isFinalSlide: activeView.slideIndex === slideIds.length - 1 + } + }); + + const handleSlideClick = useCallback< + NonNullable + >( + (e, slideId) => { + const slideIndex = slideIds.indexOf(slideId); + onSlideClick(e, slideIndex); + }, + [onSlideClick, slideIds] + ); + + const activeSlideId = slideIds[activeView.slideIndex]; + const pendingSlideId = slideIds[pendingView.slideIndex]; + + const [passed, upcoming] = useMemo(() => { + const p = new Set(); + const u = new Set(); + let foundActive = false; + for (const slideId of slideIds) { + if (foundActive) { + u.add(slideId); + } else if (slideId === activeSlideId) { + foundActive = true; + } else { + p.add(slideId); + } + } + return [p, u] as const; + }, [slideIds, activeSlideId]); + + const fullyInitialized = initialized && slideIdsInitialized; + + // Slides don't actually render their content to their position in the DOM- + // they render to this `portalNode` element. The only thing they actually + // render to their "natural" DOM location is a placeholder node which we use + // below to enumerate them. + // + // The main reason for this is so that we can be absolutely sure that no + // intermediate areas of the tree end up breaking styling, while still + // allowing users to organize their slides via component nesting: + // + // const ContentSlides = () => ( + // <> + // First Slide + //

This text will never appear, because it's not part of a Slide.

+ // Second Slide + // + // ); + // + // const Presentation = () => ( + // + // Title Slide + // + // Conclusion Slide + // + // ); + const [slidePortalNode, setSlidePortalNode] = + useState(); + + const [backdropRef, fitAspectRatioStyle] = useAspectRatioFitting({ + targetWidth: nativeSlideWidth, + targetHeight: nativeSlideHeight + }); + + const frameStyle = useMemo(() => { + const options = { + printScale, + overviewScale, + nativeSlideWidth, + nativeSlideHeight + }; + if (overviewMode) { + return overviewFrameStyle(options); + } else if (printMode) { + return printFrameStyle(options); + } + return {}; + }, [ + nativeSlideHeight, + nativeSlideWidth, + overviewMode, + overviewScale, + printMode, + printScale + ]); + + const wrapperStyle = useMemo(() => { + if (overviewMode) { + return overviewWrapperStyle({ overviewScale }); + } else if (printMode) { + return printWrapperStyle({ printScale }); + } + return {}; + }, [overviewMode, overviewScale, printMode, printScale]); + + // Try to be intelligent about the backdrop background color: we have to use + // inline styles, which will take precedence over all other styles. So, we do + // as much as we can here to detect if a backdrop color has been provided, or + // if the user has provided a custom backdrop component (in which case they're + // responsible for styling it properly.) If we don't detect an appropriate + // case, then we apply the inline style. + // + // Yes, this is slightly awkward, but IMO adding an additional `

` element + // would be even more awkward. + let useFallbackBackdropStyle = true; + const backdropStyle = themeProvidedBackdropStyle; + let BackdropComponent = 'div' as ElementType; + if (userProvidedBackdropStyle) { + Object.assign(backdropStyle, userProvidedBackdropStyle); + if (backdropStyle['background'] || backdropStyle['backgroundColor']) { + useFallbackBackdropStyle = false; + } + } + if (UserProvidedBackdropComponent) { + BackdropComponent = UserProvidedBackdropComponent; + useFallbackBackdropStyle = false; + } + if ( + useFallbackBackdropStyle && + !suppressBackdropFallback && + !themeSuppressBackdropFallback + ) { + backdropStyle['backgroundColor'] = 'black'; + } + + const doesCurrentSlideHaveItsOwnTemplate = + slideIdsWithTemplates.has(activeSlideId); + + const templateElement: ReactNode = + typeof template === 'function' + ? template({ + slideNumber: activeView.slideIndex + 1, + numberOfSlides: slideIds.length + }) + : template; + + return ( + + + + + {!doesCurrentSlideHaveItsOwnTemplate && + !overviewMode && + !printMode && ( + + {templateElement} + + )} + +
+ {children} +
+
+
+
+ ); + } +); +DeckInternal.displayName = 'Deck'; + +export const Deck = DeckInternal as FC>; + +Deck.displayName = 'Deck'; + +export type TemplateFn = (options: { + slideNumber: number; + numberOfSlides: number; +}) => ReactNode; +export type SlideId = string | number; +type MarkdownThemeOverrides = { + markdownComponentMap?: MarkdownComponentMap; +}; +type BackdropOverrides = { + Backdrop?: ElementType; + backdropStyle?: CSSObject; + suppressBackdropFallback?: boolean; +}; + +export type DeckRef = Omit< + DeckStateAndActions, + 'pendingView' | 'commitTransition' | 'cancelTransition' +> & { + numberOfSlides: number; +}; +export type DeckProps = { + id?: string | number; + className?: string; + children: ReactNode; + autoPlay?: boolean; + autoPlayLoop?: boolean; + autoPlayInterval?: number; + theme?: SpectacleThemeOverrides & MarkdownThemeOverrides & BackdropOverrides; + template?: TemplateFn | ReactNode; + printScale?: number; + overviewScale?: number; + transition?: SlideTransition; + suppressBackdropFallback?: boolean; + backgroundImage?: string; +}; +/** + * These types are only used internally, + * and are not officially part of the public API + */ +export type DeckInternalProps = DeckProps & { + initialState?: DeckView; + printMode?: boolean; + exportMode?: boolean; + overviewMode?: boolean; + onSlideClick?(e: Event, slideId: SlideId): void; + onMobileSlide?(eventData: SwipeEventData): void; + disableInteractivity?: boolean; + useAnimations?: boolean; + notePortalNode?: HTMLDivElement | null; + backdropStyle?: Partial; + onActiveStateChange?: (activeView: DeckView) => void; + backgroundImage?: string; +}; + +export default Deck; diff --git a/packages/spectacle/src/components/deck/default-deck.tsx b/packages/spectacle/src/components/deck/default-deck.tsx new file mode 100644 index 000000000..f015ac00f --- /dev/null +++ b/packages/spectacle/src/components/deck/default-deck.tsx @@ -0,0 +1,111 @@ +import { useRef, useCallback, useEffect } from 'react'; +import { DeckInternal, DeckInternalProps, DeckProps, DeckRef } from './deck'; +import useBroadcastChannel from '../../hooks/use-broadcast-channel'; +import useMousetrap from '../../hooks/use-mousetrap'; +import { + KEYBOARD_SHORTCUTS, + SPECTACLE_MODES, + ToggleModeParams +} from '../../utils/constants'; + +/** + * Spectacle DefaultDeck is a wrapper around the Deck component that adds Broadcast channel support + * for audience and presenter modes. This is intentionally not built into the base Deck component + * to allow for extensibility outside of core Spectacle functionality. + */ +const DefaultDeck = (props: DefaultDeckProps): JSX.Element => { + const { + overviewMode = false, + printMode = false, + exportMode = false, + toggleMode, + children, + ...rest + } = props; + const deck = useRef(null); + + const [postMessage] = useBroadcastChannel( + 'spectacle_presenter_bus', + (message) => { + if (message.type !== 'SYNC') return; + const nextView = message.payload; + if (deck.current!.initialized) { + deck.current!.skipTo(nextView); + } else { + deck.current!.initializeTo(nextView); + } + } + ); + + useEffect(() => { + postMessage('SYNC_REQUEST'); + }, [postMessage]); + + useMousetrap( + overviewMode + ? { + [KEYBOARD_SHORTCUTS.TAB_FORWARD_OVERVIEW_MODE]: () => + deck.current!.advanceSlide(), + [KEYBOARD_SHORTCUTS.TAB_BACKWARD_OVERVIEW_MODE]: () => + deck.current!.regressSlide({ + stepIndex: 0 + }), + [KEYBOARD_SHORTCUTS.SELECT_SLIDE_OVERVIEW_MODE]: () => + toggleMode({ + newMode: SPECTACLE_MODES.DEFAULT_MODE + }) + } + : {}, + [] + ); + + const onSlideClick = useCallback< + NonNullable + >( + (e, slideIndex) => { + if (overviewMode) { + toggleMode({ + e, + newMode: SPECTACLE_MODES.DEFAULT_MODE, + senderSlideIndex: +slideIndex + }); + } + }, + [overviewMode, toggleMode] + ); + + const onMobileSlide: DeckInternalProps['onMobileSlide'] = (e) => { + if (navigator.maxTouchPoints < 1) return; + switch (e.dir) { + case 'Left': + deck.current!.stepForward(); + break; + case 'Right': + deck.current!.regressSlide(); + break; + } + }; + + return ( + + {children} + + ); +}; + +export default DefaultDeck; + +type DefaultDeckProps = DeckProps & { + toggleMode(args: ToggleModeParams): void; + overviewMode?: boolean; + printMode?: boolean; + exportMode?: boolean; +}; diff --git a/packages/spectacle/src/components/deck/index.tsx b/packages/spectacle/src/components/deck/index.tsx new file mode 100644 index 000000000..76559df0f --- /dev/null +++ b/packages/spectacle/src/components/deck/index.tsx @@ -0,0 +1,71 @@ +import { Fragment } from 'react'; +import DefaultDeck from './default-deck'; +import PresenterMode from '../presenter-mode'; +import PrintMode from '../print-mode'; +import useMousetrap from '../../hooks/use-mousetrap'; +import { KEYBOARD_SHORTCUTS, SPECTACLE_MODES } from '../../utils/constants'; +import { DeckProps } from './deck'; +import useModes, { ModeActions } from '../../hooks/use-modes'; +import CommandBar from '../command-bar'; + +const View = ({ + getCurrentMode, + toggleMode, + ...props +}: ModeActions & DeckProps) => { + const mode = getCurrentMode(); + switch (mode) { + case SPECTACLE_MODES.DEFAULT_MODE: + return ; + + case SPECTACLE_MODES.PRESENTER_MODE: + return ; + + /** + * Print mode and export mode are identical except for the theme + * that is used. Print mode uses the print theme which is usually + * monotone and export mode uses the default theme. + */ + case SPECTACLE_MODES.PRINT_MODE: + return ; + + case SPECTACLE_MODES.EXPORT_MODE: + return ; + + case SPECTACLE_MODES.OVERVIEW_MODE: + return ; + + default: + return ; + } +}; + +const SpectacleDeck = (props: DeckProps): JSX.Element => { + const { toggleMode, getCurrentMode } = useModes(); + + useMousetrap( + { + [KEYBOARD_SHORTCUTS.PRESENTER_MODE]: (e) => + e && toggleMode({ e, newMode: SPECTACLE_MODES.PRESENTER_MODE }), + [KEYBOARD_SHORTCUTS.PRINT_MODE]: (e) => + e && toggleMode({ e, newMode: SPECTACLE_MODES.PRINT_MODE }), + [KEYBOARD_SHORTCUTS.EXPORT_MODE]: (e) => + e && toggleMode({ e, newMode: SPECTACLE_MODES.EXPORT_MODE }), + [KEYBOARD_SHORTCUTS.OVERVIEW_MODE]: (e) => + e && toggleMode({ e, newMode: SPECTACLE_MODES.OVERVIEW_MODE }) + }, + [] + ); + + return ( + + + + ); +}; + +export default SpectacleDeck; diff --git a/packages/spectacle/src/components/fullscreen.tsx b/packages/spectacle/src/components/fullscreen.tsx new file mode 100644 index 000000000..f95f3248f --- /dev/null +++ b/packages/spectacle/src/components/fullscreen.tsx @@ -0,0 +1,52 @@ +import { forwardRef } from 'react'; +import styled from 'styled-components'; +import { position } from 'styled-system'; + +import { useToggleFullScreen } from '../hooks/use-full-screen'; + +type FSProps = { + color?: string; + size?: number; +}; + +const Container = styled('div')` + ${position} + @media print { + display: none; + } +`; + +const FullScreen = forwardRef( + ({ size, color, ...props }, ref) => { + const toggleFullScreen = useToggleFullScreen(); + return ( + + + + + + ); + } +); + +FullScreen.displayName = 'Fullscreen'; + +FullScreen.defaultProps = { + color: '#fff', + size: 24 +}; + +export default FullScreen; diff --git a/packages/spectacle/src/components/image.ts b/packages/spectacle/src/components/image.ts new file mode 100644 index 000000000..49d1d649a --- /dev/null +++ b/packages/spectacle/src/components/image.ts @@ -0,0 +1,18 @@ +import { FC } from 'react'; +import styled from 'styled-components'; +import { compose, layout, position } from 'styled-system'; +import * as SS from 'styled-system'; + +type ImageType = FC< + SS.LayoutProps & SS.PositionProps & Partial +>; + +const Image = styled.img(compose(layout, position)) as ImageType; +const FullSizeImage = styled(Image) as unknown as ImageType; + +FullSizeImage.defaultProps = { + maxWidth: '100%', + maxHeight: '100%' +}; + +export { Image, FullSizeImage }; diff --git a/packages/spectacle/src/components/internal-button.ts b/packages/spectacle/src/components/internal-button.ts new file mode 100644 index 000000000..2aeaeb248 --- /dev/null +++ b/packages/spectacle/src/components/internal-button.ts @@ -0,0 +1,26 @@ +import styled from 'styled-components'; +import { SYSTEM_FONT } from '../utils/constants'; + +/** + * This button is for internal controls like the presenter display. + * It uses Formidable Spectacle-branded colors. + */ +const InternalButton = styled('button')` + background: #333; + border: 1px solid hsla(0, 0%, 0%, 0.4); + border-radius: 2px; + color: #fff; + box-shadow: inset 1px 1px 0 hsla(0, 0%, 100%, 0.1), + 1px 1px 0 hsla(0, 0%, 0%, 0.1); + padding: 3px 20px; + font-size: 14px; + font-weight: bold; + font-family: ${SYSTEM_FONT}; + + &:active { + box-shadow: inset 1px 1px 0 hsla(0, 0%, 0%, 0.25), + 1px 1px 0 hsla(0, 0%, 0%, 0.1); + } +`; + +export default InternalButton; diff --git a/packages/spectacle/src/components/layout-primitives.ts b/packages/spectacle/src/components/layout-primitives.ts new file mode 100644 index 000000000..f2d1e994e --- /dev/null +++ b/packages/spectacle/src/components/layout-primitives.ts @@ -0,0 +1,41 @@ +import styled from 'styled-components'; +import { + compose, + grid, + flexbox, + layout, + position, + border, + color, + space +} from 'styled-system'; +import * as SS from 'styled-system'; + +type BoxProps = SS.LayoutProps & + SS.SpaceProps & + SS.PositionProps & + SS.ColorProps & + SS.BorderProps; + +const Box = styled.div( + compose(layout, space, position, color, border) +); + +const FlexBox = styled.div( + compose(layout, space, position, color, border, flexbox) +); + +FlexBox.defaultProps = { + alignItems: 'center', + justifyContent: 'center', + display: 'flex' +}; + +type GridProps = SS.LayoutProps & SS.GridProps & SS.PositionProps; +const Grid = styled.div(compose(layout, grid, position)); + +Grid.defaultProps = { + display: 'grid' +}; + +export { Box, FlexBox, Grid }; diff --git a/packages/spectacle/src/components/logo.tsx b/packages/spectacle/src/components/logo.tsx new file mode 100644 index 000000000..1dbb5a5b3 --- /dev/null +++ b/packages/spectacle/src/components/logo.tsx @@ -0,0 +1,175 @@ +export default function SpectacleLogo({ size = 100 }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/spectacle/src/components/markdown/markdown.test.tsx b/packages/spectacle/src/components/markdown/markdown.test.tsx new file mode 100644 index 000000000..0ca3ed850 --- /dev/null +++ b/packages/spectacle/src/components/markdown/markdown.test.tsx @@ -0,0 +1,192 @@ +import { ReactElement } from 'react'; +import { Markdown, MarkdownSlide, MarkdownSlideSet } from './markdown'; +import Deck from '../deck'; +import { Heading } from '../typography'; +import Slide from '../slide/slide'; +import { render } from '@testing-library/react'; + +jest.mock('../../hooks/use-broadcast-channel', () => { + return { + __esModule: true, + default: function useBroadcastChannel() { + return [() => {}]; + } + }; +}); + +const mountInsideDeck = (tree: ReactElement) => { + return render({tree}); +}; + +describe('', () => { + it('should generate standard unordered lists by default', () => { + const { getByText, queryByTestId } = mountInsideDeck( + {` + - One + - Two + - Three + `} + ); + + expect(getByText('One')).toBeDefined(); + expect(getByText('Two')).toBeDefined(); + expect(getByText('Three')).toBeDefined(); + expect(queryByTestId('AppearElement')).toBeNull(); + }); + + it('should generate animated list items with animateListItems', () => { + const { getByText, queryAllByTestId } = mountInsideDeck( + {` + - One + - Two + - Three + `} + ); + + expect(getByText('One')).toBeDefined(); + expect(getByText('Two')).toBeDefined(); + expect(getByText('Three')).toBeDefined(); + expect(queryAllByTestId('AppearElement').length).toBe(3); + }); + + it('should work with raw HTML', () => { + const { container } = mountInsideDeck( + {` + - One
one-div
+ - Two two-i-1two-i-2 + - Three + `}
+ ); + + // Assert raw HTML elements are actually present. + expect( + container.querySelectorAll('li')[0].querySelectorAll('div') + ).toHaveLength(1); + expect( + container.querySelectorAll('li')[1].querySelectorAll('i') + ).toHaveLength(2); + expect(container.querySelectorAll('li')[2].children).toHaveLength(0); + }); + + it('should generate line breaks for inline paragraph elements', () => { + const { container } = mountInsideDeck( + {` + One + **Two** + _Three_ + \`Four\` + ~~Five~~ + `} + ); + expect(container.querySelectorAll('br')).toHaveLength(4); + }); + + it('should generate line breaks for inline paragraph elements with carriage returns', () => { + const { container } = mountInsideDeck( + {`One\r\n**Two**\r\n_Three_\r\n\`Four\`\r\n~~Five~~`} + ); + expect(container.querySelectorAll('br')).toHaveLength(4); + }); + + it('should generate line breaks for inline paragraph elements with mixed returns', () => { + const { container } = mountInsideDeck( + {`One\n**Two**\r\n_Three_\n\`Four\`\r\n~~Five~~`} + ); + expect(container.querySelectorAll('br')).toHaveLength(4); + }); +}); + +describe('', () => { + it('should generate standard unordered lists by default', () => { + const { container, queryAllByTestId } = mountInsideDeck( + {` + - One + - Two + - Three + + --- + + - Four + - Five + - Size + `} + ); + + expect(container.querySelectorAll('ul')).toHaveLength(2); + expect(container.querySelectorAll('li')).toHaveLength(6); + expect(queryAllByTestId('AppearElement')).toHaveLength(0); + }); + + it('should generate animated list items with animateListItems', () => { + const { container, queryAllByTestId } = mountInsideDeck( + {` + - One + - Two + - Three + + --- + + - Four + - Five + - Size + `} + ); + + expect(container.querySelectorAll('ul')).toHaveLength(2); + expect(queryAllByTestId('AppearElement')).toHaveLength(6); + }); + + it('Markdown should pass componentProps down to constituent components', () => { + const { queryByText } = mountInsideDeck( + + Im not styled... + {` + # Whats up world, Im styled. + + - List item + - And another one + `} + + ); + + expect(queryByText('Im not styled...')).not.toHaveStyle({ + color: 'purple' + }); + expect(queryByText('Whats up world, Im styled.')).toHaveStyle({ + color: 'purple' + }); + expect(queryByText('List item')).toHaveStyle({ + color: 'purple' + }); + }); + + it('MarkdownSlide should pass componentProps down to constituent components', () => { + const { getByText } = mountInsideDeck( + {` + # Whats up world, Im styled. + `} + ); + + expect(getByText('Whats up world, Im styled.')).toHaveStyle({ + color: 'purple' + }); + }); + + it('MarkdownSlideSet should pass componentProps down to constituent components', () => { + const { getByText } = mountInsideDeck( + {` + # Whats up world, Im styled. + + --- + + # Another slide + `} + ); + + expect(getByText('Whats up world, Im styled.')).toHaveStyle({ + color: 'purple' + }); + + expect(getByText('Another slide')).toHaveStyle({ color: 'purple' }); + }); +}); diff --git a/packages/spectacle/src/components/markdown/markdown.tsx b/packages/spectacle/src/components/markdown/markdown.tsx new file mode 100644 index 000000000..f44824544 --- /dev/null +++ b/packages/spectacle/src/components/markdown/markdown.tsx @@ -0,0 +1,325 @@ +/* eslint-disable react/display-name */ +import Slide from '../slide/slide'; +import { DeckContext } from '../deck/deck'; +import presenterNotesPlugin from '../../utils/remark-rehype-presenter-notes'; +import CodePane, { CodePaneProps } from '../code-pane'; +import unified from 'unified'; +import styled from 'styled-components'; +import { compose, layout, position } from 'styled-system'; +import remark from 'remark-parse'; +import remark2rehype from 'remark-rehype'; +import remarkRaw from 'rehype-raw'; +import rehype2react from 'rehype-react'; +import { isValidElementType } from 'react-is'; +import { root as mdRoot } from 'mdast-builder'; +import mdxComponentMap, { + MarkdownComponentMap +} from '../../utils/mdx-component-mapper'; +import indentNormalizer from '../../utils/indent-normalizer'; +import Notes from '../notes'; +import { ListItem } from '../../index'; +import { Appear } from '../appear'; +import React, { + ElementType, + FC, + forwardRef, + ReactElement, + useContext, + useMemo, + createElement, + Children +} from 'react'; + +type MdComponentProps = { [key: string]: any }; + +type CommonMarkdownProps = { + animateListItems?: boolean; + componentProps?: MdComponentProps; + children: string; +}; + +type MapAndTemplate = { + componentMap?: MarkdownComponentMap; + template?: { + default: ElementType; + getPropsForAST?: Function; + }; +}; + +type MarkdownProps = CommonMarkdownProps & MapAndTemplate; +const Container = styled('div')(compose(position, layout)); + +export const Markdown = forwardRef( + ( + { + componentMap: userProvidedComponentMap = mdxComponentMap, + template: { default: TemplateComponent, getPropsForAST } = { + default: 'div' + }, + children: rawMarkdownText, + animateListItems = false, + componentProps, + ...props + }, + ref + ) => { + const { theme: { markdownComponentMap: themeComponentMap = null } = {} } = + useContext(DeckContext); + + const [templateProps, noteElements] = useMemo(() => { + // Dedent and parse markdown into MDAST + const markdownText = indentNormalizer(rawMarkdownText); + const ast = unified().use(remark).parse(markdownText); + + // Extract presenter notes from the MDAST (since we want to use a different + // component map for them.) + const extractedNotes = mdRoot(); + const transformedAst = unified() + .use(presenterNotesPlugin, (...notes) => { + extractedNotes.children.push(...notes); + }) + .runSync(ast); + + // Pass the AST into the provided template function, which returns an object + // whose keys are prop names and whose values are chunks of the parsed AST. + let templatePropMDASTs: any; + if (typeof getPropsForAST === 'function') { + templatePropMDASTs = getPropsForAST(transformedAst); + } + + if (!templatePropMDASTs) { + templatePropMDASTs = { children: transformedAst }; + } + + // Construct the component map based on the current theme and any custom + // mappings provided directly to + const componentMap = { + __codeBlock: MarkdownCodePane, + ...(themeComponentMap || {}), + ...userProvidedComponentMap + }; + + // If user wants to animate list items, + // wrap ListItem in Appear + if (animateListItems) { + componentMap['li'] = AppearingListItem; + } + + // Create an HOC based on the component map which will specially handle + // fenced code blocks. (See MarkdownPreHelper for more details.) + const PreComponent = componentMap['pre']; + const CodeBlockComponent = componentMap['__codeBlock']; + const CodeInlineComponent = componentMap['code']; + componentMap['pre'] = MarkdownPreHelper( + PreComponent, + CodeInlineComponent, + CodeBlockComponent + ); + + const componentMapWithPassedThroughProps = Object.entries( + componentMap + ).reduce((newMap, [key, Component]) => { + newMap[key] = (props: any) => { + // Replace \r\n and \n with
for paragraphs + const children = + key === 'p' + ? props.children?.map((child: any) => { + if (typeof child == 'string') { + const lines = child.split(/\r\n|\n/g); + return lines.map((str, i) => ( + + {str} + {i !== lines.length - 1 &&
} +
+ )); + } + return child; + }) + : props.children; + return ( + + {children} + + ); + }; + return newMap; + }, {} as any); + + // Create the compiler for the _user-visible_ markdown (not presenter notes) + const compiler = unified() + .use(remark2rehype, { allowDangerousHtml: true }) + .use(remarkRaw) + .use(rehype2react, { + createElement, + components: componentMapWithPassedThroughProps + }); + + // Compile each of the values we got back from the template function + const templateProps = Object.entries(templatePropMDASTs).reduce( + (acc, [key, mdast]) => { + // Transform the MDAST into HAST + const hast = compiler.runSync(mdast as any); + + // Compile the HAST into React elements + acc[key] = compiler.stringify(hast); + return acc; + }, + {} as any + ); + // Create the compiler for presenter notes, which wraps the entire compiled + // chunk in a component. (Rather than React.Fragment, which is the + // default behavior.) + const notesCompiler = unified() + .use(remark2rehype, { allowDangerousHtml: true }) + .use(remarkRaw) + .use(rehype2react, { + createElement, + Fragment: Notes + }); + + // Transform and compile the notes AST. + if ( + Array.isArray(extractedNotes.children) && + extractedNotes.children.length >= 1 + ) { + const transformedNotesAst = notesCompiler.runSync(extractedNotes); + const noteElements = notesCompiler.stringify(transformedNotesAst); + return [templateProps, noteElements] as const; + } + return [templateProps, null] as const; + }, [ + rawMarkdownText, + getPropsForAST, + themeComponentMap, + userProvidedComponentMap, + animateListItems, + componentProps + ]); + + const { children, ...restProps } = templateProps; + + return ( + + + {children} + {noteElements} + + + ); + } +); + +type PropsFrom = T extends ElementType + ? Props + : never; + +const AppearingListItem = (props: PropsFrom) => ( + + + +); + +type MarkdownSlideProps = CommonMarkdownProps & MapAndTemplate; + +export const MarkdownSlide = ({ + children, + componentMap, + template, + animateListItems = false, + componentProps = {}, + ...rest +}: MarkdownSlideProps) => { + return ( + + + + ); +}; + +type MarkdownSlideSetProps = CommonMarkdownProps & { + slideProps?: Partial[]; +}; + +export const MarkdownSlideSet = ({ + children: rawMarkdownText, + slideProps = [], + ...allSlideProps +}: MarkdownSlideSetProps) => { + const dedentedMarkdownText = indentNormalizer(rawMarkdownText); + const mdSlides = dedentedMarkdownText.split(/\n\s*---\n/); + return ( + <> + {mdSlides.map((md, ix) => { + const props = {}; + Object.assign(props, allSlideProps); + if (slideProps[ix]) { + Object.assign(props, slideProps[ix]); + } + return ( + + {md} + + ); + })} + + ); +}; + +// This HOC is necessary due to the fact that `remark-rehype` transforms _inline +// code_ into ..., but _fenced code blocks_ into +//
...
. (It's also possible that
...
might +// get in there somewhere.) In order to allow the user to theme these +// differently, we detect the latter case and render CodeBlockComponent if +// needed. +export const MarkdownPreHelper = + ( + PreComponent: ElementType = 'pre', + CodeInlineComponent: ElementType = 'code', + CodeBlockComponent: ElementType + ): FC> => + ({ children, ...restProps }) => { + const pre = {children}; + + if (Children.count(children) !== 1) return pre; + const child = (children as ReactElement[])[0]; + if (child.type !== CodeInlineComponent) return pre; + if (!isValidElementType(CodeBlockComponent)) return pre; + + // Edge behavior: when `rehype-react` does its transformations, children are + // always provided as an array, even if there's only one. We extract it here + // so there are less surprises for implementers of a code block component. + const { + children: [rawCode], + ...restChildProps + } = child.props; + return ( + + {rawCode} + + ); + }; + +const MarkdownCodePane: FC<{ className?: string } & CodePaneProps> = ({ + className, + children, + ...rest +}) => { + const language = useMemo(() => { + const match = /^language-(.*)$/.exec(className || ''); + return match ? match[1] : undefined; + }, [className]); + + return ( + + {children} + + ); +}; diff --git a/packages/spectacle/src/components/notes.tsx b/packages/spectacle/src/components/notes.tsx new file mode 100644 index 000000000..7bcd9ec34 --- /dev/null +++ b/packages/spectacle/src/components/notes.tsx @@ -0,0 +1,16 @@ +import ReactDOM from 'react-dom'; +import { DeckContext } from './deck/deck'; +import { SlideContext } from './slide/slide'; +import { PropsWithChildren, useContext } from 'react'; + +const Notes = ({ children }: PropsWithChildren<{}>): JSX.Element | null => { + const { notePortalNode } = useContext(DeckContext); + const { isSlideActive } = useContext(SlideContext); + + if (!isSlideActive) return null; + if (!notePortalNode) return null; + + return ReactDOM.createPortal(
{children}
, notePortalNode); +}; + +export default Notes; diff --git a/packages/spectacle/src/components/presenter-mode/components.tsx b/packages/spectacle/src/components/presenter-mode/components.tsx new file mode 100644 index 000000000..9a3e7c13e --- /dev/null +++ b/packages/spectacle/src/components/presenter-mode/components.tsx @@ -0,0 +1,94 @@ +import styled from 'styled-components'; + +export const PresenterDeckContainer = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: row; + background-color: #181818; + overflow: hidden; + color: white; +`; + +export const NotesColumn = styled.div` + padding: 0; + display: flex; + flex-direction: column; + width: 50%; + border-right: 1px solid black; +`; + +export const PreviewColumn = styled.div` + background-color: black; + display: flex; + flex-direction: column; + height: 100%; + width: 50%; + > :first-child { + margin-bottom: 0.5em; + } +`; + +export const SlideContainer = styled.div` + display: flex; + flex-direction: column; + height: calc(50% - 1em); + width: 100%; + overflow: hidden; +`; + +export const SlideWrapper = styled.div<{ small?: boolean }>` + flex: 1; + width: 100%; + position: relative; + .spectacle-fullscreen-button { + display: none; + } + ${({ small }) => small && `flex: 0.8;`} +`; + +export const SlideCountLabel = styled.span` + background: hsla(0, 0%, 100%, 0.1); + border-radius: 4px; + font-size: 0.7em; + padding: 1px 4px; +`; + +export const NotesContainer = styled.div` + border-top: 1px solid black; + overflow-y: scroll; + flex: 1; + + ::-webkit-scrollbar { + width: 10px; + } + + /* Track */ + ::-webkit-scrollbar-track { + background-color: #111; + } + + /* Handle */ + ::-webkit-scrollbar-thumb { + background: #333; + border-radius: 10px; + } +`; + +export const deckBackdropStyles = { + currentSlide: { + width: '50vw', + height: '50vh', + left: '50vw', + top: '7vh' + }, + nextSlide: { + width: '50vw', + height: '33vh', + top: '60vh', + left: '50vw' + } +}; diff --git a/packages/spectacle/src/components/presenter-mode/index.tsx b/packages/spectacle/src/components/presenter-mode/index.tsx new file mode 100644 index 000000000..7da433f8b --- /dev/null +++ b/packages/spectacle/src/components/presenter-mode/index.tsx @@ -0,0 +1,145 @@ +import { useRef, useCallback, useState, useEffect, ReactNode } from 'react'; +import styled from 'styled-components'; +import { DeckInternal, DeckRef, TemplateFn } from '../deck/deck'; +import { Text, SpectacleLogo } from '../../index'; +import { + PresenterDeckContainer, + NotesColumn, + PreviewColumn, + deckBackdropStyles, + NotesContainer +} from './components'; +import useLocationSync from '../../hooks/use-location-sync'; +import * as queryStringMapFns from '../../location-map-fns/query-string'; +import { DeckView, GOTO_FINAL_STEP } from '../../hooks/use-deck-state'; +import { SYSTEM_FONT } from '../../utils/constants'; +import { FlexBox, Box } from '../layout-primitives'; +import { Timer } from './timer'; +import useBroadcastChannel from '../../hooks/use-broadcast-channel'; +import { SpectacleThemeOverrides } from '../../theme/default-theme'; + +const endOfNextSlide = ({ slideIndex }: DeckView) => ({ + slideIndex: slideIndex + 1, + stepIndex: GOTO_FINAL_STEP +}); + +const PreviewSlideWrapper = styled.div<{ visible?: boolean }>( + ({ visible }) => ({ + visibility: visible ? 'visible' : 'hidden' + }) +); + +const PresenterMode = (props: PresenterModeProps): JSX.Element => { + const { children, theme, backgroundImage, template } = props; + const deck = useRef(null); + const previewDeck = useRef(null); + const [notePortalNode, setNotePortalNode] = useState(); + const [showFinalSlide, setShowFinalSlide] = useState(true); + + const [postMessage] = useBroadcastChannel( + 'spectacle_presenter_bus', + (message) => { + if (message.type === 'SYNC_REQUEST') { + postMessage('SYNC', deck.current!.activeView); + } + } + ); + + const [syncLocation, setLocation] = useLocationSync({ + setState: (state) => deck.current!.skipTo(state), + ...queryStringMapFns + }); + + const onActiveStateChange = useCallback( + (activeView: DeckView) => { + setLocation(activeView); + postMessage('SYNC', activeView); + setShowFinalSlide( + (deck.current?.numberOfSlides || 0) - 1 !== + deck?.current?.activeView.slideIndex + ); + previewDeck.current!.skipTo(endOfNextSlide(activeView)); + }, + [postMessage, setLocation] + ); + + useEffect(() => { + const initialView = syncLocation({ + slideIndex: 0, + stepIndex: 0 + })!; + deck.current!.initializeTo(initialView); + postMessage('SYNC', initialView); + previewDeck.current!.initializeTo(endOfNextSlide(initialView)); + }, [postMessage, syncLocation]); + + return ( + + + + + + + Open a second browser tab at {window.location.host} to use as the + audience deck. + + + + + + + + + + + + + {children} + + + + {children} + + + + + ); +}; + +export default PresenterMode; + +type PresenterModeProps = { + theme?: SpectacleThemeOverrides; + children: ReactNode; + backgroundImage?: string; + template?: TemplateFn | ReactNode; +}; diff --git a/packages/spectacle/src/components/presenter-mode/timer.tsx b/packages/spectacle/src/components/presenter-mode/timer.tsx new file mode 100644 index 000000000..1b972c626 --- /dev/null +++ b/packages/spectacle/src/components/presenter-mode/timer.tsx @@ -0,0 +1,54 @@ +import { useState, useCallback } from 'react'; +import { Text } from '../typography'; +import { FlexBox, Box } from '../layout-primitives'; +import InternalButton from '../internal-button'; +import { useTimer } from '../../utils/use-timer'; +import { SYSTEM_FONT } from '../../utils/constants'; +import { useRegisterActions } from 'kbar'; + +export const Timer = () => { + const [timer, setTimer] = useState(0); + const [timerStarted, setTimerStarted] = useState(false); + const addToTimer = useCallback((v: number) => setTimer((s) => s + v), []); + const toggleTimer = useCallback(() => setTimerStarted((s) => !s), []); + const resetTimer = useCallback(() => setTimer(0), []); + useTimer(addToTimer, 1000, timerStarted); + const minutes = Math.floor(Math.round(timer) / 60); + + useRegisterActions([ + { + id: 'Start/Pause Timer', + name: 'Start/Pause Timer', + keywords: 'start pause', + perform: toggleTimer, + section: 'Timer' + }, + { + id: 'Restart Timer', + name: 'Restart Timer', + keywords: 'restart', + perform: resetTimer, + section: 'Timer' + } + ]); + + return ( + + + {`${String(minutes).padStart(2, '0')}:${String( + Math.round(timer) - minutes * 60 + ).padStart(2, '0')}`} + + + {timerStarted ? 'Stop Timer' : 'Start Timer'} + + + Reset + + ); +}; diff --git a/packages/spectacle/src/components/print-mode/index.tsx b/packages/spectacle/src/components/print-mode/index.tsx new file mode 100644 index 000000000..1d5f64716 --- /dev/null +++ b/packages/spectacle/src/components/print-mode/index.tsx @@ -0,0 +1,71 @@ +import { ReactNode } from 'react'; +import styled, { createGlobalStyle } from 'styled-components'; +import { DeckInternal, TemplateFn } from '../deck/deck'; +import { AnimatedDiv } from '../slide/slide'; +import defaultTheme, { + SpectacleThemeOverrides +} from '../../theme/default-theme'; + +const Backdrop = styled.div` + background-color: white; +`; + +type PrintStyleProps = { pageSize: string; pageOrientation: string }; +const PrintStyle = createGlobalStyle` + @media print { + body, html { + margin: 0; + } + @page { + size: ${({ pageSize, pageOrientation }) => + `${pageSize} ${pageOrientation}`.trim()}; + } + ${AnimatedDiv} { + @page { + margin: 0; + } + } + } +`; + +export default function PrintMode({ + children, + theme, + exportMode, + pageSize, + pageOrientation = '', + backgroundImage, + template +}: PrintModeProps) { + const width = theme?.size?.width || defaultTheme.size.width; + const height = theme?.size?.height || defaultTheme.size.height; + const computedPageSize = pageSize || `${width / 100}in ${height / 100}in`; + return ( + <> + + + {children} + + + ); +} + +type PrintModeProps = { + children: ReactNode; + theme?: SpectacleThemeOverrides; + exportMode?: boolean; + pageSize?: string; + pageOrientation?: '' | 'landscape' | 'portrait'; + backgroundImage?: string; + template?: TemplateFn | ReactNode; +}; diff --git a/packages/spectacle/src/components/progress.test.tsx b/packages/spectacle/src/components/progress.test.tsx new file mode 100644 index 000000000..9e336b81c --- /dev/null +++ b/packages/spectacle/src/components/progress.test.tsx @@ -0,0 +1,51 @@ +import { PropsWithChildren, ReactElement } from 'react'; +import { ThemeProvider } from 'styled-components'; + +import { DeckContext, DeckContextType } from './deck/deck'; +import defaultTheme from '../theme/default-theme'; +import Progress from './progress'; +import { DeepPartial } from '../types/deep-partial'; +import { render } from '@testing-library/react'; + +const mountWithContext = ( + tree: ReactElement, + context: DeepPartial +) => { + const WrappingThemeProvider = (props: PropsWithChildren<{}>) => ( + + {props.children} + + ); + return render(tree, { wrapper: WrappingThemeProvider }); +}; + +describe('', () => { + it('should render the right amount of circles', () => { + const { queryAllByTestId } = mountWithContext(, { + slideCount: 5, + activeView: { + slideIndex: 0 + } + }); + + expect(queryAllByTestId('Progress Circle')).toHaveLength(5); + }); + + it('should render the right amount of circles with the current circle in the active state', () => { + const { queryAllByTestId } = mountWithContext(, { + slideCount: 5, + activeView: { + slideIndex: 4 + } + }); + + expect(queryAllByTestId('Progress Circle')[4]).toHaveStyle({ + background: '#fff' + }); + }); +}); diff --git a/packages/spectacle/src/components/progress.tsx b/packages/spectacle/src/components/progress.tsx new file mode 100644 index 000000000..b0ae18324 --- /dev/null +++ b/packages/spectacle/src/components/progress.tsx @@ -0,0 +1,78 @@ +import { forwardRef, useContext } from 'react'; +import styled from 'styled-components'; +import { DeckContext } from './deck/deck'; +import { position, PositionProps } from 'styled-system'; + +const DEFAULT_PROGRESS_CIRCLE_SIZE = 10; +export const PROGRESS_CIRCLE_BORDER_WIDTH = 1; +const PROGRESS_CIRCLE_MARGIN = DEFAULT_PROGRESS_CIRCLE_SIZE / 3; +export const DEFAULT_PROGRESS_CIRCLE_WIDTH_INCLUDING_MARGIN = + DEFAULT_PROGRESS_CIRCLE_SIZE + + (PROGRESS_CIRCLE_BORDER_WIDTH + PROGRESS_CIRCLE_MARGIN) * 2; + +export type CircleProps = { + size: number; + margin: number; + color: string; + active: boolean; +}; +export const Circle = styled.div` + width: ${({ size }) => size}px; + height: ${({ size }) => size}px; + border: ${PROGRESS_CIRCLE_BORDER_WIDTH}px solid ${({ color }) => color}; + background: ${({ color, active }) => (active ? color : 'transparent')}; + margin: ${({ margin }) => margin}px; + border-radius: 50%; + pointer-events: all; + cursor: pointer; +`; + +export const ProgressContainer = styled.div` + ${position} + display: flex; + flex-wrap: wrap; + @media print { + display: none; + } +`; + +export type ProgressProps = { + color?: string; + size?: number; +}; + +const Progress = forwardRef( + ({ color = '#fff', size = DEFAULT_PROGRESS_CIRCLE_SIZE, ...props }, ref) => { + const { slideCount, skipTo, activeView } = useContext(DeckContext); + return ( + + {Array(slideCount) + .fill(0) + .map((_, idx) => ( + + skipTo({ + slideIndex: idx, + stepIndex: 0 + }) + } + data-testid="Progress Circle" + /> + ))} + + ); + } +); + +Progress.displayName = 'Progress'; + +export default Progress; diff --git a/packages/spectacle/src/components/slide-layout.test.tsx b/packages/spectacle/src/components/slide-layout.test.tsx new file mode 100644 index 000000000..12e1886a7 --- /dev/null +++ b/packages/spectacle/src/components/slide-layout.test.tsx @@ -0,0 +1,330 @@ +import { ReactElement } from 'react'; +import { render } from '@testing-library/react'; +import Deck from './deck'; +import SlideLayout from './slide-layout'; +import { Heading, Text } from './typography'; + +jest.mock('../hooks/use-broadcast-channel', () => { + return { + __esModule: true, + default: function useBroadcastChannel() { + return [() => {}]; + } + }; +}); + +const renderInDeck = (tree: ReactElement | JSX.Element) => + render({tree}); + +describe('SlideLayout', () => { + it('SlideLayout.Full should render a slide with children content passed through', () => { + const { getByText } = renderInDeck( + + Hey world + + ); + + expect(getByText('Hey world')).toBeDefined(); + }); + + it('SlideLayout.Center should render children content in a centered flex element', () => { + const { getByText } = renderInDeck( + + Hey world + + ); + + expect(getByText('Hey world')?.parentElement?.parentElement).toHaveStyle({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center' + }); + }); + + it('SlideLayout.TwoColumn should render content side-by-side in flex container', () => { + const { getByText } = renderInDeck( + Left} + right={Right} + /> + ); + + expect(getByText('Left')).toBeDefined(); + expect(getByText('Right')).toBeDefined(); + expect(getByText('Left')?.parentElement?.parentElement).toHaveStyle({ + display: 'flex', + flexDirection: 'row' + }); + }); + + it('SlideLayout.List should render a title if provided', () => { + const { getByText } = renderInDeck( + + ); + + expect(getByText('Title')).toBeDefined(); + }); + + it('SlideLayout.List should pass props to title if passed', () => { + const { getByText } = renderInDeck( + + ); + + expect(getByText('Title')).toHaveStyle({ color: 'purple' }); + }); + + it('SlideLayout.List render items to list', () => { + const { getByText, container } = renderInDeck( + + ); + + expect(container.querySelectorAll('ul')).toHaveLength(1); + expect(container.querySelectorAll('ul > li')).toHaveLength(3); + expect(getByText('foo')).toBeDefined(); + expect(getByText('bar')).toBeDefined(); + expect(getByText('baz')).toBeDefined(); + }); + + it('SlideLayout.List can render to ol instead of ul', () => { + const { container } = renderInDeck( + + ); + + expect(container.querySelectorAll('ol')).toHaveLength(1); + expect(container.querySelectorAll('ol > li')).toHaveLength(3); + }); + + it('SlideLayout.List should pass props to list if provided', () => { + const { container } = renderInDeck( + + ); + + expect(container.querySelector('ul')).toHaveStyle({ color: 'green' }); + }); + + it('SlideLayout.List should allow list items to be animated in', () => { + const { queryAllByTestId } = renderInDeck( + + ); + + expect(queryAllByTestId('AppearElement')).toHaveLength(3); + }); + + it('SlideLayout.Section should render a section title', () => { + const { getByText } = renderInDeck( + {'Section title'} + ); + + expect(getByText('Section title')).toBeDefined(); + }); + + it('SlideLayout.Section should render a section title within a react node', () => { + const { getByText } = renderInDeck( + + { + <> + HelloWorld! + + } + + ); + + expect(getByText('World!')).toBeDefined(); + }); + + it('SlideLayout.Section should render a section slide with props passed through', () => { + const { getByText } = renderInDeck( + + {'Section title'} + + ); + + expect(getByText('Section title')).toHaveStyle({ fontSize: '68px' }); + }); + + it('SlideLayout.Section should render a section title in a left aligned flexbox', () => { + const { getByText } = renderInDeck( + {'Section title'} + ); + + expect(getByText('Section title').parentElement).toHaveStyle({ + justifyContent: 'flex-start' + }); + }); + + it('SlideLayout.Statement should render statement text', () => { + const { getByText } = renderInDeck( + {'Statement'} + ); + + expect(getByText('Statement')).toBeDefined(); + }); + + it('SlideLayout.Statement should render statement text within a react node', () => { + const { getByText } = renderInDeck( + + { + <> + HelloWorld! + + } + + ); + + expect(getByText('World!')).toBeDefined(); + }); + + it('SlideLayout.Statement should render a statement slide with props passed through', () => { + const { getByText } = renderInDeck( + + {'Statement'} + + ); + + expect(getByText('Statement')).toHaveStyle({ fontSize: '88px' }); + }); + + it('SlideLayout.BigFact should render a slide with fact text', () => { + const { getByText } = renderInDeck( + 100% + ); + + expect(getByText('100%')).toBeDefined(); + }); + + it('SlideLayout.BigFact should render a slide with props passed through', () => { + const { getByText } = renderInDeck( + + 100% + + ); + + expect(getByText('100%')).toHaveStyle({ fontSize: '88px' }); + }); + + it('SlideLayout.BigFact should render a fact with default font size', () => { + const { getByText } = renderInDeck( + 100% + ); + + expect(getByText('100%')).toHaveStyle({ fontSize: '250px' }); + }); + + it('SlideLayout.BigFact should render a fact with customizable font size', () => { + const { getByText } = renderInDeck( + 100% + ); + + expect(getByText('100%')).toHaveStyle({ fontSize: '150px' }); + }); + + it('SlideLayout.BigFact should render a slide with fact information if it exists', () => { + const { getByText } = renderInDeck( + + 100% + + ); + + expect(getByText('We earned 100%!')).toBeDefined(); + }); + + it('SlideLayout.Quote should render a slide with a quote and attribution text', () => { + const { getByText } = renderInDeck( + + To be, or not to be... + + ); + + expect(getByText('To be, or not to be...')).toBeDefined(); + expect(getByText('William Shakespeare', { exact: false })).toBeDefined(); + }); + + it('SlideLayout.Quote should render a slide with quote and attribution props passed through', () => { + const { getByText } = renderInDeck( + + {/* eslint-disable-next-line react/no-unescaped-entities */} + I've learned that people will forget what you said, people will forget + what you did, but people will never forget how you made them feel. + + ); + + expect( + getByText( + `I've learned that people will forget what you said, people will forget what you did, but people will never forget how you made them feel.` + ) + ).toHaveStyle({ fontSize: '68px' }); + expect(getByText('Maya Angelou', { exact: false })).toHaveStyle({ + fontSize: '48px' + }); + }); + + it('SlideLayout.Code should render a titled slide with title props passed through', () => { + const { getByText } = renderInDeck( + + {'console.log("Hello World!");'} + + ); + + expect(getByText('Hello World!')).toHaveStyle({ fontSize: '24px' }); + }); + + it('SlideLayout.MultiCodeLayout should contain more than one code pane', () => { + const { queryAllByTestId } = renderInDeck( + + ); + + expect(queryAllByTestId('CodePane')).toHaveLength(2); + }); + + it('SlideLayout.MultiCodeLayout should render multiple code panes with description props passed through', () => { + const { getByText } = renderInDeck( + + ); + + expect(getByText('assign a variable to a string.')).toHaveStyle({ + color: 'blue' + }); + expect(getByText('reassign the variable.')).toHaveStyle({ + color: 'cyan' + }); + }); +}); diff --git a/packages/spectacle/src/components/slide-layout.tsx b/packages/spectacle/src/components/slide-layout.tsx new file mode 100644 index 000000000..0ed57ecc0 --- /dev/null +++ b/packages/spectacle/src/components/slide-layout.tsx @@ -0,0 +1,314 @@ +import * as React from 'react'; +import Slide, { SlideProps } from './slide/slide'; +import { Box, FlexBox, Grid } from './layout-primitives'; +import CodePane, { CodePaneProps } from './code-pane'; +import { ComponentProps, Fragment, ReactNode } from 'react'; +import { + Heading, + Text, + ListItem, + OrderedList, + UnorderedList +} from './typography'; +import { Appear } from './appear'; + +/** + * Full-slide layout + */ +const Full = ({ children, ...rest }: SlideProps) => ( + {children} +); + +/** + * Centered layout + */ +const Center = ({ children, ...rest }: SlideProps) => ( + + + {children} + + +); + +/** + * Two-column layout + */ +const TwoColumn = ({ + left, + right, + ...rest +}: Omit & { left: ReactNode; right: ReactNode }) => ( + + + {left} + {right} + + +); + +/** + * List layout with optional title + */ +const List = ({ + title, + items, + listType = 'unordered', + animateListItems = false, + titleProps, + listProps, + ...rest +}: Omit & { + title?: string; + listType?: 'unordered' | 'ordered'; + items: ReactNode[]; + animateListItems?: boolean; + titleProps?: ComponentProps; + listProps?: ComponentProps; +}) => { + const List = listType === 'unordered' ? UnorderedList : OrderedList; + + return ( + + {title ? ( + + {title} + + ) : null} + {/* @ts-ignore TODO: Resolve this in follow-up */} + + {items.map((item, i) => { + const Wrapper = animateListItems ? Appear : Fragment; + + return ( + + {item} + + ); + })} + + + ); +}; + +/** + * Generic vertically-centered Header layout + */ +const Header = ({ + flexBoxProps, + headingProps, + children, + ...rest +}: SlideProps & { + flexBoxProps?: ComponentProps; + headingProps?: ComponentProps; +}) => ( + + + {children} + + +); + +/** + * Section layout with left aligned text + */ +const Section = ({ + sectionProps, + children +}: SlideProps & { + sectionProps?: ComponentProps; +}) => ( +
+ {children} +
+); + +/** + * Statement layout with centered text + */ +const Statement = ({ + statementProps, + children +}: SlideProps & { + statementProps?: ComponentProps; +}) =>
{children}
; + +/** + * Big Fact with optional fact information + */ +const BigFact = ({ + children, + factInformation, + factProps, + factFontSize = '250px', + factInformationProps, + ...rest +}: SlideProps & { + factInformation?: string | ReactNode; + factProps?: ComponentProps; + factFontSize?: string; + factInformationProps?: ComponentProps; +}) => ( + + + + + {children} + + {factInformation ? ( + + {factInformation} + + ) : null} + + + +); + +/** + * Quote layout + */ +const Quote = ({ + children, + quoteProps, + attribution, + attributionProps, + ...rest +}: SlideProps & { + quoteProps?: ComponentProps; + attribution: string | ReactNode; + attributionProps?: ComponentProps; +}) => ( + + + + {children} + + + –{attribution} + + + +); + +/** + * Generic Codepane utility with optional Description text + */ +const CodeLayout = ({ + text, + textProps, + children, + ...props +}: CodePaneProps & { + text?: string | ReactNode; + textProps?: ComponentProps; +}) => ( + + {text ? ( + + {text} + + ) : null} + {children} + +); + +/** + * single Code Pane with optional Title layout + */ +const Code = ({ + children, + language, + title, + titleProps, + codePaneProps, + ...rest +}: Omit & { + children: string; + language: string; + title?: string | ReactNode; + titleProps?: ComponentProps; + codePaneProps?: CodePaneProps; +}) => { + return ( + + + {title ? {title} : null} + + {children} + + + + ); +}; + +/** + * multiple Code Panes with optional Description, with optional Title layout + */ +const MultiCodeLayout = ({ + codeBlocks, + title, + titleProps, + numColumns = 1, + ...rest +}: Omit & { + codeBlocks: Array< + Omit & { + code: CodePaneProps['children']; + description?: string | ReactNode; + descriptionProps?: ComponentProps; + } + >; + title?: string | ReactNode; + titleProps?: ComponentProps; + numColumns?: number; +}) => { + return ( + + + {title ? {title} : null} + + {codeBlocks.map( + ({ description, descriptionProps, code, ...codePaneProps }, i) => ( + + {code} + + ) + )} + + + + ); +}; + +/** + * Layouts to consider: + * - Image (left, right, full bleed?) + * - Intro + */ + +export default { + Full, + Center, + TwoColumn, + List, + Section, + BigFact, + Quote, + Statement, + Code, + MultiCodeLayout +}; diff --git a/packages/spectacle/src/components/slide/slide.tsx b/packages/spectacle/src/components/slide/slide.tsx new file mode 100644 index 000000000..d0bf6c9b5 --- /dev/null +++ b/packages/spectacle/src/components/slide/slide.tsx @@ -0,0 +1,408 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState +} from 'react'; +import ReactDOM from 'react-dom'; +import styled, { css, ThemeContext } from 'styled-components'; +import { + background, + BackgroundProps, + color, + ColorProps, + space, + SpaceProps +} from 'styled-system'; +import { DeckContext, SlideId, TemplateFn } from '../deck/deck'; +import { animated, useSpring } from 'react-spring'; +import { useSlide } from '../../hooks/use-slides'; +import { ActivationThresholds, useCollectSteps } from '../../hooks/use-steps'; +import { GOTO_FINAL_STEP } from '../../hooks/use-deck-state'; +import { useSwipeable } from 'react-swipeable'; +import { SlideTransition } from '../transitions'; +import TemplateWrapper from '../template-wrapper'; + +const noop = () => {}; + +export type SlideContextType = { + immediate: boolean; + slideId: SlideId; + isSlideActive: boolean; + activationThresholds: ActivationThresholds; + activeStepIndex: number; +}; + +export const SlideContext = createContext(null as any); +SlideContext.displayName = 'SlideContext'; + +type SlideContainerProps = BackgroundProps & + ColorProps & { backgroundOpacity: number }; + +const SlideContainer = styled.div` + ${color}; + width: 100%; + height: 100%; + position: relative; + overflow: hidden; + display: flex; + z-index: 0; + + &:before { + ${background}; + content: ' '; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: -1; + opacity: ${({ backgroundOpacity }) => backgroundOpacity}; + } +`; + +const SlideWrapper = styled.div( + color, + space, + css` + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-start; + ` +); + +export const AnimatedDiv = styled(animated.div)` + width: 100%; + height: 100%; + position: absolute; + background: transparent; + + ${({ tabIndex }) => + tabIndex === 0 && + css` + outline: 2px solid white; + `} +`; + +const Slide = (props: SlideProps): JSX.Element => { + const { + id: userProvidedId, + children, + backgroundColor = 'tertiary', + backgroundImage, + backgroundOpacity = 1, + backgroundPosition = 'center', + backgroundRepeat = 'no-repeat', + backgroundSize = 'cover', + padding = 2, + textColor = 'primary', + template: slideTemplate, + transition: slideTransition = {}, + className = '' + } = props; + if (useContext(SlideContext)) { + throw new Error(`Slide components may not be nested within each other.`); + } + + const slideHasTemplate = slideTemplate !== undefined; + const { slideId, placeholder } = useSlide(slideHasTemplate, userProvidedId); + const { setStepContainer, activationThresholds, finalStepIndex } = + useCollectSteps(); + const { + onSlideClick = noop, + onMobileSlide, + useAnimations, + slidePortalNode, + frameOverrideStyle = {}, + wrapperOverrideStyle = {}, + passedSlideIds, + upcomingSlideIds, + activeView, + pendingView, + advanceSlide, + regressSlide, + commitTransition, + cancelTransition, + transition, + template: deckTemplate, + slideCount, + backgroundImage: deckBackgroundImage, + inOverviewMode, + inPrintMode + } = useContext(DeckContext); + + const handleClick = useCallback( + (e: MouseEvent) => { + onSlideClick(e, slideId); + }, + [onSlideClick, slideId] + ); + + const mergedTransition = useMemo(() => { + const result = { ...transition }; + 'from' in slideTransition && (result.from = slideTransition.from); + 'enter' in slideTransition && (result.enter = slideTransition.enter); + 'leave' in slideTransition && (result.leave = slideTransition.leave); + return result; + }, [slideTransition, transition]); + + const isActive = activeView.slideId === slideId; + const isPending = pendingView.slideId === slideId; + const isPassed = passedSlideIds.has(slideId); + const isUpcoming = upcomingSlideIds.has(slideId); + + const willEnter = !isActive && isPending; + const willExit = isActive && !isPending; + + const slideWillChange = activeView.slideIndex !== pendingView.slideIndex; + const stepWillChange = activeView.stepIndex !== pendingView.stepIndex; + + const [animate, setAnimate] = useState(false); + + // If we've already been to this slide, all its elements should be visible; if + // we haven't gotten to it yet, none of them should be visible. (This helps us + // handle slides which are exiting but which are still visible while + // animated.) + const infinityDirection = isPassed ? Infinity : -Infinity; + const internalStepIndex = isActive ? activeView.stepIndex : infinityDirection; + + const [hover, setHover] = useState(false); + const onHoverChange = useCallback(() => { + setHover(!hover); + }, [hover]); + + useEffect(() => { + if (!isActive) return; + if (!stepWillChange) return; + if (slideWillChange) return; + + if (pendingView.stepIndex < 0) { + setAnimate(false); + regressSlide(); + } else if (pendingView.stepIndex > finalStepIndex) { + setAnimate(true); + advanceSlide(); + } else if (pendingView.stepIndex === GOTO_FINAL_STEP) { + setAnimate(false); + commitTransition({ + stepIndex: finalStepIndex + }); + } else { + const isSingleForwardStep = + activeView.stepIndex === pendingView.stepIndex - 1; + // the step is happening within this slide + setAnimate(isSingleForwardStep); + commitTransition(); + } + }, [ + isActive, + stepWillChange, + slideWillChange, + activeView, + pendingView, + finalStepIndex, + regressSlide, + advanceSlide, + commitTransition + ]); + + // Bounds checking for slides in the presentation. + useEffect(() => { + if (!willExit) return; + if (pendingView.slideId === undefined) { + setAnimate(false); + cancelTransition(); + } else { + const isTransitionToNextSlide = + activeView.slideIndex === pendingView.slideIndex - 1; + setAnimate(isTransitionToNextSlide); + } + }, [willExit, pendingView, cancelTransition, activeView.slideIndex]); + + useEffect(() => { + if (!willEnter) return; + if (finalStepIndex === undefined) return; + + if (pendingView.stepIndex < 0) { + setAnimate(false); + commitTransition({ + stepIndex: 0 + }); + } else if (pendingView.stepIndex === GOTO_FINAL_STEP) { + // Because elements enumerate their own steps, nobody else + // actually knows how many steps are in a slide. So other slides put a + // value of GOTO_FINAL_STEP in the step index to indicate that the slide + // should fill in the correct finalStepIndex before we commit the change. + setAnimate(false); + commitTransition({ + stepIndex: finalStepIndex + }); + } else if (pendingView.stepIndex > finalStepIndex) { + setAnimate(false); + commitTransition({ + stepIndex: finalStepIndex + }); + } else { + const isTransitionFromPreviousSlide = + activeView.slideIndex === pendingView.slideIndex - 1; + setAnimate(isTransitionFromPreviousSlide); + commitTransition(); + } + }, [willEnter, activeView, pendingView, finalStepIndex, commitTransition]); + + const target = useMemo(() => { + if (isPassed) { + return [mergedTransition.leave, { display: 'none' }]; + } + if (isActive) { + return { + ...mergedTransition.enter, + display: 'unset' + }; + } + if (isUpcoming) { + return { + ...mergedTransition.from, + display: 'none' + }; + } + return { + display: 'none' + }; + }, [ + isPassed, + isActive, + isUpcoming, + mergedTransition.leave, + mergedTransition.enter, + mergedTransition.from + ]); + + const immediate = !animate || !useAnimations; + + const springFrameStyle = useSpring({ + to: target, + immediate + }); + + const theme = useContext(ThemeContext); + const scaledWrapperOverrideStyle = useMemo(() => { + if ( + !wrapperOverrideStyle || + Object.entries(wrapperOverrideStyle).length === 0 + ) { + return {}; + } + const themeSlidePadding = theme?.space?.[padding] || 0; + return { + ...wrapperOverrideStyle, + width: `calc(${wrapperOverrideStyle.width} - ${themeSlidePadding * 2}px)`, + height: `calc(${wrapperOverrideStyle.height} - ${ + themeSlidePadding * 2 + }px)` + }; + }, [wrapperOverrideStyle, theme, padding]); + + const template = slideHasTemplate ? slideTemplate : deckTemplate; + const templateElement = + typeof template === 'function' + ? template({ + slideNumber: activeView.slideIndex + 1, + numberOfSlides: slideCount + }) + : template; + + const swipeHandler = useSwipeable({ + onSwiped: (eventData) => onMobileSlide(eventData) + }); + return ( + <> + {placeholder} + + {slidePortalNode && + ReactDOM.createPortal( + + + {((slideHasTemplate && isActive) || + inOverviewMode || + inPrintMode) && ( + + {templateElement} + + )} + + {children} + + + , + slidePortalNode + )} + + + ); +}; + +export default Slide; + +export type SlideProps = { + id?: SlideId; + className?: string; + + backgroundColor?: string; + backgroundImage?: string; + backgroundOpacity?: number; + backgroundPosition?: string; + backgroundRepeat?: string; + backgroundSize?: string; + children: ReactNode; + padding?: string | number; + textColor?: string; + template?: TemplateFn | ReactNode; + transition?: SlideTransition; +}; diff --git a/packages/spectacle/src/components/table.test.tsx b/packages/spectacle/src/components/table.test.tsx new file mode 100644 index 000000000..8f77c8b32 --- /dev/null +++ b/packages/spectacle/src/components/table.test.tsx @@ -0,0 +1,57 @@ +import { PropsWithChildren, ReactElement } from 'react'; +import { ThemeProvider } from 'styled-components'; + +import defaultTheme from '../theme/default-theme'; +import { Table, TableRow, TableCell, TableBody, TableHeader } from './table'; +import { render } from '@testing-library/react'; + +const mountWithTheme = (tree: ReactElement) => { + const WrappingThemeProvider = (props: PropsWithChildren) => ( + {props.children} + ); + + return render(tree, { wrapper: WrappingThemeProvider }); +}; + +describe('', () => { + it('should render a
with a for each row and a
with text for each cell', () => { + const { container } = mountWithTheme( + + + + Row 1 + Row 1 + + + Row 2 + Row 2 + + +
+ ); + + expect(container.querySelector('table')).toBeDefined(); + + const row1 = container.querySelectorAll('tr')[0], + row2 = container.querySelectorAll('tr')[1]; + expect(row1.querySelectorAll('td')).toHaveLength(2); + expect(row2.querySelectorAll('td')).toHaveLength(2); + }); + + it('should render a with bold text for the header row', () => { + const { container } = mountWithTheme( +
+ + + Row 1, Col 1 + Row 1, Col 2 + + +
+ ); + + expect(container.querySelector('thead')).toHaveStyle({ + fontWeight: 'bold' + }); + }); +}); diff --git a/packages/spectacle/src/components/table.tsx b/packages/spectacle/src/components/table.tsx new file mode 100644 index 000000000..904bfb1b4 --- /dev/null +++ b/packages/spectacle/src/components/table.tsx @@ -0,0 +1,85 @@ +import styled from 'styled-components'; +import { + color, + typography, + space, + compose, + border, + layout, + ColorProps, + TypographyProps, + SpaceProps, + BorderProps, + LayoutProps +} from 'styled-system'; + +export type TableProps = ColorProps & + TypographyProps & + SpaceProps & + BorderProps & + LayoutProps; + +const Table = styled.table( + compose(color, typography, space, border, layout) +); + +Table.defaultProps = { + color: 'primary', + fontFamily: 'text', + fontSize: 'text', + textAlign: 'left', + margin: 'listMargin', + width: 1 +}; + +const TableHeader = styled.thead( + compose(color, typography, space, border, layout) +); + +TableHeader.defaultProps = { + color: 'primary', + fontFamily: 'text', + fontSize: 'text', + fontWeight: 'bold', + textAlign: 'left', + margin: 'listMargin' +}; + +const TableBody = styled.tbody( + compose(color, typography, space, border, layout) +); + +TableBody.defaultProps = { + color: 'primary', + fontFamily: 'text', + fontSize: 'text', + textAlign: 'left', + margin: 'listMargin', + width: 1 +}; + +const TableRow = styled.tr( + compose(color, typography, space, border, layout) +); + +TableRow.defaultProps = { + color: 'primary', + fontFamily: 'text', + fontSize: 'text', + textAlign: 'left', + margin: 'listMargin' +}; + +const TableCell = styled.td( + compose(color, typography, space, border, layout) +); + +TableCell.defaultProps = { + color: 'primary', + fontFamily: 'text', + fontSize: 'text', + textAlign: 'left', + margin: 'listMargin' +}; + +export { Table, TableCell, TableRow, TableHeader, TableBody }; diff --git a/packages/spectacle/src/components/template-wrapper.tsx b/packages/spectacle/src/components/template-wrapper.tsx new file mode 100644 index 000000000..1202f72ef --- /dev/null +++ b/packages/spectacle/src/components/template-wrapper.tsx @@ -0,0 +1,12 @@ +import styled from 'styled-components'; + +export const TemplateWrapper = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +`; + +export default TemplateWrapper; diff --git a/packages/spectacle/src/components/transitions/index.ts b/packages/spectacle/src/components/transitions/index.ts new file mode 100644 index 000000000..23f4ff4d5 --- /dev/null +++ b/packages/spectacle/src/components/transitions/index.ts @@ -0,0 +1,37 @@ +import { CSSObject } from 'styled-components'; + +const STAGE_RIGHT = 'translateX(-100%)'; +const CENTER_STAGE = 'translateX(0%)'; +const STAGE_LEFT = 'translateX(100%)'; + +export type SlideTransition = { + from?: CSSObject; + leave?: CSSObject; + enter?: CSSObject; +}; + +export const fadeTransition: SlideTransition = { + from: { + opacity: 0 + }, + enter: { + opacity: 1 + }, + leave: { + opacity: 0 + } +}; + +export const slideTransition: SlideTransition = { + from: { + transform: STAGE_LEFT + }, + enter: { + transform: CENTER_STAGE + }, + leave: { + transform: STAGE_RIGHT + } +}; + +export const defaultTransition = slideTransition; diff --git a/packages/spectacle/src/components/typography.test.tsx b/packages/spectacle/src/components/typography.test.tsx new file mode 100644 index 000000000..9ea289970 --- /dev/null +++ b/packages/spectacle/src/components/typography.test.tsx @@ -0,0 +1,93 @@ +import { PropsWithChildren, ReactElement } from 'react'; +import { ThemeProvider } from 'styled-components'; + +import defaultTheme from '../theme/default-theme'; +import { + Text, + Heading, + Quote, + OrderedList, + UnorderedList, + ListItem, + Link, + CodeSpan +} from './typography'; +import { render } from '@testing-library/react'; + +const mountWithTheme = (tree: ReactElement | JSX.Element) => { + const WrappingThemeProvider = (props: PropsWithChildren) => ( + {props.children} + ); + return render(tree, { wrapper: WrappingThemeProvider }); +}; + +describe('', () => { + it('should render a
with text', () => { + const { container } = mountWithTheme(Spectacle!); + expect(container.querySelector('div')?.innerHTML).toBe('Spectacle!'); + }); +}); + +describe('', () => { + it('should render a component with h1 size', () => { + const { getByText } = mountWithTheme(Spectacle!); + expect(getByText('Spectacle!')).toHaveStyle({ fontSize: 'h1' }); + }); +}); + +describe('', () => { + it('should render a component with a left border', () => { + const { getByText } = mountWithTheme(Spectacle!); + expect(getByText('Spectacle!')).toHaveStyle({ + borderLeft: '1px solid #fc6986' + }); + }); +}); + +describe('', () => { + it('should render an
    with
  1. children', () => { + const { container, getByText } = mountWithTheme( + + This is an + Ordered List + + ); + + expect(container.querySelectorAll('ol')).toHaveLength(1); + expect(container.querySelectorAll('li')).toHaveLength(2); + expect(getByText('This is an')).toBeDefined(); + expect(getByText('Ordered List')).toBeDefined(); + }); +}); + +describe('', () => { + it('should render a